Warning dialogs for local backup restore.

This commit is contained in:
Alex Hart
2026-03-20 10:24:02 -03:00
committed by Cody Henthorne
parent e657a4adf3
commit 34d87cf6e1
6 changed files with 134 additions and 21 deletions

View File

@@ -13,13 +13,15 @@ import org.thoughtcrime.securesms.R
enum class RestoreLocalBackupDialog {
FAILED_TO_LOAD_ARCHIVE,
SKIP_RESTORE_WARNING
SKIP_RESTORE_WARNING,
CONFIRM_DIFFERENT_ACCOUNT
}
@Composable
fun RestoreLocalBackupDialogDisplay(
dialog: RestoreLocalBackupDialog?,
onDialogConfirmed: (RestoreLocalBackupDialog) -> Unit,
onDialogDenied: (RestoreLocalBackupDialog) -> Unit,
onDismiss: () -> Unit
) {
when (dialog) {
@@ -33,9 +35,9 @@ fun RestoreLocalBackupDialogDisplay(
RestoreLocalBackupDialog.SKIP_RESTORE_WARNING -> {
Dialogs.SimpleAlertDialog(
title = "Skip restore?",
body = "If you skip restore now you will not be able to restore later. If you re-enable backups after skipping restore, your current backup will be replaced with your new messaging history.",
confirm = "Skip restore",
title = stringResource(R.string.RestoreLocalBackupDialog__skip_restore),
body = stringResource(R.string.RestoreLocalBackupDialog__skip_restore_body),
confirm = stringResource(R.string.RestoreLocalBackupDialog__skip_restore_confirm),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = {
onDialogConfirmed(RestoreLocalBackupDialog.SKIP_RESTORE_WARNING)
@@ -44,6 +46,21 @@ fun RestoreLocalBackupDialogDisplay(
)
}
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RestoreLocalBackupDialog__restore_to_new_account),
body = stringResource(R.string.RestoreLocalBackupDialog__restore_to_new_account_body),
confirm = stringResource(R.string.RestoreLocalBackupDialog__restore),
dismiss = stringResource(android.R.string.cancel),
onConfirm = {
onDialogConfirmed(RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT)
},
onDeny = {
onDialogDenied(RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT)
}
)
}
null -> return
}
}

View File

@@ -81,14 +81,7 @@ class RestoreLocalBackupFragment : ComposeFragment() {
.collect {
sharedViewModel.registerAccountErrorShown()
if (it is RegisterAccountResult.IncorrectRecoveryPassword) {
SignalStore.account.resetAccountEntropyPool()
SignalStore.account.resetAciAndPniIdentityKeysAfterFailedRestore()
sharedViewModel.clearRecoveryPassword()
enterBackupKeyViewModel.cancelRegistering()
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = true)
findNavController().safeNavigate(
RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)
)
restoreLocalBackupViewModel.displayDifferentAccountWarning()
} else {
enterBackupKeyViewModel.handleRegistrationFailure(it)
}
@@ -141,6 +134,29 @@ class RestoreLocalBackupFragment : ComposeFragment() {
findNavController().safeNavigate(RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
}
override fun confirmRestoreWithDifferentAccount() {
SignalStore.account.resetAccountEntropyPool()
SignalStore.account.resetAciAndPniIdentityKeysAfterFailedRestore()
sharedViewModel.clearRecoveryPassword()
enterBackupKeyViewModel.cancelRegistering()
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = true)
findNavController().safeNavigate(
RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)
)
}
override fun denyRestoreWithDifferentAccount() {
SignalStore.account.resetAccountEntropyPool()
SignalStore.account.resetAciAndPniIdentityKeysAfterFailedRestore()
SignalStore.backup.localRestoreAccountEntropyPool = null
sharedViewModel.clearRecoveryPassword()
enterBackupKeyViewModel.cancelRegistering()
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = true)
findNavController().safeNavigate(
RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE)
)
}
override fun routeToLegacyBackupRestoration(uri: Uri) {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = false)
localBackupRestore.launch(RestoreActivity.getLocalRestoreIntent(requireContext(), uri))

View File

@@ -158,11 +158,23 @@ fun RestoreLocalBackupNavDisplay(
}
)
RestoreLocalBackupDialogDisplay(state.dialog, {
if (it == RestoreLocalBackupDialog.SKIP_RESTORE_WARNING) {
callback.skipRestore()
}
}, callback::clearDialog)
RestoreLocalBackupDialogDisplay(
dialog = state.dialog,
onDialogConfirmed = {
when (it) {
RestoreLocalBackupDialog.SKIP_RESTORE_WARNING -> callback.skipRestore()
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> callback.confirmRestoreWithDifferentAccount()
else -> Unit
}
},
onDialogDenied = {
when (it) {
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> callback.denyRestoreWithDifferentAccount()
else -> Unit
}
},
onDismiss = callback::clearDialog
)
}
data class RestoreLocalBackupState(
@@ -177,6 +189,8 @@ interface RestoreLocalBackupCallback {
fun displaySkipRestoreWarning()
fun clearDialog()
fun skipRestore()
fun confirmRestoreWithDifferentAccount()
fun denyRestoreWithDifferentAccount()
fun submitBackupKey()
fun routeToLegacyBackupRestoration(uri: Uri)
fun onBackupKeyChanged(key: String)
@@ -189,6 +203,8 @@ interface RestoreLocalBackupCallback {
override fun displaySkipRestoreWarning() = Unit
override fun clearDialog() = Unit
override fun skipRestore() = Unit
override fun confirmRestoreWithDifferentAccount() = Unit
override fun denyRestoreWithDifferentAccount() = Unit
override fun submitBackupKey() = Unit
override fun routeToLegacyBackupRestoration(uri: Uri) = Unit
override fun onBackupKeyChanged(key: String) = Unit

View File

@@ -10,13 +10,17 @@ import android.net.Uri
import androidx.lifecycle.ViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.DateUtils
@@ -79,6 +83,31 @@ class RestoreLocalBackupViewModel : ViewModel() {
internalState.update { it.copy(dialog = RestoreLocalBackupDialog.SKIP_RESTORE_WARNING) }
}
fun displayDifferentAccountWarning() {
internalState.update { it.copy(dialog = RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT) }
}
/** Returns true if the backup at [timestamp] was created by the currently registered account, false if it belongs to a different account. */
suspend fun backupBelongsToCurrentAccount(context: Context, backupKey: String, timestamp: Long): Boolean {
return withContext(Dispatchers.IO) {
val aep = requireNotNull(AccountEntropyPool.parseOrNull(backupKey)) { "Backup key must be valid at submission time" }
val messageBackupKey = aep.deriveMessageBackupKey()
val dirUri = requireNotNull(SignalStore.backup.newLocalBackupsDirectory) { "Backup directory must be set" }
val archiveFileSystem = requireNotNull(ArchiveFileSystem.fromUri(context, Uri.parse(dirUri))) { "Backup directory must be accessible" }
val snapshot = requireNotNull(archiveFileSystem.listSnapshots().firstOrNull { it.timestamp == timestamp }) { "Selected snapshot must still exist" }
val snapshotFs = SnapshotFileSystem(context, snapshot.file)
val actualBackupId = LocalArchiver.getBackupId(snapshotFs, messageBackupKey)
if (actualBackupId == null) {
Log.w(TAG, "backupBelongsToCurrentAccount: getBackupId returned null, treating as current account")
return@withContext true
}
val expectedBackupId = messageBackupKey.deriveBackupId(SignalStore.account.requireAci())
val matches = actualBackupId.value.contentEquals(expectedBackupId.value)
Log.d(TAG, "backupBelongsToCurrentAccount: matches=$matches")
matches
}
}
fun clearDialog() {
internalState.update { it.copy(dialog = null) }
}

View File

@@ -99,14 +99,37 @@ class PostRegistrationRestoreLocalBackupFragment : ComposeFragment() {
}
}
override fun confirmRestoreWithDifferentAccount() {
launchRestore()
}
override fun denyRestoreWithDifferentAccount() {
restoreLocalBackupViewModel.clearDialog()
}
override fun submitBackupKey() {
AccountEntropyPool.parseOrNull(enterBackupKeyViewModel.backupKey) ?: return
SignalStore.backup.localRestoreAccountEntropyPool = enterBackupKeyViewModel.backupKey
val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp
viewLifecycleOwner.lifecycleScope.launch {
val belongsToCurrentAccount = restoreLocalBackupViewModel.backupBelongsToCurrentAccount(
context = requireContext(),
backupKey = enterBackupKeyViewModel.backupKey,
timestamp = selectedTimestamp
)
if (belongsToCurrentAccount) {
launchRestore()
} else {
restoreLocalBackupViewModel.displayDifferentAccountWarning()
}
}
}
private fun launchRestore() {
val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L
SignalStore.backup.localRestoreAccountEntropyPool = enterBackupKeyViewModel.backupKey
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp
startActivity(RestoreLocalBackupActivity.getIntent(requireContext()))
requireActivity().supportFinishAfterTransition()
}

View File

@@ -9193,6 +9193,18 @@
<string name="RestoreLocalBackupDialog__failed_to_load_archive">Failed to load archive. Please select a different directory.</string>
<!-- RestoreLocalBackupDialog: Dismiss button for dialog -->
<string name="RestoreLocalBackupDialog__ok">OK</string>
<!-- RestoreLocalBackupDialog: Title of the dialog asking the user to confirm skipping backup restore -->
<string name="RestoreLocalBackupDialog__skip_restore">Skip restore?</string>
<!-- RestoreLocalBackupDialog: Body of the dialog warning the user about the consequences of skipping backup restore -->
<string name="RestoreLocalBackupDialog__skip_restore_body">If you skip restore now you will not be able to restore later. If you re-enable backups after skipping restore, your current backup will be replaced with your new messaging history.</string>
<!-- RestoreLocalBackupDialog: Confirm button label for the skip restore dialog -->
<string name="RestoreLocalBackupDialog__skip_restore_confirm">Skip restore</string>
<!-- RestoreLocalBackupDialog: Title of the dialog asking the user to confirm restoring a backup from a different account -->
<string name="RestoreLocalBackupDialog__restore_to_new_account">Restore to new account?</string>
<!-- RestoreLocalBackupDialog: Body of the dialog warning the user that the backup was created with a different account and they will lose group memberships -->
<string name="RestoreLocalBackupDialog__restore_to_new_account_body">The backup you are restoring was created using a different Signal account. You can restore this backup to a new account, but you will no longer be a member of any groups that were restored.</string>
<!-- RestoreLocalBackupDialog: Confirm button label for the restore to new account dialog -->
<string name="RestoreLocalBackupDialog__restore">Restore</string>
<!-- RestoreLocalBackupActivity: Toast message when backup restore fails -->
<string name="RestoreLocalBackupActivity__backup_restore_failed">Backup restore failed</string>