diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt index bc2dfcf1c3..3e2fd7dd1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt index deff0658ac..43c1f662ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt @@ -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())) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt index c7387f23fe..acc1a430ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt index c11aa5b976..719ef3a0d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt @@ -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 ) ) diff --git a/app/src/main/res/layout/donation_receipt_png.xml b/app/src/main/res/layout/donation_receipt_png.xml index 5824827430..f5d8c2fd6b 100644 --- a/app/src/main/res/layout/donation_receipt_png.xml +++ b/app/src/main/res/layout/donation_receipt_png.xml @@ -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" /> () + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveAsPdf) + } } diff --git a/app/src/main/res/drawable-night/ic_signal_logo_type.xml b/core/ui/src/main/res/drawable-night/image_signal_logo_wordmark.xml similarity index 100% rename from app/src/main/res/drawable-night/ic_signal_logo_type.xml rename to core/ui/src/main/res/drawable-night/image_signal_logo_wordmark.xml diff --git a/app/src/main/res/drawable/ic_signal_logo_type.xml b/core/ui/src/main/res/drawable/image_signal_logo_wordmark.xml similarity index 100% rename from app/src/main/res/drawable/ic_signal_logo_type.xml rename to core/ui/src/main/res/drawable/image_signal_logo_wordmark.xml diff --git a/app/src/main/res/drawable/ic_signal_logo_type_light.xml b/core/ui/src/main/res/drawable/image_signal_logo_wordmark_light.xml similarity index 100% rename from app/src/main/res/drawable/ic_signal_logo_type_light.xml rename to core/ui/src/main/res/drawable/image_signal_logo_wordmark_light.xml diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index caa40742f2..2022e6f08b 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.navigationEntries( // -- Signal Login View Details Screen entry { + val context = LocalContext.current + val scope = rememberCoroutineScope() val viewModel: SignalLoginViewDetailsViewModel = viewModel( factory = SignalLoginViewDetailsViewModel.Factory( parentState = registrationViewModel.state, @@ -687,6 +696,23 @@ private fun EntryProviderScope.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) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenActions.kt new file mode 100644 index 0000000000..f9657749de --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt index 7f2e8d5e0a..257b830510 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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) } } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt index f9d4c755b0..660caf1d02 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt @@ -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() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) + + assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf) + } } diff --git a/lib/signal-login/build.gradle.kts b/lib/signal-login/build.gradle.kts index 68b826ea78..d34c2e7d59 100644 --- a/lib/signal-login/build.gradle.kts +++ b/lib/signal-login/build.gradle.kts @@ -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) diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt b/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt index f829dfc75a..566ff8fc94 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt @@ -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)) } } diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt b/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt new file mode 100644 index 0000000000..cae953be84 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt @@ -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 { + 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>, 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) +} diff --git a/lib/signal-login/src/main/res/values/strings.xml b/lib/signal-login/src/main/res/values/strings.xml index 1f9d3627ea..50569532b9 100644 --- a/lib/signal-login/src/main/res/values/strings.xml +++ b/lib/signal-login/src/main/res/values/strings.xml @@ -21,4 +21,10 @@ Save to password manager Save as PDF + + Signal Login.pdf + + Unable to save PDF. Please try again. + + Can\'t save PDF to the selected location.