From 08292a6b2c3a39870bc695cc886737deaea960c5 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 15 Sep 2026 16:50:31 -0400 Subject: [PATCH] Allow resetting the recovery key for numberless accounts. --- .../securesms/backup/v2/BackupRepository.kt | 26 ++ .../subscription/BackupKeyRotationDialogs.kt | 85 +++++++ .../MessageBackupsFlowFragment.kt | 17 +- .../MessageBackupsKeyRecordScreen.kt | 51 +--- ...sageBackupsSignalLoginDetailsViewModel.kt} | 20 +- .../app/account/AccountSettingsFragment.kt | 2 +- .../ResetRecoveryKeyBottomSheet.kt | 130 ++++++++++ .../signallogin/ResetRecoveryKeyState.kt | 30 +++ .../SettingsSignalLoginDetailsEvent.kt | 30 +++ ... => SettingsSignalLoginDetailsFragment.kt} | 56 ++++- .../SettingsSignalLoginDetailsViewModel.kt | 136 ++++++++++ .../SignalLoginViewDetailsAction.kt | 21 +- .../SignalLoginViewDetailsRepository.kt | 12 +- .../remote/BackupKeyDisplayFragment.kt | 32 ++- .../remote/BackupKeyDisplayViewModel.kt | 55 ++-- .../remote/RemoteBackupsSettingsViewModel.kt | 16 +- .../app_settings_with_change_number.xml | 18 +- app/src/main/res/values/strings.xml | 11 + ...BackupsSignalLoginDetailsViewModelTest.kt} | 61 +++-- ...SettingsSignalLoginDetailsViewModelTest.kt | 237 ++++++++++++++++++ .../registration/RegistrationNavigation.kt | 14 +- ...> RegistrationSignalLoginDetailsAction.kt} | 8 +- ...egistrationSignalLoginDetailsViewModel.kt} | 20 +- ...trationSignalLoginDetailsViewModelTest.kt} | 35 ++- .../SignalLoginViewDetailsScreenTest.kt | 31 ++- .../signal/signallogin/SignalLoginTestTags.kt | 2 + .../SignalLoginViewDetailsScreen.kt | 72 +++++- .../SignalLoginViewDetailsScreenEvents.kt | 3 + .../SignalLoginViewDetailsState.kt | 10 +- .../SignalLoginViewDetailsViewModel.kt | 77 ------ .../src/main/res/values/strings.xml | 2 + 31 files changed, 1076 insertions(+), 244 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/BackupKeyRotationDialogs.kt rename app/src/main/java/org/thoughtcrime/securesms/{components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt => backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt} (65%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsEvent.kt rename app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/{SignalLoginViewDetailsFragment.kt => SettingsSignalLoginDetailsFragment.kt} (51%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt rename app/src/test/java/org/thoughtcrime/securesms/{components/settings/app/account/signallogin/SignalLoginViewDetailsViewModelTest.kt => backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt} (64%) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt rename feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/{SignalLoginViewDetailsScreenActions.kt => RegistrationSignalLoginDetailsAction.kt} (64%) rename feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/{SignalLoginViewDetailsViewModel.kt => RegistrationSignalLoginDetailsViewModel.kt} (75%) rename feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/{SignalLoginViewDetailsViewModelTest.kt => RegistrationSignalLoginDetailsViewModelTest.kt} (70%) delete mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index ae77c253c8..e04a5cc59e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -203,6 +203,32 @@ object BackupRepository { ) } + /** + * Whether the user has any key rotation permits left. If the limit can't be fetched, we assume they do. + */ + suspend fun canRotateBackupKey(): Boolean { + return withContext(SignalDispatchers.IO) { + archiveService + .getKeyRotationLimit() + .fold( + ifRight = { it.hasPermitsRemaining ?: true }, + ifLeft = { error -> + Log.w(TAG, "Error while getting rotation limit: ${error::class.simpleName}. Default to allowing key rotations.") + true + } + ) + } + } + + /** + * Turns off storage optimization and starts pulling down everything that was offloaded. Required before the user can + * rotate their AEP. + */ + fun turnOffOptimizedStorageAndDownloadMedia() { + SignalStore.backup.optimizeStorage = false + RestoreOptimizedMediaJob.enqueue() + } + /** * Saves the AEP to the local storage and kicks off a backup upload. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/BackupKeyRotationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/BackupKeyRotationDialogs.kt new file mode 100644 index 0000000000..0b13418c0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/BackupKeyRotationDialogs.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.subscription + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R + +/** + * Dialogs shared by every entry point that can rotate the user's recovery key, namely the backup key display flow and + * the Signal Login details screen. + */ + +/** + * Tells the user they have to turn off storage optimization and pull their media back down before they can rotate + * their recovery key. Storage optimization is only available with backups on, so this copy always applies. + */ +@Composable +fun DownloadMediaDialog( + onTurnOffAndDownloadClick: () -> Unit = {}, + onCancelClick: () -> Unit = {} +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.MessageBackupsKeyRecordScreen__download_media), + body = stringResource(R.string.MessageBackupsKeyRecordScreen__to_create_a_new_backup_key), + confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__turn_off_and_download), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onTurnOffAndDownloadClick, + onDeny = onCancelClick + ) +} + +/** + * Tells the user they've used up all of their recovery key rotations for now. Without backups there's nothing to turn + * off and delete, so that suggestion is dropped from the body. + */ +@Composable +fun KeyLimitExceededDialog( + areBackupsEnabled: Boolean, + onClick: () -> Unit = {} +) { + val body = if (areBackupsEnabled) { + stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_body) + } else { + stringResource(R.string.BackupKeyRotationDialogs__limit_exceeded_body_no_backups) + } + + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_title), + body = body, + confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__ok), + onConfirm = {}, + onDismiss = onClick + ) +} + +@DayNightPreviews +@Composable +private fun DownloadMediaDialogPreview() { + Previews.Preview { + DownloadMediaDialog() + } +} + +@DayNightPreviews +@Composable +private fun KeyLimitExceededDialogPreview() { + Previews.Preview { + KeyLimitExceededDialog(areBackupsEnabled = true) + } +} + +@DayNightPreviews +@Composable +private fun KeyLimitExceededDialogNoBackupsPreview() { + Previews.Preview { + KeyLimitExceededDialog(areBackupsEnabled = false) + } +} 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 3aae064aa9..de9c98f15d 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 @@ -46,7 +46,6 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsAction -import org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsViewModel import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.compose.Nav import org.thoughtcrime.securesms.database.InAppPaymentTable @@ -84,13 +83,13 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega ) } - private val signalLoginViewDetailsViewModel: SignalLoginViewDetailsViewModel by viewModels() + private val signalLoginDetailsViewModel: MessageBackupsSignalLoginDetailsViewModel by viewModels() private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument(PDF_MIME_TYPE)) { uri: Uri? -> if (uri != null) { val context = requireContext().applicationContext lifecycleScope.launch { - val result = SignalLoginPdfRenderer.renderTo(context, uri, signalLoginViewDetailsViewModel.state.value.accountKey, signalLoginViewDetailsViewModel.state.value.recoveryKeyGroups) + val result = SignalLoginPdfRenderer.renderTo(context, uri, signalLoginDetailsViewModel.state.value.accountKey, signalLoginDetailsViewModel.state.value.recoveryKeyGroups) if (result is Result.Failure) { Toast.makeText(context, result.failure.userMessageRes, Toast.LENGTH_LONG).show() } @@ -245,13 +244,13 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega } composable(route = MessageBackupsStage.Route.SIGNAL_LOGIN_VIEW_DETAILS.name) { - val signalLoginState by signalLoginViewDetailsViewModel.state.collectAsStateWithLifecycle() + val signalLoginState by signalLoginDetailsViewModel.state.collectAsStateWithLifecycle() - CollectActions(signalLoginViewDetailsViewModel.actions) { action -> handleSignalLoginViewDetailsAction(action) } + CollectActions(signalLoginDetailsViewModel.actions) { action -> handleSignalLoginViewDetailsAction(action) } SignalLoginViewDetailsScreen( state = signalLoginState, - onEvent = signalLoginViewDetailsViewModel::onEvent + onEvent = signalLoginDetailsViewModel::onEvent ) } @@ -323,15 +322,15 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega } } - private fun handleSignalLoginViewDetailsAction(action: SignalLoginViewDetailsAction) { + private fun handleSignalLoginViewDetailsAction(action: SignalLoginViewDetailsAction.Shared) { when (action) { SignalLoginViewDetailsAction.NavigateBack -> viewModel.goToPreviousStage() SignalLoginViewDetailsAction.LaunchSaveToPasswordManager -> { lifecycleScope.launch { SignalCredentialManager.saveCredential( activityContext = requireActivity(), - username = signalLoginViewDetailsViewModel.state.value.accountKey, - password = signalLoginViewDetailsViewModel.state.value.recoveryKey + username = signalLoginDetailsViewModel.state.value.accountKey, + password = signalLoginDetailsViewModel.state.value.recoveryKey ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt index 2878ccfbff..bef67a6052 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt @@ -98,7 +98,8 @@ sealed interface MessageBackupsKeyRecordMode { val onCreateNewKeyClick: () -> Unit, val onTurnOffAndDownloadClick: () -> Unit, val isOptimizedStorageEnabled: Boolean, - val canRotateKey: Boolean + val canRotateKey: Boolean, + val areBackupsEnabled: Boolean ) : MessageBackupsKeyRecordMode data class Passkey( val onSaveToPasswordManager: () -> Unit, @@ -548,6 +549,7 @@ private fun CreateNewKeyButton( if (displayKeyLimitDialog) { KeyLimitExceededDialog( + areBackupsEnabled = mode.areBackupsEnabled, onClick = { displayKeyLimitDialog = false } ) } @@ -709,34 +711,6 @@ private fun ColumnScope.CreateNewBackupKeySheetContent( } } -@Composable -private fun DownloadMediaDialog( - onTurnOffAndDownloadClick: () -> Unit = {}, - onCancelClick: () -> Unit = {} -) { - Dialogs.SimpleAlertDialog( - title = stringResource(R.string.MessageBackupsKeyRecordScreen__download_media), - body = stringResource(R.string.MessageBackupsKeyRecordScreen__to_create_a_new_backup_key), - confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__turn_off_and_download), - dismiss = stringResource(android.R.string.cancel), - onConfirm = onTurnOffAndDownloadClick, - onDeny = onCancelClick - ) -} - -@Composable -private fun KeyLimitExceededDialog( - onClick: () -> Unit = {} -) { - Dialogs.SimpleAlertDialog( - title = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_title), - body = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_body), - confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__ok), - onConfirm = {}, - onDismiss = onClick - ) -} - @Composable private fun ConfirmationFailureDialog(mode: MessageBackupsKeyRecordMode, onDismiss: () -> Unit) { Dialogs.AdvancedAlertDialog( @@ -786,7 +760,8 @@ private fun MessageBackupsKeyRecordScreenPreview() { onCreateNewKeyClick = {}, onTurnOffAndDownloadClick = {}, isOptimizedStorageEnabled = true, - canRotateKey = true + canRotateKey = true, + areBackupsEnabled = true ) ) } @@ -846,19 +821,3 @@ private fun CreateNewBackupKeySheetContentPreview() { } } } - -@DayNightPreviews -@Composable -private fun DownloadMediaDialogPreview() { - Previews.Preview { - DownloadMediaDialog() - } -} - -@DayNightPreviews -@Composable -private fun KeyLimitExceededDialogPreview() { - Previews.Preview { - KeyLimitExceededDialog() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt similarity index 65% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt rename to app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt index d13f0bde3b..bf22dfe2f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModel.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.components.settings.app.account.signallogin +package org.thoughtcrime.securesms.backup.v2.ui.subscription import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -13,19 +13,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log +import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState +import org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsAction +import org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsRepository /** - * Drives the screen that shows the user the account and recovery keys that make up their Signal Login, reached from - * account settings. + * Backs [SignalLoginViewDetailsScreen] within the backup setup flow, where the credentials are only ever shown and + * saved. Resets belong to account settings, so this only ever emits [SignalLoginViewDetailsAction.Shared]. */ -class SignalLoginViewDetailsViewModel( +class MessageBackupsSignalLoginDetailsViewModel( repository: SignalLoginViewDetailsRepository = SignalLoginViewDetailsRepository() ) : EventDrivenViewModel(TAG) { companion object { - private val TAG = Log.tag(SignalLoginViewDetailsViewModel::class) + private val TAG = Log.tag(MessageBackupsSignalLoginDetailsViewModel::class) } private val _state = MutableStateFlow( @@ -34,10 +37,10 @@ class SignalLoginViewDetailsViewModel( recoveryKey = repository.getAccountEntropyPool()?.displayValue.orEmpty() ) ) - private val _actions = Channel(Channel.BUFFERED) + private val _actions = Channel(Channel.BUFFERED) val state: StateFlow = _state.asStateFlow() - val actions: Flow = _actions.receiveAsFlow() + val actions: Flow = _actions.receiveAsFlow() override suspend fun processEvent(event: SignalLoginViewDetailsScreenEvents) { when (event) { @@ -50,6 +53,9 @@ class SignalLoginViewDetailsViewModel( SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { _actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf) } + SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked -> { + Log.w(TAG, "Recovery key resets aren't offered during backup setup.") + } is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> { _actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aci)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index abe01c1e39..e2cc328c31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -97,7 +97,7 @@ class AccountSettingsFragment : ComposeFragment() { viewModel.onEvent(AccountSettingsEvent.SignalLoginDetailsAuthenticated) } } - AccountSettingsAction.NavigateToSignalLoginDetails -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_signalLoginViewDetailsFragment) + AccountSettingsAction.NavigateToSignalLoginDetails -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_settingsSignalLoginDetailsFragment) AccountSettingsAction.NavigateToTotpSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment) is AccountSettingsAction.NavigateToRenameTotpApp -> { findNavController().safeNavigate( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyBottomSheet.kt new file mode 100644 index 0000000000..f5a615747e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyBottomSheet.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.signallogin + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.horizontalGutters +import org.thoughtcrime.securesms.R + +private val BUTTON_MAX_WIDTH = 220.dp + +/** + * Explains what resetting the recovery key entails before the user commits to it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetRecoveryKeyBottomSheet( + onContinueClick: () -> Unit, + onDismissRequest: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + ResetRecoveryKeySheetContent( + onContinueClick = onContinueClick, + onCancelClick = onDismissRequest + ) + } +} + +@Composable +private fun ResetRecoveryKeySheetContent( + onContinueClick: () -> Unit = {}, + onCancelClick: () -> Unit = {} +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.image_signal_backups_key), + contentDescription = null, + modifier = Modifier + .padding(top = 38.dp, bottom = 18.dp) + .size(80.dp) + ) + + Text( + text = stringResource(R.string.ResetRecoveryKeyBottomSheet__reset_your_recovery_key), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(bottom = 12.dp) + .horizontalGutters() + ) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(bottom = 48.dp, start = 36.dp, end = 36.dp) + ) { + Text( + text = stringResource(R.string.ResetRecoveryKeyBottomSheet__resetting_your_recovery_key_will_create_a_new_key), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(R.string.ResetRecoveryKeyBottomSheet__if_backups_are_enabled_you_will_have_to_re_upload), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + Buttons.LargeTonal( + onClick = onContinueClick, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth() + .requiredWidthIn(min = Dp.Unspecified, max = BUTTON_MAX_WIDTH) + ) { + Text(text = stringResource(R.string.ResetRecoveryKeyBottomSheet__continue)) + } + + TextButton( + onClick = onCancelClick, + modifier = Modifier + .padding(bottom = 48.dp) + .fillMaxWidth() + .requiredWidthIn(min = Dp.Unspecified, max = BUTTON_MAX_WIDTH) + ) { + Text(text = stringResource(android.R.string.cancel)) + } + } +} + +@DayNightPreviews +@Composable +private fun ResetRecoveryKeySheetContentPreview() { + Previews.BottomSheetPreview { + ResetRecoveryKeySheetContent() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyState.kt new file mode 100644 index 0000000000..6b929f0e28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/ResetRecoveryKeyState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.signallogin + +/** + * Tracks where the user is in the confirmation flow that precedes a recovery key reset. + * + * [hasResetPermitsRemaining] is null until the server tells us how many resets the user has left. + */ +data class ResetRecoveryKeyState( + val dialog: Dialog = Dialog.NONE, + val hasResetPermitsRemaining: Boolean? = null, + val areBackupsEnabled: Boolean = false +) { + enum class Dialog { + NONE, + + /** Sheet explaining what a reset entails. */ + CONFIRMATION, + + /** Storage optimization has to be turned off before the key can be reset. */ + DOWNLOAD_MEDIA, + + /** The user has used up their resets for now. */ + KEY_LIMIT_REACHED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsEvent.kt new file mode 100644 index 0000000000..7c7c05723d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsEvent.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.signallogin + +import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents + +/** + * Everything [SettingsSignalLoginDetailsViewModel] can be told about. This wraps the events the shared screen emits and + * adds the ones that only exist in the settings entry point, namely the recovery key reset confirmation flow. + */ +sealed interface SettingsSignalLoginDetailsEvent { + + /** An event emitted by the shared screen itself. */ + data class Screen(val event: SignalLoginViewDetailsScreenEvents) : SettingsSignalLoginDetailsEvent + + /** The user acknowledged what a recovery key reset entails. */ + data object ResetRecoveryKeyConfirmed : SettingsSignalLoginDetailsEvent + + /** The user backed out of one of the recovery key reset dialogs. */ + data object ResetRecoveryKeyDismissed : SettingsSignalLoginDetailsEvent + + /** The user agreed to turn off optimized storage so their offloaded media comes back down. */ + data object TurnOffOptimizedStorageClicked : SettingsSignalLoginDetailsEvent + + /** The reset flow finished and generated a new recovery key. */ + data object RecoveryKeyRotated : SettingsSignalLoginDetailsEvent +} 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/SettingsSignalLoginDetailsFragment.kt similarity index 51% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsFragment.kt index f2cb5c0a8d..ec16c760f0 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/SettingsSignalLoginDetailsFragment.kt @@ -6,13 +6,15 @@ package org.thoughtcrime.securesms.components.settings.app.account.signallogin import android.net.Uri +import android.os.Bundle import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.fragment.app.viewModels +import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch import org.signal.core.ui.compose.CollectActions import org.signal.core.ui.compose.ComposeFragment @@ -21,14 +23,19 @@ import org.signal.core.util.Util import org.signal.passwordmanager.SignalCredentialManager import org.signal.signallogin.pdf.SignalLoginPdfRenderer import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen +import org.thoughtcrime.securesms.backup.v2.ui.subscription.DownloadMediaDialog +import org.thoughtcrime.securesms.backup.v2.ui.subscription.KeyLimitExceededDialog import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity +import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyDisplayFragment +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.viewModel /** * Shows the account and recovery keys that make up the user's Signal Login, the same way registration does. */ -class SignalLoginViewDetailsFragment : ComposeFragment() { +class SettingsSignalLoginDetailsFragment : ComposeFragment() { - private val viewModel: SignalLoginViewDetailsViewModel by viewModels() + private val viewModel: SettingsSignalLoginDetailsViewModel by viewModel { SettingsSignalLoginDetailsViewModel(showResetRecoveryKeyButton = true) } private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri: Uri? -> if (uri != null) { @@ -42,9 +49,20 @@ class SignalLoginViewDetailsFragment : ComposeFragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle -> + if (bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)) { + viewModel.onEvent(SettingsSignalLoginDetailsEvent.RecoveryKeyRotated) + } + } + } + @Composable override fun FragmentContent() { val state by viewModel.state.collectAsStateWithLifecycle() + val resetRecoveryKeyState by viewModel.resetRecoveryKeyState.collectAsStateWithLifecycle() TemporaryScreenshotSecurity.bind() @@ -52,8 +70,33 @@ class SignalLoginViewDetailsFragment : ComposeFragment() { SignalLoginViewDetailsScreen( state = state, - onEvent = viewModel::onEvent + onEvent = { viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(it)) } ) + + when (resetRecoveryKeyState.dialog) { + ResetRecoveryKeyState.Dialog.NONE -> Unit + + ResetRecoveryKeyState.Dialog.CONFIRMATION -> { + ResetRecoveryKeyBottomSheet( + onContinueClick = { viewModel.onEvent(SettingsSignalLoginDetailsEvent.ResetRecoveryKeyConfirmed) }, + onDismissRequest = { viewModel.onEvent(SettingsSignalLoginDetailsEvent.ResetRecoveryKeyDismissed) } + ) + } + + ResetRecoveryKeyState.Dialog.DOWNLOAD_MEDIA -> { + DownloadMediaDialog( + onTurnOffAndDownloadClick = { viewModel.onEvent(SettingsSignalLoginDetailsEvent.TurnOffOptimizedStorageClicked) }, + onCancelClick = { viewModel.onEvent(SettingsSignalLoginDetailsEvent.ResetRecoveryKeyDismissed) } + ) + } + + ResetRecoveryKeyState.Dialog.KEY_LIMIT_REACHED -> { + KeyLimitExceededDialog( + areBackupsEnabled = resetRecoveryKeyState.areBackupsEnabled, + onClick = { viewModel.onEvent(SettingsSignalLoginDetailsEvent.ResetRecoveryKeyDismissed) } + ) + } + } } private fun handleAction(action: SignalLoginViewDetailsAction) { @@ -69,6 +112,11 @@ class SignalLoginViewDetailsFragment : ComposeFragment() { } } SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext())) + SignalLoginViewDetailsAction.LaunchRecoveryKeyReset -> { + findNavController().safeNavigate( + SettingsSignalLoginDetailsFragmentDirections.actionSettingsSignalLoginDetailsFragmentToBackupKeyDisplayFragment().setStartWithKeyRotation(true) + ) + } is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboardSensitive(requireContext(), action.text) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt new file mode 100644 index 0000000000..0f9598f958 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModel.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.signallogin + +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.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents +import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState + +/** + * Drives the screen that shows the user the account and recovery keys that make up their Signal Login, reached from + * account settings. + * + * @param showResetRecoveryKeyButton True if the option to reset the recovery key should be offered in the UI. + */ +class SettingsSignalLoginDetailsViewModel( + private val repository: SignalLoginViewDetailsRepository = SignalLoginViewDetailsRepository(), + showResetRecoveryKeyButton: Boolean = false +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(SettingsSignalLoginDetailsViewModel::class) + } + + private val _state = MutableStateFlow( + SignalLoginViewDetailsState( + accountKey = repository.getAci()?.toString()?.uppercase().orEmpty(), + recoveryKey = repository.getAccountEntropyPool()?.displayValue.orEmpty(), + showResetRecoveryKeyButton = showResetRecoveryKeyButton, + resetRecoveryKeyButtonLoading = showResetRecoveryKeyButton + ) + ) + private val _resetRecoveryKeyState = MutableStateFlow(ResetRecoveryKeyState(areBackupsEnabled = repository.areBackupsEnabled())) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val resetRecoveryKeyState: StateFlow = _resetRecoveryKeyState.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + init { + if (showResetRecoveryKeyButton) { + refreshResetLimit() + } + } + + override suspend fun processEvent(event: SettingsSignalLoginDetailsEvent) { + when (event) { + is SettingsSignalLoginDetailsEvent.Screen -> { + processScreenEvent(event.event) + } + + SettingsSignalLoginDetailsEvent.ResetRecoveryKeyConfirmed -> { + if (repository.isOptimizedStorageEnabled()) { + _resetRecoveryKeyState.update { it.copy(dialog = ResetRecoveryKeyState.Dialog.DOWNLOAD_MEDIA) } + } else { + _resetRecoveryKeyState.update { it.copy(dialog = ResetRecoveryKeyState.Dialog.NONE) } + _actions.send(SignalLoginViewDetailsAction.LaunchRecoveryKeyReset) + } + } + + SettingsSignalLoginDetailsEvent.TurnOffOptimizedStorageClicked -> { + repository.turnOffOptimizedStorageAndDownloadMedia() + _resetRecoveryKeyState.update { it.copy(dialog = ResetRecoveryKeyState.Dialog.NONE) } + _actions.send(SignalLoginViewDetailsAction.NavigateBack) + } + + SettingsSignalLoginDetailsEvent.ResetRecoveryKeyDismissed -> { + _resetRecoveryKeyState.update { it.copy(dialog = ResetRecoveryKeyState.Dialog.NONE) } + } + + SettingsSignalLoginDetailsEvent.RecoveryKeyRotated -> { + _state.update { + it.copy( + accountKey = repository.getAci()?.toString()?.uppercase().orEmpty(), + recoveryKey = repository.getAccountEntropyPool()?.displayValue.orEmpty(), + resetRecoveryKeyButtonLoading = it.showResetRecoveryKeyButton + ) + } + _resetRecoveryKeyState.update { it.copy(hasResetPermitsRemaining = null) } + refreshResetLimit() + } + } + } + + private suspend fun processScreenEvent(event: SignalLoginViewDetailsScreenEvents) { + when (event) { + SignalLoginViewDetailsScreenEvents.BackClicked -> { + _actions.send(SignalLoginViewDetailsAction.NavigateBack) + } + SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked -> { + _actions.send(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) + } + SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { + _actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf) + } + SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked -> { + _resetRecoveryKeyState.update { + when (it.hasResetPermitsRemaining) { + true -> it.copy(dialog = ResetRecoveryKeyState.Dialog.CONFIRMATION) + false -> it.copy(dialog = ResetRecoveryKeyState.Dialog.KEY_LIMIT_REACHED) + null -> { + Log.w(TAG, "Reset clicked before the limit was known. Ignoring.") + it + } + } + } + } + is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> { + _actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aci)) + } + is SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked -> { + _actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aep)) + } + } + } + + private fun refreshResetLimit() { + viewModelScope.launch { + val canReset = repository.canResetRecoveryKey() + _resetRecoveryKeyState.update { it.copy(hasResetPermitsRemaining = canReset) } + _state.update { it.copy(resetRecoveryKeyButtonLoading = false) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt index c3aead03c7..d3959d9c4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsAction.kt @@ -8,26 +8,35 @@ package org.thoughtcrime.securesms.components.settings.app.account.signallogin import org.signal.core.util.censor /** - * One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by - * [SignalLoginViewDetailsFragment] rather than the screen itself. + * One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by whatever is + * hosting the Signal Login details screen rather than the screen itself. + * + * Hosts that can't reset the recovery key collect [Shared] instead, so they never have to handle an action they can't + * produce. * * Actions are logged, so be sure `toString()` contains nothing sensitive. */ sealed interface SignalLoginViewDetailsAction { + /** The actions every host of the screen can produce. */ + sealed interface Shared : SignalLoginViewDetailsAction + /** Leave the screen. */ - data object NavigateBack : SignalLoginViewDetailsAction + data object NavigateBack : Shared /** Launch the system credential manager UI so the user can store the login in their password manager. */ - data object LaunchSaveToPasswordManager : SignalLoginViewDetailsAction + data object LaunchSaveToPasswordManager : Shared /** Launch the system document picker so the user can choose where to save the login PDF. */ - data object LaunchSaveAsPdf : SignalLoginViewDetailsAction + data object LaunchSaveAsPdf : Shared /** Copy the specified text to the clipboard */ - data class CopyTextToClipboard(val text: String) : SignalLoginViewDetailsAction { + data class CopyTextToClipboard(val text: String) : Shared { override fun toString(): String { return "CopyTextToClipboard(text=${text.censor()})" } } + + /** Hand the user off to the screen that generates and confirms their replacement recovery key. */ + data object LaunchRecoveryKeyReset : SignalLoginViewDetailsAction } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsRepository.kt index bbac544ced..0c7aab2813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsRepository.kt @@ -7,14 +7,24 @@ package org.thoughtcrime.securesms.components.settings.app.account.signallogin import org.signal.core.models.AccountEntropyPool import org.signal.core.models.ServiceId.ACI +import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.keyvalue.SignalStore /** - * Where [SignalLoginViewDetailsViewModel] reads the credentials that make up the user's Signal Login. + * Where the view models behind the Signal Login details screen read the credentials that make up the user's Signal + * Login, and the state the recovery key reset flow depends on. */ class SignalLoginViewDetailsRepository { fun getAci(): ACI? = SignalStore.account.aci fun getAccountEntropyPool(): AccountEntropyPool? = SignalStore.account.accountEntropyPoolOrNull + + fun isOptimizedStorageEnabled(): Boolean = SignalStore.backup.optimizeStorage + + fun areBackupsEnabled(): Boolean = SignalStore.backup.areBackupsEnabled + + suspend fun canResetRecoveryKey(): Boolean = BackupRepository.canRotateBackupKey() + + fun turnOffOptimizedStorageAndDownloadMedia() = BackupRepository.turnOffOptimizedStorageAndDownloadMedia() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt index dde313eba4..8dc8c190c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt @@ -24,6 +24,8 @@ import androidx.navigation.fragment.navArgs import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.compose.Dialogs import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.subscription.DownloadMediaDialog +import org.thoughtcrime.securesms.backup.v2.ui.subscription.KeyLimitExceededDialog import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordMode import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen @@ -71,7 +73,7 @@ class BackupKeyDisplayFragment : ComposeFragment() { displayWarningDialog = true } - val mode = remember(state.rotationState, state.canRotateKey) { + val mode = remember(state.rotationState, state.canRotateKey, state.areBackupsEnabled) { if (state.rotationState == BackupKeyRotationState.NOT_STARTED) { MessageBackupsKeyRecordMode.CreateNewKey( onCreateNewKeyClick = { @@ -82,7 +84,8 @@ class BackupKeyDisplayFragment : ComposeFragment() { findNavController().popBackStack() }, isOptimizedStorageEnabled = state.isOptimizedStorageEnabled, - canRotateKey = state.canRotateKey + canRotateKey = state.canRotateKey, + areBackupsEnabled = state.areBackupsEnabled ) } else { MessageBackupsKeyRecordMode.Next( @@ -97,6 +100,31 @@ class BackupKeyDisplayFragment : ComposeFragment() { Dialogs.IndeterminateProgressDialog() } + if (state.rotationState == BackupKeyRotationState.NOT_ALLOWED) { + val onAcknowledged = { + if (args.startWithKeyRotation) { + findNavController().popBackStack() + } else { + viewModel.onRotationRefusalAcknowledged() + } + } + + if (!state.canRotateKey) { + KeyLimitExceededDialog( + areBackupsEnabled = state.areBackupsEnabled, + onClick = { onAcknowledged() } + ) + } else { + DownloadMediaDialog( + onTurnOffAndDownloadClick = { + viewModel.turnOffOptimizedStorageAndDownloadMedia() + findNavController().popBackStack() + }, + onCancelClick = { onAcknowledged() } + ) + } + } + if (displayWarningDialog) { BackupKeyNotCommitedWarningDialog( onConfirm = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt index c38f9420b5..d2921d8ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt @@ -18,15 +18,12 @@ import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations -import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.net.SignalNetwork class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler { companion object { - private val TAG = Log.tag(BackupKeyDisplayViewModel::class.java) + private val TAG = Log.tag(BackupKeyDisplayViewModel::class) } private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState()) @@ -40,8 +37,32 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler getKeyRotationLimit() } + /** + * Generates a replacement AEP, provided the user is still allowed to. Callers can be several screens removed from the + * checks that gate this, so everything is re-verified here rather than trusted. + */ fun rotateBackupKey() { viewModelScope.launch { + if (internalUiState.value.rotationState != BackupKeyRotationState.NOT_STARTED) { + Log.w(TAG, "Rotation already underway. Ignoring.") + return@launch + } + + val canRotateKey = BackupRepository.canRotateBackupKey() + val isOptimizedStorageEnabled = SignalStore.backup.optimizeStorage + + if (!canRotateKey || isOptimizedStorageEnabled) { + Log.w(TAG, "Refusing to rotate the backup key. canRotateKey: $canRotateKey, isOptimizedStorageEnabled: $isOptimizedStorageEnabled") + internalUiState.update { + it.copy( + canRotateKey = canRotateKey, + isOptimizedStorageEnabled = isOptimizedStorageEnabled, + rotationState = BackupKeyRotationState.NOT_ALLOWED + ) + } + return@launch + } + internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) } val stagedKeyRotations = withContext(SignalDispatchers.Default) { @@ -73,22 +94,20 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler } fun getKeyRotationLimit() { - viewModelScope.launch(SignalDispatchers.IO) { - SignalNetwork.archiveService - .getKeyRotationLimit() - .onRight { limit -> - internalUiState.update { it.copy(canRotateKey = limit.hasPermitsRemaining ?: true) } - } - .onLeft { error -> - Log.w(TAG, "Error while getting rotation limit: ${error::class.simpleName}. Default to allowing key rotations.") - } + viewModelScope.launch { + val canRotateKey = BackupRepository.canRotateBackupKey() + internalUiState.update { it.copy(canRotateKey = canRotateKey) } } } + /** The user dismissed the dialog explaining why we refused to rotate their key. */ + fun onRotationRefusalAcknowledged() { + internalUiState.update { it.copy(rotationState = BackupKeyRotationState.NOT_STARTED) } + } + fun turnOffOptimizedStorageAndDownloadMedia() { - SignalStore.backup.optimizeStorage = false // TODO - flag to notify when complete. - AppDependencies.jobManager.add(RestoreOptimizedMediaJob()) + BackupRepository.turnOffOptimizedStorageAndDownloadMedia() } } @@ -98,11 +117,15 @@ data class BackupKeyDisplayUiState( val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage, val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED, val stagedKeyRotations: StagedBackupKeyRotations? = null, - val canRotateKey: Boolean = true + val canRotateKey: Boolean = true, + val areBackupsEnabled: Boolean = SignalStore.backup.areBackupsEnabled ) enum class BackupKeyRotationState { NOT_STARTED, + + /** We refused to start a rotation because the user is out of permits or still has storage optimization on. */ + NOT_ALLOWED, GENERATING_KEY, USER_VERIFICATION, COMMITTING_KEY, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index a93c72ee57..00af4d24db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.withContext import org.signal.core.util.bytes -import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log import org.signal.core.util.mebiBytes import org.signal.core.util.throttleLatest @@ -49,7 +48,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.BackupMessagesConstraint import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState -import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.util.Environment import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.TextSecurePreferences @@ -282,18 +280,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() { } fun getKeyRotationLimit() { - viewModelScope.launch(SignalDispatchers.IO) { - val canRotateKey = SignalNetwork.archiveService - .getKeyRotationLimit() - .fold( - ifRight = { it.hasPermitsRemaining!! }, - ifLeft = { error -> - Log.w(TAG, "Error while getting rotation limit: ${error::class.simpleName}. Default to allowing key rotations.") - true - } - ) - - if (!canRotateKey) { + viewModelScope.launch { + if (!BackupRepository.canRotateBackupKey()) { requestDialog(RemoteBackupsSettingsState.Dialog.KEY_ROTATION_LIMIT_REACHED) } } diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 13d892e2b7..a21f90b599 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -217,8 +217,8 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + android:id="@+id/settingsSignalLoginDetailsFragment" + android:name="org.thoughtcrime.securesms.components.settings.app.account.signallogin.SettingsSignalLoginDetailsFragment" + android:label="settings_signal_login_details_fragment"> + + Join me on Signal + + Reset your recovery key + + Resetting your recovery key will create a new key for your Signal Login. This is only necessary if someone else knows your key. + + If backups are enabled you will have to re-upload your backup, including media. If you are using \"Optimize Signal storage\" you will have to download offloaded media first. + + Continue + + You\'ve exhausted the number of new keys you can create. You will be able to create a new recovery key in 7 days. + diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt similarity index 64% rename from app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModelTest.kt rename to app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt index 20569cdfa1..02c35b3fa2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsSignalLoginDetailsViewModelTest.kt @@ -3,17 +3,20 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.components.settings.app.account.signallogin +package org.thoughtcrime.securesms.backup.v2.ui.subscription import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -24,10 +27,12 @@ import org.junit.Test import org.signal.core.models.AccountEntropyPool import org.signal.core.models.ServiceId.ACI import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents +import org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsAction +import org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsRepository import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) -class SignalLoginViewDetailsViewModelTest { +class MessageBackupsSignalLoginDetailsViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() @@ -52,7 +57,7 @@ class SignalLoginViewDetailsViewModelTest { every { repository.getAci() } returns ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91")) every { repository.getAccountEntropyPool() } returns aep - val viewModel = SignalLoginViewDetailsViewModel(repository) + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) assertThat(viewModel.state.value.accountKey).isEqualTo("A6B28482-2E32-83D0-7F23-91360A4C2B91") assertThat(viewModel.state.value.recoveryKey).isEqualTo(aep.displayValue) @@ -60,17 +65,23 @@ class SignalLoginViewDetailsViewModelTest { @Test fun `initial state is empty when there are no stored credentials`() { - val viewModel = SignalLoginViewDetailsViewModel(repository) + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) assertThat(viewModel.state.value.accountKey).isEqualTo("") assertThat(viewModel.state.value.recoveryKey).isEqualTo("") } + @Test + fun `the reset button is never offered during backup setup`() { + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + + assertThat(viewModel.state.value.showResetRecoveryKeyButton).isFalse() + } + @Test fun `BackClicked navigates back`() = runTest(testDispatcher) { - val viewModel = SignalLoginViewDetailsViewModel(repository) - val actions = mutableListOf() - backgroundScope.launch { viewModel.actions.toList(actions) } + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + val actions = collectActions(viewModel) viewModel.onEvent(SignalLoginViewDetailsScreenEvents.BackClicked) @@ -79,9 +90,8 @@ class SignalLoginViewDetailsViewModelTest { @Test fun `SaveToPasswordManagerClicked launches the save to password manager flow`() = runTest(testDispatcher) { - val viewModel = SignalLoginViewDetailsViewModel(repository) - val actions = mutableListOf() - backgroundScope.launch { viewModel.actions.toList(actions) } + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + val actions = collectActions(viewModel) viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) @@ -90,9 +100,8 @@ class SignalLoginViewDetailsViewModelTest { @Test fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) { - val viewModel = SignalLoginViewDetailsViewModel(repository) - val actions = mutableListOf() - backgroundScope.launch { viewModel.actions.toList(actions) } + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + val actions = collectActions(viewModel) viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) @@ -101,9 +110,8 @@ class SignalLoginViewDetailsViewModelTest { @Test fun `CopyAccountIdClicked copies the account key to the clipboard`() = runTest(testDispatcher) { - val viewModel = SignalLoginViewDetailsViewModel(repository) - val actions = mutableListOf() - backgroundScope.launch { viewModel.actions.toList(actions) } + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + val actions = collectActions(viewModel) viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91")) @@ -113,12 +121,27 @@ class SignalLoginViewDetailsViewModelTest { @Test fun `CopyRecoveryKeyClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) { val recoveryKey = AccountEntropyPool.generate().displayValue - val viewModel = SignalLoginViewDetailsViewModel(repository) - val actions = mutableListOf() - backgroundScope.launch { viewModel.actions.toList(actions) } + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + val actions = collectActions(viewModel) viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(recoveryKey)) assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard(recoveryKey)) } + + @Test + fun `ResetRecoveryKeyClicked produces no action`() = runTest(testDispatcher) { + val viewModel = MessageBackupsSignalLoginDetailsViewModel(repository) + val actions = collectActions(viewModel) + + viewModel.onEvent(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked) + + assertThat(actions).isEmpty() + } + + private fun TestScope.collectActions(viewModel: MessageBackupsSignalLoginDetailsViewModel): List { + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + return actions + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt new file mode 100644 index 0000000000..abb4d90676 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SettingsSignalLoginDetailsViewModelTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.signallogin + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsSignalLoginDetailsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val repository = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { repository.getAci() } returns null + every { repository.getAccountEntropyPool() } returns null + every { repository.isOptimizedStorageEnabled() } returns false + every { repository.areBackupsEnabled() } returns true + every { repository.turnOffOptimizedStorageAndDownloadMedia() } returns Unit + coEvery { repository.canResetRecoveryKey() } returns true + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state maps the stored credentials into display form`() { + val aep = AccountEntropyPool.generate() + every { repository.getAci() } returns ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91")) + every { repository.getAccountEntropyPool() } returns aep + + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + + assertThat(viewModel.state.value.accountKey).isEqualTo("A6B28482-2E32-83D0-7F23-91360A4C2B91") + assertThat(viewModel.state.value.recoveryKey).isEqualTo(aep.displayValue) + } + + @Test + fun `initial state is empty when there are no stored credentials`() { + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + + assertThat(viewModel.state.value.accountKey).isEqualTo("") + assertThat(viewModel.state.value.recoveryKey).isEqualTo("") + } + + @Test + fun `BackClicked navigates back`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.BackClicked)) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.NavigateBack) + } + + @Test + fun `SaveToPasswordManagerClicked launches the save to password manager flow`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked)) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveToPasswordManager) + } + + @Test + fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked)) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveAsPdf) + } + + @Test + fun `CopyAccountIdClicked copies the account key to the clipboard`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91"))) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91")) + } + + @Test + fun `CopyRecoveryKeyClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) { + val recoveryKey = AccountEntropyPool.generate().displayValue + val viewModel = SettingsSignalLoginDetailsViewModel(repository) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(recoveryKey))) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard(recoveryKey)) + } + + @Test + fun `the reset button is hidden unless the caller asks for it`() { + assertThat(SettingsSignalLoginDetailsViewModel(repository).state.value.showResetRecoveryKeyButton).isFalse() + assertThat(SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true).state.value.showResetRecoveryKeyButton).isTrue() + } + + @Test + fun `ResetRecoveryKeyClicked shows the confirmation sheet`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked)) + + assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.CONFIRMATION) + } + + @Test + fun `ResetRecoveryKeyClicked shows the limit dialog when there are no resets left`() = runTest(testDispatcher) { + coEvery { repository.canResetRecoveryKey() } returns false + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked)) + + assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.KEY_LIMIT_REACHED) + } + + @Test + fun `confirming the reset launches the reset flow`() = runTest(testDispatcher) { + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.ResetRecoveryKeyConfirmed) + + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchRecoveryKeyReset) + assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.NONE) + } + + @Test + fun `confirming the reset asks the user to download offloaded media first`() = runTest(testDispatcher) { + every { repository.isOptimizedStorageEnabled() } returns true + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.ResetRecoveryKeyConfirmed) + + assertThat(actions).isEmpty() + assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.DOWNLOAD_MEDIA) + } + + @Test + fun `turning off optimized storage starts the download and leaves the screen`() = runTest(testDispatcher) { + every { repository.isOptimizedStorageEnabled() } returns true + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.TurnOffOptimizedStorageClicked) + + verify { repository.turnOffOptimizedStorageAndDownloadMedia() } + assertThat(actions).containsExactly(SignalLoginViewDetailsAction.NavigateBack) + assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.NONE) + } + + @Test + fun `RecoveryKeyRotated picks up the key the reset generated`() = runTest(testDispatcher) { + val original = AccountEntropyPool.generate() + val replacement = AccountEntropyPool.generate() + every { repository.getAccountEntropyPool() } returns original + + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + assertThat(viewModel.state.value.recoveryKey).isEqualTo(original.displayValue) + + every { repository.getAccountEntropyPool() } returns replacement + viewModel.onEvent(SettingsSignalLoginDetailsEvent.RecoveryKeyRotated) + + assertThat(viewModel.state.value.recoveryKey).isEqualTo(replacement.displayValue) + } + + @Test + fun `a spinner stands in for the reset button until the reset limit is known`() = runTest(testDispatcher) { + val limit = CompletableDeferred() + coEvery { repository.canResetRecoveryKey() } coAnswers { limit.await() } + + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + + assertThat(viewModel.state.value.resetRecoveryKeyButtonLoading).isTrue() + + limit.complete(true) + + assertThat(viewModel.state.value.resetRecoveryKeyButtonLoading).isFalse() + } + + @Test + fun `ResetRecoveryKeyClicked is ignored while the reset limit is unknown`() = runTest(testDispatcher) { + coEvery { repository.canResetRecoveryKey() } coAnswers { CompletableDeferred().await() } + val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true) + + viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked)) + + assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.NONE) + } +} 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 f1771618e5..defacf564a 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -130,8 +130,8 @@ import org.signal.registration.screens.signallogincredentials.SignalLoginCredent 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.signallogindetails.RegistrationSignalLoginDetailsAction +import org.signal.registration.screens.signallogindetails.RegistrationSignalLoginDetailsViewModel import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreenActions import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreenEvents @@ -820,8 +820,8 @@ private fun EntryProviderScope.navigationEntries( entry { val context = LocalContext.current val scope = rememberCoroutineScope() - val viewModel: SignalLoginViewDetailsViewModel = viewModel { - SignalLoginViewDetailsViewModel( + val viewModel: RegistrationSignalLoginDetailsViewModel = viewModel { + RegistrationSignalLoginDetailsViewModel( parentState = registrationViewModel.state, parentEventEmitter = registrationViewModel::onEvent ) @@ -841,7 +841,7 @@ private fun EntryProviderScope.navigationEntries( CollectActions(viewModel.actions) { action -> when (action) { - SignalLoginViewDetailsScreenActions.LaunchSaveToPasswordManager -> { + RegistrationSignalLoginDetailsAction.LaunchSaveToPasswordManager -> { scope.launch { SignalCredentialManager.saveCredential( activityContext = context, @@ -851,9 +851,9 @@ private fun EntryProviderScope.navigationEntries( } } - SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context)) + RegistrationSignalLoginDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context)) - is SignalLoginViewDetailsScreenActions.CopyTextToClipboard -> Util.copyToClipboardSensitive(context, action.text) + is RegistrationSignalLoginDetailsAction.CopyTextToClipboard -> Util.copyToClipboardSensitive(context, action.text) } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsAction.kt similarity index 64% rename from feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenActions.kt rename to feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsAction.kt index 27d3ca800c..8d932cb12b 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenActions.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsAction.kt @@ -7,15 +7,15 @@ package org.signal.registration.screens.signallogindetails import org.signal.core.util.censor -sealed interface SignalLoginViewDetailsScreenActions { +sealed interface RegistrationSignalLoginDetailsAction { /** Launch the system credential manager UI so the user can store the login in their password manager. */ - data object LaunchSaveToPasswordManager : SignalLoginViewDetailsScreenActions + data object LaunchSaveToPasswordManager : RegistrationSignalLoginDetailsAction /** Launch the system document picker so the user can choose where to save the login PDF. */ - data object LaunchSaveAsPdf : SignalLoginViewDetailsScreenActions + data object LaunchSaveAsPdf : RegistrationSignalLoginDetailsAction /** Copy the specified text to the clipboard. */ - data class CopyTextToClipboard(val text: String) : SignalLoginViewDetailsScreenActions { + data class CopyTextToClipboard(val text: String) : RegistrationSignalLoginDetailsAction { override fun toString(): String { return "CopyTextToClipboard(text=${text.censor()})" } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsViewModel.kt similarity index 75% rename from feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt rename to feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsViewModel.kt index 7dd243b4af..3645d99968 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsViewModel.kt @@ -27,13 +27,13 @@ import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState /** * View model backing [SignalLoginViewDetailsScreen] within the registration flow. */ -class SignalLoginViewDetailsViewModel( +class RegistrationSignalLoginDetailsViewModel( parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : EventDrivenViewModel(TAG) { companion object { - private val TAG = Log.tag(SignalLoginViewDetailsViewModel::class) + private val TAG = Log.tag(RegistrationSignalLoginDetailsViewModel::class) } private val _state = MutableStateFlow( @@ -44,8 +44,8 @@ class SignalLoginViewDetailsViewModel( ) val state: StateFlow = _state.asStateFlow() - private val _actions = Channel(Channel.BUFFERED) - val actions: Flow = _actions.receiveAsFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _actions.receiveAsFlow() init { _state @@ -65,19 +65,23 @@ class SignalLoginViewDetailsViewModel( } is SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked -> { - _actions.trySend(SignalLoginViewDetailsScreenActions.LaunchSaveToPasswordManager) + _actions.trySend(RegistrationSignalLoginDetailsAction.LaunchSaveToPasswordManager) } is SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { - _actions.trySend(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf) + _actions.trySend(RegistrationSignalLoginDetailsAction.LaunchSaveAsPdf) + } + + is SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked -> { + Log.w(TAG, "Recovery key resets aren't offered during registration.") } is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> { - _actions.trySend(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(event.aci)) + _actions.trySend(RegistrationSignalLoginDetailsAction.CopyTextToClipboard(event.aci)) } is SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked -> { - _actions.trySend(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(event.aep)) + _actions.trySend(RegistrationSignalLoginDetailsAction.CopyTextToClipboard(event.aep)) } } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsViewModelTest.kt similarity index 70% rename from feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt rename to feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsViewModelTest.kt index df70687aec..bfc6b76123 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/RegistrationSignalLoginDetailsViewModelTest.kt @@ -7,6 +7,7 @@ package org.signal.registration.screens.signallogindetails import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,16 +29,16 @@ import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) -class SignalLoginViewDetailsViewModelTest { +class RegistrationSignalLoginDetailsViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var viewModel: SignalLoginViewDetailsViewModel + private lateinit var viewModel: RegistrationSignalLoginDetailsViewModel @Before fun setup() { Dispatchers.setMain(testDispatcher) - viewModel = SignalLoginViewDetailsViewModel( + viewModel = RegistrationSignalLoginDetailsViewModel( parentState = MutableStateFlow(RegistrationFlowState()), parentEventEmitter = {} ) @@ -53,7 +54,7 @@ class SignalLoginViewDetailsViewModelTest { val aci = ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91")) val aep = AccountEntropyPool.generate() - val viewModel = SignalLoginViewDetailsViewModel( + val viewModel = RegistrationSignalLoginDetailsViewModel( parentState = MutableStateFlow(RegistrationFlowState(aci = aci, accountEntropyPool = aep)), parentEventEmitter = {} ) @@ -73,42 +74,52 @@ class SignalLoginViewDetailsViewModelTest { @Test fun `SaveToPasswordManagerClicked launches the save to password manager flow`() = runTest(testDispatcher) { - val actions = mutableListOf() + val actions = mutableListOf() backgroundScope.launch { viewModel.actions.toList(actions) } viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) - assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.LaunchSaveToPasswordManager) + assertThat(actions).containsExactly(RegistrationSignalLoginDetailsAction.LaunchSaveToPasswordManager) } @Test fun `SaveAsPdfClicked launches the save as PDF flow`() = runTest(testDispatcher) { - val actions = mutableListOf() + val actions = mutableListOf() backgroundScope.launch { viewModel.actions.toList(actions) } viewModel.onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) - assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf) + assertThat(actions).containsExactly(RegistrationSignalLoginDetailsAction.LaunchSaveAsPdf) + } + + @Test + fun `ResetRecoveryKeyClicked produces no action`() = runTest(testDispatcher) { + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.toList(actions) } + + viewModel.onEvent(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked) + + assertThat(actions).isEmpty() } @Test fun `CopyAccountIdClicked copies the account key to the clipboard`() = runTest(testDispatcher) { - val actions = mutableListOf() + val actions = mutableListOf() backgroundScope.launch { viewModel.actions.toList(actions) } viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91")) - assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91")) + assertThat(actions).containsExactly(RegistrationSignalLoginDetailsAction.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() + val actions = mutableListOf() backgroundScope.launch { viewModel.actions.toList(actions) } viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(recoveryKey)) - assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(recoveryKey)) + assertThat(actions).containsExactly(RegistrationSignalLoginDetailsAction.CopyTextToClipboard(recoveryKey)) } } 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 d591adb93e..1c90761ffc 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 @@ -7,6 +7,7 @@ package org.signal.registration.screens.signallogindetails import android.app.Application import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -75,13 +76,39 @@ class SignalLoginViewDetailsScreenTest { assertThat(events).contains(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(RECOVERY_KEY)) } - private fun setContent() { + @Test + fun `when the screen cannot reset the recovery key, the reset button is not shown`() { + setContent() + + composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON).assertIsNotDisplayed() + } + + @Test + fun `when the reset recovery key button is clicked, ResetRecoveryKeyClicked is emitted`() { + setContent(showResetRecoveryKeyButton = true) + + composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON).performClick() + + assertThat(events).contains(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked) + } + + @Test + fun `when the reset limit is still loading, a spinner replaces the reset button`() { + setContent(showResetRecoveryKeyButton = true, resetRecoveryKeyButtonLoading = true) + + composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_SPINNER).assertIsDisplayed() + composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON).assertIsNotDisplayed() + } + + private fun setContent(showResetRecoveryKeyButton: Boolean = false, resetRecoveryKeyButtonLoading: Boolean = false) { composeTestRule.setContent { SignalTheme { SignalLoginViewDetailsScreen( state = SignalLoginViewDetailsState( accountKey = ACCOUNT_KEY, - recoveryKey = RECOVERY_KEY + recoveryKey = RECOVERY_KEY, + showResetRecoveryKeyButton = showResetRecoveryKeyButton, + resetRecoveryKeyButtonLoading = resetRecoveryKeyButtonLoading ), onEvent = { events += it } ) 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 9c8f25e986..f8e49a6932 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 @@ -17,6 +17,8 @@ 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_RESET_RECOVERY_KEY_BUTTON = "signal_login_view_details_reset_recovery_key_button" + const val VIEW_DETAILS_RESET_RECOVERY_KEY_SPINNER = "signal_login_view_details_reset_recovery_key_spinner" 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" 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 23671d94c8..ad3710ddda 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 @@ -7,6 +7,7 @@ package org.signal.signallogin.viewdetails import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,8 +21,10 @@ 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.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -101,7 +104,11 @@ fun SignalLoginViewDetailsScreen( ) } - Footer(onEvent = onEvent) + Footer( + showResetRecoveryKeyButton = state.showResetRecoveryKeyButton, + resetRecoveryKeyButtonLoading = state.resetRecoveryKeyButtonLoading, + onEvent = onEvent + ) } } } @@ -138,7 +145,11 @@ private fun MiniCard(modifier: Modifier = Modifier) { } @Composable -private fun Footer(onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit) { +private fun Footer( + showResetRecoveryKeyButton: Boolean, + resetRecoveryKeyButtonLoading: Boolean, + onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit +) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), @@ -173,6 +184,32 @@ private fun Footer(onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit) { ) { Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_as_pdf)) } + + if (showResetRecoveryKeyButton) { + if (resetRecoveryKeyButtonLoading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .height(ButtonDefaults.MinHeight) + .testTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_SPINNER) + ) { + CircularProgressIndicator( + strokeWidth = 3.dp, + modifier = Modifier.size(24.dp) + ) + } + } else { + TextButton( + onClick = { onEvent(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked) }, + modifier = Modifier + .widthIn(max = BUTTON_MAX_WIDTH) + .fillMaxWidth() + .testTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON) + ) { + Text(stringResource(R.string.SignalLoginViewDetailsScreen__reset_recovery_key)) + } + } + } } } @@ -189,3 +226,34 @@ private fun SignalLoginViewDetailsScreenPreview() { ) } } + +@DayNightPreviews +@Composable +private fun SignalLoginViewDetailsScreenWithResetPreview() { + Previews.Preview { + SignalLoginViewDetailsScreen( + state = SignalLoginViewDetailsState( + accountKey = "A6B28482-2E32-83D0-7F23-91360A4C2B91", + recoveryKey = "UY38JH2778HJJHJ8LK19GA61S672JSJ=89R=23S6A578=9BAP92J2YH5T326VV7T", + showResetRecoveryKeyButton = true + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun SignalLoginViewDetailsScreenWithResetLoadingPreview() { + Previews.Preview { + SignalLoginViewDetailsScreen( + state = SignalLoginViewDetailsState( + accountKey = "A6B28482-2E32-83D0-7F23-91360A4C2B91", + recoveryKey = "UY38JH2778HJJHJ8LK19GA61S672JSJ=89R=23S6A578=9BAP92J2YH5T326VV7T", + showResetRecoveryKeyButton = true, + resetRecoveryKeyButtonLoading = true + ), + onEvent = {} + ) + } +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt index aee81aa33e..7a07b70b89 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt @@ -17,6 +17,9 @@ sealed class SignalLoginViewDetailsScreenEvents { /** The user chose to save the credentials as a PDF. */ data object SaveAsPdfClicked : SignalLoginViewDetailsScreenEvents() + /** The user chose to start the flow that replaces their recovery key with a new one. */ + data object ResetRecoveryKeyClicked : SignalLoginViewDetailsScreenEvents() + /** The user tapped the copy button on the account ID field. */ data class CopyAccountIdClicked(val aci: String) : SignalLoginViewDetailsScreenEvents() { override fun toString(): String { 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 03e72ffacf..6d53044874 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 @@ -10,14 +10,20 @@ import org.signal.signallogin.RecoveryKeyGroups /** * State for the screen that shows the user the full keys that make up their Signal Login. + * + * [showResetRecoveryKeyButton] is only true when the screen is reached from account settings, which is the only entry + * point that can put the user through a recovery key reset. While [resetRecoveryKeyButtonLoading] is true we don't yet + * know whether the user has any resets left, so a spinner stands in for the button. */ data class SignalLoginViewDetailsState( val accountKey: String = "", - val recoveryKey: String = "" + val recoveryKey: String = "", + val showResetRecoveryKeyButton: Boolean = false, + val resetRecoveryKeyButtonLoading: Boolean = false ) { /** The recovery key broken into character groups, in display order. */ val recoveryKeyGroups: RecoveryKeyGroups get() = RecoveryKeyGroups.from(recoveryKey) - override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()})" + override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()}, showResetRecoveryKeyButton=$showResetRecoveryKeyButton, resetRecoveryKeyButtonLoading=$resetRecoveryKeyButtonLoading)" } diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt deleted file mode 100644 index 7cec19b421..0000000000 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.signallogin.viewdetails - -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.models.AccountEntropyPool -import org.signal.core.models.ServiceId -import org.signal.core.ui.compose.EventDrivenViewModel -import org.signal.core.util.logging.Log - -/** - * View model for [SignalLoginViewDetailsScreen]. - * - * The screen only renders the credentials it is given, so the state is fully derived from the constructor arguments. - * None of the actions the screen can produce are implemented yet -- every event is routed here and handled explicitly - * so that filling in the business logic is a matter of replacing the TODO branches. - */ -class SignalLoginViewDetailsViewModel( - aci: ServiceId.ACI, - aep: AccountEntropyPool -) : EventDrivenViewModel(TAG) { - - companion object { - private val TAG = Log.tag(SignalLoginViewDetailsViewModel::class) - } - - private val _state = MutableStateFlow( - SignalLoginViewDetailsState( - accountKey = aci.toString().uppercase(), - recoveryKey = aep.displayValue - ) - ) - val state: StateFlow = _state.asStateFlow() - - init { - _state - .onEach { Log.d(TAG, "[State] $it") } - .launchIn(viewModelScope) - } - - override suspend fun processEvent(event: SignalLoginViewDetailsScreenEvents) { - when (event) { - is SignalLoginViewDetailsScreenEvents.BackClicked -> { - // TODO [phonenumberless] Navigate back once this screen is hooked into a flow. - Log.i(TAG, "Back clicked, but navigation isn't implemented yet.") - } - - is SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked -> { - // TODO [phonenumberless] Store the credentials via the credential manager. - Log.i(TAG, "Save to password manager clicked, but the flow isn't implemented yet.") - } - - is SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { - // TODO [phonenumberless] Render the credentials to a PDF and hand it to the user. - Log.i(TAG, "Save as PDF clicked, but the flow isn't implemented yet.") - } - - is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> { - // TODO [phonenumberless] Copy the account key to the clipboard. - Log.i(TAG, "Account key copy clicked, but the copy flow isn't implemented yet.") - } - - is SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked -> { - // TODO [phonenumberless] Copy the recovery key to the clipboard. - Log.i(TAG, "Recovery key copy clicked, but the copy flow isn't implemented yet.") - } - } - } -} diff --git a/lib/signal-login/src/main/res/values/strings.xml b/lib/signal-login/src/main/res/values/strings.xml index c43f22252d..eb2c364ac9 100644 --- a/lib/signal-login/src/main/res/values/strings.xml +++ b/lib/signal-login/src/main/res/values/strings.xml @@ -31,6 +31,8 @@ Save to password manager Save as PDF + + Reset recovery key Signal Login.pdf