mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Add signal login manual confirmation flow during account creation.
This commit is contained in:
committed by
Cody Henthorne
parent
3151effc7f
commit
1f29540bbf
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+66
-1
@@ -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 {
|
||||
|
||||
+41
-11
@@ -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() {
|
||||
|
||||
+39
@@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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()
|
||||
|
||||
|
||||
+12
-3
@@ -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. */
|
||||
|
||||
+7
-13
@@ -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 -> {
|
||||
|
||||
+151
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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 -> {
|
||||
|
||||
+142
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+270
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
@@ -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()})"
|
||||
}
|
||||
}
|
||||
+42
@@ -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()
|
||||
}
|
||||
+26
@@ -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)"
|
||||
}
|
||||
+106
@@ -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>
|
||||
+42
@@ -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. -->
|
||||
|
||||
+10
@@ -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())
|
||||
|
||||
+155
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -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"
|
||||
}
|
||||
|
||||
+234
@@ -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
-175
@@ -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(
|
||||
|
||||
+3
-6
@@ -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
Reference in New Issue
Block a user