From d5329d07947c893cb2d0765c552f6179121c378e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 26 Mar 2026 12:25:37 -0300 Subject: [PATCH] Support directly selecting signalbackup. --- .../backup/v2/local/ArchiveFileSystem.kt | 36 +++++- .../RestoreLocalBackupActivityViewModel.kt | 6 +- .../backup/v2/local/ArchiveFileSystemTest.kt | 109 ++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystemTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt index 3c3da0bb6a..6bd8d07b8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.local import android.content.Context import android.net.Uri +import androidx.annotation.VisibleForTesting import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -77,15 +78,28 @@ class ArchiveFileSystem private constructor(private val context: Context, root: fun openForRestore(context: Context, uri: Uri): ArchiveFileSystem? { val root = DocumentFile.fromTreeUri(context, uri) ?: return null if (!root.canRead()) return null - if (root.findFile(MAIN_DIRECTORY_NAME) == null) return null + return openForRestore(context, root) + } + + @VisibleForTesting + fun openForRestore(context: Context, root: DocumentFile): ArchiveFileSystem? { + if (root.findFile(MAIN_DIRECTORY_NAME) == null && !looksLikeSignalBackupsDirectory(root)) return null return try { ArchiveFileSystem(context, root, readOnly = true) } catch (e: IOException) { - Log.w(TAG, "Unable to open backup directory for restore: $uri", e) + Log.w(TAG, "Unable to open backup directory for restore", e) null } } + /** + * Returns true if [dir] appears to be a SignalBackups directory based on its name and + * expected internal structure (presence of the "files" subdirectory). + */ + private fun looksLikeSignalBackupsDirectory(dir: DocumentFile): Boolean { + return dir.name == MAIN_DIRECTORY_NAME && dir.findFile("files") != null + } + /** * Attempt to create an [ArchiveFileSystem] from a regular [File]. * @@ -105,12 +119,28 @@ class ArchiveFileSystem private constructor(private val context: Context, root: /** File access to shared super-set of archive related files (e.g., media + attachments) */ val filesFileSystem: FilesFileSystem + /** + * True if this file system was opened directly from the SignalBackups directory itself (rather than its parent). + * In this case, the URI cannot be reused as a backup destination since we lack access to the parent directory. + */ + val isRootedAtSignalBackups: Boolean + init { if (readOnly) { - signalBackups = root.findFile(MAIN_DIRECTORY_NAME) ?: throw IOException("SignalBackups directory not found in $root") + val child = root.findFile(MAIN_DIRECTORY_NAME) + if (child != null) { + signalBackups = child + isRootedAtSignalBackups = false + } else if (looksLikeSignalBackupsDirectory(root)) { + signalBackups = root + isRootedAtSignalBackups = true + } else { + throw IOException("SignalBackups directory not found in $root") + } val filesDirectory = signalBackups.findFile("files") ?: throw IOException("files directory not found in $signalBackups") filesFileSystem = FilesFileSystem(context, filesDirectory, readOnly = true) } else { + isRootedAtSignalBackups = false signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory") val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory") filesFileSystem = FilesFileSystem(context, filesDirectory) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt index ac16b636c8..2e4d91b89f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt @@ -147,11 +147,13 @@ class RestoreLocalBackupActivityViewModel : ViewModel() { StorageServiceRestore.restore() RegistrationUtil.maybeMarkRegistrationComplete() + val canReenableBackups = backupIdMatchesCurrentAccount && !archiveFileSystem.isRootedAtSignalBackups + internalState.update { it.copy( restorePhase = RestorePhase.COMPLETE, - backupDirectory = if (backupIdMatchesCurrentAccount) backupDirectory else null, - dialog = if (backupIdMatchesCurrentAccount) RestoreLocalBackupActivityDialog.CONFIRM_BACKUP_LOCATION + backupDirectory = if (canReenableBackups) backupDirectory else null, + dialog = if (canReenableBackups) RestoreLocalBackupActivityDialog.CONFIRM_BACKUP_LOCATION else RestoreLocalBackupActivityDialog.LOCAL_BACKUPS_DISABLED ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystemTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystemTest.kt new file mode 100644 index 0000000000..104662816c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystemTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.local + +import android.app.Application +import android.content.Context +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class ArchiveFileSystemTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `openForRestore succeeds when given the parent directory`() { + val parent = temporaryFolder.newFolder() + buildSignalBackupsStructure(parent) + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(parent)) + + assertThat(result).isNotNull() + } + + @Test + fun `openForRestore isRootedAtSignalBackups is false when given the parent directory`() { + val parent = temporaryFolder.newFolder() + buildSignalBackupsStructure(parent) + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(parent))!! + + assertThat(result.isRootedAtSignalBackups).isFalse() + } + + @Test + fun `openForRestore succeeds when given the SignalBackups directory directly`() { + val parent = temporaryFolder.newFolder() + val signalBackups = buildSignalBackupsStructure(parent) + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(signalBackups)) + + assertThat(result).isNotNull() + } + + @Test + fun `openForRestore isRootedAtSignalBackups is true when given the SignalBackups directory directly`() { + val parent = temporaryFolder.newFolder() + val signalBackups = buildSignalBackupsStructure(parent) + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(signalBackups))!! + + assertThat(result.isRootedAtSignalBackups).isTrue() + } + + @Test + fun `openForRestore isRootedAtSignalBackups is false when parent is named SignalBackups but contains a real SignalBackups subfolder`() { + val outerSignalBackups = temporaryFolder.newFolder(ArchiveFileSystem.MAIN_DIRECTORY_NAME) + buildSignalBackupsStructure(outerSignalBackups) + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(outerSignalBackups))!! + + assertThat(result.isRootedAtSignalBackups).isFalse() + } + + @Test + fun `openForRestore returns null for a directory named SignalBackups without expected structure`() { + val fakeSignalBackups = temporaryFolder.newFolder(ArchiveFileSystem.MAIN_DIRECTORY_NAME) + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(fakeSignalBackups)) + + assertThat(result).isNull() + } + + @Test + fun `openForRestore returns null for an unrelated directory`() { + val unrelated = temporaryFolder.newFolder("SomeOtherFolder") + + val result = ArchiveFileSystem.openForRestore(context, DocumentFile.fromFile(unrelated)) + + assertThat(result).isNull() + } + + /** + * Creates the SignalBackups directory structure inside [parent] and returns the SignalBackups directory. + */ + private fun buildSignalBackupsStructure(parent: java.io.File): java.io.File { + val signalBackups = parent.resolve(ArchiveFileSystem.MAIN_DIRECTORY_NAME).also { it.mkdir() } + signalBackups.resolve("files").mkdir() + return signalBackups + } +}