mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add ability to save signal login as PDF.
This commit is contained in:
committed by
Alex Hart
parent
cfcb242ed7
commit
820b2bc74c
+3
@@ -15,4 +15,7 @@ sealed interface SignalLoginViewDetailsAction {
|
||||
|
||||
/** Leave the screen. */
|
||||
data object NavigateBack : SignalLoginViewDetailsAction
|
||||
|
||||
/** Launch the system document picker so the user can choose where to save the login PDF. */
|
||||
data object LaunchSaveAsPdf : SignalLoginViewDetailsAction
|
||||
}
|
||||
|
||||
+20
@@ -5,12 +5,19 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.signallogin
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.CollectActions
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.signallogin.pdf.SignalLoginPdfRenderer
|
||||
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen
|
||||
|
||||
/**
|
||||
@@ -20,6 +27,18 @@ class SignalLoginViewDetailsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: SignalLoginViewDetailsViewModel by viewModels()
|
||||
|
||||
private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val context = requireContext().applicationContext
|
||||
lifecycleScope.launch {
|
||||
val result = SignalLoginPdfRenderer.renderTo(context, uri, viewModel.state.value)
|
||||
if (result is Result.Failure) {
|
||||
Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -35,6 +54,7 @@ class SignalLoginViewDetailsFragment : ComposeFragment() {
|
||||
private fun handleAction(action: SignalLoginViewDetailsAction) {
|
||||
when (action) {
|
||||
SignalLoginViewDetailsAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -49,8 +49,7 @@ class SignalLoginViewDetailsViewModel(
|
||||
Log.i(TAG, "Save to password manager clicked, but the flow isn't implemented yet.")
|
||||
}
|
||||
SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> {
|
||||
// TODO [phonenumberless] Render the credentials to a PDF and hand it to the user.
|
||||
Log.i(TAG, "Save as PDF clicked, but the flow isn't implemented yet.")
|
||||
_actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
|
||||
return configure {
|
||||
customPref(
|
||||
SplashImage.Model(
|
||||
splashImageResId = R.drawable.ic_signal_logo_type
|
||||
splashImageResId = CoreUiR.drawable.image_signal_logo_wordmark
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_signal_logo_type_light" />
|
||||
app:srcCompat="@drawable/image_signal_logo_wordmark_light" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date"
|
||||
|
||||
+11
@@ -76,4 +76,15 @@ class SignalLoginViewDetailsViewModelTest {
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) {
|
||||
val viewModel = SignalLoginViewDetailsViewModel(repository)
|
||||
val actions = mutableListOf<SignalLoginViewDetailsAction>()
|
||||
backgroundScope.launch { viewModel.actions.toList(actions) }
|
||||
|
||||
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked)
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveAsPdf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -35,6 +38,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -44,6 +48,7 @@ import org.signal.core.ui.navigation.ResultEffect
|
||||
import org.signal.core.ui.navigation.TransitionSpecs
|
||||
import org.signal.core.util.LinkActions
|
||||
import org.signal.core.util.LinkActions.OpenUrlError
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.core.util.serialization.AccountEntropyPoolSerializer
|
||||
import org.signal.network.api.RegistrationApiV2.SessionMetadata
|
||||
import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
@@ -112,6 +117,7 @@ import org.signal.registration.screens.restoreselection.RegisteredState
|
||||
import org.signal.registration.screens.signallogin.SignalLoginScreen
|
||||
import org.signal.registration.screens.signallogin.SignalLoginScreenActions
|
||||
import org.signal.registration.screens.signallogin.SignalLoginViewModel
|
||||
import org.signal.registration.screens.signallogindetails.SignalLoginViewDetailsScreenActions
|
||||
import org.signal.registration.screens.signallogindetails.SignalLoginViewDetailsViewModel
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoViewModel
|
||||
@@ -128,6 +134,7 @@ import org.signal.registration.screens.welcome.WelcomeScreenViewModel
|
||||
import org.signal.registration.util.AccountEntropyPoolParceler
|
||||
import org.signal.registration.util.SessionMetadataParceler
|
||||
import org.signal.registration.util.SvrCredentialsParceler
|
||||
import org.signal.signallogin.pdf.SignalLoginPdfRenderer
|
||||
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen
|
||||
|
||||
/**
|
||||
@@ -679,6 +686,8 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
|
||||
// -- Signal Login View Details Screen
|
||||
entry<RegistrationRoute.SignalLoginViewDetails> {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel: SignalLoginViewDetailsViewModel = viewModel(
|
||||
factory = SignalLoginViewDetailsViewModel.Factory(
|
||||
parentState = registrationViewModel.state,
|
||||
@@ -687,6 +696,23 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val savePdfLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri ->
|
||||
if (uri != null) {
|
||||
scope.launch {
|
||||
val result = SignalLoginPdfRenderer.renderTo(context, uri, viewModel.state.value)
|
||||
if (result is Result.Failure) {
|
||||
Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollectActions(viewModel.actions) { action ->
|
||||
when (action) {
|
||||
SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context))
|
||||
}
|
||||
}
|
||||
|
||||
SignalLoginViewDetailsScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogindetails
|
||||
|
||||
sealed interface SignalLoginViewDetailsScreenActions {
|
||||
/** Launch the system document picker so the user can choose where to save the login PDF. */
|
||||
data object LaunchSaveAsPdf : SignalLoginViewDetailsScreenActions
|
||||
}
|
||||
+7
-2
@@ -9,11 +9,14 @@ import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
@@ -43,6 +46,9 @@ class SignalLoginViewDetailsViewModel(
|
||||
)
|
||||
val state: StateFlow<SignalLoginViewDetailsState> = _state.asStateFlow()
|
||||
|
||||
private val _actions = Channel<SignalLoginViewDetailsScreenActions>(Channel.BUFFERED)
|
||||
val actions: Flow<SignalLoginViewDetailsScreenActions> = _actions.receiveAsFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
@@ -66,8 +72,7 @@ class SignalLoginViewDetailsViewModel(
|
||||
}
|
||||
|
||||
is SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> {
|
||||
// TODO [phonenumberless] Render the credentials to a PDF and hand it to the user.
|
||||
Log.i(TAG, "Save as PDF clicked, but the flow isn't implemented yet.")
|
||||
_actions.trySend(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -11,6 +11,8 @@ import assertk.assertions.isEqualTo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -68,4 +70,14 @@ class SignalLoginViewDetailsViewModelTest {
|
||||
|
||||
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) {
|
||||
val actions = mutableListOf<SignalLoginViewDetailsScreenActions>()
|
||||
backgroundScope.launch { viewModel.actions.toList(actions) }
|
||||
|
||||
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked)
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies {
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(libs.libsignal.android)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.signal.signallogin.fonts
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -14,11 +15,15 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
* Special monospace font, primarily used for rendering AEPs.
|
||||
*/
|
||||
object MonoTypeface {
|
||||
@Volatile
|
||||
private var cached: Typeface? = null
|
||||
|
||||
fun typeface(context: Context): Typeface {
|
||||
return cached ?: Typeface.createFromAsset(context.assets, "fonts/MonoSpecial-Regular.otf").also { cached = it }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fontFamily(): FontFamily {
|
||||
val context = LocalContext.current
|
||||
return FontFamily(cached ?: Typeface.createFromAsset(context.assets, "fonts/MonoSpecial-Regular.otf").also { cached = it })
|
||||
return FontFamily(typeface(LocalContext.current))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.signallogin.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.signallogin.R
|
||||
import org.signal.signallogin.fonts.MonoTypeface
|
||||
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Renders the keys that make up a Signal Login into a single-page PDF, styled to match
|
||||
* [org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen]: the Signal logo up top, then each key
|
||||
* in a rounded block, with the recovery key broken into character groups.
|
||||
*
|
||||
* All dimensions are in PostScript points (1/72 inch), on an A4 page.
|
||||
*/
|
||||
object SignalLoginPdfRenderer {
|
||||
|
||||
private val TAG = Log.tag(SignalLoginPdfRenderer::class)
|
||||
|
||||
/** The file name to prefill in the system save dialog. */
|
||||
fun suggestedFileName(context: Context): String {
|
||||
return context.getString(R.string.SignalLoginViewDetailsScreen__signal_login_pdf)
|
||||
}
|
||||
|
||||
private const val PAGE_WIDTH = 595
|
||||
private const val PAGE_HEIGHT = 842
|
||||
private const val MARGIN = 56f
|
||||
|
||||
private const val LOGO_WIDTH = 140f
|
||||
private const val LOGO_BOTTOM_SPACING = 28f
|
||||
|
||||
private const val HEADER_TEXT_SIZE = 12f
|
||||
private const val HEADER_TOP_PADDING = 16f
|
||||
private const val HEADER_BOTTOM_PADDING = 12f
|
||||
|
||||
private const val KEY_TEXT_SIZE = 13f
|
||||
private const val KEY_LINE_HEIGHT = 21f
|
||||
private const val KEY_LETTER_SPACING_EM = 0.08f
|
||||
|
||||
private const val BLOCK_CORNER_RADIUS = 18f
|
||||
private const val BLOCK_HORIZONTAL_PADDING = 28f
|
||||
private const val BLOCK_VERTICAL_PADDING = 20f
|
||||
|
||||
private const val GROUPS_PER_ROW = 4
|
||||
|
||||
/** Light-theme colorSurface2 and onSurface, respectively. The PDF is always rendered as if in light theme. */
|
||||
private const val BLOCK_COLOR = 0xFFEDF0F6.toInt()
|
||||
private const val TEXT_COLOR = 0xFF1B1B1D.toInt()
|
||||
|
||||
/**
|
||||
* Renders the credentials in [state] to a PDF and writes it to [uri].
|
||||
*/
|
||||
suspend fun renderTo(context: Context, uri: Uri, state: SignalLoginViewDetailsState): Result<Unit, SignalLoginPdfError> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bytes = render(context, state)
|
||||
val stream = context.contentResolver.openOutputStream(uri)
|
||||
if (stream == null) {
|
||||
Log.w(TAG, "Could not open an output stream for the chosen location.")
|
||||
Result.failure(SignalLoginPdfError.UnableToOpenDocument)
|
||||
} else {
|
||||
stream.use { it.write(bytes) }
|
||||
Result.success(Unit)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to write the Signal Login PDF.", e)
|
||||
Result.failure(SignalLoginPdfError.WriteFailed)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Not allowed to write the Signal Login PDF to the chosen location.", e)
|
||||
Result.failure(SignalLoginPdfError.NotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun render(context: Context, state: SignalLoginViewDetailsState): ByteArray {
|
||||
val document = PdfDocument()
|
||||
try {
|
||||
val page = document.startPage(PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create())
|
||||
drawPage(context, page.canvas, state)
|
||||
document.finishPage(page)
|
||||
|
||||
return ByteArrayOutputStream().use { stream ->
|
||||
document.writeTo(stream)
|
||||
stream.toByteArray()
|
||||
}
|
||||
} finally {
|
||||
document.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawPage(context: Context, canvas: Canvas, state: SignalLoginViewDetailsState) {
|
||||
val headerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||||
textSize = HEADER_TEXT_SIZE
|
||||
color = TEXT_COLOR
|
||||
}
|
||||
|
||||
val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = MonoTypeface.typeface(context)
|
||||
textSize = KEY_TEXT_SIZE
|
||||
letterSpacing = KEY_LETTER_SPACING_EM
|
||||
color = TEXT_COLOR
|
||||
}
|
||||
|
||||
var y = drawLogo(context, canvas, top = MARGIN) + LOGO_BOTTOM_SPACING
|
||||
|
||||
y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginViewDetailsScreen__account_key), y)
|
||||
y = drawKeyBlock(canvas, keyPaint, rows = listOf(listOf(state.accountKey)), top = y)
|
||||
|
||||
y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginViewDetailsScreen__recovery_key), y)
|
||||
drawKeyBlock(canvas, keyPaint, rows = state.recoveryKeyGroups.chunked(GROUPS_PER_ROW), top = y)
|
||||
}
|
||||
|
||||
/** Draws the Signal logo centered at the top of the page, returning the y position of its bottom edge. */
|
||||
private fun drawLogo(context: Context, canvas: Canvas, top: Float): Float {
|
||||
val logo = requireNotNull(ContextCompat.getDrawable(context, CoreUiR.drawable.image_signal_logo_wordmark_light))
|
||||
val height = LOGO_WIDTH * logo.intrinsicHeight / logo.intrinsicWidth
|
||||
val left = (PAGE_WIDTH - LOGO_WIDTH) / 2f
|
||||
|
||||
logo.setBounds(left.toInt(), top.toInt(), (left + LOGO_WIDTH).toInt(), (top + height).toInt())
|
||||
logo.draw(canvas)
|
||||
|
||||
return top + height
|
||||
}
|
||||
|
||||
/** Draws a section header above a key block, returning the y position content below it should start at. */
|
||||
private fun drawSectionHeader(canvas: Canvas, paint: Paint, text: String, top: Float): Float {
|
||||
val textTop = top + HEADER_TOP_PADDING
|
||||
canvas.drawText(text, MARGIN, textTop - paint.fontMetrics.ascent, paint)
|
||||
return textTop + paint.fontMetrics.let { it.descent - it.ascent } + HEADER_BOTTOM_PADDING
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a rounded block containing rows of key text, returning the y position of the block's bottom edge.
|
||||
* Rows with a single entry are drawn left-aligned; rows with multiple groups are spaced evenly across the
|
||||
* block, mirroring how the screen lays out recovery key groups.
|
||||
*/
|
||||
private fun drawKeyBlock(canvas: Canvas, paint: Paint, rows: List<List<String>>, top: Float): Float {
|
||||
val blockWidth = PAGE_WIDTH - 2 * MARGIN
|
||||
val blockHeight = 2 * BLOCK_VERTICAL_PADDING + rows.size * KEY_LINE_HEIGHT
|
||||
|
||||
val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = BLOCK_COLOR }
|
||||
canvas.drawRoundRect(RectF(MARGIN, top, MARGIN + blockWidth, top + blockHeight), BLOCK_CORNER_RADIUS, BLOCK_CORNER_RADIUS, backgroundPaint)
|
||||
|
||||
val innerLeft = MARGIN + BLOCK_HORIZONTAL_PADDING
|
||||
val innerWidth = blockWidth - 2 * BLOCK_HORIZONTAL_PADDING
|
||||
val groupWidth = rows.flatten().maxOfOrNull { paint.measureText(it) } ?: 0f
|
||||
|
||||
var lineTop = top + BLOCK_VERTICAL_PADDING
|
||||
for (row in rows) {
|
||||
val spacing = if (row.size > 1) (innerWidth - GROUPS_PER_ROW * groupWidth) / (GROUPS_PER_ROW - 1) else 0f
|
||||
val baseline = lineTop + (KEY_LINE_HEIGHT - (paint.fontMetrics.descent - paint.fontMetrics.ascent)) / 2f - paint.fontMetrics.ascent
|
||||
|
||||
row.forEachIndexed { index, group ->
|
||||
canvas.drawText(group, innerLeft + index * (groupWidth + spacing), baseline, paint)
|
||||
}
|
||||
|
||||
lineTop += KEY_LINE_HEIGHT
|
||||
}
|
||||
|
||||
return top + blockHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways saving the login PDF can fail, each carrying the message to show the user.
|
||||
*/
|
||||
sealed class SignalLoginPdfError(@StringRes val userMessageRes: Int) {
|
||||
/** The system could not provide an output stream for the chosen location. */
|
||||
data object UnableToOpenDocument : SignalLoginPdfError(R.string.SignalLoginViewDetailsScreen__unable_to_save_pdf)
|
||||
|
||||
/** Writing the rendered bytes failed. */
|
||||
data object WriteFailed : SignalLoginPdfError(R.string.SignalLoginViewDetailsScreen__unable_to_save_pdf)
|
||||
|
||||
/** The document provider rejected the write, e.g. a permission grant that has since been revoked. */
|
||||
data object NotAllowed : SignalLoginPdfError(R.string.SignalLoginViewDetailsScreen__cant_save_pdf_to_the_selected_location)
|
||||
}
|
||||
@@ -21,4 +21,10 @@
|
||||
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Save to password manager</string>
|
||||
<!-- Action button that saves the Signal Login as a PDF. -->
|
||||
<string name="SignalLoginViewDetailsScreen__save_as_pdf">Save as PDF</string>
|
||||
<!-- Suggested file name prefilled in the system save dialog when saving the Signal Login as a PDF. Keep the .pdf extension. -->
|
||||
<string name="SignalLoginViewDetailsScreen__signal_login_pdf">Signal Login.pdf</string>
|
||||
<!-- Toast shown when saving the Signal Login PDF fails. -->
|
||||
<string name="SignalLoginViewDetailsScreen__unable_to_save_pdf">Unable to save PDF. Please try again.</string>
|
||||
<!-- Toast shown when the location the user picked for the Signal Login PDF refuses the write. -->
|
||||
<string name="SignalLoginViewDetailsScreen__cant_save_pdf_to_the_selected_location">Can\'t save PDF to the selected location.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user