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.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.
*/

View File

@@ -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,

View File

@@ -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()
}
}

View File

@@ -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) }
)