Add ability to save remote backup key to device password manager.

Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
jeffrey-signal
2025-05-30 15:23:10 -04:00
committed by Cody Henthorne
parent 7616ec1fd2
commit 015fc9be2c
12 changed files with 392 additions and 88 deletions

View File

@@ -142,11 +142,15 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage,
onCopyToClipboardClick = {
Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS)
}
},
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted
)
}
@@ -191,6 +195,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
requireActivity().setResult(Activity.RESULT_OK, MessageBackupsCheckoutActivity.createResultData())
requireActivity().finishAfterTransition()
}
else -> Unit
}
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.AccountEntropyPool
@@ -21,7 +22,8 @@ data class MessageBackupsFlowState(
val stage: MessageBackupsStage = startScreen,
val accountEntropyPool: AccountEntropyPool = SignalStore.account.accountEntropyPool,
val failure: Throwable? = null,
val paymentReadyState: PaymentReadyState = PaymentReadyState.NOT_READY
val paymentReadyState: PaymentReadyState = PaymentReadyState.NOT_READY,
val backupKeySaveState: BackupKeySaveState? = null
) {
enum class PaymentReadyState {
NOT_READY,

View File

@@ -28,6 +28,8 @@ import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
@@ -50,7 +52,7 @@ import kotlin.time.Duration.Companion.seconds
class MessageBackupsFlowViewModel(
initialTierSelection: MessageBackupTier?,
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
) : ViewModel() {
) : ViewModel(), BackupKeyCredentialManagerHandler {
companion object {
private val TAG = Log.tag(MessageBackupsFlowViewModel::class)
@@ -342,4 +344,8 @@ class MessageBackupsFlowViewModel(
return
}
}
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
internalStateFlow.update { it.copy(backupKeySaveState = newState) }
}
}

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -16,11 +18,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -29,12 +34,22 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialInterruptedException
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.components.settings.app.backups.remote.CredentialManagerError
import org.thoughtcrime.securesms.components.settings.app.backups.remote.CredentialManagerResult
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.signal.core.ui.R as CoreUiR
@@ -45,10 +60,15 @@ import org.signal.core.ui.R as CoreUiR
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
keySaveState: BackupKeySaveState?,
onNavigationClick: () -> Unit = {},
onCopyToClipboardClick: (String) -> Unit = {},
onRequestSaveToPasswordManager: () -> Unit = {},
onConfirmSaveToPasswordManager: () -> Unit = {},
onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {},
onNextClick: () -> Unit = {}
) {
val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
}
@@ -56,99 +76,182 @@ fun MessageBackupsKeyRecordScreen(
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
onNavigationClick = onNavigationClick,
snackbarHost = { Snackbars.Host(snackbarHostState = snackbarHostState) }
) { paddingValues ->
Column(
Box(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.testTag("message-backups-key-record-screen-lazy-column")
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Image(
painter = painterResource(R.drawable.image_signal_backups_lock),
contentDescription = null,
modifier = Modifier
.padding(top = 24.dp)
.size(80.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Box(
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
)
)
}
}
item {
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.testTag("message-backups-key-record-screen-lazy-column")
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
)
item {
Image(
painter = painterResource(R.drawable.image_signal_backups_lock),
contentDescription = null,
modifier = Modifier
.padding(top = 24.dp)
.size(80.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Box(
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
)
)
}
}
item {
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
}
if (Build.VERSION.SDK_INT >= 24) {
item {
Buttons.Small(
onClick = { onRequestSaveToPasswordManager() }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager)
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
)
}
}
}
when (keySaveState) {
is BackupKeySaveState.RequestingConfirmation -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_save_to_password_manager_title),
body = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_save_to_password_manager_body),
dismiss = stringResource(android.R.string.cancel),
onDismiss = { onSaveToPasswordManagerComplete(CredentialManagerResult.UserCanceled) },
confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__continue),
onConfirm = onConfirmSaveToPasswordManager
)
}
is BackupKeySaveState.AwaitingCredentialManager -> {
val context = LocalContext.current
LaunchedEffect(keySaveState) {
val result = saveKeyToCredentialManager(context, backupKey)
onSaveToPasswordManagerComplete(result)
}
}
is BackupKeySaveState.Success -> {
val snackbarMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager_success)
LaunchedEffect(keySaveState) {
snackbarHostState.showSnackbar(snackbarMessage)
}
}
is BackupKeySaveState.Error -> {
if (keySaveState.errorType is CredentialManagerError.MissingCredentialManager) {
Dialogs.SimpleMessageDialog(
title = stringResource(R.string.MessageBackupsKeyRecordScreen__missing_password_manager_title),
message = stringResource(R.string.MessageBackupsKeyRecordScreen__missing_password_manager_message),
dismiss = stringResource(R.string.MessageBackupsKeyRecordScreen__dismiss),
onDismiss = { onSaveToPasswordManagerComplete(CredentialManagerResult.UserCanceled) }
)
}
}
null -> Unit
}
}
}
}
private suspend fun saveKeyToCredentialManager(
context: Context,
backupKey: String
): CredentialManagerResult {
return try {
CredentialManager.create(context)
.createCredential(
context = context,
request = CreatePasswordRequest(
id = context.getString(R.string.RemoteBackupsSettingsFragment__signal_backups),
password = backupKey,
preferImmediatelyAvailableCredentials = false,
isAutoSelectAllowed = false
)
)
CredentialManagerResult.Success
} catch (e: Exception) {
when (e) {
is CreateCredentialCancellationException -> CredentialManagerResult.UserCanceled
is CreateCredentialInterruptedException -> CredentialManagerResult.Interrupted(e)
is CreateCredentialNoCreateOptionException -> CredentialManagerError.MissingCredentialManager(e)
else -> CredentialManagerError.Unexpected(e)
}
}
}
@@ -158,7 +261,19 @@ fun MessageBackupsKeyRecordScreen(
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0"
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
keySaveState = null
)
}
}
@SignalPreview
@Composable
private fun SaveKeyConfirmationDialogPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
keySaveState = BackupKeySaveState.RequestingConfirmation
)
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
/**
* Handles the process of storing a backup key to the device password manager.
*/
interface BackupKeyCredentialManagerHandler {
companion object {
private val TAG = Log.tag(BackupKeyCredentialManagerHandler::class)
}
/** Updates the [BackupKeySaveState]. Implementers must update their associated state to match [newState]. */
fun updateBackupKeySaveState(newState: BackupKeySaveState?)
/** Called when the user initiates the backup key save flow. */
fun onBackupKeySaveRequested() = updateBackupKeySaveState(BackupKeySaveState.RequestingConfirmation)
/** Called when the user confirms they want to save the backup key to the password manager. */
fun onBackupKeySaveConfirmed() = updateBackupKeySaveState(BackupKeySaveState.AwaitingCredentialManager(isRetry = false))
/** Handles the password manager save operation response. */
fun onBackupKeySaveCompleted(result: CredentialManagerResult) {
when (result) {
is CredentialManagerResult.Success -> {
Log.d(TAG, "Successfully saved backup key to credential manager.")
updateBackupKeySaveState(newState = BackupKeySaveState.Success)
}
is CredentialManagerResult.UserCanceled -> {
Log.d(TAG, "User canceled saving backup key to credential manager.")
updateBackupKeySaveState(newState = null)
}
is CredentialManagerResult.Interrupted -> {
Log.i(TAG, "Retry saving backup key to credential manager after interruption.", result.exception)
updateBackupKeySaveState(newState = BackupKeySaveState.AwaitingCredentialManager(isRetry = true))
}
is CredentialManagerError.MissingCredentialManager -> {
Log.w(TAG, "Error saving backup key to credential manager: no credential manager is configured.", result.exception)
updateBackupKeySaveState(newState = BackupKeySaveState.Error(result))
}
is CredentialManagerError.Unexpected -> {
throw result.exception.logW(TAG, "Unexpected error when saving backup key to credential manager.")
}
}
}
}
/** Represents state related to saving a backup key to the device password manager. */
sealed interface BackupKeySaveState {
/** Awaiting the user to confirm they want to save the backup key. */
data object RequestingConfirmation : BackupKeySaveState
/** Awaiting the password manager's response for the backup key save operation. */
data class AwaitingCredentialManager(val isRetry: Boolean) : BackupKeySaveState
data object Success : BackupKeySaveState
data class Error(val errorType: CredentialManagerError) : BackupKeySaveState
}
sealed interface CredentialManagerResult {
data object Success : CredentialManagerResult
data object UserCanceled : CredentialManagerResult
/** The backup key save operation was interrupted and should be retried. */
data class Interrupted(val exception: Exception) : CredentialManagerResult
}
sealed class CredentialManagerError : CredentialManagerResult {
abstract val exception: Exception
/** No password manager is configured on the device. */
data class MissingCredentialManager(override val exception: Exception) : CredentialManagerError()
data class Unexpected(override val exception: Exception) : CredentialManagerError()
}

View File

@@ -6,11 +6,14 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment which only displays the backup key to the user.
@@ -21,12 +24,20 @@ class BackupKeyDisplayFragment : ComposeFragment() {
const val CLIPBOARD_TIMEOUT_SECONDS = 60
}
private val viewModel: BackupKeyDisplayViewModel by viewModel { BackupKeyDisplayViewModel() }
@Composable
override fun FragmentContent() {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MessageBackupsKeyRecordScreen(
backupKey = SignalStore.account.accountEntropyPool.displayValue,
keySaveState = state.keySaveState,
onNavigationClick = { findNavController().popBackStack() },
onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
onNextClick = { findNavController().popBackStack() }
)
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
private val _uiState = MutableStateFlow(BackupKeyDisplayUiState())
val uiState: StateFlow<BackupKeyDisplayUiState> = _uiState.asStateFlow()
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
_uiState.update { it.copy(keySaveState = newState) }
}
}
data class BackupKeyDisplayUiState(
val keySaveState: BackupKeySaveState? = null
)