Ensure backups are re-enabled post-reg in regV5.

This commit is contained in:
Greyson Parrelli
2026-07-09 15:44:22 -04:00
parent ba43403009
commit db8a705704
9 changed files with 85 additions and 31 deletions
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
@@ -43,10 +44,12 @@ import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.thoughtcrime.securesms.backup.BackupEvent
import org.thoughtcrime.securesms.backup.BackupPassphrase
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
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.crypto.AppAttachmentSecretStore
@@ -68,6 +71,8 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import java.io.File
@@ -290,14 +295,14 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
}
override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = callbackFlow {
Log.d(TAG, "Starting V1 local backup restore from: $uri")
override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = callbackFlow {
Log.d(TAG, "Starting V1 local backup restore from: $backupUri")
trySend(LocalBackupRestoreProgress.Preparing)
// The importer only reports a running frame count with no total, so we track bytes read from the backup file against
// its size to produce a real progress fraction, sampling the counting stream on each frame-progress event.
val totalBytes = context.contentResolver.getLength(uri) ?: 0L
val totalBytes = context.contentResolver.getLength(backupUri) ?: 0L
var countingStream: CountingInputStream? = null
val subscriber = object {
@@ -313,14 +318,14 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
launch(Dispatchers.IO) {
try {
if (!FullBackupImporter.validatePassphrase(context, uri, passphrase)) {
if (!FullBackupImporter.validatePassphrase(context, backupUri, passphrase)) {
Log.w(TAG, "V1 restore failed: incorrect passphrase")
trySend(LocalBackupRestoreProgress.IncorrectCredential)
return@launch
}
val database = SignalDatabase.backupDatabase
val inputStream = context.contentResolver.openInputStream(uri) ?: throw IOException("Unable to open backup stream for $uri")
val inputStream = context.contentResolver.openInputStream(backupUri) ?: throw IOException("Unable to open backup stream for $backupUri")
CountingInputStream(inputStream).use { counting ->
countingStream = counting
FullBackupImporter.importFile(
@@ -335,6 +340,12 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
SignalDatabase.runPostBackupRestoreTasks(database)
// The importer writes the restored key-value store straight to disk, bypassing the in-memory SignalStore cache.
// Reset it so the state we read below reflects the restored values rather than stale pre-restore ones.
SignalStore.onPostBackupRestore()
reenableLegacyLocalBackups(rootUri, passphrase)
trySend(readRestoredLocalBackupState(includePreRegistrationKeys = true))
Log.d(TAG, "V1 restore complete.")
} catch (e: FullBackupImporter.DatabaseDowngradeException) {
@@ -397,6 +408,20 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
Log.i(TAG, "V2 local backup belongs to current account; adopting entered recovery key.")
SignalStore.account.restoreAccountEntropyPool(aep)
updateInProgressRegistrationData { this.accountEntropyPool = aep.value }
// Re-enable new-style local backups pointing at the restored location, so the user keeps getting backups.
// Skip it if the folder is the SignalBackups directory itself, since it can't be reused as a destination.
val archiveFileSystem = ArchiveFileSystem.openForRestore(context, rootUri)
if (archiveFileSystem != null && !archiveFileSystem.isRootedAtSignalBackups) {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(rootUri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = rootUri.toString()
SignalStore.backup.newLocalBackupsEnabled = true
LocalBackupListener.setNextBackupTimeToIntervalFromNow(context)
LocalBackupListener.schedule(context)
} else {
Log.w(TAG, "V2 local backup directory can't be reused as a destination; not re-enabling local backups.")
}
} else {
Log.w(TAG, "V2 local backup does not belong to current account; keeping existing recovery key.")
}
@@ -414,6 +439,30 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
}.flowOn(Dispatchers.IO)
/**
* Persists the restored backup folder as the backup directory and re-enables scheduled local backups, so the user
* keeps getting backups after restoring. Best-effort: a failure here must not fail the restore itself.
*/
private fun reenableLegacyLocalBackups(rootUri: Uri, passphrase: String) {
try {
BackupPassphrase.set(context, passphrase)
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(rootUri, takeFlags)
SignalStore.settings.setSignalBackupDirectory(rootUri)
if (BackupUtil.canUserAccessBackupDirectory(context)) {
LocalBackupListener.setNextBackupTimeToIntervalFromNow(context)
SignalStore.settings.isBackupEnabled = true
LocalBackupListener.schedule(context)
} else {
Log.w(TAG, "Can't access restored backup directory; not re-enabling local backups.")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to re-enable local backups after V1 restore.", e)
}
}
private fun readRestoredLocalBackupState(includePreRegistrationKeys: Boolean = false): LocalBackupRestoreProgress.Complete {
val restoredPin = SignalStore.svr.pin?.takeIf { it.isNotBlank() }
val restoredProfileKey = SignalStore.account.aci
@@ -298,10 +298,10 @@ class DemoStorageController(private val context: Context) : StorageController {
backups.sortedByDescending { it.date }
}
override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = flow {
Log.d(TAG, "Starting simulated V1 local backup restore from: $uri")
override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = flow {
Log.d(TAG, "Starting simulated V1 local backup restore from: $backupUri")
require(DocumentFile.fromSingleUri(context, uri)?.exists() == true) { "Backup file does not exist: $uri" }
require(DocumentFile.fromSingleUri(context, backupUri)?.exists() == true) { "Backup file does not exist: $backupUri" }
emit(LocalBackupRestoreProgress.Preparing)
delay(SIMULATED_STAGE_DELAY_MS)
@@ -847,8 +847,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ
data.aci.isNotEmpty() && data.pni.isNotEmpty()
}
fun restoreV1Backup(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> {
return storageController.restoreLocalBackupV1(uri, passphrase)
fun restoreV1Backup(rootUri: Uri, backupUri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> {
return storageController.restoreLocalBackupV1(rootUri, backupUri, passphrase)
}
fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> {
@@ -97,12 +97,15 @@ interface StorageController {
suspend fun setRestoreDecision(decision: RestoreDecision)
/**
* Begins restoring from a V1 (.backup) file identified by the given [uri].
* Begins restoring from a V1 (.backup) file identified by the given [backupUri].
*
* Returns a [Flow] of [LocalBackupRestoreProgress] that reports the state of the restore operation
* from preparation through completion or error.
* @param rootUri The backup directory that contains the [backupUri] file. Persisted as the backup directory so
* local backups can be re-enabled after the restore.
* @param backupUri The specific .backup file to restore from.
* @return A [Flow] of [LocalBackupRestoreProgress] that reports the state of the restore operation
* from preparation through completion or error.
*/
fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress>
fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress>
/**
* Begins restoring from a V2 (folder-based) backup.
@@ -238,7 +238,7 @@ private fun PassphraseTextField(
onValueChange = { newValue ->
onPassphraseChange(newValue.filter { it.isDigit() })
},
label = { Text(stringResource(R.string.LocalBackupRestoreScreen__recovery_key)) },
label = { Text(stringResource(R.string.LocalBackupRestoreScreen__passphrase)) },
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
lineHeight = 36.sp
@@ -12,7 +12,7 @@ import org.signal.core.models.AccountEntropyPool
* to the phone number entry screen via the result bus.
*/
sealed interface LocalBackupRestoreResult {
/** The restore completed successfully. Contains the AEP if V2 backup, null if V1. */
/** The restore completed successfully. Contains the AEP if V2 backup, or the restored AEP for V1. */
data class Success(val aep: AccountEntropyPool?) : LocalBackupRestoreResult
/** The user canceled the restore flow. The pending restore option should be cleared. */
@@ -188,7 +188,7 @@ class LocalBackupRestoreViewModel(
restoreJob = viewModelScope.launch {
val currentState = _state.value
val restoreFlow = when (backup.type) {
LocalBackupInfo.BackupType.V1 -> repository.restoreV1Backup(backup.uri, passphrase = credential)
LocalBackupInfo.BackupType.V1 -> repository.restoreV1Backup(rootUri = rootUri!!, backupUri = backup.uri, passphrase = credential)
LocalBackupInfo.BackupType.V2 -> repository.restoreV2Backup(rootUri = rootUri!!, backupUri = backup.uri, aep = aep!!)
}
restoreFlow.collect { progress ->
@@ -203,6 +203,8 @@
<string name="LocalBackupRestoreScreen__enter_the_30_digit_passphrase">Your local backup passphrase is a 30-digit code required to recover your account and data.</string>
<!-- Label for the recovery key text field -->
<string name="LocalBackupRestoreScreen__recovery_key">Recovery key</string>
<!-- Label for the backup passphrase text field -->
<string name="LocalBackupRestoreScreen__passphrase">Passphrase</string>
<!-- Error text when passphrase is too long -->
<string name="LocalBackupRestoreScreen__too_long">Too long (%1$d/%2$d)</string>
<!-- Link for users who don\'t have their passphrase -->
@@ -135,7 +135,7 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.RestoreBackup, stateEmitter)
@@ -157,7 +157,7 @@ class LocalBackupRestoreViewModelTest {
name = "backup.bin",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.RestoreBackup, stateEmitter)
@@ -279,9 +279,9 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = "1234", restoredProfileKey = null))
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = "1234", restoredProfileKey = null))
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted("passphrase"), stateEmitter)
@@ -300,9 +300,9 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted("passphrase"), stateEmitter)
@@ -324,9 +324,9 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null))
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted("passphrase"), stateEmitter)
@@ -352,9 +352,9 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(LocalBackupRestoreProgress.IncorrectCredential)
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(LocalBackupRestoreProgress.IncorrectCredential)
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted("passphrase"), stateEmitter)
@@ -394,13 +394,13 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
val profileKey = ProfileKey(ByteArray(32))
val aciIdentityKey = IdentityKeyPair.generate()
val pniIdentityKey = IdentityKeyPair.generate()
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(
LocalBackupRestoreProgress.Complete(
restoredSvrPin = "1234",
restoredProfileKey = profileKey,
@@ -424,11 +424,11 @@ class LocalBackupRestoreViewModelTest {
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
val restoredAep = AccountEntropyPool(VALID_AEP)
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(
every { mockRepository.restoreV1Backup(any(), any(), any()) } returns flowOf(
LocalBackupRestoreProgress.Complete(
restoredSvrPin = null,
restoredProfileKey = null,