diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index ea034f6b8b..1055bfbefb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -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 = callbackFlow { - Log.d(TAG, "Starting V1 local backup restore from: $uri") + override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow = 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 diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index 0ca5817dae..be1982b03a 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -298,10 +298,10 @@ class DemoStorageController(private val context: Context) : StorageController { backups.sortedByDescending { it.date } } - override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow = flow { - Log.d(TAG, "Starting simulated V1 local backup restore from: $uri") + override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow = 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) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 880ab62102..6e8dc4c99b 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 { - return storageController.restoreLocalBackupV1(uri, passphrase) + fun restoreV1Backup(rootUri: Uri, backupUri: Uri, passphrase: String): Flow { + return storageController.restoreLocalBackupV1(rootUri, backupUri, passphrase) } fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow { diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index ae1629c808..502694e519 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -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 + fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow /** * Begins restoring from a V2 (folder-based) backup. diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt index 74621c9b62..61173037d5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/EnterLocalBackupV1PassphaseScreen.kt @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt index f8106f5423..cd59242726 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreResult.kt @@ -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. */ diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt index b913762891..026af247e0 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -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 -> diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 530c665888..5d834531a7 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -203,6 +203,8 @@ Your local backup passphrase is a 30-digit code required to recover your account and data. Recovery key + + Passphrase Too long (%1$d/%2$d) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt index cffd6b3780..2248f677a1 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt @@ -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,