mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Allow resetting the recovery key for numberless accounts.
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
+85
@@ -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)
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-46
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -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))
|
||||
}
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+130
@@ -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()
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+52
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
+136
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -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
|
||||
}
|
||||
|
||||
+11
-1
@@ -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()
|
||||
}
|
||||
|
||||
+30
-2
@@ -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 = {
|
||||
|
||||
+39
-16
@@ -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,
|
||||
|
||||
+2
-14
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
+42
-19
@@ -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
|
||||
}
|
||||
}
|
||||
+237
@@ -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)
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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()})"
|
||||
}
|
||||
+12
-8
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-12
@@ -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))
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -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"
|
||||
|
||||
+70
-2
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+8
-2
@@ -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)"
|
||||
}
|
||||
|
||||
-77
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user