Allow user to rotate AEP.

This commit is contained in:
Alex Hart
2025-07-18 10:36:49 -03:00
committed by GitHub
parent a9455b95ac
commit 36de1284c7
10 changed files with 457 additions and 44 deletions

View File

@@ -9,10 +9,12 @@ import android.app.PendingIntent
import android.database.Cursor import android.database.Cursor
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.annotation.CheckResult
import androidx.annotation.Discouraged import androidx.annotation.Discouraged
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.ByteString 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.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.BackupDeleteJob import org.thoughtcrime.securesms.jobs.BackupDeleteJob
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
@@ -175,10 +179,7 @@ object BackupRepository {
when (error.code) { when (error.code) {
401 -> { 401 -> {
Log.w(TAG, "Received status 401. Resetting initialized state + auth credentials.", error.exception) Log.w(TAG, "Received status 401. Resetting initialized state + auth credentials.", error.exception)
SignalStore.backup.backupsInitialized = false resetInitializedStateAndAuthCredentials()
SignalStore.backup.messageCredentials.clearAll()
SignalStore.backup.mediaCredentials.clearAll()
SignalStore.backup.cachedMediaCdnPath = null
} }
403 -> { 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. * Triggers backup id reservation. As documented, this is safe to perform multiple times.
*/ */

View File

@@ -14,6 +14,7 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
@@ -147,7 +148,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
keySaveState = state.backupKeySaveState, keySaveState = state.backupKeySaveState,
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
onNavigationClick = viewModel::goToPreviousStage, onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage, mode = remember { MessageBackupsKeyRecordMode.Next(viewModel::goToNextStage) },
onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) }, onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,

View File

@@ -11,29 +11,39 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Buttons 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.Scaffolds
import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState 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.thoughtcrime.securesms.util.storage.CredentialManagerResult
import org.signal.core.ui.R as CoreUiR 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 * Screen displaying the backup key allowing the user to write it down
* or copy it. * or copy it.
@@ -65,8 +86,8 @@ fun MessageBackupsKeyRecordScreen(
onRequestSaveToPasswordManager: () -> Unit = {}, onRequestSaveToPasswordManager: () -> Unit = {},
onConfirmSaveToPasswordManager: () -> Unit = {}, onConfirmSaveToPasswordManager: () -> Unit = {},
onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {}, onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {},
onNextClick: () -> Unit = {}, onGoToPasswordManagerSettingsClick: () -> Unit = {},
onGoToPasswordManagerSettingsClick: () -> Unit = {} mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {})
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) { val backupKeyString = remember(backupKey) {
@@ -96,7 +117,7 @@ fun MessageBackupsKeyRecordScreen(
) { ) {
item { item {
Image( Image(
painter = painterResource(R.drawable.image_signal_backups_lock), imageVector = ImageVector.vectorResource(R.drawable.image_signal_backups_lock),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(top = 24.dp) .padding(top = 24.dp)
@@ -170,13 +191,14 @@ fun MessageBackupsKeyRecordScreen(
} }
} }
if (mode is MessageBackupsKeyRecordMode.Next) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 24.dp) .padding(bottom = 24.dp)
) { ) {
Buttons.LargeTonal( Buttons.LargeTonal(
onClick = onNextClick, onClick = mode.onNextClick,
modifier = Modifier.align(Alignment.BottomEnd) modifier = Modifier.align(Alignment.BottomEnd)
) { ) {
Text( Text(
@@ -184,6 +206,9 @@ fun MessageBackupsKeyRecordScreen(
) )
} }
} }
} else if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
CreateNewKeyButton(mode)
}
} }
when (keySaveState) { when (keySaveState) {
@@ -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 @Composable
private fun BackupKeySaveErrorDialog( private fun BackupKeySaveErrorDialog(
error: BackupKeySaveState.Error, 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( private suspend fun saveKeyToCredentialManager(
@UiContext activityContext: Context, @UiContext activityContext: Context,
backupKey: String backupKey: String
@@ -286,7 +428,12 @@ private fun MessageBackupsKeyRecordScreenPreview() {
MessageBackupsKeyRecordScreen( 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, keySaveState = null,
canOpenPasswordManagerSettings = true canOpenPasswordManagerSettings = true,
mode = MessageBackupsKeyRecordMode.CreateNewKey(
onCreateNewKeyClick = {},
onTurnOffAndDownloadClick = {},
isOptimizedStorageEnabled = true
)
) )
} }
} }
@@ -298,7 +445,27 @@ private fun SaveKeyConfirmationDialogPreview() {
MessageBackupsKeyRecordScreen( 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 = BackupKeySaveState.RequestingConfirmation, 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()
}
}

View File

@@ -3,7 +3,9 @@ package org.thoughtcrime.securesms.backup.v2.ui.verify
import android.R import android.R
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -36,13 +38,15 @@ class ForgotBackupKeyFragment : ComposeFragment() {
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
onNextClick = { mode = remember {
MessageBackupsKeyRecordMode.Next(onNextClick = {
requireActivity() requireActivity()
.supportFragmentManager .supportFragmentManager
.beginTransaction() .beginTransaction()
.add(R.id.content, ConfirmBackupKeyDisplayFragment()) .add(R.id.content, ConfirmBackupKeyDisplayFragment())
.addToBackStack(null) .addToBackStack(null)
.commit() .commit()
})
}, },
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) } onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
) )

View File

@@ -5,13 +5,28 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.fragment.findNavController 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.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
import org.thoughtcrime.securesms.compose.ComposeFragment 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.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.viewModel
@@ -22,6 +37,7 @@ import org.thoughtcrime.securesms.util.viewModel
class BackupKeyDisplayFragment : ComposeFragment() { class BackupKeyDisplayFragment : ComposeFragment() {
companion object { companion object {
const val AEP_ROTATION_KEY = "AEP_ROTATION_KEY"
const val CLIPBOARD_TIMEOUT_SECONDS = 60 const val CLIPBOARD_TIMEOUT_SECONDS = 60
} }
@@ -32,17 +48,108 @@ class BackupKeyDisplayFragment : ComposeFragment() {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext()) val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
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( MessageBackupsKeyRecordScreen(
backupKey = SignalStore.account.accountEntropyPool.displayValue, backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.keySaveState, keySaveState = state.keySaveState,
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
onNavigationClick = { findNavController().popBackStack() }, onNavigationClick = { onBackPressedDispatcher?.onBackPressed() },
onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) }, onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
onNextClick = { findNavController().popBackStack() }, mode = mode,
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) } 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")
} }

View File

@@ -6,10 +6,19 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote package org.thoughtcrime.securesms.components.settings.app.backups.remote
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update 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 { class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
private val _uiState = MutableStateFlow(BackupKeyDisplayUiState()) private val _uiState = MutableStateFlow(BackupKeyDisplayUiState())
@@ -18,8 +27,54 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) { override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
_uiState.update { it.copy(keySaveState = newState) } _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( 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
}

View File

@@ -70,6 +70,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs 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) { if (savedInstanceState == null && args.backupLaterSelected) {
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.BACKUP_WILL_BE_CREATED_OVERNIGHT) 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.SUBSCRIPTION_CANCELLED -> R.string.RemoteBackupsSettingsFragment__subscription_cancelled
RemoteBackupsSettingsState.Snackbar.DOWNLOAD_COMPLETE -> R.string.RemoteBackupsSettingsFragment__download_complete 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.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
} }
} }

View File

@@ -50,6 +50,7 @@ data class RemoteBackupsSettingsState(
BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED, BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED,
SUBSCRIPTION_CANCELLED, SUBSCRIPTION_CANCELLED,
DOWNLOAD_COMPLETE, DOWNLOAD_COMPLETE,
BACKUP_WILL_BE_CREATED_OVERNIGHT BACKUP_WILL_BE_CREATED_OVERNIGHT,
AEP_KEY_ROTATED
} }
} }

View File

@@ -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) { fun restoreAccountEntropyPool(aep: AccountEntropyPool) {
AEP_LOCK.withLock { AEP_LOCK.withLock {
store store

View File

@@ -8156,6 +8156,8 @@
<string name="RemoteBackupsSettingsFragment__download_complete">Download complete</string> <string name="RemoteBackupsSettingsFragment__download_complete">Download complete</string>
<!-- Snackbar text displayed when backup will be created overnight --> <!-- Snackbar text displayed when backup will be created overnight -->
<string name="RemoteBackupsSettingsFragment__backup_will_be_created_overnight">Backup will be created overnight.</string> <string name="RemoteBackupsSettingsFragment__backup_will_be_created_overnight">Backup will be created overnight.</string>
<!-- Snackbar text displayed when user rotates AEP -->
<string name="RemoteBackupsSettingsFragment__new_backup_key_created">New backup key created</string>
<!-- Text displayed in card when subscription is inactive --> <!-- Text displayed in card when subscription is inactive -->
<string name="RemoteBackupsSettingsFragment__subscription_inactive">Subscription inactive</string> <string name="RemoteBackupsSettingsFragment__subscription_inactive">Subscription inactive</string>
<!-- Text displayed in card when free tier is inactive --> <!-- Text displayed in card when free tier is inactive -->
@@ -8405,6 +8407,28 @@
<string name="MessageBackupsKeyRecordScreen__continue">Continue</string> <string name="MessageBackupsKeyRecordScreen__continue">Continue</string>
<!-- Sheet secondary action button label --> <!-- Sheet secondary action button label -->
<string name="MessageBackupsKeyRecordScreen__see_key_again">See key again</string> <string name="MessageBackupsKeyRecordScreen__see_key_again">See key again</string>
<!-- Action label to rotate AEP key -->
<string name="MessageBackupsKeyRecordScreen__create_new_key">Create new key</string>
<!-- Dialog title for confirming you need to disable optimized storage and download media to rotate AEP key -->
<string name="MessageBackupsKeyRecordScreen__download_media">Download media</string>
<!-- Dialog body for confirming you need to disable optimized storage and download media to rotate AEP key -->
<string name="MessageBackupsKeyRecordScreen__to_create_a_new_backup_key">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.</string>
<!-- Action label to turn off optimized storage and download media -->
<string name="MessageBackupsKeyRecordScreen__turn_off_and_download">Turn off and download</string>
<!-- Bottom Sheet title for creating a new AEP key -->
<string name="MessageBackupsKeyRecordScreen__create_a_new_backup_key">Create a new backup key</string>
<!-- Bottom Sheet body for creating a new AEP key -->
<string name="MessageBackupsKeyRecordScreen__creating_a_new_key_is_only_necessary">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.</string>
<!-- BackupKeyDisplayFragment -->
<!-- Dialog title when exiting before confirming new key -->
<string name="BackupKeyDisplayFragment__cancel_key_creation_question">Cancel key creation?</string>
<!-- Dialog body when exiting before confirming new key -->
<string name="BackupKeyDisplayFragment__your_new_backup_key">Your new backup key won\'t be created unless you confirm it.</string>
<!-- Dialog button to continue confirming new backup key-->
<string name="BackupKeyDisplayFragment__confirm_key">Confirm key</string>
<!-- Dialog button to cancel new key creation -->
<string name="BackupKeyDisplayFragment__cancel_key_creation">Cancel key creation</string>
<!-- Confirm key title --> <!-- Confirm key title -->
<string name="MessageBackupsKeyVerifyScreen__confirm_your_backup_key">Confirm your backup key</string> <string name="MessageBackupsKeyVerifyScreen__confirm_your_backup_key">Confirm your backup key</string>