From 5c64a9186475b8537e2f61b19461c8e318ffdb34 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Wed, 24 Jun 2026 13:01:19 -0400 Subject: [PATCH] Present the backup key as a passphrase. --- .../ConfirmRecoveryKeyBottomSheet.kt | 85 ++++++ .../MessageBackupsFlowFragment.kt | 29 +- .../MessageBackupsFlowViewModel.kt | 15 +- .../MessageBackupsKeyRecordScreen.kt | 280 +++++++++++++++--- .../v2/ui/subscription/MessageBackupsStage.kt | 2 + .../upgrade/UpgradeToPaidTierBottomSheet.kt | 2 + .../BackupKeyCredentialManagerHandler.kt | 3 + .../storage/AndroidCredentialRepository.kt | 33 ++- .../main/res/drawable/backup_confirm_80.xml | 12 + app/src/main/res/drawable/symbol_tap_20.xml | 13 + app/src/main/res/values/strings.xml | 18 ++ 11 files changed, 450 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmRecoveryKeyBottomSheet.kt create mode 100644 app/src/main/res/drawable/backup_confirm_80.xml create mode 100644 app/src/main/res/drawable/symbol_tap_20.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmRecoveryKeyBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmRecoveryKeyBottomSheet.kt new file mode 100644 index 0000000000..94b3aeade5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmRecoveryKeyBottomSheet.kt @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.backup.v2.ui.subscription + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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 +import androidx.compose.ui.graphics.Color +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 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 + +/** + * Bottom sheet shown when confirming your recovery key after saving to password manager + */ +@Composable +fun ConfirmRecoveryKeySheet( + onConfirm: () -> Unit = {}, + onSeeAgain: () -> Unit = {}, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.horizontalGutters(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.backup_confirm_80), + tint = Color.Unspecified, + contentDescription = null, + modifier = Modifier.padding(top = 24.dp, bottom = 16.dp) + ) + + Text( + text = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Text( + text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_that_your_recovery), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.size(60.dp)) + + Buttons.LargeTonal(onClick = onConfirm) { + Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_recovery)) + } + + TextButton( + onClick = onSeeAgain, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)) + } + } +} + +@DayNightPreviews +@Composable +private fun ConfirmRecoveryKeyPreview() { + Previews.BottomSheetContentPreview { + ConfirmRecoveryKeySheet( + onConfirm = {}, + onSeeAgain = {}, + modifier = Modifier.fillMaxSize() + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index c73b342389..2fa0281297 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -68,7 +68,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel( initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java), - googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()) + googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()), + isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()) ) } @@ -155,6 +156,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega val context = LocalContext.current val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext()) + MessageBackupsKeyRecordScreen( + backupKey = state.accountEntropyPool.displayValue, + keySaveState = state.backupKeySaveState, + canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, + onNavigationClick = viewModel::goToPreviousStage, + mode = remember { + MessageBackupsKeyRecordMode.Passkey( + onSaveToPasswordManager = viewModel::onBackupKeySaveRequested, + onSaveManually = viewModel::goToRecordManually, + onSaveSuccessful = viewModel::goToNextStage + ) + }, + onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) }, + onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, + onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, + onSaveStateCleared = viewModel::onBackupKeySaveStateCleared, + onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, + onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }, + notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled + ) + } + + composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD_MANUALLY.name) { + val context = LocalContext.current + val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext()) + MessageBackupsKeyRecordScreen( backupKey = state.accountEntropyPool.displayValue, keySaveState = state.backupKeySaveState, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index d024eb76bc..1063440cea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -53,6 +53,7 @@ import kotlin.time.Duration.Companion.seconds class MessageBackupsFlowViewModel( private val initialTierSelection: MessageBackupTier?, googlePlayApiAvailability: Int, + private val isCredentialManagerSupported: Boolean, startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION ) : ViewModel(), BackupKeyCredentialManagerHandler { @@ -238,8 +239,9 @@ class MessageBackupsFlowViewModel( when (it.stage) { MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL") MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION) - MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD) - MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY) + MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY) + MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION) + MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY) MessageBackupsStage.BACKUP_KEY_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION) MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it) MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT) @@ -262,7 +264,8 @@ class MessageBackupsFlowViewModel( MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION - MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD + MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_EDUCATION + MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT @@ -277,6 +280,12 @@ class MessageBackupsFlowViewModel( } } + fun goToRecordManually() { + internalStateFlow.update { + it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY) + } + } + fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) { internalStateFlow.update { it.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt index d8e1fdb8ca..3c7fd9e3d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt @@ -6,14 +6,22 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription import android.content.Context +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.UiContext +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -22,6 +30,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHostState @@ -34,9 +43,16 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -48,6 +64,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.DayNightPreviews @@ -85,6 +102,11 @@ sealed interface MessageBackupsKeyRecordMode { val isOptimizedStorageEnabled: Boolean, val canRotateKey: Boolean ) : MessageBackupsKeyRecordMode + data class Passkey( + val onSaveToPasswordManager: () -> Unit, + val onSaveManually: () -> Unit, + val onSaveSuccessful: () -> Unit + ) : MessageBackupsKeyRecordMode } /** @@ -136,18 +158,24 @@ fun MessageBackupsKeyRecordScreen( onRequestSaveToPasswordManager: () -> Unit = {}, onConfirmSaveToPasswordManager: () -> Unit = {}, onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {}, + onSaveStateCleared: () -> Unit = {}, onGoToPasswordManagerSettingsClick: () -> Unit = {}, mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}), notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false ) { TemporaryScreenshotSecurity.bind() + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val backupKeyString = remember(backupKey) { backupKey.chunked(4).joinToString(" ") } - if (mode is MessageBackupsKeyRecordMode.Next) { + val showAsPasskey = mode is MessageBackupsKeyRecordMode.Passkey + var showExpandedPasskey by remember { mutableStateOf(false) } + + if (mode is MessageBackupsKeyRecordMode.Next || mode is MessageBackupsKeyRecordMode.Passkey) { RecordScreenBackHandler() } @@ -183,6 +211,41 @@ fun MessageBackupsKeyRecordScreen( } } + var displayKeyVerificationError by remember { mutableStateOf(false) } + if (displayKeyVerificationError) { + ConfirmationFailureDialog(mode) { + displayKeyVerificationError = false + } + } + + var displayConfirmKey by remember { mutableStateOf(false) } + if (displayConfirmKey) { + val context = LocalContext.current + val credentialId = stringResource(R.string.MessageBackupsKeyRecordScreen__backup_key_password_manager_id) + val successMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_confirmed) + ModalBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = SignalTheme.colors.colorSurface1, + onDismissRequest = { displayConfirmKey = false } + ) { + ConfirmRecoveryKeySheet( + onConfirm = { + coroutineScope.launch { + val retrieved = getKeyFromCredentialManager(context, credentialId) + if (retrieved == backupKey) { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + (mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveSuccessful() + } else { + displayKeyVerificationError = true + } + } + displayConfirmKey = false + }, + onSeeAgain = { displayConfirmKey = false } + ) + } + } + Scaffolds.Settings( title = "", navigationIcon = SignalIcons.ArrowStart.imageVector, @@ -216,7 +279,11 @@ fun MessageBackupsKeyRecordScreen( item { Text( - text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key), + text = if (showAsPasskey) { + stringResource(R.string.MessageBackupsKeyRecordScreen__save_your_recovery_key) + } else { + stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key) + }, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 16.dp) ) @@ -225,6 +292,8 @@ fun MessageBackupsKeyRecordScreen( item { val text = if (notifyKeyIsSameAsOnDeviceBackupKey) { stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_the_same_as_your_on_device_recovery_key) + } else if (showAsPasskey) { + stringResource(R.string.MessageBackupsKeyRecordScreen__your_recovery_key) } else { stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover) } @@ -239,47 +308,113 @@ fun MessageBackupsKeyRecordScreen( } 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() + AnimatedContent( + targetState = showAsPasskey && !showExpandedPasskey, + transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = false) }, + label = "passkey", + modifier = Modifier.padding(top = 24.dp, bottom = 16.dp) + ) { isCollapsed -> + if (isCollapsed) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.background( + color = SignalTheme.colors.colorSurface1, + shape = RoundedCornerShape(50.dp) + ) + ) { + Text( + text = backupKeyString, + maxLines = 1, + style = MaterialTheme.typography.bodyLarge + .copy( + fontSize = 18.sp, + fontWeight = FontWeight(400), + letterSpacing = 1.44.sp, + lineHeight = 36.sp, + textAlign = TextAlign.Center, + fontFamily = MonoTypeface.fontFamily() + ), + modifier = Modifier + .padding(10.dp) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawRect( + brush = Brush.horizontalGradient( + 0f to Color.Black, + 0.75f to Color.Transparent + ), + blendMode = BlendMode.DstIn + ) + } ) - ) - } - } - item { - Buttons.Small( - onClick = { - if (mode is MessageBackupsKeyRecordMode.CreateNewKey) { - displayRecoveryKeyCopyWarning = true - } else { - onCopyToClipboardClick(backupKeyString) + Row( + modifier = Modifier + .background( + color = SignalTheme.colors.colorSurface1, + shape = RoundedCornerShape(50.dp) + ) + .padding(horizontal = 12.dp) + .clickable(onClick = { showExpandedPasskey = true }), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_tap_20), + contentDescription = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key) + ) + Text( + text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 4.dp, end = 12.dp) + ) + } + } + } else { + Box( + modifier = Modifier + .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() + ) + ) } } - ) { - Text( - text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard) - ) } } - if (AndroidCredentialRepository.isCredentialManagerSupported) { + if (!showAsPasskey || showExpandedPasskey) { + item { + Buttons.Small( + onClick = { + if (mode is MessageBackupsKeyRecordMode.CreateNewKey) { + displayRecoveryKeyCopyWarning = true + } else { + onCopyToClipboardClick(backupKeyString) + } + } + ) { + Text( + text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard) + ) + } + } + } + + if (!showAsPasskey && AndroidCredentialRepository.isCredentialManagerSupported(context)) { item { Buttons.Small( onClick = { onRequestSaveToPasswordManager() } @@ -300,6 +435,10 @@ fun MessageBackupsKeyRecordScreen( is MessageBackupsKeyRecordMode.CreateNewKey -> { CreateNewKeyButton(mode) } + + is MessageBackupsKeyRecordMode.Passkey -> { + SaveButtons(mode) + } } } @@ -326,7 +465,12 @@ fun MessageBackupsKeyRecordScreen( is BackupKeySaveState.Success -> { val snackbarMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager_success) LaunchedEffect(keySaveState) { - snackbarHostState.showSnackbar(snackbarMessage) + if (showAsPasskey) { + displayConfirmKey = true + } else { + snackbarHostState.showSnackbar(snackbarMessage) + } + onSaveStateCleared() } } @@ -343,6 +487,24 @@ fun MessageBackupsKeyRecordScreen( } } +@Composable +private fun SaveButtons(mode: MessageBackupsKeyRecordMode.Passkey) { + Buttons.LargeTonal( + onClick = mode.onSaveToPasswordManager + ) { + Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager)) + } + + TextButton( + onClick = mode.onSaveManually, + modifier = Modifier + .padding(vertical = 24.dp) + .horizontalGutters() + ) { + Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually)) + } +} + @Composable private fun NextButton(onNextClick: () -> Unit) { Box( @@ -578,6 +740,26 @@ private fun KeyLimitExceededDialog( ) } +@Composable +private fun ConfirmationFailureDialog(mode: MessageBackupsKeyRecordMode, onDismiss: () -> Unit) { + Dialogs.AdvancedAlertDialog( + title = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error), + body = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error_body), + positive = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager), + onPositive = { + (mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveToPasswordManager() + onDismiss() + }, + neutral = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually), + onNeutral = { + (mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveManually() + onDismiss() + }, + negative = stringResource(android.R.string.cancel), + onNegative = onDismiss + ) +} + private suspend fun saveKeyToCredentialManager( @UiContext activityContext: Context, backupKey: String @@ -589,6 +771,13 @@ private suspend fun saveKeyToCredentialManager( ) } +private suspend fun getKeyFromCredentialManager( + @UiContext activityContext: Context, + id: String +): String? { + return AndroidCredentialRepository.getCredential(activityContext, id) +} + @DayNightPreviews @Composable private fun MessageBackupsKeyRecordScreenPreview() { @@ -621,6 +810,23 @@ private fun MessageBackupsKeyRecordScreenSameAsOnDeviceKeyPreview() { } } +@DayNightPreviews +@Composable +private fun MessageBackupsKeySaveScreenPreview() { + Previews.Preview { + MessageBackupsKeyRecordScreen( + backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0", + keySaveState = null, + canOpenPasswordManagerSettings = true, + mode = MessageBackupsKeyRecordMode.Passkey( + onSaveToPasswordManager = {}, + onSaveManually = {}, + onSaveSuccessful = {} + ) + ) + } +} + @DayNightPreviews @Composable private fun SaveKeyConfirmationDialogPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt index c07523cee7..fac97a4478 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt @@ -15,6 +15,7 @@ enum class MessageBackupsStage( EDUCATION(route = Route.EDUCATION), BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION), BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD), + BACKUP_KEY_RECORD_MANUALLY(route = Route.BACKUP_KEY_RECORD_MANUALLY), BACKUP_KEY_VERIFY(route = Route.BACKUP_KEY_VERIFY), TYPE_SELECTION(route = Route.TYPE_SELECTION), CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION), @@ -32,6 +33,7 @@ enum class MessageBackupsStage( EDUCATION, BACKUP_KEY_EDUCATION, BACKUP_KEY_RECORD, + BACKUP_KEY_RECORD_MANUALLY, BACKUP_KEY_VERIFY, TYPE_SELECTION; diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt index b204053909..3af3a83abd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.viewModel /** @@ -61,6 +62,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment() MessageBackupsFlowViewModel( initialTierSelection = MessageBackupTier.PAID, googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()), + isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()), startScreen = MessageBackupsStage.TYPE_SELECTION ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt index 908b2bc465..29783c5156 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt @@ -27,6 +27,9 @@ interface BackupKeyCredentialManagerHandler { /** Called when the user confirms they want to save the backup key to the password manager. */ fun onBackupKeySaveConfirmed() = updateBackupKeySaveState(BackupKeySaveState.AwaitingCredentialManager(isRetry = false)) + /** Called to clear the key save state. */ + fun onBackupKeySaveStateCleared() = updateBackupKeySaveState(null) + /** Handles the password manager save operation response. */ fun onBackupKeySaveCompleted(result: CredentialManagerResult) { when (result) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt index 602278fdf5..20f1d1e8e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/storage/AndroidCredentialRepository.kt @@ -14,11 +14,15 @@ import android.view.autofill.AutofillManager import androidx.core.content.getSystemService import androidx.credentials.CreatePasswordRequest import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential import androidx.credentials.exceptions.CreateCredentialCancellationException import androidx.credentials.exceptions.CreateCredentialInterruptedException import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException import org.signal.core.util.logging.Log /** @@ -31,7 +35,13 @@ object AndroidCredentialRepository { private const val ERROR_CODE_MISSING_CREDENTIAL_MANAGER = "[28434]" private const val ERROR_CODE_SAVE_PROMPT_DISABLED = "[28435]" - val isCredentialManagerSupported: Boolean = Build.VERSION.SDK_INT >= 19 + fun isCredentialManagerSupported(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 26) { + context.getSystemService()?.isEnabled == true + } else { + true + } + } suspend fun saveCredential( activityContext: Context, @@ -81,6 +91,27 @@ object AndroidCredentialRepository { } } + /** + * Retrieves a previously saved password credential by username. Returns null if the credential + * cannot be found or if retrieval fails. + */ + suspend fun getCredential( + activityContext: Context, + id: String + ): String? = try { + val result = CredentialManager.create(activityContext).getCredential(activityContext, GetCredentialRequest(listOf(GetPasswordOption()))) + val credential = result.credential + if (credential is PasswordCredential && credential.id == id) { + credential.password + } else { + Log.w(TAG, "Failed to find credential from password manager") + null + } + } catch (e: GetCredentialException) { + Log.w(TAG, "Failed to find credential from password manager.", e) + null + } + /** * Returns an [Intent] that can be used to launch the device's password manager settings. */ diff --git a/app/src/main/res/drawable/backup_confirm_80.xml b/app/src/main/res/drawable/backup_confirm_80.xml new file mode 100644 index 0000000000..0e923f30de --- /dev/null +++ b/app/src/main/res/drawable/backup_confirm_80.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_tap_20.xml b/app/src/main/res/drawable/symbol_tap_20.xml new file mode 100644 index 0000000000..40ed82259c --- /dev/null +++ b/app/src/main/res/drawable/symbol_tap_20.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33b88fd858..bc55f57601 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9114,6 +9114,24 @@ You have not finished setting up backups, and no backup has been created. You can enable backups in Settings. Exit backup setup + + Save your recovery key + + Your recovery key is a 64-character code that lets you restore your backup. If you forget your key, you will not be able to recover your account. + + See full key + + Confirm that your recovery key was recorded correctly. You will be prompted to fill your saved password in the next step. + + Confirm recovery key + + Recovery key confirmed + + Error confirming recovery key + + Your recovery key could not be confirmed. Make sure you’ve saved it in your password manager, or save it manually. + + Save key manually