Add signal login manual confirmation flow during account creation.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:44:25 -04:00
committed by Cody Henthorne
parent 3151effc7f
commit 1f29540bbf
103 changed files with 1915 additions and 303 deletions
@@ -91,7 +91,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
if (uri != null) {
val context = requireContext().applicationContext
lifecycleScope.launch {
val result = SignalLoginPdfRenderer.renderTo(context, uri, signalLoginViewDetailsViewModel.state.value)
val result = SignalLoginPdfRenderer.renderTo(context, uri, signalLoginViewDetailsViewModel.state.value.accountKey, signalLoginViewDetailsViewModel.state.value.recoveryKeyGroups)
if (result is Result.Failure) {
Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show()
}
@@ -37,7 +37,7 @@ class SignalLoginViewDetailsFragment : ComposeFragment() {
if (uri != null) {
val context = requireContext().applicationContext
lifecycleScope.launch {
val result = SignalLoginPdfRenderer.renderTo(context, uri, viewModel.state.value)
val result = SignalLoginPdfRenderer.renderTo(context, uri, viewModel.state.value.accountKey, viewModel.state.value.recoveryKeyGroups)
if (result is Result.Failure) {
Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show()
}
@@ -123,10 +123,14 @@ import org.signal.registration.screens.signallogincredentials.SignalLoginCredent
import org.signal.registration.screens.signallogincredentials.SignalLoginCredentialEntryScreenActions
import org.signal.registration.screens.signallogincredentials.SignalLoginCredentialEntryScreenEvents
import org.signal.registration.screens.signallogincredentials.SignalLoginCredentialEntryViewModel
import org.signal.registration.screens.signallogincredentials.SignalLoginManualSaveConfirmationViewModel
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
import org.signal.registration.screens.signalloginmanualsave.SignalLoginViewDetailsForManualSaveScreen
import org.signal.registration.screens.signalloginmanualsave.SignalLoginViewDetailsForManualSaveScreenActions
import org.signal.registration.screens.signalloginmanualsave.SignalLoginViewDetailsForManualSaveViewModel
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreen
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreenActions
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreenEvents
@@ -193,6 +197,14 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object SignalLoginViewDetails : RegistrationRoute
/** Spells out a freshly-purchased Signal Login so the user can record it themselves. */
@Serializable
data object SignalLoginViewDetailsForManualSave : RegistrationRoute
/** Typing back a freshly-purchased Signal Login to prove it was recorded correctly. */
@Serializable
data object SignalLoginManualSaveConfirmation : RegistrationRoute
/**
* Logging in with a Signal Login the user already owns: the account ID and the recovery key that pairs with it.
*
@@ -765,7 +777,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val savePdfLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri ->
if (uri != null) {
scope.launch {
val result = SignalLoginPdfRenderer.renderTo(context, uri, viewModel.state.value)
val result = SignalLoginPdfRenderer.renderTo(context, uri, viewModel.state.value.accountKey, viewModel.state.value.recoveryKeyGroups)
if (result is Result.Failure) {
Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show()
}
@@ -797,6 +809,59 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
// -- Signal Login View Details For Manual Save Screen
entry<RegistrationRoute.SignalLoginViewDetailsForManualSave> {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val viewModel: SignalLoginViewDetailsForManualSaveViewModel = viewModel {
SignalLoginViewDetailsForManualSaveViewModel(
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
}
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.accountId, viewModel.state.value.recoveryKeyGroups)
if (result is Result.Failure) {
Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show()
}
}
}
}
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginViewDetailsForManualSaveScreenActions.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context))
is SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard -> Util.copyToClipboard(context, action.text, CLIPBOARD_TIMEOUT_SECONDS)
}
}
SignalLoginViewDetailsForManualSaveScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Signal Login Manual Save Confirmation Screen
entry<RegistrationRoute.SignalLoginManualSaveConfirmation> {
val viewModel: SignalLoginManualSaveConfirmationViewModel = viewModel {
SignalLoginManualSaveConfirmationViewModel(
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
}
val state by viewModel.state.collectAsStateWithLifecycle()
SignalLoginCredentialEntryScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Signal Login Credential Entry Screen
entry<RegistrationRoute.SignalLoginCredentialEntry> { key ->
val viewModel: SignalLoginCredentialEntryViewModel = viewModel {
@@ -81,13 +81,12 @@ import org.signal.registration.screens.shared.AccountIdVisualTransformation
import org.signal.registration.screens.shared.BackTopAppBar
import org.signal.registration.screens.shared.accountIdTextStyle
import org.signal.registration.test.TestTags
/** How the recovery key is grouped when it is spelled out rather than masked. */
private const val RECOVERY_KEY_CHUNK_LENGTH = 4
import org.signal.signallogin.RecoveryKeyGroups
/**
* Collects an existing Signal Login -- the account ID and the recovery key that pairs with it -- and logs the user
* back in with the two together.
* Collects a Signal Login -- the account ID and the recovery key that pairs with it. What happens with the pair depends
* on [SignalLoginCredentialEntryState.mode] and the view model behind it: it either logs an existing user back in, or
* checks a brand new login against the one the user was just shown.
*/
@Composable
fun SignalLoginCredentialEntryScreen(
@@ -151,7 +150,7 @@ private fun OnePaneLayout(
.verticalScroll(scrollState)
.padding(paddingValues)
) {
Header()
Header(mode = state.mode)
Spacer(modifier = Modifier.height(32.dp))
@@ -186,7 +185,7 @@ private fun TwoPaneLayout(
.verticalScroll(firstPaneScrollState)
.padding(paddingValues)
) {
Header(twoPane = true)
Header(mode = state.mode, twoPane = true)
}
},
secondPane = { paddingValues ->
@@ -207,7 +206,12 @@ private fun TwoPaneLayout(
}
@Composable
private fun Header(twoPane: Boolean = false) {
private fun Header(mode: SignalLoginCredentialEntryState.Mode, twoPane: Boolean = false) {
val subtitle = when (mode) {
SignalLoginCredentialEntryState.Mode.Login -> stringResource(R.string.SignalLoginCredentialEntryScreen__enter_your_account_id_followed_by_your_recovery_key)
SignalLoginCredentialEntryState.Mode.ConfirmSaved -> stringResource(R.string.SignalLoginCredentialEntryScreen__enter_your_signal_login_to_confirm_it_was_recorded_correctly)
}
Image(
painter = painterResource(R.drawable.image_signal_login_ring),
contentDescription = null,
@@ -228,7 +232,7 @@ private fun Header(twoPane: Boolean = false) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.SignalLoginCredentialEntryScreen__enter_your_account_id_followed_by_your_recovery_key),
text = subtitle,
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -322,7 +326,7 @@ private fun RecoveryKeyTextField(
val autoFillHelper = passwordAutoFillHelper { onEvent(SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(it)) }
val revealed = state.isRecoveryKeyRevealed
val visualTransformation = remember(revealed) {
if (revealed) AepVisualTransformation(RECOVERY_KEY_CHUNK_LENGTH) else PasswordVisualTransformation()
if (revealed) AepVisualTransformation(RecoveryKeyGroups.GROUP_SIZE) else PasswordVisualTransformation()
}
TextField(
@@ -361,6 +365,7 @@ private fun RecoveryKeyTextField(
supportingText = {
val error = state.recoveryKey.error
when {
state.areCredentialsIncorrect && state.mode == SignalLoginCredentialEntryState.Mode.ConfirmSaved -> Text(stringResource(R.string.SignalLoginCredentialEntryScreen__that_doesnt_match_the_signal_login_you_were_shown))
state.areCredentialsIncorrect -> Text(stringResource(R.string.SignalLoginCredentialEntryScreen__incorrect_account_id_or_recovery_key))
error is AepValidationError.TooLong -> Text(stringResource(R.string.EnterAepScreen__too_long, error.count, error.max))
error != null -> Text(stringResource(R.string.EnterAepScreen__invalid_recovery_key))
@@ -409,7 +414,10 @@ private fun Footer(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f)
) {
NeedHelpButton(onEvent)
when (state.mode) {
SignalLoginCredentialEntryState.Mode.Login -> NeedHelpButton(onEvent)
SignalLoginCredentialEntryState.Mode.ConfirmSaved -> ShowLoginInfoAgainButton(onEvent)
}
}
Box(
@@ -433,6 +441,17 @@ private fun NeedHelpButton(onEvent: (SignalLoginCredentialEntryScreenEvents) ->
}
}
@Composable
private fun ShowLoginInfoAgainButton(onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit) {
TextButton(
shape = RoundedCornerShape(0.dp),
onClick = { onEvent(SignalLoginCredentialEntryScreenEvents.ShowLoginInfoAgainClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_SHOW_LOGIN_INFO_AGAIN_BUTTON)
) {
Text(text = stringResource(R.string.SignalLoginCredentialEntryScreen__show_login_info_again))
}
}
@Composable
private fun NextButton(state: SignalLoginCredentialEntryState, onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit) {
Buttons.LargeTonal(
@@ -492,6 +511,17 @@ private fun SignalLoginCredentialEntryScreenRevealedPreview() {
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginCredentialEntryScreenConfirmSavedPreview() {
Previews.Preview {
SignalLoginCredentialEntryScreen(
state = SignalLoginCredentialEntryState(mode = SignalLoginCredentialEntryState.Mode.ConfirmSaved),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginCredentialEntryScreenErrorPreview() {
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.screens.shared.AccountIdFormat
/**
* The events that mean the same thing no matter which job [SignalLoginCredentialEntryScreen] is doing, so that the view
* model behind each [SignalLoginCredentialEntryState.Mode] only has to handle the ones that differ.
*/
object SignalLoginCredentialEntryScreenEventHandler {
fun applyEvent(state: SignalLoginCredentialEntryState, event: SignalLoginCredentialEntryScreenEvents): SignalLoginCredentialEntryState {
return when (event) {
is SignalLoginCredentialEntryScreenEvents.AccountIdChanged -> {
val accountId = AccountIdFormat.normalize(event.value)
state.copy(accountId = accountId, accountIdError = AccountIdFormat.validate(accountId), isAccountIdPrefilled = false, areCredentialsIncorrect = false)
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged -> {
state.copy(recoveryKey = AepInput.from(event.value, state.recoveryKey.error), areCredentialsIncorrect = false)
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled -> {
state.copy(isRecoveryKeyRevealed = !state.isRecoveryKeyRevealed)
}
is SignalLoginCredentialEntryScreenEvents.DismissError -> {
state.copy(loginError = null)
}
else -> throw UnsupportedOperationException("This event is not handled generically!")
}
}
}
@@ -29,9 +29,12 @@ sealed class SignalLoginCredentialEntryScreenEvents {
/** The user tapped the eye button that switches the recovery key between masked and spelled out. */
data object RecoveryKeyVisibilityToggled : SignalLoginCredentialEntryScreenEvents()
/** The user tapped "Need help?". */
/** The user tapped "Need help?". Only reachable in [SignalLoginCredentialEntryState.Mode.Login]. */
data object NeedHelpClicked : SignalLoginCredentialEntryScreenEvents()
/** The user tapped "Show login info again". Only reachable in [SignalLoginCredentialEntryState.Mode.ConfirmSaved]. */
data object ShowLoginInfoAgainClicked : SignalLoginCredentialEntryScreenEvents()
/** The user submitted the login, either with the next button or the keyboard's done action. */
data object NextClicked : SignalLoginCredentialEntryScreenEvents()
@@ -11,13 +11,14 @@ import org.signal.registration.screens.shared.AccountIdError
import org.signal.registration.screens.shared.AccountIdFormat
/**
* State for the screen where a user who already owns a Signal Login types both halves of it in: the account ID and the
* recovery key that pairs with it.
* State for the screen where a user types both halves of a Signal Login in: the account ID and the recovery key that
* pairs with it. [mode] says which of the two jobs the screen is doing, which the flow pairs with a matching view model.
*
* [accountId] holds the ID without any of the formatting the user sees: the screen renders the dashes and the
* uppercasing itself, so what is stored here is always the raw lowercase value.
*/
data class SignalLoginCredentialEntryState(
val mode: Mode = Mode.Login,
val accountId: String = "",
val accountIdError: AccountIdError? = null,
/** Whether [accountId] was handed to the screen by the flow rather than typed by the user, and so isn't user input. */
@@ -44,7 +45,15 @@ data class SignalLoginCredentialEntryState(
val canPromptPasswordManager: Boolean
get() = recoveryKey.enteredText.isEmpty() && (accountId.isEmpty() || isAccountIdPrefilled)
override fun toString(): String = "SignalLoginCredentialEntryState(accountId=${accountId.censor()}, accountIdError=$accountIdError, isAccountIdPrefilled=$isAccountIdPrefilled, recoveryKey=$recoveryKey, isRecoveryKeyRevealed=$isRecoveryKeyRevealed, areCredentialsIncorrect=$areCredentialsIncorrect, isLoggingIn=$isLoggingIn, loginError=$loginError)"
override fun toString(): String = "SignalLoginCredentialEntryState(mode=$mode, accountId=${accountId.censor()}, accountIdError=$accountIdError, isAccountIdPrefilled=$isAccountIdPrefilled, recoveryKey=$recoveryKey, isRecoveryKeyRevealed=$isRecoveryKeyRevealed, areCredentialsIncorrect=$areCredentialsIncorrect, isLoggingIn=$isLoggingIn, loginError=$loginError)"
enum class Mode {
/** The user owns a Signal Login from before and is typing it in to get back into their account. */
Login,
/** The user just bought a Signal Login and is typing it back to prove they recorded it. */
ConfirmSaved
}
}
/** A login failure that the text fields can't express, so it gets a dialog instead. */
@@ -80,29 +80,23 @@ class SignalLoginCredentialEntryViewModel(
parentEventEmitter.navigateBack()
}
is SignalLoginCredentialEntryScreenEvents.AccountIdChanged -> {
val accountId = AccountIdFormat.normalize(event.value)
stateEmitter(state.copy(accountId = accountId, accountIdError = AccountIdFormat.validate(accountId), isAccountIdPrefilled = false, areCredentialsIncorrect = false))
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged -> {
stateEmitter(state.copy(recoveryKey = AepInput.from(event.value, state.recoveryKey.error), areCredentialsIncorrect = false))
is SignalLoginCredentialEntryScreenEvents.AccountIdChanged,
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged,
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled,
is SignalLoginCredentialEntryScreenEvents.DismissError -> {
stateEmitter(SignalLoginCredentialEntryScreenEventHandler.applyEvent(state, event))
}
is SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected -> {
applyPasswordManagerCredentialSelected(state, event, parentEventEmitter, stateEmitter)
}
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled -> {
stateEmitter(state.copy(isRecoveryKeyRevealed = !state.isRecoveryKeyRevealed))
}
is SignalLoginCredentialEntryScreenEvents.NeedHelpClicked -> {
_actions.trySend(SignalLoginCredentialEntryScreenActions.OpenNeedHelpArticle)
}
is SignalLoginCredentialEntryScreenEvents.DismissError -> {
stateEmitter(state.copy(loginError = null))
is SignalLoginCredentialEntryScreenEvents.ShowLoginInfoAgainClicked -> {
error("There is no 'show login info again' button in ${SignalLoginCredentialEntryState.Mode.Login} mode, so this event can't happen.")
}
is SignalLoginCredentialEntryScreenEvents.NextClicked -> {
@@ -0,0 +1,151 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.viewModelScope
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 org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.aepentry.AepInput
import org.signal.registration.screens.shared.AccountIdFormat
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
/**
* Drives [SignalLoginCredentialEntryScreen] in [SignalLoginCredentialEntryState.Mode.ConfirmSaved], where the user
* types back the Signal Login they were just shown to prove they recorded it.
*
* Nothing here talks to the service: the account already exists and the credentials are sitting in the parent flow
* state, so confirming is a local comparison against what the user was handed.
*/
class SignalLoginManualSaveConfirmationViewModel(
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<SignalLoginCredentialEntryScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(SignalLoginManualSaveConfirmationViewModel::class)
}
private val _state = MutableStateFlow(SignalLoginCredentialEntryState(mode = SignalLoginCredentialEntryState.Mode.ConfirmSaved))
val state: StateFlow<SignalLoginCredentialEntryState> = _state.asStateFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: SignalLoginCredentialEntryScreenEvents) {
applyEvent(_state.value, event, parentState.value, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
fun applyEvent(
state: SignalLoginCredentialEntryState,
event: SignalLoginCredentialEntryScreenEvents,
parentState: RegistrationFlowState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
when (event) {
is SignalLoginCredentialEntryScreenEvents.AccountIdChanged,
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged,
is SignalLoginCredentialEntryScreenEvents.RecoveryKeyVisibilityToggled,
is SignalLoginCredentialEntryScreenEvents.DismissError -> {
stateEmitter(SignalLoginCredentialEntryScreenEventHandler.applyEvent(state, event))
}
is SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected -> {
applyPasswordManagerCredentialSelected(state, event, parentState, parentEventEmitter, stateEmitter)
}
is SignalLoginCredentialEntryScreenEvents.BackClicked,
is SignalLoginCredentialEntryScreenEvents.ShowLoginInfoAgainClicked -> {
parentEventEmitter.navigateBack()
}
is SignalLoginCredentialEntryScreenEvents.NextClicked -> {
applyNextClicked(state, parentState, parentEventEmitter, stateEmitter)
}
is SignalLoginCredentialEntryScreenEvents.NeedHelpClicked -> {
error("There is no 'need help' button in ${SignalLoginCredentialEntryState.Mode.ConfirmSaved} mode, so this event can't happen.")
}
is SignalLoginCredentialEntryScreenEvents.TwoFactorCodeEntered -> {
error("Confirming a saved login never talks to the service, so it can never ask for a two-factor code.")
}
}
}
/**
* Fills both fields from the password manager, then checks the pair straight away if what came back is complete.
*/
private fun applyPasswordManagerCredentialSelected(
state: SignalLoginCredentialEntryState,
event: SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected,
parentState: RegistrationFlowState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
val accountId = AccountIdFormat.normalize(event.accountId).ifEmpty { state.accountId }
val filledState = state.copy(
accountId = accountId,
accountIdError = AccountIdFormat.validate(accountId),
isAccountIdPrefilled = false,
recoveryKey = AepInput.from(event.recoveryKey),
areCredentialsIncorrect = false
)
stateEmitter(filledState)
if (filledState.isNextEnabled) {
Log.i(TAG, "[CredentialSelected] The password manager supplied a complete login. Checking it.")
applyNextClicked(filledState, parentState, parentEventEmitter, stateEmitter)
} else {
Log.w(TAG, "[CredentialSelected] The password manager supplied a login we can't check as-is. Leaving it in the fields for the user to fix.")
}
}
/**
* Compares what the user typed against the login they were shown. A mismatch is flagged on both fields rather than
* one, since either half could be the one they mis-copied.
*/
private fun applyNextClicked(
state: SignalLoginCredentialEntryState,
parentState: RegistrationFlowState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginCredentialEntryState) -> Unit
) {
val expectedAci = parentState.aci
val expectedAep = parentState.accountEntropyPool
if (expectedAci == null || expectedAep == null) {
Log.w(TAG, "[Next] There is no login in the flow state to confirm against. Sending the user on rather than trapping them here.")
parentEventEmitter.navigateTo(RegistrationRoute.AddUsername)
return
}
val matches = AccountIdFormat.toAciOrNull(state.accountId) == expectedAci && state.recoveryKey.normalized == expectedAep.value
if (matches) {
Log.i(TAG, "[Next] The user typed back the login they were shown.")
parentEventEmitter.navigateTo(RegistrationRoute.AddUsername)
} else {
Log.w(TAG, "[Next] What the user typed doesn't match the login they were shown.")
stateEmitter(state.copy(areCredentialsIncorrect = true))
}
}
}
@@ -73,8 +73,7 @@ class SignalLoginInfoViewModel(
}
is SignalLoginInfoScreenEvents.SaveManuallyClicked -> {
// TODO [phonenumberless] Advance to the confirm-you-saved-it step instead of skipping straight ahead.
parentEventEmitter.navigateTo(RegistrationRoute.AddUsername)
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginViewDetailsForManualSave)
}
is SignalLoginInfoScreenEvents.SaveFailedDialogDismissed -> {
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.dismissWithAnimation
import org.signal.registration.R
import org.signal.registration.test.TestTags
/**
* Sheet shown once the user says they've recorded their Signal Login, warning them that they're about to be asked for
* it and offering a way back to the keys if they aren't sure.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmLoginSavedBottomSheet(
maxButtonWidth: Dp,
onContinue: () -> Unit,
onShowLoginInfoAgain: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
BottomSheets.BottomSheet(
onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) },
sheetState = sheetState
) {
ConfirmLoginSavedBottomSheetContent(
maxButtonWidth = maxButtonWidth,
onContinueClick = { sheetState.dismissWithAnimation(scope, onComplete = onContinue) },
onShowLoginInfoAgainClick = { sheetState.dismissWithAnimation(scope, onComplete = onShowLoginInfoAgain) }
)
}
}
@Composable
private fun ConfirmLoginSavedBottomSheetContent(
maxButtonWidth: Dp,
onContinueClick: () -> Unit,
onShowLoginInfoAgainClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 16.dp)
.testTag(TestTags.CONFIRM_LOGIN_SAVED_SHEET)
) {
Image(
painter = painterResource(R.drawable.image_signal_login_lock_checkmark),
contentDescription = null,
modifier = Modifier.size(96.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.ConfirmLoginSavedBottomSheet__confirm_your_login_info_is_saved),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.ConfirmLoginSavedBottomSheet__you_will_be_prompted_to_sign_in),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(36.dp))
Buttons.LargeTonal(
onClick = onContinueClick,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.widthIn(max = maxButtonWidth)
.fillMaxWidth()
.testTag(TestTags.CONFIRM_LOGIN_SAVED_CONTINUE_BUTTON)
) {
Text(stringResource(R.string.ConfirmLoginSavedBottomSheet__continue))
}
TextButton(
onClick = onShowLoginInfoAgainClick,
modifier = Modifier
.widthIn(max = maxButtonWidth)
.fillMaxWidth()
.testTag(TestTags.CONFIRM_LOGIN_SAVED_SHOW_LOGIN_INFO_AGAIN_BUTTON)
) {
Text(stringResource(R.string.ConfirmLoginSavedBottomSheet__show_login_info_again))
}
}
}
@Preview
@Composable
private fun ConfirmLoginSavedBottomSheetPreview() {
Previews.BottomSheetPreview {
ConfirmLoginSavedBottomSheetContent(
maxButtonWidth = 320.dp,
onContinueClick = {},
onShowLoginInfoAgainClick = {}
)
}
}
@@ -0,0 +1,270 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.OnePaneRegistrationScaffold
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.registration.screens.shared.BackTopAppBar
import org.signal.registration.test.TestTags
import org.signal.signallogin.details.SignalLoginKeyDetails
/**
* Spells out both halves of the Signal Login the user just bought so they can record it somewhere themselves, then
* sends them on to a screen that confirms they did.
*/
@Composable
fun SignalLoginViewDetailsForManualSaveScreen(
state: SignalLoginViewDetailsForManualSaveState,
onEvent: (SignalLoginViewDetailsForManualSaveScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val params = RegistrationScaffold.rememberLayoutParams()
if (state.showConfirmSavedSheet) {
ConfirmLoginSavedBottomSheet(
maxButtonWidth = params.maxButtonWidth,
onContinue = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedContinueClicked) },
onShowLoginInfoAgain = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.ShowLoginInfoAgainClicked) },
onDismiss = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedSheetDismissed) }
)
}
Surface(
modifier = modifier
.fillMaxSize()
.testTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_SCREEN)
) {
when (params) {
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
state: SignalLoginViewDetailsForManualSaveState,
onEvent: (SignalLoginViewDetailsForManualSaveScreenEvents) -> Unit
) {
val scrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
OnePaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.BackClicked) }) },
content = { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(paddingValues.vertical())
) {
Header(modifier = Modifier.padding(paddingValues.horizontal()))
Spacer(modifier = Modifier.height(16.dp))
KeyDetails(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, scrollState.canScrollForward, onEvent) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
state: SignalLoginViewDetailsForManualSaveState,
onEvent: (SignalLoginViewDetailsForManualSaveScreenEvents) -> Unit
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
TwoPaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.BackClicked) }) },
firstPane = { paddingValues ->
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(firstPaneScrollState)
.padding(paddingValues)
) {
Header(twoPane = true)
}
},
secondPane = { paddingValues ->
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(secondPaneScrollState)
.padding(paddingValues.vertical())
) {
KeyDetails(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
)
}
@Composable
private fun Header(modifier: Modifier = Modifier, twoPane: Boolean = false) {
Column(modifier = modifier) {
Image(
painter = painterResource(R.drawable.image_signal_login_lock),
contentDescription = null,
modifier = Modifier
.padding(bottom = 24.dp)
.align(Alignment.CenterHorizontally)
.size(96.dp)
)
Text(
text = stringResource(R.string.SignalLoginViewDetailsForManualSaveScreen__save_your_signal_login),
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.attachDebugLogHelper()
)
Text(
text = stringResource(R.string.SignalLoginViewDetailsForManualSaveScreen__store_your_signal_login_somewhere_safe),
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
}
@Composable
private fun KeyDetails(
state: SignalLoginViewDetailsForManualSaveState,
onEvent: (SignalLoginViewDetailsForManualSaveScreenEvents) -> Unit
) {
SignalLoginKeyDetails(
accountId = state.accountId,
recoveryKeyGroups = state.recoveryKeyGroups,
onCopyAccountId = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.CopyAccountIdClicked(it)) },
onCopyRecoveryKey = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.CopyRecoveryKeyClicked(it)) }
)
}
@Composable
private fun Footer(
params: RegistrationScaffold.Params,
isElevated: Boolean,
onEvent: (SignalLoginViewDetailsForManualSaveScreenEvents) -> Unit
) {
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(params.footerPadding)
) {
Buttons.LargeTonal(
onClick = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.SaveAsPdfClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.widthIn(max = params.maxButtonWidth)
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_SAVE_AS_PDF_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsForManualSaveScreen__save_as_pdf))
}
Buttons.LargeTonal(
onClick = { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.ContinueClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.widthIn(max = params.maxButtonWidth)
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsForManualSaveScreen__continue))
}
}
}
}
/** The vertical half of a scaffold's pane padding, for content that supplies its own horizontal inset. */
private fun PaddingValues.vertical(): PaddingValues {
return PaddingValues(top = calculateTopPadding(), bottom = calculateBottomPadding())
}
/** The horizontal half of a scaffold's pane padding, to hand to the content that [vertical] left un-inset. */
@Composable
private fun PaddingValues.horizontal(): PaddingValues {
val layoutDirection = LocalLayoutDirection.current
return PaddingValues(start = calculateStartPadding(layoutDirection), end = calculateEndPadding(layoutDirection))
}
@AllDevicePreviews
@Composable
private fun SignalLoginViewDetailsForManualSaveScreenPreview() {
Previews.Preview {
SignalLoginViewDetailsForManualSaveScreen(
state = SignalLoginViewDetailsForManualSaveState(
accountId = "A6B28482-2E32-83D0-7F23-91360A4C2B91",
recoveryKey = "UY38JH2778HJJHJ8LK19GA61S672JSJ=89R=23S6A578=9BAP92J2YH5T326VV7T"
),
onEvent = {}
)
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import org.signal.core.util.censor
sealed interface SignalLoginViewDetailsForManualSaveScreenActions {
/** Launch the system document picker so the user can choose where to save the login PDF. */
data object LaunchSaveAsPdf : SignalLoginViewDetailsForManualSaveScreenActions
/** Copy the specified text to the clipboard. */
data class CopyTextToClipboard(val text: String) : SignalLoginViewDetailsForManualSaveScreenActions {
override fun toString(): String = "CopyTextToClipboard(text=${text.censor()})"
}
}
@@ -0,0 +1,42 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import org.signal.core.util.censor
import org.signal.registration.RegistrationFlowState
sealed class SignalLoginViewDetailsForManualSaveScreenEvents {
/** The parent registration flow state changed. */
data class ParentStateChanged(val parentState: RegistrationFlowState) : SignalLoginViewDetailsForManualSaveScreenEvents()
/** The user tapped the back arrow. */
data object BackClicked : SignalLoginViewDetailsForManualSaveScreenEvents()
/** The user tapped the copy button on the account ID field. */
data class CopyAccountIdClicked(val accountId: String) : SignalLoginViewDetailsForManualSaveScreenEvents() {
override fun toString(): String = "CopyAccountIdClicked(accountId=${accountId.censor()})"
}
/** The user tapped the copy button on the recovery key field. */
data class CopyRecoveryKeyClicked(val recoveryKey: String) : SignalLoginViewDetailsForManualSaveScreenEvents() {
override fun toString(): String = "CopyRecoveryKeyClicked(recoveryKey=${recoveryKey.censor()})"
}
/** The user chose to save the credentials as a PDF. */
data object SaveAsPdfClicked : SignalLoginViewDetailsForManualSaveScreenEvents()
/** The user says they're done recording their login. */
data object ContinueClicked : SignalLoginViewDetailsForManualSaveScreenEvents()
/** The user confirmed on the sheet that their login really is saved. */
data object ConfirmSavedContinueClicked : SignalLoginViewDetailsForManualSaveScreenEvents()
/** The user tapped "show login info again" on the sheet, sending them back to the keys. */
data object ShowLoginInfoAgainClicked : SignalLoginViewDetailsForManualSaveScreenEvents()
/** The user dismissed the confirm-you-saved-it sheet without answering it. */
data object ConfirmSavedSheetDismissed : SignalLoginViewDetailsForManualSaveScreenEvents()
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import org.signal.core.util.censor
import org.signal.signallogin.RecoveryKeyGroups
/**
* State for the screen that spells out the user's Signal Login so they can record it themselves before being asked to
* type it back in.
*/
data class SignalLoginViewDetailsForManualSaveState(
val accountId: String = "",
val recoveryKey: String = "",
/** Whether the sheet that double-checks the user really saved their login is up. */
val showConfirmSavedSheet: Boolean = false
) {
/** The recovery key broken into character groups, in display order. */
val recoveryKeyGroups: RecoveryKeyGroups
get() = RecoveryKeyGroups.from(recoveryKey)
override fun toString(): String = "SignalLoginViewDetailsForManualSaveState(accountId=${accountId.censor()}, recoveryKey=${recoveryKey.censor()}, showConfirmSavedSheet=$showConfirmSavedSheet)"
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import androidx.annotation.VisibleForTesting
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
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
/**
* View model for [SignalLoginViewDetailsForManualSaveScreen].
*/
class SignalLoginViewDetailsForManualSaveViewModel(
parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<SignalLoginViewDetailsForManualSaveScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(SignalLoginViewDetailsForManualSaveViewModel::class)
}
private val _state = MutableStateFlow(SignalLoginViewDetailsForManualSaveState())
val state: StateFlow<SignalLoginViewDetailsForManualSaveState> = _state.asStateFlow()
private val _actions = Channel<SignalLoginViewDetailsForManualSaveScreenActions>(Channel.BUFFERED)
val actions: Flow<SignalLoginViewDetailsForManualSaveScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
parentState
.onEach { onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.ParentStateChanged(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: SignalLoginViewDetailsForManualSaveScreenEvents) {
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
fun applyEvent(
state: SignalLoginViewDetailsForManualSaveState,
event: SignalLoginViewDetailsForManualSaveScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginViewDetailsForManualSaveState) -> Unit
) {
when (event) {
is SignalLoginViewDetailsForManualSaveScreenEvents.ParentStateChanged -> {
stateEmitter(
state.copy(
accountId = event.parentState.aci?.toString()?.uppercase().orEmpty(),
recoveryKey = event.parentState.accountEntropyPool?.displayValue.orEmpty()
)
)
}
is SignalLoginViewDetailsForManualSaveScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
is SignalLoginViewDetailsForManualSaveScreenEvents.CopyAccountIdClicked -> {
_actions.trySend(SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard(event.accountId))
}
is SignalLoginViewDetailsForManualSaveScreenEvents.CopyRecoveryKeyClicked -> {
_actions.trySend(SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard(event.recoveryKey))
}
is SignalLoginViewDetailsForManualSaveScreenEvents.SaveAsPdfClicked -> {
_actions.trySend(SignalLoginViewDetailsForManualSaveScreenActions.LaunchSaveAsPdf)
}
is SignalLoginViewDetailsForManualSaveScreenEvents.ContinueClicked -> {
stateEmitter(state.copy(showConfirmSavedSheet = true))
}
is SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedContinueClicked -> {
stateEmitter(state.copy(showConfirmSavedSheet = false))
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginManualSaveConfirmation)
}
is SignalLoginViewDetailsForManualSaveScreenEvents.ShowLoginInfoAgainClicked,
is SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedSheetDismissed -> {
stateEmitter(state.copy(showConfirmSavedSheet = false))
}
}
}
}
@@ -63,6 +63,7 @@ object TestTags {
const val SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD = "signal_login_credential_recovery_key_field"
const val SIGNAL_LOGIN_CREDENTIAL_REVEAL_RECOVERY_KEY_BUTTON = "signal_login_credential_reveal_recovery_key_button"
const val SIGNAL_LOGIN_CREDENTIAL_NEED_HELP_BUTTON = "signal_login_credential_need_help_button"
const val SIGNAL_LOGIN_CREDENTIAL_SHOW_LOGIN_INFO_AGAIN_BUTTON = "signal_login_credential_show_login_info_again_button"
const val SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON = "signal_login_credential_next_button"
// Signal Login Info Screen
@@ -71,6 +72,14 @@ object TestTags {
const val SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON = "signal_login_info_save_to_password_manager_button"
const val SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON = "signal_login_info_save_manually_button"
// Save Signal Login Screen
const val SIGNAL_LOGIN_MANUAL_SAVE_SCREEN = "signal_login_manual_save_screen"
const val SIGNAL_LOGIN_MANUAL_SAVE_SAVE_AS_PDF_BUTTON = "signal_login_manual_save_save_as_pdf_button"
const val SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON = "signal_login_manual_save_continue_button"
const val CONFIRM_LOGIN_SAVED_SHEET = "confirm_login_saved_sheet"
const val CONFIRM_LOGIN_SAVED_CONTINUE_BUTTON = "confirm_login_saved_continue_button"
const val CONFIRM_LOGIN_SAVED_SHOW_LOGIN_INFO_AGAIN_BUTTON = "confirm_login_saved_show_login_info_again_button"
// Add Username Screen
const val ADD_USERNAME_SCREEN = "add_username_screen"
const val ADD_USERNAME_FIELD = "add_username_field"
@@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:strokeColor="#FFA1B2D7"
android:strokeWidth="2"
android:pathData="M47.5 15a15.5 15.5 0 0 1 15.5 15.5v19a15.5 15.5 0 0 1-15.5 15.5 15.5 15.5 0 0 1-15.5-15.5v-19A15.5 15.5 0 0 1 47.5 15Z"/>
<path
android:strokeColor="#FFC5D0E8"
android:strokeWidth="2"
android:pathData="M47.5 13a17.5 17.5 0 0 1 17.5 17.5v19a17.5 17.5 0 0 1-17.5 17.5 17.5 17.5 0 0 1-17.5-17.5v-19A17.5 17.5 0 0 1 47.5 13Z"/>
<path
android:strokeColor="#FF8C90CB"
android:strokeWidth="2"
android:pathData="M61 30.5C61 23.04 54.96 17 47.5 17S34 23.04 34 30.5v19C34 56.96 40.04 63 47.5 63S61 56.96 61 49.5v-19Zm6 19C67 60.27 58.27 69 47.5 69S28 60.27 28 49.5v-19C28 19.73 36.73 11 47.5 11S67 19.73 67 30.5v19Z"/>
<path
android:fillColor="#FFD9E0EE"
android:strokeColor="#FF929AAC"
android:strokeWidth="2"
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<group>
<clip-path
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<path
android:fillColor="#FFC0CBE2"
android:pathData="M67.55 42.5A11 11 0 0 1 73 52v20c0 6.08-4.93 11-11 11H34a11 11 0 0 1-7.33-2.8 8 8 0 0 1 6.4-3.2h26.48a8 8 0 0 0 8-8V42.5Z"/>
<path
android:fillColor="#FFEAEEF6"
android:pathData="M42.92 41C41.23 51.66 33.31 60.25 23 62.9V52c0-6.08 4.92-11 11-11h8.92Z"/>
</group>
</vector>
@@ -0,0 +1,42 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:strokeColor="#FFA1B2D7"
android:strokeWidth="2"
android:pathData="M47.5 15a15.5 15.5 0 0 1 15.5 15.5v19a15.5 15.5 0 0 1-15.5 15.5 15.5 15.5 0 0 1-15.5-15.5v-19A15.5 15.5 0 0 1 47.5 15Z"/>
<path
android:strokeColor="#FFC5D0E8"
android:strokeWidth="2"
android:pathData="M47.5 13a17.5 17.5 0 0 1 17.5 17.5v19a17.5 17.5 0 0 1-17.5 17.5 17.5 17.5 0 0 1-17.5-17.5v-19A17.5 17.5 0 0 1 47.5 13Z"/>
<path
android:strokeColor="#FF8C90CB"
android:strokeWidth="2"
android:pathData="M61 30.5C61 23.04 54.96 17 47.5 17S34 23.04 34 30.5v19C34 56.96 40.04 63 47.5 63S61 56.96 61 49.5v-19Zm6 19C67 60.27 58.27 69 47.5 69S28 60.27 28 49.5v-19C28 19.73 36.73 11 47.5 11S67 19.73 67 30.5v19Z"/>
<path
android:fillColor="#FFD9E0EE"
android:strokeColor="#FF929AAC"
android:strokeWidth="2"
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<group>
<clip-path
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<path
android:fillColor="#FFC0CBE2"
android:pathData="M67.55 42.5A11 11 0 0 1 73 52v20c0 6.08-4.93 11-11 11H34a11 11 0 0 1-7.33-2.8 8 8 0 0 1 6.4-3.2h26.48a8 8 0 0 0 8-8V42.5Z"/>
<path
android:fillColor="#FFEAEEF6"
android:pathData="M42.92 41C41.23 51.66 33.31 60.25 23 62.9V52c0-6.08 4.92-11 11-11h8.92Z"/>
</group>
<path
android:fillColor="#FFA3B881"
android:strokeColor="#FF618427"
android:strokeWidth="2.5"
android:pathData="M71 59.25a14.75 14.75 0 1 1 0 29.5 14.75 14.75 0 0 1 0-29.5Z"/>
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M77.32 67.64a1.24 1.24 0 0 1 0.4 1.71l-7.09 11.33c-0.21 0.34-0.58 0.56-0.98 0.58a1.24 1.24 0 0 1-1.04-0.46l-4.25-5.31a1.24 1.24 0 1 1 1.94-1.55l3.16 3.95 6.15-9.85a1.24 1.24 0 0 1 1.71-0.4Z"/>
</vector>
@@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:strokeColor="#FFA1B2D7"
android:strokeWidth="2"
android:pathData="M47.5 15a15.5 15.5 0 0 1 15.5 15.5v19a15.5 15.5 0 0 1-15.5 15.5 15.5 15.5 0 0 1-15.5-15.5v-19A15.5 15.5 0 0 1 47.5 15Z"/>
<path
android:strokeColor="#FFC5D0E8"
android:strokeWidth="2"
android:pathData="M47.5 13a17.5 17.5 0 0 1 17.5 17.5v19a17.5 17.5 0 0 1-17.5 17.5 17.5 17.5 0 0 1-17.5-17.5v-19A17.5 17.5 0 0 1 47.5 13Z"/>
<path
android:strokeColor="#FF697EAD"
android:strokeWidth="2"
android:pathData="M61 30.5C61 23.04 54.96 17 47.5 17S34 23.04 34 30.5v19C34 56.96 40.04 63 47.5 63S61 56.96 61 49.5v-19Zm6 19C67 60.27 58.27 69 47.5 69S28 60.27 28 49.5v-19C28 19.73 36.73 11 47.5 11S67 19.73 67 30.5v19Z"/>
<path
android:fillColor="#FFD9E0EE"
android:strokeColor="#FF61739A"
android:strokeWidth="2"
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<group>
<clip-path
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<path
android:fillColor="#FFC0CBE2"
android:pathData="M67.55 42.5A11 11 0 0 1 73 52v20c0 6.08-4.93 11-11 11H34a11 11 0 0 1-7.33-2.8 8 8 0 0 1 6.4-3.2h26.48a8 8 0 0 0 8-8V42.5Z"/>
<path
android:fillColor="#FFEAEEF6"
android:pathData="M42.92 41C41.23 51.66 33.31 60.25 23 62.9V52c0-6.08 4.92-11 11-11h8.92Z"/>
</group>
</vector>
@@ -0,0 +1,42 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:strokeColor="#FFA1B2D7"
android:strokeWidth="2"
android:pathData="M47.5 15a15.5 15.5 0 0 1 15.5 15.5v19a15.5 15.5 0 0 1-15.5 15.5 15.5 15.5 0 0 1-15.5-15.5v-19A15.5 15.5 0 0 1 47.5 15Z"/>
<path
android:strokeColor="#FFC5D0E8"
android:strokeWidth="2"
android:pathData="M47.5 13a17.5 17.5 0 0 1 17.5 17.5v19a17.5 17.5 0 0 1-17.5 17.5 17.5 17.5 0 0 1-17.5-17.5v-19A17.5 17.5 0 0 1 47.5 13Z"/>
<path
android:strokeColor="#FF697EAD"
android:strokeWidth="2"
android:pathData="M61 30.5C61 23.04 54.96 17 47.5 17S34 23.04 34 30.5v19C34 56.96 40.04 63 47.5 63S61 56.96 61 49.5v-19Zm6 19C67 60.27 58.27 69 47.5 69S28 60.27 28 49.5v-19C28 19.73 36.73 11 47.5 11S67 19.73 67 30.5v19Z"/>
<path
android:fillColor="#FFD9E0EE"
android:strokeColor="#FF61739A"
android:strokeWidth="2"
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<group>
<clip-path
android:pathData="M34 40h28a12 12 0 0 1 12 12v20a12 12 0 0 1-12 12H34a12 12 0 0 1-12-12V52a12 12 0 0 1 12-12Z"/>
<path
android:fillColor="#FFC0CBE2"
android:pathData="M67.55 42.5A11 11 0 0 1 73 52v20c0 6.08-4.93 11-11 11H34a11 11 0 0 1-7.33-2.8 8 8 0 0 1 6.4-3.2h26.48a8 8 0 0 0 8-8V42.5Z"/>
<path
android:fillColor="#FFEAEEF6"
android:pathData="M42.92 41C41.23 51.66 33.31 60.25 23 62.9V52c0-6.08 4.92-11 11-11h8.92Z"/>
</group>
<path
android:fillColor="#FFA3B881"
android:strokeColor="#FF618427"
android:strokeWidth="2.5"
android:pathData="M71 59.25a14.75 14.75 0 1 1 0 29.5 14.75 14.75 0 0 1 0-29.5Z"/>
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M77.32 67.64a1.24 1.24 0 0 1 0.4 1.71l-7.09 11.33c-0.21 0.34-0.58 0.56-0.98 0.58a1.24 1.24 0 0 1-1.04-0.46l-4.25-5.31a1.24 1.24 0 1 1 1.94-1.55l3.16 3.95 6.15-9.85a1.24 1.24 0 0 1 1.71-0.4Z"/>
</vector>
@@ -670,6 +670,26 @@
<!-- Error shown when the Signal Login could not be stored in the password manager. -->
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_saved">Your Signal Login could not be saved. Please save it manually instead.</string>
<!-- Save Signal Login screen -->
<!-- Title of the screen that spells out the Signal Login so the user can record it themselves. -->
<string name="SignalLoginViewDetailsForManualSaveScreen__save_your_signal_login">Save your Signal Login</string>
<!-- Description telling the user to keep their Signal Login somewhere safe and private. -->
<string name="SignalLoginViewDetailsForManualSaveScreen__store_your_signal_login_somewhere_safe">Store your Signal Login somewhere safe and secure. Never share your login info with anyone.</string>
<!-- Action button that saves the Signal Login to a PDF file. -->
<string name="SignalLoginViewDetailsForManualSaveScreen__save_as_pdf">Save as PDF</string>
<!-- Action button for users who are done recording their Signal Login. -->
<string name="SignalLoginViewDetailsForManualSaveScreen__continue">Continue</string>
<!-- Sheet that double-checks the user really saved their Signal Login -->
<!-- Title of the sheet asking the user to confirm they saved their Signal Login. -->
<string name="ConfirmLoginSavedBottomSheet__confirm_your_login_info_is_saved">Confirm your login info is saved</string>
<!-- Description warning the user that they are about to be asked for their Signal Login and that it cannot be recovered. -->
<string name="ConfirmLoginSavedBottomSheet__you_will_be_prompted_to_sign_in">You will be prompted to sign in with your Signal Login next. Make sure you\'ve saved or copied these details. Signal will not be able to help you recover your account.</string>
<!-- Action button confirming the Signal Login is saved. -->
<string name="ConfirmLoginSavedBottomSheet__continue">Continue</string>
<!-- Action button that sends the user back to their Signal Login. -->
<string name="ConfirmLoginSavedBottomSheet__show_login_info_again">Show login info again</string>
<!-- Errors shown beneath an account ID text field, wherever one is offered -->
<!-- Error shown when too many characters were entered in an account ID field. First placeholder is how many were entered, second is the maximum. -->
<string name="AccountIdField__too_long">Too long. %1$d/%2$d characters.</string>
@@ -695,6 +715,12 @@
<string name="SignalLoginCredentialEntryScreen__next">Next</string>
<!-- Error shown when the service rejects the entered login, which means one of the two halves is wrong. -->
<string name="SignalLoginCredentialEntryScreen__incorrect_account_id_or_recovery_key">Incorrect account ID or recovery key</string>
<!-- Description shown instead when the user is typing back the Signal Login they were just given, to check they recorded it correctly. -->
<string name="SignalLoginCredentialEntryScreen__enter_your_signal_login_to_confirm_it_was_recorded_correctly">Enter your Signal Login to confirm it was recorded correctly.</string>
<!-- Button that takes the user back to their Signal Login if they can't remember what they recorded. -->
<string name="SignalLoginCredentialEntryScreen__show_login_info_again">Show login info again</string>
<!-- Error shown when what the user typed back doesn't match the Signal Login they were given. -->
<string name="SignalLoginCredentialEntryScreen__that_doesnt_match_the_signal_login_you_were_shown">That doesn\'t match the Signal Login you were shown</string>
<!-- Add username screen -->
<!-- Title of the screen offering the user an optional username. -->
@@ -155,6 +155,16 @@ class SignalLoginCredentialEntryScreenTest {
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.NeedHelpClicked)
}
@Test
fun `in confirm-saved mode, Show login info again takes the place of Need help`() {
setContent(SignalLoginCredentialEntryState(mode = SignalLoginCredentialEntryState.Mode.ConfirmSaved))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEED_HELP_BUTTON).assertDoesNotExist()
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_SHOW_LOGIN_INFO_AGAIN_BUTTON).performClick()
assertThat(events).contains(SignalLoginCredentialEntryScreenEvents.ShowLoginInfoAgainClicked)
}
@Test
fun `when Next is clicked with a complete login, NextClicked is emitted`() {
setContent(completeState())
@@ -0,0 +1,155 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogincredentials
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.aepentry.AepInput
import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
class SignalLoginManualSaveConfirmationViewModelTest {
companion object {
private val ACI_VALUE = ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91"))
private const val RAW_ACCOUNT_ID = "a6b284822e3283d07f2391360a4c2b91"
}
private val testDispatcher = UnconfinedTestDispatcher()
private val aep = AccountEntropyPool.generate()
private val parentState = RegistrationFlowState(aci = ACI_VALUE, accountEntropyPool = aep)
private lateinit var viewModel: SignalLoginManualSaveConfirmationViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
viewModel = SignalLoginManualSaveConfirmationViewModel(
parentState = MutableStateFlow(parentState),
parentEventEmitter = {}
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the screen starts in confirm-saved mode`() {
assertThat(viewModel.state.value.mode).isEqualTo(SignalLoginCredentialEntryState.Mode.ConfirmSaved)
}
@Test
fun `NextClicked with the login the user was shown advances to the add username screen`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(matchingState(), SignalLoginCredentialEntryScreenEvents.NextClicked, parentState, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.AddUsername))
}
@Test
fun `NextClicked with a mistyped recovery key flags both fields and stays put`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
var emittedState: SignalLoginCredentialEntryState? = null
val state = matchingState().copy(recoveryKey = AepInput.from(AccountEntropyPool.generate().displayValue))
viewModel.applyEvent(state, SignalLoginCredentialEntryScreenEvents.NextClicked, parentState, { parentEvents.add(it) }) { emittedState = it }
assertThat(emittedState?.areCredentialsIncorrect).isEqualTo(true)
assertThat(parentEvents).isEmpty()
}
@Test
fun `NextClicked with a mistyped account ID flags both fields and stays put`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
var emittedState: SignalLoginCredentialEntryState? = null
val state = matchingState().copy(accountId = "a6b284822e3283d07f2391360a4c2b92")
viewModel.applyEvent(state, SignalLoginCredentialEntryScreenEvents.NextClicked, parentState, { parentEvents.add(it) }) { emittedState = it }
assertThat(emittedState?.areCredentialsIncorrect).isEqualTo(true)
assertThat(parentEvents).isEmpty()
}
@Test
fun `NextClicked with no login in the flow state lets the user through rather than trapping them`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(matchingState(), SignalLoginCredentialEntryScreenEvents.NextClicked, RegistrationFlowState(), { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.AddUsername))
}
@Test
fun `ShowLoginInfoAgainClicked navigates back to the keys`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(matchingState(), SignalLoginCredentialEntryScreenEvents.ShowLoginInfoAgainClicked, parentState, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `BackClicked navigates back`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(matchingState(), SignalLoginCredentialEntryScreenEvents.BackClicked, parentState, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `a password manager credential that matches confirms straight away`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(
SignalLoginCredentialEntryState(mode = SignalLoginCredentialEntryState.Mode.ConfirmSaved),
SignalLoginCredentialEntryScreenEvents.PasswordManagerCredentialSelected(accountId = RAW_ACCOUNT_ID, recoveryKey = aep.displayValue),
parentState,
{ parentEvents.add(it) }
) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.AddUsername))
}
@Test
fun `AccountIdChanged clears the mismatch flag`() = runTest(testDispatcher) {
var emittedState: SignalLoginCredentialEntryState? = null
val state = matchingState().copy(areCredentialsIncorrect = true)
viewModel.applyEvent(state, SignalLoginCredentialEntryScreenEvents.AccountIdChanged(RAW_ACCOUNT_ID), parentState, {}) { emittedState = it }
assertThat(emittedState?.areCredentialsIncorrect).isEqualTo(false)
}
private fun matchingState(): SignalLoginCredentialEntryState {
return SignalLoginCredentialEntryState(
mode = SignalLoginCredentialEntryState.Mode.ConfirmSaved,
accountId = RAW_ACCOUNT_ID,
recoveryKey = AepInput.from(aep.displayValue)
)
}
}
@@ -46,7 +46,7 @@ class SignalLoginViewDetailsScreenTest {
fun `when the account key copy button is clicked, CopyAccountIdClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_COPY_BUTTON).performScrollTo().performClick()
composeTestRule.onNodeWithTag(SignalLoginTestTags.KEY_DETAILS_ACCOUNT_ID_COPY_BUTTON).performScrollTo().performClick()
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(ACCOUNT_KEY))
}
@@ -55,7 +55,7 @@ class SignalLoginViewDetailsScreenTest {
fun `when the recovery key copy button is clicked, CopyRecoveryKeyClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_COPY_BUTTON).performScrollTo().performClick()
composeTestRule.onNodeWithTag(SignalLoginTestTags.KEY_DETAILS_RECOVERY_KEY_COPY_BUTTON).performScrollTo().performClick()
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(RECOVERY_KEY))
}
@@ -89,11 +89,11 @@ class SignalLoginInfoViewModelTest {
}
@Test
fun `SaveManuallyClicked advances to the add username screen`() = runTest(testDispatcher) {
fun `SaveManuallyClicked advances to the save your login screen`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(SignalLoginInfoState(), SignalLoginInfoScreenEvents.SaveManuallyClicked, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.AddUsername))
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.SignalLoginViewDetailsForManualSave))
}
}
@@ -0,0 +1,111 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import android.app.Application
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
import org.signal.signallogin.SignalLoginTestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SignalLoginViewDetailsForManualSaveScreenTest {
companion object {
private const val ACCOUNT_ID = "A6B28482-2E32-83D0-7F23-91360A4C2B91"
private const val RECOVERY_KEY = "UY38JH2778HJJHJ8LK19GA61S672JSJ089R023S6A57809BAP92J2YH5T326VV7T"
}
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<SignalLoginViewDetailsForManualSaveScreenEvents>()
@Test
fun `when the account ID copy button is clicked, CopyAccountIdClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.KEY_DETAILS_ACCOUNT_ID_COPY_BUTTON).performScrollTo().performClick()
assertThat(events).contains(SignalLoginViewDetailsForManualSaveScreenEvents.CopyAccountIdClicked(ACCOUNT_ID))
}
@Test
fun `when the recovery key copy button is clicked, CopyRecoveryKeyClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.KEY_DETAILS_RECOVERY_KEY_COPY_BUTTON).performScrollTo().performClick()
assertThat(events).contains(SignalLoginViewDetailsForManualSaveScreenEvents.CopyRecoveryKeyClicked(RECOVERY_KEY))
}
@Test
fun `when save as PDF is clicked, SaveAsPdfClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_SAVE_AS_PDF_BUTTON).performClick()
assertThat(events).contains(SignalLoginViewDetailsForManualSaveScreenEvents.SaveAsPdfClicked)
}
@Test
fun `when continue is clicked, ContinueClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON).performClick()
assertThat(events).contains(SignalLoginViewDetailsForManualSaveScreenEvents.ContinueClicked)
}
@Test
fun `when continue on the confirm sheet is clicked, ConfirmSavedContinueClicked is emitted`() {
setContent(showConfirmSavedSheet = true)
composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_CONTINUE_BUTTON).performClick()
assertThat(events).contains(SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedContinueClicked)
}
@Test
fun `when show login info again on the confirm sheet is clicked, ShowLoginInfoAgainClicked is emitted`() {
setContent(showConfirmSavedSheet = true)
composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_SHOW_LOGIN_INFO_AGAIN_BUTTON).performClick()
assertThat(events).contains(SignalLoginViewDetailsForManualSaveScreenEvents.ShowLoginInfoAgainClicked)
}
private fun setContent(showConfirmSavedSheet: Boolean = false) {
composeTestRule.setContent {
SignalTheme {
SignalLoginViewDetailsForManualSaveScreen(
state = SignalLoginViewDetailsForManualSaveState(
accountId = ACCOUNT_ID,
recoveryKey = RECOVERY_KEY,
showConfirmSavedSheet = showConfirmSavedSheet
),
onEvent = { events += it }
)
}
}
}
}
@@ -0,0 +1,162 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginmanualsave
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
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
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRoute
import org.signal.signallogin.RecoveryKeyGroups
import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
class SignalLoginViewDetailsForManualSaveViewModelTest {
companion object {
private val ACI_VALUE = ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91"))
}
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: SignalLoginViewDetailsForManualSaveViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
viewModel = SignalLoginViewDetailsForManualSaveViewModel(
parentState = MutableStateFlow(RegistrationFlowState()),
parentEventEmitter = {}
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `ParentStateChanged maps the credentials into display form`() = runTest(testDispatcher) {
val aep = AccountEntropyPool.generate()
var emittedState: SignalLoginViewDetailsForManualSaveState? = null
viewModel.applyEvent(
SignalLoginViewDetailsForManualSaveState(),
SignalLoginViewDetailsForManualSaveScreenEvents.ParentStateChanged(RegistrationFlowState(aci = ACI_VALUE, accountEntropyPool = aep)),
{}
) { emittedState = it }
assertThat(emittedState?.accountId).isEqualTo("A6B28482-2E32-83D0-7F23-91360A4C2B91")
assertThat(emittedState?.recoveryKey).isEqualTo(aep.displayValue)
assertThat(emittedState?.recoveryKeyGroups?.groups?.size).isEqualTo(AccountEntropyPool.LENGTH / RecoveryKeyGroups.GROUP_SIZE)
}
@Test
fun `BackClicked navigates back`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(SignalLoginViewDetailsForManualSaveState(), SignalLoginViewDetailsForManualSaveScreenEvents.BackClicked, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) {
val actions = mutableListOf<SignalLoginViewDetailsForManualSaveScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.SaveAsPdfClicked)
assertThat(actions).containsExactly(SignalLoginViewDetailsForManualSaveScreenActions.LaunchSaveAsPdf)
}
@Test
fun `CopyAccountIdClicked copies the account ID to the clipboard`() = runTest(testDispatcher) {
val actions = mutableListOf<SignalLoginViewDetailsForManualSaveScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.CopyAccountIdClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
assertThat(actions).containsExactly(SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
}
@Test
fun `CopyRecoveryKeyClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) {
val recoveryKey = AccountEntropyPool.generate().displayValue
val actions = mutableListOf<SignalLoginViewDetailsForManualSaveScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsForManualSaveScreenEvents.CopyRecoveryKeyClicked(recoveryKey))
assertThat(actions).containsExactly(SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard(recoveryKey))
}
@Test
fun `ContinueClicked raises the confirm-you-saved-it sheet without navigating`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
var emittedState: SignalLoginViewDetailsForManualSaveState? = null
viewModel.applyEvent(SignalLoginViewDetailsForManualSaveState(), SignalLoginViewDetailsForManualSaveScreenEvents.ContinueClicked, { parentEvents.add(it) }) { emittedState = it }
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(true)
assertThat(parentEvents).isEmpty()
}
@Test
fun `ConfirmSavedContinueClicked drops the sheet and moves on to confirming the login`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
var emittedState: SignalLoginViewDetailsForManualSaveState? = null
viewModel.applyEvent(
SignalLoginViewDetailsForManualSaveState(showConfirmSavedSheet = true),
SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedContinueClicked,
{ parentEvents.add(it) }
) { emittedState = it }
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(false)
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.SignalLoginManualSaveConfirmation))
}
@Test
fun `ShowLoginInfoAgainClicked drops the sheet and stays put`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
var emittedState: SignalLoginViewDetailsForManualSaveState? = null
viewModel.applyEvent(
SignalLoginViewDetailsForManualSaveState(showConfirmSavedSheet = true),
SignalLoginViewDetailsForManualSaveScreenEvents.ShowLoginInfoAgainClicked,
{ parentEvents.add(it) }
) { emittedState = it }
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(false)
assertThat(parentEvents).isEmpty()
}
@Test
fun `ConfirmSavedSheetDismissed drops the sheet`() = runTest(testDispatcher) {
var emittedState: SignalLoginViewDetailsForManualSaveState? = null
viewModel.applyEvent(SignalLoginViewDetailsForManualSaveState(showConfirmSavedSheet = true), SignalLoginViewDetailsForManualSaveScreenEvents.ConfirmSavedSheetDismissed, {}) { emittedState = it }
assertThat(emittedState?.showConfirmSavedSheet).isEqualTo(false)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.signallogin
/**
* A recovery key split into the fixed-size character groups it is always shown in, along with the ways those groups get
* turned back into a string.
*/
@JvmInline
value class RecoveryKeyGroups private constructor(val groups: List<String>) {
companion object {
/** How many characters make up a single displayed group of a recovery key. */
const val GROUP_SIZE = 4
fun from(recoveryKey: String): RecoveryKeyGroups = RecoveryKeyGroups(recoveryKey.chunked(GROUP_SIZE))
}
/** The key with no separators, which is the form everything outside of the UI expects. */
val flat: String
get() = groups.joinToString(separator = "")
/** The key as one space-separated string, for when the groups have to wrap as ordinary text. */
val spaced: String
get() = groups.joinToString(separator = " ")
/** The groups laid out [perRow] at a time. */
fun rows(perRow: Int): List<List<String>> = groups.chunked(perRow)
}
@@ -14,8 +14,9 @@ object SignalLoginTestTags {
const val VIEW_DETAILS_SCREEN = "signal_login_view_details_screen"
const val VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON = "signal_login_view_details_save_to_password_manager_button"
const val VIEW_DETAILS_SAVE_AS_PDF_BUTTON = "signal_login_view_details_save_as_pdf_button"
const val VIEW_DETAILS_ACCOUNT_KEY_BLOCK = "signal_login_view_details_account_key_block"
const val VIEW_DETAILS_RECOVERY_KEY_BLOCK = "signal_login_view_details_recovery_key_block"
const val VIEW_DETAILS_ACCOUNT_KEY_COPY_BUTTON = "signal_login_view_details_account_key_copy_button"
const val VIEW_DETAILS_RECOVERY_KEY_COPY_BUTTON = "signal_login_view_details_recovery_key_copy_button"
const val KEY_DETAILS_ACCOUNT_ID_BLOCK = "signal_login_key_details_account_id_block"
const val KEY_DETAILS_RECOVERY_KEY_BLOCK = "signal_login_key_details_recovery_key_block"
const val KEY_DETAILS_ACCOUNT_ID_COPY_BUTTON = "signal_login_key_details_account_id_copy_button"
const val KEY_DETAILS_RECOVERY_KEY_COPY_BUTTON = "signal_login_key_details_recovery_key_copy_button"
}
@@ -0,0 +1,234 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.signallogin.details
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.signallogin.R
import org.signal.signallogin.RecoveryKeyGroups
import org.signal.signallogin.SignalLoginTestTags
import org.signal.signallogin.fonts.MonoTypeface
private const val GROUPS_PER_ROW = 4
/** The least amount of space allowed between recovery key groups before falling back to natural text wrapping. */
private val MIN_GROUP_SPACING = 12.dp
private val KEY_BLOCK_TEXT_PADDING_HORIZONTAL = 28.dp
private val KEY_BLOCK_TEXT_PADDING_VERTICAL = 20.dp
/** Insets that center the copy button's icon on the first line of key text and put it 16dp from the block's end edge, accounting for the button's own 12dp of internal padding. */
private val COPY_BUTTON_PADDING_TOP = 10.dp
private val COPY_BUTTON_PADDING_END = 4.dp
/**
* Both halves of a Signal Login spelled out in full -- the account ID and the recovery key -- each under a section
* header and each with a button that copies it to the clipboard.
*
* Shared by every screen that shows the user their keys, so the screens themselves only supply the surrounding
* chrome: headers, footers and whatever buttons that particular step of the flow needs.
*/
@Composable
fun SignalLoginKeyDetails(
accountId: String,
recoveryKeyGroups: RecoveryKeyGroups,
onCopyAccountId: (String) -> Unit,
onCopyRecoveryKey: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Texts.SectionHeader(text = stringResource(R.string.SignalLoginKeyDetails__account_id))
AccountIdBlock(
text = accountId,
onCopyAccountId = onCopyAccountId,
modifier = Modifier.testTag(SignalLoginTestTags.KEY_DETAILS_ACCOUNT_ID_BLOCK)
)
Texts.SectionHeader(text = stringResource(R.string.SignalLoginKeyDetails__recovery_key))
RecoveryKeyBlock(
groups = recoveryKeyGroups,
onCopyRecoveryKey = onCopyRecoveryKey,
modifier = Modifier.testTag(SignalLoginTestTags.KEY_DETAILS_RECOVERY_KEY_BLOCK)
)
}
}
/**
* A full credential rendered in the special monospace font on a rounded surface.
*/
@Composable
private fun AccountIdBlock(
text: String,
onCopyAccountId: (String) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier.keyBlockSurface()
) {
Text(
text = text,
style = keyTextStyle(),
modifier = Modifier
.weight(1f)
.padding(start = KEY_BLOCK_TEXT_PADDING_HORIZONTAL, top = KEY_BLOCK_TEXT_PADDING_VERTICAL, bottom = KEY_BLOCK_TEXT_PADDING_VERTICAL)
)
CopyButton(
contentDescription = stringResource(R.string.SignalLoginKeyDetails__copy_account_id),
onClick = { onCopyAccountId(text) },
modifier = Modifier.testTag(SignalLoginTestTags.KEY_DETAILS_ACCOUNT_ID_COPY_BUTTON)
)
}
}
/**
* The recovery key rendered as character groups. When four groups fit per row with at least
* [MIN_GROUP_SPACING] between them, renders rows of four groups evenly spaced across the full
* width. Otherwise renders the whole key as a single space-separated string that wraps naturally.
*/
@Composable
private fun RecoveryKeyBlock(
groups: RecoveryKeyGroups,
onCopyRecoveryKey: (String) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier.keyBlockSurface()
) {
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.padding(start = KEY_BLOCK_TEXT_PADDING_HORIZONTAL, top = KEY_BLOCK_TEXT_PADDING_VERTICAL, bottom = KEY_BLOCK_TEXT_PADDING_VERTICAL)
) {
val style = keyTextStyle()
val textMeasurer = rememberTextMeasurer()
val maxWidth = constraints.maxWidth
val groupWidth = remember(groups, style) {
groups.groups.maxOfOrNull { group -> textMeasurer.measure(text = group, style = style).size.width } ?: 0
}
val minSpacing = with(LocalDensity.current) { MIN_GROUP_SPACING.roundToPx() }
val fitsFourPerRow = groupWidth * GROUPS_PER_ROW + minSpacing * (GROUPS_PER_ROW - 1) <= maxWidth
if (fitsFourPerRow) {
val spacing = with(LocalDensity.current) { ((maxWidth - groupWidth * GROUPS_PER_ROW) / (GROUPS_PER_ROW - 1)).toDp() }
Column {
groups.rows(GROUPS_PER_ROW).forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = Modifier.fillMaxWidth()
) {
row.forEach { group ->
Text(
text = group,
style = style
)
}
}
}
}
} else {
Text(
text = groups.spaced,
style = style
)
}
}
CopyButton(
contentDescription = stringResource(R.string.SignalLoginKeyDetails__copy_recovery_key),
onClick = { onCopyRecoveryKey(groups.flat) },
modifier = Modifier.testTag(SignalLoginTestTags.KEY_DETAILS_RECOVERY_KEY_COPY_BUTTON)
)
}
}
/**
* The button in the top-end corner of a key block that copies the key to the clipboard.
*/
@Composable
private fun CopyButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier.padding(top = COPY_BUTTON_PADDING_TOP, end = COPY_BUTTON_PADDING_END)
) {
Icon(
painter = SignalIcons.Copy.painter,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun Modifier.keyBlockSurface(): Modifier {
return this
.horizontalGutters()
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SignalTheme.colors.colorSurface2)
}
@Composable
private fun keyTextStyle(): TextStyle {
return MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
fontSize = 18.sp,
lineHeight = 28.sp,
letterSpacing = 1.44.sp
)
}
@DayNightPreviews
@Composable
private fun SignalLoginKeyDetailsPreview() {
Previews.Preview {
SignalLoginKeyDetails(
accountId = "A6B28482-2E32-83D0-7F23-91360A4C2B91",
recoveryKeyGroups = RecoveryKeyGroups.from("UY38JH2778HJJHJ8LK19GA61S672JSJ=89R=23S6A578=9BAP92J2YH5T326VV7T"),
onCopyAccountId = {},
onCopyRecoveryKey = {}
)
}
}
@@ -23,8 +23,8 @@ 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.RecoveryKeyGroups
import org.signal.signallogin.fonts.MonoTypeface
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState
import java.io.ByteArrayOutputStream
import java.io.IOException
import kotlin.math.ceil
@@ -90,12 +90,12 @@ object SignalLoginPdfRenderer {
private const val BODY_TEXT_COLOR = 0xFF4D4D4D.toInt()
/**
* Renders the credentials in [state] to a PDF and writes it to [uri].
* Renders the given credentials to a PDF and writes it to [uri].
*/
suspend fun renderTo(context: Context, uri: Uri, state: SignalLoginViewDetailsState): Result<Unit, SignalLoginPdfError> {
suspend fun renderTo(context: Context, uri: Uri, accountId: String, recoveryKeyGroups: RecoveryKeyGroups): Result<Unit, SignalLoginPdfError> {
return withContext(Dispatchers.IO) {
try {
val bytes = render(context, state)
val bytes = render(context, accountId, recoveryKeyGroups)
val stream = context.contentResolver.openOutputStream(uri)
if (stream == null) {
Log.w(TAG, "Could not open an output stream for the chosen location.")
@@ -114,11 +114,11 @@ object SignalLoginPdfRenderer {
}
}
fun render(context: Context, state: SignalLoginViewDetailsState): ByteArray {
fun render(context: Context, accountId: String, recoveryKeyGroups: RecoveryKeyGroups): ByteArray {
val document = PdfDocument()
try {
val page = document.startPage(PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create())
drawPage(context, page.canvas, state)
drawPage(context, page.canvas, accountId, recoveryKeyGroups)
document.finishPage(page)
return ByteArrayOutputStream().use { stream ->
@@ -130,7 +130,7 @@ object SignalLoginPdfRenderer {
}
}
private fun drawPage(context: Context, canvas: Canvas, state: SignalLoginViewDetailsState) {
private fun drawPage(context: Context, canvas: Canvas, accountId: String, recoveryKeyGroups: RecoveryKeyGroups) {
val titlePaint = textPaint(TITLE_TEXT_SIZE, TITLE_LETTER_SPACING_EM, TEXT_COLOR, semiBold = true)
val bodyPaint = textPaint(BODY_TEXT_SIZE, BODY_LETTER_SPACING_EM, BODY_TEXT_COLOR, semiBold = false)
val headerPaint = textPaint(HEADER_TEXT_SIZE, HEADER_LETTER_SPACING_EM, TEXT_COLOR, semiBold = true)
@@ -168,7 +168,7 @@ object SignalLoginPdfRenderer {
y = drawKeyBlock(canvas, top = y, rowCount = 1, lineHeight = ACCOUNT_KEY_LINE_HEIGHT) { contentTop ->
drawText(
canvas = canvas,
text = state.accountKey,
text = accountId,
paint = keyPaint,
left = CONTENT_LEFT,
top = contentTop,
@@ -178,7 +178,7 @@ object SignalLoginPdfRenderer {
)
}
val rows = state.recoveryKeyGroups.chunked(GROUPS_PER_ROW)
val rows = recoveryKeyGroups.rows(GROUPS_PER_ROW)
y = drawSectionHeader(canvas, headerPaint, context.getString(R.string.SignalLoginPdf__recovery_key), top = y)
drawKeyBlock(canvas, top = y, rowCount = rows.size, lineHeight = RECOVERY_KEY_LINE_HEIGHT) { contentTop ->
drawRecoveryKeyGroups(canvas, keyPaint, rows, contentTop)
@@ -6,11 +6,8 @@
package org.signal.signallogin.viewdetails
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,36 +19,25 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.signallogin.R
import org.signal.signallogin.SignalLoginTestTags
import org.signal.signallogin.fonts.MonoTypeface
import org.signal.signallogin.details.SignalLoginKeyDetails
/** Size of the miniature credential card artwork shown at the top of the screen, from the design. */
private val MINI_CARD_WIDTH = 175.dp
@@ -62,18 +48,6 @@ private val MINI_CARD_CORNER_RADIUS = 13.dp
private val BUTTON_MAX_WIDTH = 331.dp
private const val GROUPS_PER_ROW = 4
/** The least amount of space allowed between recovery key groups before falling back to natural text wrapping. */
private val MIN_GROUP_SPACING = 12.dp
private val KEY_BLOCK_TEXT_PADDING_HORIZONTAL = 28.dp
private val KEY_BLOCK_TEXT_PADDING_VERTICAL = 20.dp
/** Insets that center the copy button's icon on the first line of key text and put it 16dp from the block's end edge, accounting for the button's own 12dp of internal padding. */
private val COPY_BUTTON_PADDING_TOP = 10.dp
private val COPY_BUTTON_PADDING_END = 4.dp
/**
* Shows the user the full keys that make up their Signal Login and offers ways to save them.
*/
@@ -109,20 +83,11 @@ fun SignalLoginViewDetailsScreen(
Spacer(modifier = Modifier.height(16.dp))
Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__account_id))
AccountIdBlock(
text = state.accountKey,
onEvent = onEvent,
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_BLOCK)
)
Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__recovery_key))
RecoveryKeyBlock(
groups = state.recoveryKeyGroups,
onEvent = onEvent,
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_BLOCK)
SignalLoginKeyDetails(
accountId = state.accountKey,
recoveryKeyGroups = state.recoveryKeyGroups,
onCopyAccountId = { onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(it)) },
onCopyRecoveryKey = { onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(it)) }
)
}
@@ -146,140 +111,6 @@ private fun MiniCard(modifier: Modifier = Modifier) {
)
}
/**
* A full credential rendered in the special monospace font on a rounded surface.
*/
@Composable
private fun AccountIdBlock(
text: String,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier.keyBlockSurface()
) {
Text(
text = text,
style = keyTextStyle(),
modifier = Modifier
.weight(1f)
.padding(start = KEY_BLOCK_TEXT_PADDING_HORIZONTAL, top = KEY_BLOCK_TEXT_PADDING_VERTICAL, bottom = KEY_BLOCK_TEXT_PADDING_VERTICAL)
)
CopyButton(
contentDescription = stringResource(R.string.SignalLoginViewDetailsScreen__copy_account_id),
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(text)) },
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_COPY_BUTTON)
)
}
}
/**
* The recovery key rendered as character groups. When four groups fit per row with at least
* [MIN_GROUP_SPACING] between them, renders rows of four groups evenly spaced across the full
* width. Otherwise renders the whole key as a single space-separated string that wraps naturally.
*/
@Composable
private fun RecoveryKeyBlock(
groups: List<String>,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier.keyBlockSurface()
) {
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.padding(start = KEY_BLOCK_TEXT_PADDING_HORIZONTAL, top = KEY_BLOCK_TEXT_PADDING_VERTICAL, bottom = KEY_BLOCK_TEXT_PADDING_VERTICAL)
) {
val style = keyTextStyle()
val textMeasurer = rememberTextMeasurer()
val maxWidth = constraints.maxWidth
val groupWidth = remember(groups, style) {
groups.maxOfOrNull { group -> textMeasurer.measure(text = group, style = style).size.width } ?: 0
}
val minSpacing = with(LocalDensity.current) { MIN_GROUP_SPACING.roundToPx() }
val fitsFourPerRow = groupWidth * GROUPS_PER_ROW + minSpacing * (GROUPS_PER_ROW - 1) <= maxWidth
if (fitsFourPerRow) {
val spacing = with(LocalDensity.current) { ((maxWidth - groupWidth * GROUPS_PER_ROW) / (GROUPS_PER_ROW - 1)).toDp() }
Column {
groups.chunked(GROUPS_PER_ROW).forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = Modifier.fillMaxWidth()
) {
row.forEach { group ->
Text(
text = group,
style = style
)
}
}
}
}
} else {
Text(
text = groups.joinToString(separator = " "),
style = style
)
}
}
CopyButton(
contentDescription = stringResource(R.string.SignalLoginViewDetailsScreen__copy_recovery_key),
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(groups.joinToString(separator = ""))) },
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_COPY_BUTTON)
)
}
}
/**
* The button in the top-end corner of a key block that copies the key to the clipboard.
*/
@Composable
private fun CopyButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier.padding(top = COPY_BUTTON_PADDING_TOP, end = COPY_BUTTON_PADDING_END)
) {
Icon(
painter = SignalIcons.Copy.painter,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun Modifier.keyBlockSurface(): Modifier {
return this
.horizontalGutters()
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SignalTheme.colors.colorSurface2)
}
@Composable
private fun keyTextStyle(): TextStyle {
return MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
fontSize = 18.sp,
lineHeight = 28.sp,
letterSpacing = 1.44.sp
)
}
@Composable
private fun Footer(onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit) {
Column(
@@ -6,6 +6,7 @@
package org.signal.signallogin.viewdetails
import org.signal.core.util.censor
import org.signal.signallogin.RecoveryKeyGroups
/**
* State for the screen that shows the user the full keys that make up their Signal Login.
@@ -14,13 +15,9 @@ data class SignalLoginViewDetailsState(
val accountKey: String = "",
val recoveryKey: String = ""
) {
companion object {
private const val GROUP_SIZE = 4
}
/** The recovery key broken into character groups, in display order. */
val recoveryKeyGroups: List<String>
get() = recoveryKey.chunked(GROUP_SIZE)
val recoveryKeyGroups: RecoveryKeyGroups
get() = RecoveryKeyGroups.from(recoveryKey)
override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()})"
}
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Rekeningsleutel</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Herwinsleutel</string>
<string name="SignalLoginKeyDetails__recovery_key">Herwinsleutel</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Stoor in wagwoordbestuurstelsel</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">مفتاح الحساب</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">مفتاح الاستعادة</string>
<string name="SignalLoginKeyDetails__recovery_key">مفتاح الاستعادة</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">حفظ في مدير كلمات المرور</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Hesab şifrəsi</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Bərpa şifrəsi</string>
<string name="SignalLoginKeyDetails__recovery_key">Bərpa şifrəsi</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Parol menecerində yaddaşda saxlayın</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ключ уліковага запісу</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Код для аднаўлення</string>
<string name="SignalLoginKeyDetails__recovery_key">Код для аднаўлення</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Захаваць у менеджары пароляў</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ключ на акаунта</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Ключ за възстановяване</string>
<string name="SignalLoginKeyDetails__recovery_key">Ключ за възстановяване</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Запазване в мениджър на пароли</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">অ্যাকাউন্ট \'কি\'</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">পুনরুদ্ধার \'কি\'</string>
<string name="SignalLoginKeyDetails__recovery_key">পুনরুদ্ধার \'কি\'</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">পাসওয়ার্ড ম্যানেজারে সেভ করুন</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ključ računa</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Ključ za oporavak</string>
<string name="SignalLoginKeyDetails__recovery_key">Ključ za oporavak</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Sačuvaj u upravitelju lozinki</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Clau del compte</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Clau de recuperació</string>
<string name="SignalLoginKeyDetails__recovery_key">Clau de recuperació</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Guardar al gestor de contrasenyes</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Klíč účtu</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Klíč pro obnovení</string>
<string name="SignalLoginKeyDetails__recovery_key">Klíč pro obnovení</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Uložit do správce hesel</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kontonøgle</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Gendannelsesnøgle</string>
<string name="SignalLoginKeyDetails__recovery_key">Gendannelsesnøgle</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Gem i adgangskodeadministrator</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kontoschlüssel</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Wiederherstellungsschlüssel</string>
<string name="SignalLoginKeyDetails__recovery_key">Wiederherstellungsschlüssel</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Im Passwortmanager speichern</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Κλειδί λογαριασμού</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Κλειδί ανάκτησης</string>
<string name="SignalLoginKeyDetails__recovery_key">Κλειδί ανάκτησης</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Αποθήκευση στον διαχειριστή κωδικών πρόσβασης</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Clave de cuenta</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Clave de recuperación</string>
<string name="SignalLoginKeyDetails__recovery_key">Clave de recuperación</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Guardar en el gestor de contraseñas</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Konto võti</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Varukoopia võti</string>
<string name="SignalLoginKeyDetails__recovery_key">Varukoopia võti</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Salvesta paroolihaldurisse</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kontuaren gakoa</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Berreskuratze-gakoa</string>
<string name="SignalLoginKeyDetails__recovery_key">Berreskuratze-gakoa</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Gorde pasahitz-kudeatzailean</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">رمز حساب</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">رمز بازیابی</string>
<string name="SignalLoginKeyDetails__recovery_key">رمز بازیابی</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">ذخیره در مدیر رمز عبور</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Tiliavain</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Palautusavain</string>
<string name="SignalLoginKeyDetails__recovery_key">Palautusavain</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Tallenna salasananhallintaan</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Clé de compte</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Clé de récupération</string>
<string name="SignalLoginKeyDetails__recovery_key">Clé de récupération</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Enregistrer dans le gestionnaire de mots de passe</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Eochair an chuntais</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Eochair athshlánaithe</string>
<string name="SignalLoginKeyDetails__recovery_key">Eochair athshlánaithe</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Sábháil chuig an mbainisteoir pasfhocal</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Clave da conta</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Clave de recuperación</string>
<string name="SignalLoginKeyDetails__recovery_key">Clave de recuperación</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Gardar no xestor de contrasinais</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">એકાઉન્ટ કી</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">રિકવરી કી</string>
<string name="SignalLoginKeyDetails__recovery_key">રિકવરી કી</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">પાસવર્ડ મેનેજરમાં સેવ કરો</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">अकाउंट की</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">\'रिकवरी की\'</string>
<string name="SignalLoginKeyDetails__recovery_key">\'रिकवरी की\'</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">पासवर्ड मैनेजर में सेव करें</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ključ računa</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Ključ za oporavak</string>
<string name="SignalLoginKeyDetails__recovery_key">Ključ za oporavak</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Spremi u upravitelju lozinki</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Fiókkulcs</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Helyreállítási kulcs</string>
<string name="SignalLoginKeyDetails__recovery_key">Helyreállítási kulcs</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Mentés a jelszókezelőbe</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kunci akun</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Kunci pemulihan</string>
<string name="SignalLoginKeyDetails__recovery_key">Kunci pemulihan</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Simpan di pengelola kata sandi</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Chiave dell\'account</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Chiave di ripristino</string>
<string name="SignalLoginKeyDetails__recovery_key">Chiave di ripristino</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Salva sul password manager</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">מפתח חשבון</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">מפתח שחזור</string>
<string name="SignalLoginKeyDetails__recovery_key">מפתח שחזור</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">שמירה במנהל הסיסמאות</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">アカウントキー</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">回復キー</string>
<string name="SignalLoginKeyDetails__recovery_key">回復キー</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">パスワードマネージャーに保存する</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">ანგარიშის გასაღები</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">აღდგენის გასაღები</string>
<string name="SignalLoginKeyDetails__recovery_key">აღდგენის გასაღები</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">პაროლების მენეჯერში შენახვა</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Аккаунт кілті</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Қалпына келтіру кілті</string>
<string name="SignalLoginKeyDetails__recovery_key">Қалпына келтіру кілті</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Құпия сөз менеджерінде сақтау</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">លេខកូដគណនី</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">សោស្តារ</string>
<string name="SignalLoginKeyDetails__recovery_key">សោស្តារ</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">រក្សាទុកក្នុងមុខងារគ្រប់គ្រងពាក្យសម្ងាត់</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">ಖಾತೆ ಕೀ</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">ರಿಕವರಿ ಕೀ</string>
<string name="SignalLoginKeyDetails__recovery_key">ರಿಕವರಿ ಕೀ</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">ಪಾಸ್‌ವರ್ಡ್ ಮ್ಯಾನೇಜರ್‌ಗೆ ಸೇವ್ ಮಾಡಿ</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">계정 키</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">복구 키</string>
<string name="SignalLoginKeyDetails__recovery_key">복구 키</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">비밀번호 관리자에 저장</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Аккаунттун ачкычы</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Калыбына келтирүү ачкычы</string>
<string name="SignalLoginKeyDetails__recovery_key">Калыбына келтирүү ачкычы</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Сырсөздөрдү башкаргычка сактоо</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Paskyros raktas</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Atkūrimo raktas</string>
<string name="SignalLoginKeyDetails__recovery_key">Atkūrimo raktas</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Išsaugoti slaptažodžių tvarkyklėje</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Konta atslēga</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Atkopšanas atslēga</string>
<string name="SignalLoginKeyDetails__recovery_key">Atkopšanas atslēga</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Saglabāt paroļu pārvaldniekā</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Клуч за корисничка сметка</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Клуч за враќање резервни копии</string>
<string name="SignalLoginKeyDetails__recovery_key">Клуч за враќање резервни копии</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Зачувајте во управувачот со лозинки</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">അക്കൗണ്ട് കീ</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">വീണ്ടെടുക്കൽ കീ</string>
<string name="SignalLoginKeyDetails__recovery_key">വീണ്ടെടുക്കൽ കീ</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">പാസ്‌വേഡ് മാനേജറിൽ സംരക്ഷിക്കുക</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">खाते की</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">रीकव्हरी की</string>
<string name="SignalLoginKeyDetails__recovery_key">रीकव्हरी की</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">पासवर्ड व्यवस्थापकामध्ये जतन करा</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kunci akaun</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Kunci pemulihan</string>
<string name="SignalLoginKeyDetails__recovery_key">Kunci pemulihan</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Simpan ke pengurus kata laluan</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">အကောင့်ကီး</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">ပြန်လည်ရယူရေးကီး</string>
<string name="SignalLoginKeyDetails__recovery_key">ပြန်လည်ရယူရေးကီး</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">စကားဝှက်စီမံရေးတွင် သိမ်းဆည်းပါ</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kontokode</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Sikkerhetskode</string>
<string name="SignalLoginKeyDetails__recovery_key">Sikkerhetskode</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Lagre i passordverktøyet ditt</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Accountsleutel</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Herstelsleutel</string>
<string name="SignalLoginKeyDetails__recovery_key">Herstelsleutel</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Opslaan in wachtwoordmanager</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">ਖਾਤੇ ਦੀ ਕੁੰਜੀ</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">ਰਿਕਵਰੀ ਕੁੰਜੀ</string>
<string name="SignalLoginKeyDetails__recovery_key">ਰਿਕਵਰੀ ਕੁੰਜੀ</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">ਪਾਸਵਰਡ ਮੈਨੇਜਰ ਵਿੱਚ ਸੇਵ ਕਰੋ</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Klucz konta</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Kod odzyskiwania</string>
<string name="SignalLoginKeyDetails__recovery_key">Kod odzyskiwania</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Zapisz w menedżerze haseł</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Chave da conta</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Chave de recuperação</string>
<string name="SignalLoginKeyDetails__recovery_key">Chave de recuperação</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Salvar no gerenciador de senhas</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Chave da conta</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Chave de recuperação</string>
<string name="SignalLoginKeyDetails__recovery_key">Chave de recuperação</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Guardar no gestor de palavras-passe</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Cheie pentru cont</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Cod de recuperare</string>
<string name="SignalLoginKeyDetails__recovery_key">Cod de recuperare</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Salvează în managerul de parole</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ключ к учётной записи</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Ключ восстановления</string>
<string name="SignalLoginKeyDetails__recovery_key">Ключ восстановления</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Сохранить в менеджере паролей</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kľúč účtu</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Kľúč na obnovenie</string>
<string name="SignalLoginKeyDetails__recovery_key">Kľúč na obnovenie</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Uložiť do správcu hesiel</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ključ računa</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Obnovitveni ključ</string>
<string name="SignalLoginKeyDetails__recovery_key">Obnovitveni ključ</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Shrani v upravitelja gesel</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kodi i llogarisë</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Kodi i rikthimit</string>
<string name="SignalLoginKeyDetails__recovery_key">Kodi i rikthimit</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Ruaj te menaxheri i fjalëkalimeve</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Кључ налога</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Кључ за опоравак</string>
<string name="SignalLoginKeyDetails__recovery_key">Кључ за опоравак</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Сачувај у менаџер лозинки</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Kontonyckel</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Återställningsnyckel</string>
<string name="SignalLoginKeyDetails__recovery_key">Återställningsnyckel</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Spara i lösenordshanterare</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ufunguo wa akaunti</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Ufunguo wa kurejesha akaunti</string>
<string name="SignalLoginKeyDetails__recovery_key">Ufunguo wa kurejesha akaunti</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Hifadhi kwenye kidhibiti cha manenosiri</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">கணக்குக் குறியீடு</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">மீட்புக் குறியீடு</string>
<string name="SignalLoginKeyDetails__recovery_key">மீட்புக் குறியீடு</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">கடவுச்சொல் மேலாளரில் சேமித்திடுக</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">ఖాతా కీ</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">రికవరీ కీ</string>
<string name="SignalLoginKeyDetails__recovery_key">రికవరీ కీ</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">పాస్‌వర్డ్ మేనేజర్‌లో సేవ్ చేయండి</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">กุญแจเข้าบัญชี</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">กุญแจกู้คืน</string>
<string name="SignalLoginKeyDetails__recovery_key">กุญแจกู้คืน</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">บันทึกไปยังตัวจัดการรหัสผ่าน</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Account key</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Recovery key</string>
<string name="SignalLoginKeyDetails__recovery_key">Recovery key</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">I-save sa password manager</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Hesap anahtarı</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Kurtarma anahtarı</string>
<string name="SignalLoginKeyDetails__recovery_key">Kurtarma anahtarı</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Parola yöneticisine kaydet</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">ھېسابات ئاچقۇچى</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">ئەسلىگە كەلتۈرۈش ئاچقۇچى</string>
<string name="SignalLoginKeyDetails__recovery_key">ئەسلىگە كەلتۈرۈش ئاچقۇچى</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">پارول باشقۇرغۇچىغا ساقلاڭ</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Ключ від акаунту</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Ключ відновлення</string>
<string name="SignalLoginKeyDetails__recovery_key">Ключ відновлення</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Зберегти в менеджер паролів</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">اکاؤنٹ کیی</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">ریکوری کیی</string>
<string name="SignalLoginKeyDetails__recovery_key">ریکوری کیی</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">پاسورڈ مینیجر میں محفوظ کریں</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Mã khóa tài khoản</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Mã khóa khôi phục</string>
<string name="SignalLoginKeyDetails__recovery_key">Mã khóa khôi phục</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Lưu vào công cụ quản lý mật khẩu</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">帳戶金鑰</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">恢復金鑰</string>
<string name="SignalLoginKeyDetails__recovery_key">恢復金鑰</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">儲存去密碼管理器</string>
<!-- Action button that saves the Signal Login as a PDF. -->
@@ -16,7 +16,7 @@
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">账户密钥</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">恢复密钥</string>
<string name="SignalLoginKeyDetails__recovery_key">恢复密钥</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">保存到密码管理器</string>
<!-- Action button that saves the Signal Login as a PDF. -->

Some files were not shown because too many files have changed in this diff Show More