Allow resetting the recovery key for numberless accounts.

This commit is contained in:
Greyson Parrelli
2026-09-15 18:26:06 -04:00
parent f2827c934b
commit 08292a6b2c
31 changed files with 1076 additions and 244 deletions
@@ -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.
*/
@@ -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)
}
}
@@ -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
)
}
}
@@ -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()
}
}
@@ -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<SignalLoginViewDetailsScreenEvents>(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<SignalLoginViewDetailsAction>(Channel.BUFFERED)
private val _actions = Channel<SignalLoginViewDetailsAction.Shared>(Channel.BUFFERED)
val state: StateFlow<SignalLoginViewDetailsState> = _state.asStateFlow()
val actions: Flow<SignalLoginViewDetailsAction> = _actions.receiveAsFlow()
val actions: Flow<SignalLoginViewDetailsAction.Shared> = _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))
}
@@ -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(
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
@@ -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)
}
}
@@ -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<SettingsSignalLoginDetailsEvent>(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<SignalLoginViewDetailsAction>(Channel.BUFFERED)
val state: StateFlow<SignalLoginViewDetailsState> = _state.asStateFlow()
val resetRecoveryKeyState: StateFlow<ResetRecoveryKeyState> = _resetRecoveryKeyState.asStateFlow()
val actions: Flow<SignalLoginViewDetailsAction> = _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) }
}
}
}
@@ -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
}
@@ -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()
}
@@ -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 = {
@@ -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,
@@ -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)
}
}
@@ -217,8 +217,8 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_signalLoginViewDetailsFragment"
app:destination="@id/signalLoginViewDetailsFragment"
android:id="@+id/action_accountSettingsFragment_to_settingsSignalLoginDetailsFragment"
app:destination="@id/settingsSignalLoginDetailsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
@@ -240,9 +240,17 @@
</fragment>
<fragment
android:id="@+id/signalLoginViewDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsFragment"
android:label="signal_login_view_details_fragment" />
android:id="@+id/settingsSignalLoginDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.signallogin.SettingsSignalLoginDetailsFragment"
android:label="settings_signal_login_details_fragment">
<action
android:id="@+id/action_settingsSignalLoginDetailsFragment_to_backupKeyDisplayFragment"
app:destination="@id/backupKeyDisplayFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/authenticatorSetupFragment"
+11
View File
@@ -9598,5 +9598,16 @@
<!-- Subject line of an email invitation to Signal -->
<string name="SharedContactDetailsScreen__join_me_on_signal">Join me on Signal</string>
<!-- Title of the bottom sheet that confirms the user wants a brand new recovery key. -->
<string name="ResetRecoveryKeyBottomSheet__reset_your_recovery_key">Reset your recovery key</string>
<!-- First paragraph of the bottom sheet body, explaining what a reset does. -->
<string name="ResetRecoveryKeyBottomSheet__resetting_your_recovery_key_will_create_a_new_key">Resetting your recovery key will create a new key for your Signal Login. This is only necessary if someone else knows your key.</string>
<!-- Second paragraph of the bottom sheet body, explaining what a reset means for backups. -->
<string name="ResetRecoveryKeyBottomSheet__if_backups_are_enabled_you_will_have_to_re_upload">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.</string>
<!-- Bottom sheet action that continues on to reset the recovery key. -->
<string name="ResetRecoveryKeyBottomSheet__continue">Continue</string>
<!-- Dialog body shown when the recovery key change limit is exhausted and the user does not have backups enabled. -->
<string name="BackupKeyRotationDialogs__limit_exceeded_body_no_backups">You\'ve exhausted the number of new keys you can create. You will be able to create a new recovery key in 7 days.</string>
<!-- EOF -->
</resources>
@@ -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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction.Shared> {
val actions = mutableListOf<SignalLoginViewDetailsAction.Shared>()
backgroundScope.launch { viewModel.actions.toList(actions) }
return actions
}
}
@@ -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<SignalLoginViewDetailsRepository>()
@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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<SignalLoginViewDetailsAction>()
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<Boolean>()
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<Boolean>().await() }
val viewModel = SettingsSignalLoginDetailsViewModel(repository, showResetRecoveryKeyButton = true)
viewModel.onEvent(SettingsSignalLoginDetailsEvent.Screen(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked))
assertThat(viewModel.resetRecoveryKeyState.value.dialog).isEqualTo(ResetRecoveryKeyState.Dialog.NONE)
}
}
@@ -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<NavKey>.navigationEntries(
entry<RegistrationRoute.SignalLoginViewDetails> {
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<NavKey>.navigationEntries(
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginViewDetailsScreenActions.LaunchSaveToPasswordManager -> {
RegistrationSignalLoginDetailsAction.LaunchSaveToPasswordManager -> {
scope.launch {
SignalCredentialManager.saveCredential(
activityContext = context,
@@ -851,9 +851,9 @@ private fun EntryProviderScope<NavKey>.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)
}
}
@@ -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()})"
}
@@ -27,13 +27,13 @@ import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState
/**
* View model backing [SignalLoginViewDetailsScreen] within the registration flow.
*/
class SignalLoginViewDetailsViewModel(
class RegistrationSignalLoginDetailsViewModel(
parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<SignalLoginViewDetailsScreenEvents>(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<SignalLoginViewDetailsState> = _state.asStateFlow()
private val _actions = Channel<SignalLoginViewDetailsScreenActions>(Channel.BUFFERED)
val actions: Flow<SignalLoginViewDetailsScreenActions> = _actions.receiveAsFlow()
private val _actions = Channel<RegistrationSignalLoginDetailsAction>(Channel.BUFFERED)
val actions: Flow<RegistrationSignalLoginDetailsAction> = _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))
}
}
}
@@ -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<SignalLoginViewDetailsScreenActions>()
val actions = mutableListOf<RegistrationSignalLoginDetailsAction>()
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<SignalLoginViewDetailsScreenActions>()
val actions = mutableListOf<RegistrationSignalLoginDetailsAction>()
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<RegistrationSignalLoginDetailsAction>()
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<SignalLoginViewDetailsScreenActions>()
val actions = mutableListOf<RegistrationSignalLoginDetailsAction>()
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<SignalLoginViewDetailsScreenActions>()
val actions = mutableListOf<RegistrationSignalLoginDetailsAction>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(recoveryKey))
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(recoveryKey))
assertThat(actions).containsExactly(RegistrationSignalLoginDetailsAction.CopyTextToClipboard(recoveryKey))
}
}
@@ -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 }
)
@@ -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"
@@ -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 = {}
)
}
}
@@ -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 {
@@ -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)"
}
@@ -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<SignalLoginViewDetailsScreenEvents>(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<SignalLoginViewDetailsState> = _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.")
}
}
}
}
@@ -31,6 +31,8 @@
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Save to password manager</string>
<!-- Action button that saves the Signal Login as a PDF. -->
<string name="SignalLoginViewDetailsScreen__save_as_pdf">Save as PDF</string>
<!-- Action button that starts the flow to replace the recovery key with a newly-generated one. -->
<string name="SignalLoginViewDetailsScreen__reset_recovery_key">Reset recovery key</string>
<!-- Suggested file name prefilled in the system save dialog when saving the Signal Login as a PDF. Keep the .pdf extension. -->
<string name="SignalLoginViewDetailsScreen__signal_login_pdf">Signal Login.pdf</string>
<!-- Toast shown when saving the Signal Login PDF fails. -->