From 36de1284c704e0fb22e12d66f0ec032c62ae104e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 18 Jul 2025 10:36:49 -0300 Subject: [PATCH] Allow user to rotate AEP. --- .../securesms/backup/v2/BackupRepository.kt | 44 +++- .../MessageBackupsFlowFragment.kt | 3 +- .../MessageBackupsKeyRecordScreen.kt | 201 ++++++++++++++++-- .../v2/ui/verify/ForgotBackupKeyFragment.kt | 18 +- .../remote/BackupKeyDisplayFragment.kt | 133 ++++++++++-- .../remote/BackupKeyDisplayViewModel.kt | 57 ++++- .../remote/RemoteBackupsSettingsFragment.kt | 9 + .../remote/RemoteBackupsSettingsState.kt | 3 +- .../securesms/keyvalue/AccountValues.kt | 9 + app/src/main/res/values/strings.xml | 24 +++ 10 files changed, 457 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index feda926ac0..e592ace6c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -9,10 +9,12 @@ import android.app.PendingIntent import android.database.Cursor import android.os.Environment import android.os.StatFs +import androidx.annotation.CheckResult import androidx.annotation.Discouraged import androidx.annotation.WorkerThread import androidx.core.app.NotificationCompat import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import okio.ByteString @@ -97,9 +99,11 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob import org.thoughtcrime.securesms.jobs.BackupDeleteJob +import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob +import org.thoughtcrime.securesms.jobs.LocalBackupJob import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob @@ -175,10 +179,7 @@ object BackupRepository { when (error.code) { 401 -> { Log.w(TAG, "Received status 401. Resetting initialized state + auth credentials.", error.exception) - SignalStore.backup.backupsInitialized = false - SignalStore.backup.messageCredentials.clearAll() - SignalStore.backup.mediaCredentials.clearAll() - SignalStore.backup.cachedMediaCdnPath = null + resetInitializedStateAndAuthCredentials() } 403 -> { @@ -207,6 +208,41 @@ object BackupRepository { } } + /** + * Generates a new AEP that the user can choose to confirm. + */ + @CheckResult + fun stageAEPKeyRotation(): AccountEntropyPool { + return AccountEntropyPool.generate() + } + + /** + * Saves the AEP to the local storage and kicks off a backup upload. + */ + suspend fun commitAEPKeyRotation(accountEntropyPool: AccountEntropyPool) { + haltAllJobs() + resetInitializedStateAndAuthCredentials() + SignalStore.account.rotateAccountEntropyPool(accountEntropyPool) + BackupMessagesJob.enqueue() + } + + private fun resetInitializedStateAndAuthCredentials() { + SignalStore.backup.backupsInitialized = false + SignalStore.backup.messageCredentials.clearAll() + SignalStore.backup.mediaCredentials.clearAll() + SignalStore.backup.cachedMediaCdnPath = null + } + + private suspend fun haltAllJobs() { + ArchiveUploadProgress.cancelAndBlock() + AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE) + + Log.d(TAG, "Waiting for local backup job cancelations to occur...") + while (!AppDependencies.jobManager.areQueuesEmpty(setOf(LocalBackupJob.QUEUE))) { + delay(1.seconds) + } + } + /** * Triggers backup id reservation. As documented, this is safe to perform multiple times. */ 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 68e092d909..b45ab9ec81 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 @@ -14,6 +14,7 @@ import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.os.bundleOf @@ -147,7 +148,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega keySaveState = state.backupKeySaveState, canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, onNavigationClick = viewModel::goToPreviousStage, - onNextClick = viewModel::goToNextStage, + mode = remember { MessageBackupsKeyRecordMode.Next(viewModel::goToNextStage) }, onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) }, onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, 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 990ecaca51..cef7562daf 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 @@ -11,29 +11,39 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize 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.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource 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.dp import androidx.compose.ui.unit.sp import org.signal.core.ui.compose.Buttons @@ -42,6 +52,7 @@ 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.horizontalGutters import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState @@ -51,6 +62,16 @@ import org.thoughtcrime.securesms.util.storage.CredentialManagerError import org.thoughtcrime.securesms.util.storage.CredentialManagerResult import org.signal.core.ui.R as CoreUiR +@Stable +sealed interface MessageBackupsKeyRecordMode { + data class Next(val onNextClick: () -> Unit) : MessageBackupsKeyRecordMode + data class CreateNewKey( + val onCreateNewKeyClick: () -> Unit, + val onTurnOffAndDownloadClick: () -> Unit, + val isOptimizedStorageEnabled: Boolean + ) : MessageBackupsKeyRecordMode +} + /** * Screen displaying the backup key allowing the user to write it down * or copy it. @@ -65,8 +86,8 @@ fun MessageBackupsKeyRecordScreen( onRequestSaveToPasswordManager: () -> Unit = {}, onConfirmSaveToPasswordManager: () -> Unit = {}, onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {}, - onNextClick: () -> Unit = {}, - onGoToPasswordManagerSettingsClick: () -> Unit = {} + onGoToPasswordManagerSettingsClick: () -> Unit = {}, + mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}) ) { val snackbarHostState = remember { SnackbarHostState() } val backupKeyString = remember(backupKey) { @@ -96,7 +117,7 @@ fun MessageBackupsKeyRecordScreen( ) { item { Image( - painter = painterResource(R.drawable.image_signal_backups_lock), + imageVector = ImageVector.vectorResource(R.drawable.image_signal_backups_lock), contentDescription = null, modifier = Modifier .padding(top = 24.dp) @@ -170,19 +191,23 @@ fun MessageBackupsKeyRecordScreen( } } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp) - ) { - Buttons.LargeTonal( - onClick = onNextClick, - modifier = Modifier.align(Alignment.BottomEnd) + if (mode is MessageBackupsKeyRecordMode.Next) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) ) { - Text( - text = stringResource(R.string.MessageBackupsKeyRecordScreen__next) - ) + Buttons.LargeTonal( + onClick = mode.onNextClick, + modifier = Modifier.align(Alignment.BottomEnd) + ) { + Text( + text = stringResource(R.string.MessageBackupsKeyRecordScreen__next) + ) + } } + } else if (mode is MessageBackupsKeyRecordMode.CreateNewKey) { + CreateNewKeyButton(mode) } } @@ -226,6 +251,51 @@ fun MessageBackupsKeyRecordScreen( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateNewKeyButton( + mode: MessageBackupsKeyRecordMode.CreateNewKey +) { + var displayBottomSheet by remember { mutableStateOf(false) } + var displayDialog by remember { mutableStateOf(false) } + + TextButton( + onClick = { displayBottomSheet = true }, + modifier = Modifier + .padding(bottom = 24.dp) + .horizontalGutters() + .fillMaxWidth() + .requiredWidthIn(min = Dp.Unspecified, max = 264.dp) + ) { + Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_new_key)) + } + + if (displayDialog) { + DownloadMediaDialog( + onTurnOffAndDownloadClick = mode.onTurnOffAndDownloadClick, + onCancelClick = { displayDialog = false } + ) + } + + if (displayBottomSheet) { + ModalBottomSheet( + onDismissRequest = { displayBottomSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + CreateNewBackupKeySheetContent( + onContinueClick = { + if (mode.isOptimizedStorageEnabled) { + displayDialog = true + } else { + mode.onCreateNewKeyClick() + } + }, + onCancelClick = { displayBottomSheet = false } + ) + } + } +} + @Composable private fun BackupKeySaveErrorDialog( error: BackupKeySaveState.Error, @@ -268,6 +338,78 @@ private fun BackupKeySaveErrorDialog( } } +@Composable +private fun ColumnScope.CreateNewBackupKeySheetContent( + onContinueClick: () -> Unit = {}, + onCancelClick: () -> Unit = {} +) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.image_signal_backups_key), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 38.dp, bottom = 18.dp) + .size(80.dp) + ) + + Text( + text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_a_new_backup_key), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 12.dp) + .horizontalGutters() + ) + + Text( + text = stringResource(R.string.MessageBackupsKeyRecordScreen__creating_a_new_key_is_only_necessary), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(bottom = 91.dp, start = 36.dp, end = 36.dp) + .align(Alignment.CenterHorizontally) + ) + + Buttons.LargeTonal( + onClick = onContinueClick, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth() + .requiredWidthIn(min = Dp.Unspecified, max = 220.dp) + .align(Alignment.CenterHorizontally) + ) { + Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue)) + } + + TextButton( + onClick = onCancelClick, + modifier = Modifier + .padding(bottom = 48.dp) + .fillMaxWidth() + .requiredWidthIn(min = Dp.Unspecified, max = 220.dp) + .align(Alignment.CenterHorizontally) + ) { + Text(text = stringResource(android.R.string.cancel)) + } +} + +@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 + ) +} + private suspend fun saveKeyToCredentialManager( @UiContext activityContext: Context, backupKey: String @@ -286,7 +428,12 @@ private fun MessageBackupsKeyRecordScreenPreview() { MessageBackupsKeyRecordScreen( backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0", keySaveState = null, - canOpenPasswordManagerSettings = true + canOpenPasswordManagerSettings = true, + mode = MessageBackupsKeyRecordMode.CreateNewKey( + onCreateNewKeyClick = {}, + onTurnOffAndDownloadClick = {}, + isOptimizedStorageEnabled = true + ) ) } } @@ -298,7 +445,27 @@ private fun SaveKeyConfirmationDialogPreview() { MessageBackupsKeyRecordScreen( backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0", keySaveState = BackupKeySaveState.RequestingConfirmation, - canOpenPasswordManagerSettings = true + canOpenPasswordManagerSettings = true, + mode = MessageBackupsKeyRecordMode.Next(onNextClick = {}) ) } } + +@OptIn(ExperimentalMaterial3Api::class) +@SignalPreview +@Composable +private fun CreateNewBackupKeySheetContentPreview() { + Previews.BottomSheetPreview { + Column { + CreateNewBackupKeySheetContent() + } + } +} + +@SignalPreview +@Composable +private fun DownloadMediaDialogPreview() { + Previews.Preview { + DownloadMediaDialog() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt index dc5b9455a3..91d2a76242 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt @@ -3,7 +3,9 @@ package org.thoughtcrime.securesms.backup.v2.ui.verify import android.R import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordMode import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -36,13 +38,15 @@ class ForgotBackupKeyFragment : ComposeFragment() { onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, - onNextClick = { - requireActivity() - .supportFragmentManager - .beginTransaction() - .add(R.id.content, ConfirmBackupKeyDisplayFragment()) - .addToBackStack(null) - .commit() + mode = remember { + MessageBackupsKeyRecordMode.Next(onNextClick = { + requireActivity() + .supportFragmentManager + .beginTransaction() + .add(R.id.content, ConfirmBackupKeyDisplayFragment()) + .addToBackStack(null) + .commit() + }) }, onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) } ) 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 e07f2aeb1a..a181a8f229 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 @@ -5,13 +5,28 @@ package org.thoughtcrime.securesms.components.settings.app.backups.remote +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import androidx.navigation.fragment.findNavController +import org.signal.core.ui.compose.Dialogs +import org.thoughtcrime.securesms.R +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 import org.thoughtcrime.securesms.compose.ComposeFragment -import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.compose.Nav import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.viewModel @@ -22,6 +37,7 @@ import org.thoughtcrime.securesms.util.viewModel class BackupKeyDisplayFragment : ComposeFragment() { companion object { + const val AEP_ROTATION_KEY = "AEP_ROTATION_KEY" const val CLIPBOARD_TIMEOUT_SECONDS = 60 } @@ -32,17 +48,108 @@ class BackupKeyDisplayFragment : ComposeFragment() { val state by viewModel.uiState.collectAsStateWithLifecycle() val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext()) - MessageBackupsKeyRecordScreen( - backupKey = SignalStore.account.accountEntropyPool.displayValue, - keySaveState = state.keySaveState, - canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, - onNavigationClick = { findNavController().popBackStack() }, - onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) }, - onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, - onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, - onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, - onNextClick = { findNavController().popBackStack() }, - onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) } - ) + val navController = rememberNavController() + LaunchedEffect(Unit) { + navController.setLifecycleOwner(this@BackupKeyDisplayFragment) + navController.enableOnBackPressed(true) + } + + LaunchedEffect(state.rotationState) { + if (state.rotationState == BackupKeyRotationState.FINISHED) { + setFragmentResult(AEP_ROTATION_KEY, bundleOf(AEP_ROTATION_KEY to true)) + findNavController().popBackStack() + } + } + + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + var displayWarningDialog by remember { mutableStateOf(false) } + BackHandler(enabled = state.rotationState == BackupKeyRotationState.USER_VERIFICATION) { + displayWarningDialog = true + } + + val mode = remember(state.rotationState) { + if (state.rotationState == BackupKeyRotationState.NOT_STARTED) { + MessageBackupsKeyRecordMode.CreateNewKey( + onCreateNewKeyClick = { + viewModel.rotateBackupKey() + }, + onTurnOffAndDownloadClick = { + viewModel.turnOffOptimizedStorageAndDownloadMedia() + findNavController().popBackStack() + }, + isOptimizedStorageEnabled = state.isOptimizedStorageEnabled + ) + } else { + MessageBackupsKeyRecordMode.Next( + onNextClick = { + navController.navigate(Screen.Verify.route) + } + ) + } + } + + if (state.rotationState == BackupKeyRotationState.GENERATING_KEY || state.rotationState == BackupKeyRotationState.COMMITTING_KEY) { + Dialogs.IndeterminateProgressDialog() + } + + if (displayWarningDialog) { + BackupKeyNotCommitedWarningDialog( + onConfirm = { + findNavController().popBackStack() + }, + onCancel = { + displayWarningDialog = false + navController.navigate(Screen.Verify.route) + } + ) + } + + Nav.Host( + navController = navController, + startDestination = Screen.Record.route + ) { + composable(Screen.Record.route) { + MessageBackupsKeyRecordScreen( + backupKey = state.accountEntropyPool.displayValue, + keySaveState = state.keySaveState, + canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, + onNavigationClick = { onBackPressedDispatcher?.onBackPressed() }, + onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) }, + onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, + onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, + onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, + mode = mode, + onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) } + ) + } + + composable(Screen.Verify.route) { + MessageBackupsKeyVerifyScreen( + backupKey = state.accountEntropyPool.displayValue, + onNavigationClick = { onBackPressedDispatcher?.onBackPressed() }, + onNextClick = { viewModel.commitBackupKey() } + ) + } + } } } + +@Composable +private fun BackupKeyNotCommitedWarningDialog( + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.BackupKeyDisplayFragment__cancel_key_creation_question), + body = stringResource(R.string.BackupKeyDisplayFragment__your_new_backup_key), + confirm = stringResource(R.string.BackupKeyDisplayFragment__cancel_key_creation), + dismiss = stringResource(R.string.BackupKeyDisplayFragment__confirm_key), + onConfirm = onConfirm, + onDeny = onCancel + ) +} + +private enum class Screen(val route: String) { + Record("record-screen"), + Verify("verify-screen") +} 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 index 0f6e2c012f..b8096298e7 100644 --- 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 @@ -6,10 +6,19 @@ package org.thoughtcrime.securesms.components.settings.app.backups.remote import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.concurrent.SignalDispatchers +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.AccountEntropyPool class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler { private val _uiState = MutableStateFlow(BackupKeyDisplayUiState()) @@ -18,8 +27,54 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler override fun updateBackupKeySaveState(newState: BackupKeySaveState?) { _uiState.update { it.copy(keySaveState = newState) } } + + fun rotateBackupKey() { + viewModelScope.launch { + _uiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) } + + val stagedAEP = withContext(SignalDispatchers.IO) { + BackupRepository.stageAEPKeyRotation() + } + + _uiState.update { + it.copy( + accountEntropyPool = stagedAEP, + rotationState = BackupKeyRotationState.USER_VERIFICATION + ) + } + } + } + + fun commitBackupKey() { + viewModelScope.launch { + _uiState.update { it.copy(rotationState = BackupKeyRotationState.COMMITTING_KEY) } + + withContext(SignalDispatchers.IO) { + BackupRepository.commitAEPKeyRotation(_uiState.value.accountEntropyPool) + } + + _uiState.update { it.copy(rotationState = BackupKeyRotationState.FINISHED) } + } + } + + fun turnOffOptimizedStorageAndDownloadMedia() { + SignalStore.backup.optimizeStorage = false + // TODO - flag to notify when complete. + AppDependencies.jobManager.add(RestoreOptimizedMediaJob()) + } } data class BackupKeyDisplayUiState( - val keySaveState: BackupKeySaveState? = null + val accountEntropyPool: AccountEntropyPool = SignalStore.account.accountEntropyPool, + val keySaveState: BackupKeySaveState? = null, + val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage, + val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED ) + +enum class BackupKeyRotationState { + NOT_STARTED, + GENERATING_KEY, + USER_VERIFICATION, + COMMITTING_KEY, + FINISHED +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index bcc4c5d61b..fcea8563e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -304,6 +305,13 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { } } + setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle -> + val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false) + if (didRotate) { + viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED) + } + } + if (savedInstanceState == null && args.backupLaterSelected) { viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.BACKUP_WILL_BE_CREATED_OVERNIGHT) } @@ -628,6 +636,7 @@ private fun RemoteBackupsSettingsContent( RemoteBackupsSettingsState.Snackbar.SUBSCRIPTION_CANCELLED -> R.string.RemoteBackupsSettingsFragment__subscription_cancelled RemoteBackupsSettingsState.Snackbar.DOWNLOAD_COMPLETE -> R.string.RemoteBackupsSettingsFragment__download_complete RemoteBackupsSettingsState.Snackbar.BACKUP_WILL_BE_CREATED_OVERNIGHT -> R.string.RemoteBackupsSettingsFragment__backup_will_be_created_overnight + RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED -> R.string.RemoteBackupsSettingsFragment__new_backup_key_created } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index d101d5a762..5cb6dcafda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -50,6 +50,7 @@ data class RemoteBackupsSettingsState( BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED, SUBSCRIPTION_CANCELLED, DOWNLOAD_COMPLETE, - BACKUP_WILL_BE_CREATED_OVERNIGHT + BACKUP_WILL_BE_CREATED_OVERNIGHT, + AEP_KEY_ROTATED } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index e990a01719..dcf5ee76d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -141,6 +141,15 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) } } + fun rotateAccountEntropyPool(aep: AccountEntropyPool) { + AEP_LOCK.withLock { + store + .beginWrite() + .putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value) + .commit() + } + } + fun restoreAccountEntropyPool(aep: AccountEntropyPool) { AEP_LOCK.withLock { store diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b8722d839..54c0ad3c3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8156,6 +8156,8 @@ Download complete Backup will be created overnight. + + New backup key created Subscription inactive @@ -8405,6 +8407,28 @@ Continue See key again + + Create new key + + Download media + + To create a new backup key, you must turn off \"Optimize storage\" and wait until your media has been downloaded. When the download is complete, you can create a new key. + + Turn off and download + + Create a new backup key + + Creating a new key is only necessary if someone else knows your key. You will have to re-upload your backup, including media. If you are using \"Optimize storage\" you will have to download offloaded media first. + + + + Cancel key creation? + + Your new backup key won\'t be created unless you confirm it. + + Confirm key + + Cancel key creation Confirm your backup key