Add ability to save signal login as PDF.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:25 -03:00
committed by Alex Hart
parent cfcb242ed7
commit 820b2bc74c
17 changed files with 303 additions and 8 deletions
@@ -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
}
@@ -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()))
}
}
}
@@ -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)
}
}
}
@@ -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"
@@ -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) }
@@ -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
}
@@ -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)
}
}
}
@@ -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)
}
}
+1
View File
@@ -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>