Support directly selecting signalbackup.

This commit is contained in:
Alex Hart
2026-03-26 12:25:37 -03:00
committed by GitHub
parent ff04e5c5c3
commit d5329d0794
3 changed files with 146 additions and 5 deletions

View File

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

View File

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

View File

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