diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt index c71d4509..d302bece 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt @@ -3,12 +3,19 @@ package com.stevesoltys.seedvault.plugins.saf import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile import com.stevesoltys.seedvault.transport.backup.BackupPlugin +import com.stevesoltys.seedvault.transport.backup.EncryptedMetadata import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin +import java.io.FileNotFoundException import java.io.IOException +import java.io.InputStream import java.io.OutputStream +private val TAG = DocumentsProviderBackupPlugin::class.java.simpleName private const val MIME_TYPE_APK = "application/vnd.android.package-archive" @Suppress("BlockingMethodInNonBlockingContext") @@ -45,12 +52,60 @@ internal class DocumentsProviderBackupPlugin( } @Throws(IOException::class) - override suspend fun getMetadataOutputStream(): OutputStream { - val setDir = storage.getSetDir() ?: throw IOException() + override suspend fun hasData(token: Long, name: String): Boolean { + val setDir = storage.getSetDir(token) ?: return false + return setDir.findFileBlocking(context, name) != null + } + + @Throws(IOException::class) + override suspend fun getOutputStream(token: Long, name: String): OutputStream { + val setDir = storage.getSetDir(token) ?: throw IOException() + val file = setDir.createOrGetFile(context, name) + return storage.getOutputStream(file) + } + + @Throws(IOException::class) + override suspend fun getInputStream(token: Long, name: String): InputStream { + val setDir = storage.getSetDir(token) ?: throw IOException() + val file = setDir.findFileBlocking(context, name) ?: throw FileNotFoundException() + return storage.getInputStream(file) + } + + @Throws(IOException::class) + override suspend fun removeData(token: Long, name: String) { + val setDir = storage.getSetDir(token) ?: throw IOException() + val file = setDir.findFileBlocking(context, name) ?: return + if (!file.delete()) throw IOException("Failed to delete $name") + } + + @Throws(IOException::class) + override suspend fun getMetadataOutputStream(token: Long): OutputStream { + val setDir = storage.getSetDir(token) ?: throw IOException() val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA) return storage.getOutputStream(metadataFile) } + @Throws(IOException::class) + override suspend fun hasBackup(uri: Uri): Boolean { + val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError() + val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false + val backupSets = getBackups(context, rootDir) + return backupSets.isNotEmpty() + } + + override suspend fun getAvailableBackups(): Sequence? { + val rootDir = storage.rootBackupDir ?: return null + val backupSets = getBackups(context, rootDir) + val iterator = backupSets.iterator() + return generateSequence { + if (!iterator.hasNext()) return@generateSequence null // end sequence + val backupSet = iterator.next() + EncryptedMetadata(backupSet.token) { + storage.getInputStream(backupSet.metadataFile) + } + } + } + @Throws(IOException::class) override suspend fun getApkOutputStream( packageInfo: PackageInfo, @@ -69,3 +124,64 @@ internal class DocumentsProviderBackupPlugin( } } + +class BackupSet(val token: Long, val metadataFile: DocumentFile) + +@Suppress("BlockingMethodInNonBlockingContext") +internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List { + val backupSets = ArrayList() + val files = try { + // block until the DocumentsProvider has results + rootDir.listFilesBlocking(context) + } catch (e: IOException) { + Log.e(TAG, "Error loading backups from storage", e) + return backupSets + } + for (set in files) { + // retrieve name only once as this causes a DB query + val name = set.name + + // get current token from set or continue to next file/set + val token = set.getTokenOrNull(name) ?: continue + + // block until children of set are available + val metadata = try { + set.findFileBlocking(context, FILE_BACKUP_METADATA) + } catch (e: IOException) { + Log.e(TAG, "Error reading metadata file in backup set folder: $name", e) + null + } + if (metadata == null) { + Log.w(TAG, "Missing metadata file in backup set folder: $name") + } else { + backupSets.add(BackupSet(token, metadata)) + } + } + return backupSets +} + +private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286 +private val chunkFolderRegex = Regex("[a-f0-9]{2}") + +private fun DocumentFile.getTokenOrNull(name: String?): Long? { + val looksLikeToken = name != null && tokenRegex.matches(name) + // check for isDirectory only if we already have a valid token (causes DB query) + if (!looksLikeToken || !isDirectory) { + // only log unexpected output + if (name != null && isUnexpectedFile(name)) { + Log.w(TAG, "Found invalid backup set folder: $name") + } + return null + } + return try { + name?.toLong() + } catch (e: NumberFormatException) { + throw AssertionError(e) + } +} + +private fun isUnexpectedFile(name: String): Boolean { + return name != FILE_NO_MEDIA && + !chunkFolderRegex.matches(name) && + !name.endsWith(".SeedSnap") +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt index 596ee1f4..ff88a0d1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt @@ -24,9 +24,6 @@ internal class DocumentsProviderRestorePlugin( override val fullRestorePlugin: FullRestorePlugin ) : RestorePlugin { - private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286 - private val chunkFolderRegex = Regex("[a-f0-9]{2}") - @Throws(IOException::class) override suspend fun hasBackup(uri: Uri): Boolean { val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError() @@ -52,61 +49,6 @@ internal class DocumentsProviderRestorePlugin( } } - private suspend fun getBackups(context: Context, rootDir: DocumentFile): List { - val backupSets = ArrayList() - val files = try { - // block until the DocumentsProvider has results - rootDir.listFilesBlocking(context) - } catch (e: IOException) { - Log.e(TAG, "Error loading backups from storage", e) - return backupSets - } - for (set in files) { - // retrieve name only once as this causes a DB query - val name = set.name - - // get current token from set or continue to next file/set - val token = set.getTokenOrNull(name) ?: continue - - // block until children of set are available - val metadata = try { - set.findFileBlocking(context, FILE_BACKUP_METADATA) - } catch (e: IOException) { - Log.e(TAG, "Error reading metadata file in backup set folder: $name", e) - null - } - if (metadata == null) { - Log.w(TAG, "Missing metadata file in backup set folder: $name") - } else { - backupSets.add(BackupSet(token, metadata)) - } - } - return backupSets - } - - private fun DocumentFile.getTokenOrNull(name: String?): Long? { - val looksLikeToken = name != null && tokenRegex.matches(name) - // check for isDirectory only if we already have a valid token (causes DB query) - if (!looksLikeToken || !isDirectory) { - // only log unexpected output - if (name != null && isUnexpectedFile(name)) { - Log.w(TAG, "Found invalid backup set folder: $name") - } - return null - } - return try { - name?.toLong() - } catch (e: NumberFormatException) { - throw AssertionError(e) - } - } - - private fun isUnexpectedFile(name: String): Boolean { - return name != FILE_NO_MEDIA && - !chunkFolderRegex.matches(name) && - !name.endsWith(".SeedSnap") - } - @Throws(IOException::class) override suspend fun getApkInputStream( token: Long, @@ -120,5 +62,3 @@ internal class DocumentsProviderRestorePlugin( } } - -class BackupSet(val token: Long, val metadataFile: DocumentFile) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index 543bc47d..aa4cd21b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -28,9 +28,11 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED +import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import java.io.IOException +import java.io.OutputStream import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.HOURS @@ -492,4 +494,9 @@ internal class BackupCoordinator( } } + private suspend fun BackupPlugin.getMetadataOutputStream(): OutputStream { + val token = settingsManager.getToken() ?: throw IOException("no current token") + return getOutputStream(token, FILE_BACKUP_METADATA) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt index f8672503..893cc557 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt @@ -2,13 +2,18 @@ package com.stevesoltys.seedvault.transport.backup import android.app.backup.RestoreSet import android.content.pm.PackageInfo +import android.net.Uri +import androidx.annotation.WorkerThread import java.io.IOException +import java.io.InputStream import java.io.OutputStream interface BackupPlugin { + @Deprecated("Use methods in this interface instead") val kvBackupPlugin: KVBackupPlugin + @Deprecated("Use methods in this interface instead") val fullBackupPlugin: FullBackupPlugin /** @@ -25,16 +30,60 @@ interface BackupPlugin { @Throws(IOException::class) suspend fun initializeDevice() + /** + * Return true if there is data stored for the given name. + */ + @Throws(IOException::class) + suspend fun hasData(token: Long, name: String): Boolean + + /** + * Return a raw byte stream for writing data for the given name. + */ + @Throws(IOException::class) + suspend fun getOutputStream(token: Long, name: String): OutputStream + + /** + * Return a raw byte stream with data for the given name. + */ + @Throws(IOException::class) + suspend fun getInputStream(token: Long, name: String): InputStream + + /** + * Remove all data associated with the given name. + */ + @Throws(IOException::class) + suspend fun removeData(token: Long, name: String) + /** * Returns an [OutputStream] for writing backup metadata. */ @Throws(IOException::class) - suspend fun getMetadataOutputStream(): OutputStream + @Deprecated("use getOutputStream() instead") + suspend fun getMetadataOutputStream(token: Long): OutputStream + + /** + * Searches if there's really a backup available in the given location. + * Returns true if at least one was found and false otherwise. + * + * FIXME: Passing a Uri is too plugin-specific and should be handled differently + */ + @WorkerThread + @Throws(IOException::class) + suspend fun hasBackup(uri: Uri): Boolean + + /** + * Get the set of all backups currently available for restore. + * + * @return metadata for the set of restore images available, + * or null if an error occurred (the attempt should be rescheduled). + **/ + suspend fun getAvailableBackups(): Sequence? /** * Returns an [OutputStream] for writing an APK to be backed up. */ @Throws(IOException::class) + @Deprecated("Use getOutputStream() instead") suspend fun getApkOutputStream(packageInfo: PackageInfo, suffix: String): OutputStream /** @@ -48,3 +97,5 @@ interface BackupPlugin { val providerPackageName: String? } + +class EncryptedMetadata(val token: Long, val inputStreamRetriever: () -> InputStream) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt index 197de844..1173cf7d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt @@ -4,6 +4,7 @@ import android.content.pm.PackageInfo import java.io.IOException import java.io.OutputStream +@Deprecated("Use BackupPlugin instead") interface FullBackupPlugin { fun getQuota(): Long diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt index a7f5b7d4..8a4cecc3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt @@ -4,6 +4,7 @@ import android.content.pm.PackageInfo import java.io.IOException import java.io.OutputStream +@Deprecated("Use BackupPlugin instead") interface KVBackupPlugin { /** diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index ee5084bd..67d3d035 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR +import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.transport.backup.ApkBackup import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupPlugin @@ -166,7 +167,10 @@ internal class CoordinatorIntegrationTest : TransportTest() { any() ) } returns packageMetadata - coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { + backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) + } returns metadataOutputStream every { metadataManager.onApkBackedUp( packageInfo, @@ -244,7 +248,10 @@ internal class CoordinatorIntegrationTest : TransportTest() { } returns bOutputStream every { kvBackupPlugin.packageFinished(packageInfo) } just Runs coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null - coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { + backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) + } returns metadataOutputStream every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs // start and finish K/V backup @@ -296,7 +303,10 @@ internal class CoordinatorIntegrationTest : TransportTest() { any() ) } returns packageMetadata - coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { + backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) + } returns metadataOutputStream every { metadataManager.onApkBackedUp( packageInfo, diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 24c9373b..bac93f40 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED +import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.Runs @@ -85,7 +86,7 @@ internal class BackupCoordinatorTest : BackupTest() { fun `device initialization succeeds and delegates to plugin`() = runBlocking { every { settingsManager.getToken() } returns token coEvery { plugin.initializeDevice() } just Runs - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs every { kv.hasState() } returns false every { full.hasState() } returns false @@ -251,7 +252,8 @@ internal class BackupCoordinatorTest : BackupTest() { every { kv.hasState() } returns true every { full.hasState() } returns false every { kv.getCurrentPackage() } returns packageInfo - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { kv.finishBackup() } returns result every { metadataOutputStream.close() } just Runs @@ -268,7 +270,8 @@ internal class BackupCoordinatorTest : BackupTest() { every { kv.hasState() } returns false every { full.hasState() } returns true every { full.getCurrentPackage() } returns packageInfo - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { full.finishBackup() } returns result every { metadataOutputStream.close() } just Runs @@ -412,7 +415,8 @@ internal class BackupCoordinatorTest : BackupTest() { coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any()) } returns packageMetadata - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onApkBackedUp( notAllowedPackages[1], @@ -445,7 +449,8 @@ internal class BackupCoordinatorTest : BackupTest() { } returns oldPackageMetadata // state differs now, was stopped before every { oldPackageMetadata.state } returns WAS_STOPPED - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onPackageBackupError( packageInfo, @@ -473,7 +478,8 @@ internal class BackupCoordinatorTest : BackupTest() { every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns null - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onPackageBackupError( packageInfo, @@ -499,7 +505,8 @@ internal class BackupCoordinatorTest : BackupTest() { any() ) } returns packageMetadata - coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { metadataManager.onApkBackedUp( any(),