From 1f29540bbfe96b16fd413a4b5c99c3cd6914df7d Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 8 Sep 2026 10:49:41 -0400 Subject: [PATCH] Add signal login manual confirmation flow during account creation. --- .../MessageBackupsFlowFragment.kt | 2 +- .../SignalLoginViewDetailsFragment.kt | 2 +- .../registration/RegistrationNavigation.kt | 67 ++++- .../SignalLoginCredentialEntryScreen.kt | 52 +++- ...lLoginCredentialEntryScreenEventHandler.kt | 39 +++ .../SignalLoginCredentialEntryScreenEvents.kt | 5 +- .../SignalLoginCredentialEntryState.kt | 15 +- .../SignalLoginCredentialEntryViewModel.kt | 20 +- ...nalLoginManualSaveConfirmationViewModel.kt | 151 ++++++++++ .../SignalLoginInfoViewModel.kt | 3 +- .../ConfirmLoginSavedBottomSheet.kt | 142 +++++++++ ...gnalLoginViewDetailsForManualSaveScreen.kt | 270 ++++++++++++++++++ ...inViewDetailsForManualSaveScreenActions.kt | 18 ++ ...ginViewDetailsForManualSaveScreenEvents.kt | 42 +++ ...ignalLoginViewDetailsForManualSaveState.kt | 26 ++ ...lLoginViewDetailsForManualSaveViewModel.kt | 106 +++++++ .../org/signal/registration/test/TestTags.kt | 9 + .../image_signal_login_lock.xml | 33 +++ .../image_signal_login_lock_checkmark.xml | 42 +++ .../res/drawable/image_signal_login_lock.xml | 33 +++ .../image_signal_login_lock_checkmark.xml | 42 +++ .../src/main/res/values/strings.xml | 26 ++ .../SignalLoginCredentialEntryScreenTest.kt | 10 + ...oginManualSaveConfirmationViewModelTest.kt | 155 ++++++++++ .../SignalLoginViewDetailsScreenTest.kt | 4 +- .../SignalLoginInfoViewModelTest.kt | 4 +- ...LoginViewDetailsForManualSaveScreenTest.kt | 111 +++++++ ...inViewDetailsForManualSaveViewModelTest.kt | 162 +++++++++++ .../signal/signallogin/RecoveryKeyGroups.kt | 32 +++ .../signal/signallogin/SignalLoginTestTags.kt | 9 +- .../details/SignalLoginKeyDetails.kt | 234 +++++++++++++++ .../signallogin/pdf/SignalLoginPdfRenderer.kt | 18 +- .../SignalLoginViewDetailsScreen.kt | 181 +----------- .../SignalLoginViewDetailsState.kt | 9 +- .../src/main/res/values-af/strings.xml | 2 +- .../src/main/res/values-ar/strings.xml | 2 +- .../src/main/res/values-az/strings.xml | 2 +- .../src/main/res/values-be/strings.xml | 2 +- .../src/main/res/values-bg/strings.xml | 2 +- .../src/main/res/values-bn/strings.xml | 2 +- .../src/main/res/values-bs/strings.xml | 2 +- .../src/main/res/values-ca/strings.xml | 2 +- .../src/main/res/values-cs/strings.xml | 2 +- .../src/main/res/values-da/strings.xml | 2 +- .../src/main/res/values-de/strings.xml | 2 +- .../src/main/res/values-el/strings.xml | 2 +- .../src/main/res/values-es/strings.xml | 2 +- .../src/main/res/values-et/strings.xml | 2 +- .../src/main/res/values-eu/strings.xml | 2 +- .../src/main/res/values-fa/strings.xml | 2 +- .../src/main/res/values-fi/strings.xml | 2 +- .../src/main/res/values-fr/strings.xml | 2 +- .../src/main/res/values-ga/strings.xml | 2 +- .../src/main/res/values-gl/strings.xml | 2 +- .../src/main/res/values-gu/strings.xml | 2 +- .../src/main/res/values-hi/strings.xml | 2 +- .../src/main/res/values-hr/strings.xml | 2 +- .../src/main/res/values-hu/strings.xml | 2 +- .../src/main/res/values-in/strings.xml | 2 +- .../src/main/res/values-it/strings.xml | 2 +- .../src/main/res/values-iw/strings.xml | 2 +- .../src/main/res/values-ja/strings.xml | 2 +- .../src/main/res/values-ka/strings.xml | 2 +- .../src/main/res/values-kk/strings.xml | 2 +- .../src/main/res/values-km/strings.xml | 2 +- .../src/main/res/values-kn/strings.xml | 2 +- .../src/main/res/values-ko/strings.xml | 2 +- .../src/main/res/values-ky/strings.xml | 2 +- .../src/main/res/values-lt/strings.xml | 2 +- .../src/main/res/values-lv/strings.xml | 2 +- .../src/main/res/values-mk/strings.xml | 2 +- .../src/main/res/values-ml/strings.xml | 2 +- .../src/main/res/values-mr/strings.xml | 2 +- .../src/main/res/values-ms/strings.xml | 2 +- .../src/main/res/values-my/strings.xml | 2 +- .../src/main/res/values-nb/strings.xml | 2 +- .../src/main/res/values-nl/strings.xml | 2 +- .../src/main/res/values-pa/strings.xml | 2 +- .../src/main/res/values-pl/strings.xml | 2 +- .../src/main/res/values-pt-rBR/strings.xml | 2 +- .../src/main/res/values-pt/strings.xml | 2 +- .../src/main/res/values-ro/strings.xml | 2 +- .../src/main/res/values-ru/strings.xml | 2 +- .../src/main/res/values-sk/strings.xml | 2 +- .../src/main/res/values-sl/strings.xml | 2 +- .../src/main/res/values-sq/strings.xml | 2 +- .../src/main/res/values-sr/strings.xml | 2 +- .../src/main/res/values-sv/strings.xml | 2 +- .../src/main/res/values-sw/strings.xml | 2 +- .../src/main/res/values-ta/strings.xml | 2 +- .../src/main/res/values-te/strings.xml | 2 +- .../src/main/res/values-th/strings.xml | 2 +- .../src/main/res/values-tl/strings.xml | 2 +- .../src/main/res/values-tr/strings.xml | 2 +- .../src/main/res/values-ug/strings.xml | 2 +- .../src/main/res/values-uk/strings.xml | 2 +- .../src/main/res/values-ur/strings.xml | 2 +- .../src/main/res/values-vi/strings.xml | 2 +- .../src/main/res/values-yue/strings.xml | 2 +- .../src/main/res/values-zh-rCN/strings.xml | 2 +- .../src/main/res/values-zh-rHK/strings.xml | 2 +- .../src/main/res/values-zh-rTW/strings.xml | 2 +- .../src/main/res/values/strings.xml | 8 +- 103 files changed, 1915 insertions(+), 303 deletions(-) create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEventHandler.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModel.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/ConfirmLoginSavedBottomSheet.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreen.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenActions.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenEvents.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveState.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModel.kt create mode 100644 feature/registration/src/main/res/drawable-night/image_signal_login_lock.xml create mode 100644 feature/registration/src/main/res/drawable-night/image_signal_login_lock_checkmark.xml create mode 100644 feature/registration/src/main/res/drawable/image_signal_login_lock.xml create mode 100644 feature/registration/src/main/res/drawable/image_signal_login_lock_checkmark.xml create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModelTest.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/RecoveryKeyGroups.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/details/SignalLoginKeyDetails.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index 32bf54bb68..baa97920ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt index 964b5843b4..4728c5aa3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt @@ -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() } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 032d0eeb42..9984872015 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.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.navigationEntries( ) } + // -- Signal Login View Details For Manual Save Screen + entry { + 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 { + 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 { key -> val viewModel: SignalLoginCredentialEntryViewModel = viewModel { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt index fa42afc490..504a936ef9 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreen.kt @@ -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() { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEventHandler.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEventHandler.kt new file mode 100644 index 0000000000..353c685d06 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEventHandler.kt @@ -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!") + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt index be80df8569..510724dc94 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenEvents.kt @@ -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() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt index 6aeb12e032..17d6e4976b 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryState.kt @@ -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. */ diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt index ada1aeb3b5..5c8484bae1 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModel.kt new file mode 100644 index 0000000000..15b8d85556 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModel.kt @@ -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, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(SignalLoginManualSaveConfirmationViewModel::class) + } + + private val _state = MutableStateFlow(SignalLoginCredentialEntryState(mode = SignalLoginCredentialEntryState.Mode.ConfirmSaved)) + val state: StateFlow = _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)) + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt index 26ad01c61d..ef2133c467 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/ConfirmLoginSavedBottomSheet.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/ConfirmLoginSavedBottomSheet.kt new file mode 100644 index 0000000000..9e29274a85 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/ConfirmLoginSavedBottomSheet.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreen.kt new file mode 100644 index 0000000000..5a21b72c77 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenActions.kt new file mode 100644 index 0000000000..87a9069393 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenActions.kt @@ -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()})" + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenEvents.kt new file mode 100644 index 0000000000..b67770796c --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveState.kt new file mode 100644 index 0000000000..7dbf8d0711 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveState.kt @@ -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)" +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModel.kt new file mode 100644 index 0000000000..fda483684a --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModel.kt @@ -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, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(SignalLoginViewDetailsForManualSaveViewModel::class) + } + + private val _state = MutableStateFlow(SignalLoginViewDetailsForManualSaveState()) + val state: StateFlow = _state.asStateFlow() + + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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)) + } + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index 274b9853ee..52f1c76b27 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -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" diff --git a/feature/registration/src/main/res/drawable-night/image_signal_login_lock.xml b/feature/registration/src/main/res/drawable-night/image_signal_login_lock.xml new file mode 100644 index 0000000000..cc851d6547 --- /dev/null +++ b/feature/registration/src/main/res/drawable-night/image_signal_login_lock.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable-night/image_signal_login_lock_checkmark.xml b/feature/registration/src/main/res/drawable-night/image_signal_login_lock_checkmark.xml new file mode 100644 index 0000000000..9cfdffcf11 --- /dev/null +++ b/feature/registration/src/main/res/drawable-night/image_signal_login_lock_checkmark.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable/image_signal_login_lock.xml b/feature/registration/src/main/res/drawable/image_signal_login_lock.xml new file mode 100644 index 0000000000..bd94399de8 --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_signal_login_lock.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable/image_signal_login_lock_checkmark.xml b/feature/registration/src/main/res/drawable/image_signal_login_lock_checkmark.xml new file mode 100644 index 0000000000..2705f95111 --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_signal_login_lock_checkmark.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 5fe894abed..7381b7f349 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -670,6 +670,26 @@ Your Signal Login could not be saved. Please save it manually instead. + + + Save your Signal Login + + Store your Signal Login somewhere safe and secure. Never share your login info with anyone. + + Save as PDF + + Continue + + + + Confirm your login info is saved + + 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. + + Continue + + Show login info again + Too long. %1$d/%2$d characters. @@ -695,6 +715,12 @@ Next Incorrect account ID or recovery key + + Enter your Signal Login to confirm it was recorded correctly. + + Show login info again + + That doesn\'t match the Signal Login you were shown diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt index 63caaa4172..463b04f391 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginCredentialEntryScreenTest.kt @@ -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()) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModelTest.kt new file mode 100644 index 0000000000..59699a0d17 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogincredentials/SignalLoginManualSaveConfirmationViewModelTest.kt @@ -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() + + 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() + 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() + 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() + + 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() + + viewModel.applyEvent(matchingState(), SignalLoginCredentialEntryScreenEvents.ShowLoginInfoAgainClicked, parentState, { parentEvents.add(it) }) {} + + assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack) + } + + @Test + fun `BackClicked navigates back`() = runTest(testDispatcher) { + val parentEvents = mutableListOf() + + 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() + + 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) + ) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt index 42e9e1bbb5..143c4c96c5 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt @@ -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)) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt index dd395c9362..53d1b1f739 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModelTest.kt @@ -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() viewModel.applyEvent(SignalLoginInfoState(), SignalLoginInfoScreenEvents.SaveManuallyClicked, { parentEvents.add(it) }) {} - assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.AddUsername)) + assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.SignalLoginViewDetailsForManualSave)) } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenTest.kt new file mode 100644 index 0000000000..4991894526 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveScreenTest.kt @@ -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() + + @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 } + ) + } + } + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModelTest.kt new file mode 100644 index 0000000000..957b3dd4b7 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signalloginmanualsave/SignalLoginViewDetailsForManualSaveViewModelTest.kt @@ -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() + + 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() + 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() + 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() + 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() + 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() + 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() + 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) + } +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/RecoveryKeyGroups.kt b/lib/signal-login/src/main/java/org/signal/signallogin/RecoveryKeyGroups.kt new file mode 100644 index 0000000000..f207f4bb65 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/RecoveryKeyGroups.kt @@ -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) { + + 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> = groups.chunked(perRow) +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt b/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt index a44a3b5941..a5ea41c938 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt @@ -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" } diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/details/SignalLoginKeyDetails.kt b/lib/signal-login/src/main/java/org/signal/signallogin/details/SignalLoginKeyDetails.kt new file mode 100644 index 0000000000..160ae0fcb2 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/details/SignalLoginKeyDetails.kt @@ -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 = {} + ) + } +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt b/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt index 1fe59bf3f2..44bb614f09 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/pdf/SignalLoginPdfRenderer.kt @@ -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 { + suspend fun renderTo(context: Context, uri: Uri, accountId: String, recoveryKeyGroups: RecoveryKeyGroups): Result { 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) diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt index 1564604af7..4757d63b9e 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt @@ -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, - 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( diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt index 6722c8ee5a..03e72ffacf 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt @@ -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 - get() = recoveryKey.chunked(GROUP_SIZE) + val recoveryKeyGroups: RecoveryKeyGroups + get() = RecoveryKeyGroups.from(recoveryKey) override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()})" } diff --git a/lib/signal-login/src/main/res/values-af/strings.xml b/lib/signal-login/src/main/res/values-af/strings.xml index 94f31ee97c..dcb6f87703 100644 --- a/lib/signal-login/src/main/res/values-af/strings.xml +++ b/lib/signal-login/src/main/res/values-af/strings.xml @@ -16,7 +16,7 @@ Rekeningsleutel - Herwinsleutel + Herwinsleutel Stoor in wagwoordbestuurstelsel diff --git a/lib/signal-login/src/main/res/values-ar/strings.xml b/lib/signal-login/src/main/res/values-ar/strings.xml index bcbd1e793c..0c83981566 100644 --- a/lib/signal-login/src/main/res/values-ar/strings.xml +++ b/lib/signal-login/src/main/res/values-ar/strings.xml @@ -16,7 +16,7 @@ مفتاح الحساب - مفتاح الاستعادة + مفتاح الاستعادة حفظ في مدير كلمات المرور diff --git a/lib/signal-login/src/main/res/values-az/strings.xml b/lib/signal-login/src/main/res/values-az/strings.xml index f0914641e8..e26eb7c778 100644 --- a/lib/signal-login/src/main/res/values-az/strings.xml +++ b/lib/signal-login/src/main/res/values-az/strings.xml @@ -16,7 +16,7 @@ Hesab şifrəsi - Bərpa şifrəsi + Bərpa şifrəsi Parol menecerində yaddaşda saxlayın diff --git a/lib/signal-login/src/main/res/values-be/strings.xml b/lib/signal-login/src/main/res/values-be/strings.xml index 4b3597c116..68373b3972 100644 --- a/lib/signal-login/src/main/res/values-be/strings.xml +++ b/lib/signal-login/src/main/res/values-be/strings.xml @@ -16,7 +16,7 @@ Ключ уліковага запісу - Код для аднаўлення + Код для аднаўлення Захаваць у менеджары пароляў diff --git a/lib/signal-login/src/main/res/values-bg/strings.xml b/lib/signal-login/src/main/res/values-bg/strings.xml index 08beb9835e..7e0a04137a 100644 --- a/lib/signal-login/src/main/res/values-bg/strings.xml +++ b/lib/signal-login/src/main/res/values-bg/strings.xml @@ -16,7 +16,7 @@ Ключ на акаунта - Ключ за възстановяване + Ключ за възстановяване Запазване в мениджър на пароли diff --git a/lib/signal-login/src/main/res/values-bn/strings.xml b/lib/signal-login/src/main/res/values-bn/strings.xml index ee1a9adc06..317e4c0846 100644 --- a/lib/signal-login/src/main/res/values-bn/strings.xml +++ b/lib/signal-login/src/main/res/values-bn/strings.xml @@ -16,7 +16,7 @@ অ্যাকাউন্ট \'কি\' - পুনরুদ্ধার \'কি\' + পুনরুদ্ধার \'কি\' পাসওয়ার্ড ম্যানেজারে সেভ করুন diff --git a/lib/signal-login/src/main/res/values-bs/strings.xml b/lib/signal-login/src/main/res/values-bs/strings.xml index 7a09e06386..e7bf7d4f90 100644 --- a/lib/signal-login/src/main/res/values-bs/strings.xml +++ b/lib/signal-login/src/main/res/values-bs/strings.xml @@ -16,7 +16,7 @@ Ključ računa - Ključ za oporavak + Ključ za oporavak Sačuvaj u upravitelju lozinki diff --git a/lib/signal-login/src/main/res/values-ca/strings.xml b/lib/signal-login/src/main/res/values-ca/strings.xml index c7621c5ef5..331121131a 100644 --- a/lib/signal-login/src/main/res/values-ca/strings.xml +++ b/lib/signal-login/src/main/res/values-ca/strings.xml @@ -16,7 +16,7 @@ Clau del compte - Clau de recuperació + Clau de recuperació Guardar al gestor de contrasenyes diff --git a/lib/signal-login/src/main/res/values-cs/strings.xml b/lib/signal-login/src/main/res/values-cs/strings.xml index 48aff9d764..d94f06b8ab 100644 --- a/lib/signal-login/src/main/res/values-cs/strings.xml +++ b/lib/signal-login/src/main/res/values-cs/strings.xml @@ -16,7 +16,7 @@ Klíč účtu - Klíč pro obnovení + Klíč pro obnovení Uložit do správce hesel diff --git a/lib/signal-login/src/main/res/values-da/strings.xml b/lib/signal-login/src/main/res/values-da/strings.xml index 31d095b595..a7c24b7cc3 100644 --- a/lib/signal-login/src/main/res/values-da/strings.xml +++ b/lib/signal-login/src/main/res/values-da/strings.xml @@ -16,7 +16,7 @@ Kontonøgle - Gendannelsesnøgle + Gendannelsesnøgle Gem i adgangskodeadministrator diff --git a/lib/signal-login/src/main/res/values-de/strings.xml b/lib/signal-login/src/main/res/values-de/strings.xml index 063466b464..221a315931 100644 --- a/lib/signal-login/src/main/res/values-de/strings.xml +++ b/lib/signal-login/src/main/res/values-de/strings.xml @@ -16,7 +16,7 @@ Kontoschlüssel - Wiederherstellungsschlüssel + Wiederherstellungsschlüssel Im Passwortmanager speichern diff --git a/lib/signal-login/src/main/res/values-el/strings.xml b/lib/signal-login/src/main/res/values-el/strings.xml index 52fb2fca21..04a5878de8 100644 --- a/lib/signal-login/src/main/res/values-el/strings.xml +++ b/lib/signal-login/src/main/res/values-el/strings.xml @@ -16,7 +16,7 @@ Κλειδί λογαριασμού - Κλειδί ανάκτησης + Κλειδί ανάκτησης Αποθήκευση στον διαχειριστή κωδικών πρόσβασης diff --git a/lib/signal-login/src/main/res/values-es/strings.xml b/lib/signal-login/src/main/res/values-es/strings.xml index 49d46ee698..a9fef15b18 100644 --- a/lib/signal-login/src/main/res/values-es/strings.xml +++ b/lib/signal-login/src/main/res/values-es/strings.xml @@ -16,7 +16,7 @@ Clave de cuenta - Clave de recuperación + Clave de recuperación Guardar en el gestor de contraseñas diff --git a/lib/signal-login/src/main/res/values-et/strings.xml b/lib/signal-login/src/main/res/values-et/strings.xml index cc0094026d..ba9a048097 100644 --- a/lib/signal-login/src/main/res/values-et/strings.xml +++ b/lib/signal-login/src/main/res/values-et/strings.xml @@ -16,7 +16,7 @@ Konto võti - Varukoopia võti + Varukoopia võti Salvesta paroolihaldurisse diff --git a/lib/signal-login/src/main/res/values-eu/strings.xml b/lib/signal-login/src/main/res/values-eu/strings.xml index 3eee3e4c04..dbc3ad4561 100644 --- a/lib/signal-login/src/main/res/values-eu/strings.xml +++ b/lib/signal-login/src/main/res/values-eu/strings.xml @@ -16,7 +16,7 @@ Kontuaren gakoa - Berreskuratze-gakoa + Berreskuratze-gakoa Gorde pasahitz-kudeatzailean diff --git a/lib/signal-login/src/main/res/values-fa/strings.xml b/lib/signal-login/src/main/res/values-fa/strings.xml index 94eaea9062..bf4ee71aa6 100644 --- a/lib/signal-login/src/main/res/values-fa/strings.xml +++ b/lib/signal-login/src/main/res/values-fa/strings.xml @@ -16,7 +16,7 @@ رمز حساب - رمز بازیابی + رمز بازیابی ذخیره در مدیر رمز عبور diff --git a/lib/signal-login/src/main/res/values-fi/strings.xml b/lib/signal-login/src/main/res/values-fi/strings.xml index acd7290cbc..c274a14cab 100644 --- a/lib/signal-login/src/main/res/values-fi/strings.xml +++ b/lib/signal-login/src/main/res/values-fi/strings.xml @@ -16,7 +16,7 @@ Tiliavain - Palautusavain + Palautusavain Tallenna salasananhallintaan diff --git a/lib/signal-login/src/main/res/values-fr/strings.xml b/lib/signal-login/src/main/res/values-fr/strings.xml index 3545df1c5b..5d9613b4e6 100644 --- a/lib/signal-login/src/main/res/values-fr/strings.xml +++ b/lib/signal-login/src/main/res/values-fr/strings.xml @@ -16,7 +16,7 @@ Clé de compte - Clé de récupération + Clé de récupération Enregistrer dans le gestionnaire de mots de passe diff --git a/lib/signal-login/src/main/res/values-ga/strings.xml b/lib/signal-login/src/main/res/values-ga/strings.xml index 51b963f733..5b0d1b8ae4 100644 --- a/lib/signal-login/src/main/res/values-ga/strings.xml +++ b/lib/signal-login/src/main/res/values-ga/strings.xml @@ -16,7 +16,7 @@ Eochair an chuntais - Eochair athshlánaithe + Eochair athshlánaithe Sábháil chuig an mbainisteoir pasfhocal diff --git a/lib/signal-login/src/main/res/values-gl/strings.xml b/lib/signal-login/src/main/res/values-gl/strings.xml index 8685cb4efa..c2a17d63e7 100644 --- a/lib/signal-login/src/main/res/values-gl/strings.xml +++ b/lib/signal-login/src/main/res/values-gl/strings.xml @@ -16,7 +16,7 @@ Clave da conta - Clave de recuperación + Clave de recuperación Gardar no xestor de contrasinais diff --git a/lib/signal-login/src/main/res/values-gu/strings.xml b/lib/signal-login/src/main/res/values-gu/strings.xml index dcc864d70e..ad118b80d4 100644 --- a/lib/signal-login/src/main/res/values-gu/strings.xml +++ b/lib/signal-login/src/main/res/values-gu/strings.xml @@ -16,7 +16,7 @@ એકાઉન્ટ કી - રિકવરી કી + રિકવરી કી પાસવર્ડ મેનેજરમાં સેવ કરો diff --git a/lib/signal-login/src/main/res/values-hi/strings.xml b/lib/signal-login/src/main/res/values-hi/strings.xml index 36995b272e..9fd9e653ac 100644 --- a/lib/signal-login/src/main/res/values-hi/strings.xml +++ b/lib/signal-login/src/main/res/values-hi/strings.xml @@ -16,7 +16,7 @@ अकाउंट की - \'रिकवरी की\' + \'रिकवरी की\' पासवर्ड मैनेजर में सेव करें diff --git a/lib/signal-login/src/main/res/values-hr/strings.xml b/lib/signal-login/src/main/res/values-hr/strings.xml index 0b403f7d9b..3113dae8eb 100644 --- a/lib/signal-login/src/main/res/values-hr/strings.xml +++ b/lib/signal-login/src/main/res/values-hr/strings.xml @@ -16,7 +16,7 @@ Ključ računa - Ključ za oporavak + Ključ za oporavak Spremi u upravitelju lozinki diff --git a/lib/signal-login/src/main/res/values-hu/strings.xml b/lib/signal-login/src/main/res/values-hu/strings.xml index 106d5c660b..42956732f3 100644 --- a/lib/signal-login/src/main/res/values-hu/strings.xml +++ b/lib/signal-login/src/main/res/values-hu/strings.xml @@ -16,7 +16,7 @@ Fiókkulcs - Helyreállítási kulcs + Helyreállítási kulcs Mentés a jelszókezelőbe diff --git a/lib/signal-login/src/main/res/values-in/strings.xml b/lib/signal-login/src/main/res/values-in/strings.xml index db35a95c04..c273c67f6d 100644 --- a/lib/signal-login/src/main/res/values-in/strings.xml +++ b/lib/signal-login/src/main/res/values-in/strings.xml @@ -16,7 +16,7 @@ Kunci akun - Kunci pemulihan + Kunci pemulihan Simpan di pengelola kata sandi diff --git a/lib/signal-login/src/main/res/values-it/strings.xml b/lib/signal-login/src/main/res/values-it/strings.xml index 528ab452fa..e7fb90b5ae 100644 --- a/lib/signal-login/src/main/res/values-it/strings.xml +++ b/lib/signal-login/src/main/res/values-it/strings.xml @@ -16,7 +16,7 @@ Chiave dell\'account - Chiave di ripristino + Chiave di ripristino Salva sul password manager diff --git a/lib/signal-login/src/main/res/values-iw/strings.xml b/lib/signal-login/src/main/res/values-iw/strings.xml index 58d168a95c..00c8388163 100644 --- a/lib/signal-login/src/main/res/values-iw/strings.xml +++ b/lib/signal-login/src/main/res/values-iw/strings.xml @@ -16,7 +16,7 @@ מפתח חשבון - מפתח שחזור + מפתח שחזור שמירה במנהל הסיסמאות diff --git a/lib/signal-login/src/main/res/values-ja/strings.xml b/lib/signal-login/src/main/res/values-ja/strings.xml index 5d971fb685..71a05e81f6 100644 --- a/lib/signal-login/src/main/res/values-ja/strings.xml +++ b/lib/signal-login/src/main/res/values-ja/strings.xml @@ -16,7 +16,7 @@ アカウントキー - 回復キー + 回復キー パスワードマネージャーに保存する diff --git a/lib/signal-login/src/main/res/values-ka/strings.xml b/lib/signal-login/src/main/res/values-ka/strings.xml index a384ff5e2f..baa193e3c3 100644 --- a/lib/signal-login/src/main/res/values-ka/strings.xml +++ b/lib/signal-login/src/main/res/values-ka/strings.xml @@ -16,7 +16,7 @@ ანგარიშის გასაღები - აღდგენის გასაღები + აღდგენის გასაღები პაროლების მენეჯერში შენახვა diff --git a/lib/signal-login/src/main/res/values-kk/strings.xml b/lib/signal-login/src/main/res/values-kk/strings.xml index 7d6c0640a4..ad7eb48d1f 100644 --- a/lib/signal-login/src/main/res/values-kk/strings.xml +++ b/lib/signal-login/src/main/res/values-kk/strings.xml @@ -16,7 +16,7 @@ Аккаунт кілті - Қалпына келтіру кілті + Қалпына келтіру кілті Құпия сөз менеджерінде сақтау diff --git a/lib/signal-login/src/main/res/values-km/strings.xml b/lib/signal-login/src/main/res/values-km/strings.xml index ec4a2e1808..74660da3b2 100644 --- a/lib/signal-login/src/main/res/values-km/strings.xml +++ b/lib/signal-login/src/main/res/values-km/strings.xml @@ -16,7 +16,7 @@ លេខកូដគណនី - សោស្តារ + សោស្តារ រក្សាទុកក្នុងមុខងារគ្រប់គ្រងពាក្យសម្ងាត់ diff --git a/lib/signal-login/src/main/res/values-kn/strings.xml b/lib/signal-login/src/main/res/values-kn/strings.xml index 1f1f38b697..6979b81b4c 100644 --- a/lib/signal-login/src/main/res/values-kn/strings.xml +++ b/lib/signal-login/src/main/res/values-kn/strings.xml @@ -16,7 +16,7 @@ ಖಾತೆ ಕೀ - ರಿಕವರಿ ಕೀ + ರಿಕವರಿ ಕೀ ಪಾಸ್‌ವರ್ಡ್ ಮ್ಯಾನೇಜರ್‌ಗೆ ಸೇವ್ ಮಾಡಿ diff --git a/lib/signal-login/src/main/res/values-ko/strings.xml b/lib/signal-login/src/main/res/values-ko/strings.xml index 2d14238144..e1c298aff3 100644 --- a/lib/signal-login/src/main/res/values-ko/strings.xml +++ b/lib/signal-login/src/main/res/values-ko/strings.xml @@ -16,7 +16,7 @@ 계정 키 - 복구 키 + 복구 키 비밀번호 관리자에 저장 diff --git a/lib/signal-login/src/main/res/values-ky/strings.xml b/lib/signal-login/src/main/res/values-ky/strings.xml index 5b9976e0fe..19a2dec53d 100644 --- a/lib/signal-login/src/main/res/values-ky/strings.xml +++ b/lib/signal-login/src/main/res/values-ky/strings.xml @@ -16,7 +16,7 @@ Аккаунттун ачкычы - Калыбына келтирүү ачкычы + Калыбына келтирүү ачкычы Сырсөздөрдү башкаргычка сактоо diff --git a/lib/signal-login/src/main/res/values-lt/strings.xml b/lib/signal-login/src/main/res/values-lt/strings.xml index 8a5018825e..de962cee19 100644 --- a/lib/signal-login/src/main/res/values-lt/strings.xml +++ b/lib/signal-login/src/main/res/values-lt/strings.xml @@ -16,7 +16,7 @@ Paskyros raktas - Atkūrimo raktas + Atkūrimo raktas Išsaugoti slaptažodžių tvarkyklėje diff --git a/lib/signal-login/src/main/res/values-lv/strings.xml b/lib/signal-login/src/main/res/values-lv/strings.xml index 416fa7f784..16f142c662 100644 --- a/lib/signal-login/src/main/res/values-lv/strings.xml +++ b/lib/signal-login/src/main/res/values-lv/strings.xml @@ -16,7 +16,7 @@ Konta atslēga - Atkopšanas atslēga + Atkopšanas atslēga Saglabāt paroļu pārvaldniekā diff --git a/lib/signal-login/src/main/res/values-mk/strings.xml b/lib/signal-login/src/main/res/values-mk/strings.xml index 95a9bb27b7..b1bc53e623 100644 --- a/lib/signal-login/src/main/res/values-mk/strings.xml +++ b/lib/signal-login/src/main/res/values-mk/strings.xml @@ -16,7 +16,7 @@ Клуч за корисничка сметка - Клуч за враќање резервни копии + Клуч за враќање резервни копии Зачувајте во управувачот со лозинки diff --git a/lib/signal-login/src/main/res/values-ml/strings.xml b/lib/signal-login/src/main/res/values-ml/strings.xml index fbee1059b7..30f35c792f 100644 --- a/lib/signal-login/src/main/res/values-ml/strings.xml +++ b/lib/signal-login/src/main/res/values-ml/strings.xml @@ -16,7 +16,7 @@ അക്കൗണ്ട് കീ - വീണ്ടെടുക്കൽ കീ + വീണ്ടെടുക്കൽ കീ പാസ്‌വേഡ് മാനേജറിൽ സംരക്ഷിക്കുക diff --git a/lib/signal-login/src/main/res/values-mr/strings.xml b/lib/signal-login/src/main/res/values-mr/strings.xml index 91e42469fc..c3353cd7bc 100644 --- a/lib/signal-login/src/main/res/values-mr/strings.xml +++ b/lib/signal-login/src/main/res/values-mr/strings.xml @@ -16,7 +16,7 @@ खाते की - रीकव्हरी की + रीकव्हरी की पासवर्ड व्यवस्थापकामध्ये जतन करा diff --git a/lib/signal-login/src/main/res/values-ms/strings.xml b/lib/signal-login/src/main/res/values-ms/strings.xml index 768ab9d0a2..f05bf867a3 100644 --- a/lib/signal-login/src/main/res/values-ms/strings.xml +++ b/lib/signal-login/src/main/res/values-ms/strings.xml @@ -16,7 +16,7 @@ Kunci akaun - Kunci pemulihan + Kunci pemulihan Simpan ke pengurus kata laluan diff --git a/lib/signal-login/src/main/res/values-my/strings.xml b/lib/signal-login/src/main/res/values-my/strings.xml index 7a3959c797..e07d5ed5f2 100644 --- a/lib/signal-login/src/main/res/values-my/strings.xml +++ b/lib/signal-login/src/main/res/values-my/strings.xml @@ -16,7 +16,7 @@ အကောင့်ကီး - ပြန်လည်ရယူရေးကီး + ပြန်လည်ရယူရေးကီး စကားဝှက်စီမံရေးတွင် သိမ်းဆည်းပါ diff --git a/lib/signal-login/src/main/res/values-nb/strings.xml b/lib/signal-login/src/main/res/values-nb/strings.xml index c87f15d443..1c18b67af8 100644 --- a/lib/signal-login/src/main/res/values-nb/strings.xml +++ b/lib/signal-login/src/main/res/values-nb/strings.xml @@ -16,7 +16,7 @@ Kontokode - Sikkerhetskode + Sikkerhetskode Lagre i passordverktøyet ditt diff --git a/lib/signal-login/src/main/res/values-nl/strings.xml b/lib/signal-login/src/main/res/values-nl/strings.xml index c4b6eae711..9b5c65e87e 100644 --- a/lib/signal-login/src/main/res/values-nl/strings.xml +++ b/lib/signal-login/src/main/res/values-nl/strings.xml @@ -16,7 +16,7 @@ Accountsleutel - Herstelsleutel + Herstelsleutel Opslaan in wachtwoordmanager diff --git a/lib/signal-login/src/main/res/values-pa/strings.xml b/lib/signal-login/src/main/res/values-pa/strings.xml index d0b5087a3e..ae47f4ce1b 100644 --- a/lib/signal-login/src/main/res/values-pa/strings.xml +++ b/lib/signal-login/src/main/res/values-pa/strings.xml @@ -16,7 +16,7 @@ ਖਾਤੇ ਦੀ ਕੁੰਜੀ - ਰਿਕਵਰੀ ਕੁੰਜੀ + ਰਿਕਵਰੀ ਕੁੰਜੀ ਪਾਸਵਰਡ ਮੈਨੇਜਰ ਵਿੱਚ ਸੇਵ ਕਰੋ diff --git a/lib/signal-login/src/main/res/values-pl/strings.xml b/lib/signal-login/src/main/res/values-pl/strings.xml index 00bf2d4809..5625baba91 100644 --- a/lib/signal-login/src/main/res/values-pl/strings.xml +++ b/lib/signal-login/src/main/res/values-pl/strings.xml @@ -16,7 +16,7 @@ Klucz konta - Kod odzyskiwania + Kod odzyskiwania Zapisz w menedżerze haseł diff --git a/lib/signal-login/src/main/res/values-pt-rBR/strings.xml b/lib/signal-login/src/main/res/values-pt-rBR/strings.xml index 394d6fae38..1dcfb11631 100644 --- a/lib/signal-login/src/main/res/values-pt-rBR/strings.xml +++ b/lib/signal-login/src/main/res/values-pt-rBR/strings.xml @@ -16,7 +16,7 @@ Chave da conta - Chave de recuperação + Chave de recuperação Salvar no gerenciador de senhas diff --git a/lib/signal-login/src/main/res/values-pt/strings.xml b/lib/signal-login/src/main/res/values-pt/strings.xml index 798cfb8a63..75302bdebd 100644 --- a/lib/signal-login/src/main/res/values-pt/strings.xml +++ b/lib/signal-login/src/main/res/values-pt/strings.xml @@ -16,7 +16,7 @@ Chave da conta - Chave de recuperação + Chave de recuperação Guardar no gestor de palavras-passe diff --git a/lib/signal-login/src/main/res/values-ro/strings.xml b/lib/signal-login/src/main/res/values-ro/strings.xml index b9f1299ddc..08e6246120 100644 --- a/lib/signal-login/src/main/res/values-ro/strings.xml +++ b/lib/signal-login/src/main/res/values-ro/strings.xml @@ -16,7 +16,7 @@ Cheie pentru cont - Cod de recuperare + Cod de recuperare Salvează în managerul de parole diff --git a/lib/signal-login/src/main/res/values-ru/strings.xml b/lib/signal-login/src/main/res/values-ru/strings.xml index fcec6a3dac..60427b5b66 100644 --- a/lib/signal-login/src/main/res/values-ru/strings.xml +++ b/lib/signal-login/src/main/res/values-ru/strings.xml @@ -16,7 +16,7 @@ Ключ к учётной записи - Ключ восстановления + Ключ восстановления Сохранить в менеджере паролей diff --git a/lib/signal-login/src/main/res/values-sk/strings.xml b/lib/signal-login/src/main/res/values-sk/strings.xml index 485b2b1977..a74639d6c4 100644 --- a/lib/signal-login/src/main/res/values-sk/strings.xml +++ b/lib/signal-login/src/main/res/values-sk/strings.xml @@ -16,7 +16,7 @@ Kľúč účtu - Kľúč na obnovenie + Kľúč na obnovenie Uložiť do správcu hesiel diff --git a/lib/signal-login/src/main/res/values-sl/strings.xml b/lib/signal-login/src/main/res/values-sl/strings.xml index 7eee865dda..94d78ce458 100644 --- a/lib/signal-login/src/main/res/values-sl/strings.xml +++ b/lib/signal-login/src/main/res/values-sl/strings.xml @@ -16,7 +16,7 @@ Ključ računa - Obnovitveni ključ + Obnovitveni ključ Shrani v upravitelja gesel diff --git a/lib/signal-login/src/main/res/values-sq/strings.xml b/lib/signal-login/src/main/res/values-sq/strings.xml index 2307c62a61..a778669195 100644 --- a/lib/signal-login/src/main/res/values-sq/strings.xml +++ b/lib/signal-login/src/main/res/values-sq/strings.xml @@ -16,7 +16,7 @@ Kodi i llogarisë - Kodi i rikthimit + Kodi i rikthimit Ruaj te menaxheri i fjalëkalimeve diff --git a/lib/signal-login/src/main/res/values-sr/strings.xml b/lib/signal-login/src/main/res/values-sr/strings.xml index 91da5b773b..a52c9cbb2e 100644 --- a/lib/signal-login/src/main/res/values-sr/strings.xml +++ b/lib/signal-login/src/main/res/values-sr/strings.xml @@ -16,7 +16,7 @@ Кључ налога - Кључ за опоравак + Кључ за опоравак Сачувај у менаџер лозинки diff --git a/lib/signal-login/src/main/res/values-sv/strings.xml b/lib/signal-login/src/main/res/values-sv/strings.xml index 4d7de1e46d..8b0b17def6 100644 --- a/lib/signal-login/src/main/res/values-sv/strings.xml +++ b/lib/signal-login/src/main/res/values-sv/strings.xml @@ -16,7 +16,7 @@ Kontonyckel - Återställningsnyckel + Återställningsnyckel Spara i lösenordshanterare diff --git a/lib/signal-login/src/main/res/values-sw/strings.xml b/lib/signal-login/src/main/res/values-sw/strings.xml index d469980ce6..8f21294275 100644 --- a/lib/signal-login/src/main/res/values-sw/strings.xml +++ b/lib/signal-login/src/main/res/values-sw/strings.xml @@ -16,7 +16,7 @@ Ufunguo wa akaunti - Ufunguo wa kurejesha akaunti + Ufunguo wa kurejesha akaunti Hifadhi kwenye kidhibiti cha manenosiri diff --git a/lib/signal-login/src/main/res/values-ta/strings.xml b/lib/signal-login/src/main/res/values-ta/strings.xml index 91f60512d8..bf439dc271 100644 --- a/lib/signal-login/src/main/res/values-ta/strings.xml +++ b/lib/signal-login/src/main/res/values-ta/strings.xml @@ -16,7 +16,7 @@ கணக்குக் குறியீடு - மீட்புக் குறியீடு + மீட்புக் குறியீடு கடவுச்சொல் மேலாளரில் சேமித்திடுக diff --git a/lib/signal-login/src/main/res/values-te/strings.xml b/lib/signal-login/src/main/res/values-te/strings.xml index 6555f678c5..a5cbbe55df 100644 --- a/lib/signal-login/src/main/res/values-te/strings.xml +++ b/lib/signal-login/src/main/res/values-te/strings.xml @@ -16,7 +16,7 @@ ఖాతా కీ - రికవరీ కీ + రికవరీ కీ పాస్‌వర్డ్ మేనేజర్‌లో సేవ్ చేయండి diff --git a/lib/signal-login/src/main/res/values-th/strings.xml b/lib/signal-login/src/main/res/values-th/strings.xml index 5a8732cb25..691929b203 100644 --- a/lib/signal-login/src/main/res/values-th/strings.xml +++ b/lib/signal-login/src/main/res/values-th/strings.xml @@ -16,7 +16,7 @@ กุญแจเข้าบัญชี - กุญแจกู้คืน + กุญแจกู้คืน บันทึกไปยังตัวจัดการรหัสผ่าน diff --git a/lib/signal-login/src/main/res/values-tl/strings.xml b/lib/signal-login/src/main/res/values-tl/strings.xml index b7c1d99caf..ef2eb1220c 100644 --- a/lib/signal-login/src/main/res/values-tl/strings.xml +++ b/lib/signal-login/src/main/res/values-tl/strings.xml @@ -16,7 +16,7 @@ Account key - Recovery key + Recovery key I-save sa password manager diff --git a/lib/signal-login/src/main/res/values-tr/strings.xml b/lib/signal-login/src/main/res/values-tr/strings.xml index ee1721ab93..50d69f9d61 100644 --- a/lib/signal-login/src/main/res/values-tr/strings.xml +++ b/lib/signal-login/src/main/res/values-tr/strings.xml @@ -16,7 +16,7 @@ Hesap anahtarı - Kurtarma anahtarı + Kurtarma anahtarı Parola yöneticisine kaydet diff --git a/lib/signal-login/src/main/res/values-ug/strings.xml b/lib/signal-login/src/main/res/values-ug/strings.xml index 322911ea72..7828ba2fef 100644 --- a/lib/signal-login/src/main/res/values-ug/strings.xml +++ b/lib/signal-login/src/main/res/values-ug/strings.xml @@ -16,7 +16,7 @@ ھېسابات ئاچقۇچى - ئەسلىگە كەلتۈرۈش ئاچقۇچى + ئەسلىگە كەلتۈرۈش ئاچقۇچى پارول باشقۇرغۇچىغا ساقلاڭ diff --git a/lib/signal-login/src/main/res/values-uk/strings.xml b/lib/signal-login/src/main/res/values-uk/strings.xml index 2525c0ae63..e8323acd2b 100644 --- a/lib/signal-login/src/main/res/values-uk/strings.xml +++ b/lib/signal-login/src/main/res/values-uk/strings.xml @@ -16,7 +16,7 @@ Ключ від акаунту - Ключ відновлення + Ключ відновлення Зберегти в менеджер паролів diff --git a/lib/signal-login/src/main/res/values-ur/strings.xml b/lib/signal-login/src/main/res/values-ur/strings.xml index ab1bf95c4d..7efb92132b 100644 --- a/lib/signal-login/src/main/res/values-ur/strings.xml +++ b/lib/signal-login/src/main/res/values-ur/strings.xml @@ -16,7 +16,7 @@ اکاؤنٹ کیی - ریکوری کیی + ریکوری کیی پاسورڈ مینیجر میں محفوظ کریں diff --git a/lib/signal-login/src/main/res/values-vi/strings.xml b/lib/signal-login/src/main/res/values-vi/strings.xml index 4c4d8be7cc..df3930bbd1 100644 --- a/lib/signal-login/src/main/res/values-vi/strings.xml +++ b/lib/signal-login/src/main/res/values-vi/strings.xml @@ -16,7 +16,7 @@ Mã khóa tài khoản - Mã khóa khôi phục + Mã khóa khôi phục Lưu vào công cụ quản lý mật khẩu diff --git a/lib/signal-login/src/main/res/values-yue/strings.xml b/lib/signal-login/src/main/res/values-yue/strings.xml index 07df593ae3..80b113ac30 100644 --- a/lib/signal-login/src/main/res/values-yue/strings.xml +++ b/lib/signal-login/src/main/res/values-yue/strings.xml @@ -16,7 +16,7 @@ 帳戶金鑰 - 恢復金鑰 + 恢復金鑰 儲存去密碼管理器 diff --git a/lib/signal-login/src/main/res/values-zh-rCN/strings.xml b/lib/signal-login/src/main/res/values-zh-rCN/strings.xml index db9ea08af5..01e106f990 100644 --- a/lib/signal-login/src/main/res/values-zh-rCN/strings.xml +++ b/lib/signal-login/src/main/res/values-zh-rCN/strings.xml @@ -16,7 +16,7 @@ 账户密钥 - 恢复密钥 + 恢复密钥 保存到密码管理器 diff --git a/lib/signal-login/src/main/res/values-zh-rHK/strings.xml b/lib/signal-login/src/main/res/values-zh-rHK/strings.xml index 11aefd90bc..32203f7970 100644 --- a/lib/signal-login/src/main/res/values-zh-rHK/strings.xml +++ b/lib/signal-login/src/main/res/values-zh-rHK/strings.xml @@ -16,7 +16,7 @@ 帳戶金鑰 - 恢復金鑰 + 恢復金鑰 儲存至密碼管理器 diff --git a/lib/signal-login/src/main/res/values-zh-rTW/strings.xml b/lib/signal-login/src/main/res/values-zh-rTW/strings.xml index 6cce6a7bb5..d6691ab13d 100644 --- a/lib/signal-login/src/main/res/values-zh-rTW/strings.xml +++ b/lib/signal-login/src/main/res/values-zh-rTW/strings.xml @@ -16,7 +16,7 @@ 帳戶金鑰 - 恢復金鑰 + 恢復金鑰 儲存至密碼管理器 diff --git a/lib/signal-login/src/main/res/values/strings.xml b/lib/signal-login/src/main/res/values/strings.xml index f6f754bf25..01c50587c5 100644 --- a/lib/signal-login/src/main/res/values/strings.xml +++ b/lib/signal-login/src/main/res/values/strings.xml @@ -14,13 +14,13 @@ Navigate back - Account ID + Account ID - Recovery key + Recovery key - Copy account ID + Copy account ID - Copy recovery key + Copy recovery key Save to password manager