diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ab79d38ed4..5f0ef077a5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -585,6 +585,8 @@ dependencies {
implementation(libs.rxjava3.rxandroid)
implementation(libs.rxjava3.rxkotlin)
implementation(libs.rxdogtag)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.compat)
"playImplementation"(project(":billing"))
"nightlyImplementation"(project(":billing"))
diff --git a/app/lint.xml b/app/lint.xml
index 822499eace..20134c9334 100644
--- a/app/lint.xml
+++ b/app/lint.xml
@@ -44,4 +44,7 @@
+
+
+
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 b8ece27212..0791c7ba00 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
@@ -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
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt
index 155e64d2a7..9cca6c2585 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt
@@ -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,
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 d54f897918..b102990fce 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
@@ -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) }
+ }
}
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 2e98c779ca..441693f7a0 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
@@ -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
)
}
}
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
new file mode 100644
index 0000000000..bc99ccb00a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyCredentialManagerHandler.kt
@@ -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()
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt
index 1930d7fe97..da93b4af88 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt
@@ -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() }
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt
new file mode 100644
index 0000000000..0f6e2c012f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt
@@ -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 = _uiState.asStateFlow()
+
+ override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
+ _uiState.update { it.copy(keySaveState = newState) }
+ }
+}
+
+data class BackupKeyDisplayUiState(
+ val keySaveState: BackupKeySaveState? = null
+)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ed481f8162..6f79abe8d4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8279,6 +8279,20 @@
This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you won’t be able to recover your account.
Copy to clipboard
+
+ Save to password manager
+
+ Save to password manager?
+
+ Only store your backup key in a password manager that you trust is secure. Signal does not make a recommendation on which password manager is right for you.
+
+ Saved to your password manager
+
+ Password Manager Unavailable
+
+ Unable to save your backup key. Check your device settings to install or enable a compatible password manager.
+
+ Dismiss
Next
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7be1aee1e6..76dded12d8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -117,6 +117,8 @@ androidx-asynclayoutinflater = "androidx.asynclayoutinflater:asynclayoutinflater
androidx-asynclayoutinflater-appcompat = "androidx.asynclayoutinflater:asynclayoutinflater-appcompat:1.1.0-alpha01"
androidx-emoji2 = "androidx.emoji2:emoji2:1.5.0"
androidx-documentfile = "androidx.documentfile:documentfile:1.0.1"
+androidx-credentials = "androidx.credentials:credentials:1.5.0"
+androidx-credentials-compat = "androidx.credentials:credentials-play-services-auth:1.5.0"
android-billing = "com.android.billingclient:billing-ktx:7.1.1"
# Billing
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 292271aaa4..e6af11f62d 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -1295,6 +1295,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3949,6 +3965,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
@@ -3969,11 +3990,21 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
+
+
@@ -4009,6 +4040,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+