diff --git a/Android.bp b/Android.bp index 46529b7a..d9112808 100644 --- a/Android.bp +++ b/Android.bp @@ -26,7 +26,8 @@ android_app { "com.google.android.material_material", "kotlinx-coroutines-android", "kotlinx-coroutines-core", - // storage backup lib + // our own gradle module libs + "seedvault-lib-core", "seedvault-lib-storage", // koin "seedvault-lib-koin-core-jvm", // did not manage to add this as transitive dependency @@ -36,7 +37,6 @@ android_app { // WebDAV "seedvault-lib-dav4jvm", "seedvault-lib-okhttp", - "seedvault-lib-okio", ], manifest: "app/src/main/AndroidManifest.xml", diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2e6a243..985822f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -106,19 +106,7 @@ android { } dependencies { - - val aospLibs = fileTree("$projectDir/libs") { - // For more information about this module: - // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507 - // framework_intermediates/classes-header.jar works for gradle build as well, - // but not unit tests, so we use the actual classes (without updatable modules). - // - // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar - include("android.jar") - // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar - include("libcore.jar") - } - + val aospLibs: FileTree by rootProject.extra compileOnly(aospLibs) /** @@ -149,6 +137,7 @@ dependencies { /** * Storage Dependencies */ + implementation(project(":core")) implementation(project(":storage:lib")) /** @@ -188,6 +177,7 @@ dependencies { testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") androidTestImplementation(aospLibs) + androidTestImplementation(kotlin("test")) androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("androidx.test.ext:junit:1.1.3") @@ -197,7 +187,7 @@ dependencies { gradle.projectsEvaluated { tasks.withType(JavaCompile::class) { - options.compilerArgs.add("-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar") + options.compilerArgs.add("-Xbootclasspath/p:libs/aosp/android.jar:libs/aosp/libcore.jar") } } diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index 6c0fbdf5..6212e855 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -56,7 +56,7 @@ class KoinInstrumentationTestApp : App() { apkRestore = get(), iconManager = get(), storageBackup = get(), - pluginManager = get(), + backendManager = get(), fileSelectionManager = get(), ) ) diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt index 0ffaca99..b5313c01 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt @@ -5,26 +5,23 @@ package com.stevesoltys.seedvault -import android.net.Uri import androidx.test.core.content.pm.PackageInfoBuilder import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.platform.app.InstrumentationRegistry -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderLegacyPlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA -import com.stevesoltys.seedvault.plugins.saf.deleteContents +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.getAvailableBackups +import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin +import com.stevesoltys.seedvault.backend.saf.DocumentsStorage import com.stevesoltys.seedvault.settings.SettingsManager import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.backends.saf.SafBackend import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -42,11 +39,10 @@ class PluginTest : KoinComponent { private val mockedSettingsManager: SettingsManager = mockk() private val storage = DocumentsStorage( appContext = context, - settingsManager = mockedSettingsManager, - safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"), + safStorage = settingsManager.getSafProperties() ?: error("No SAF storage"), ) - private val storagePlugin: StoragePlugin = DocumentsProviderStoragePlugin(context, storage) + private val backend = SafBackend(context, storage.safStorage) @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) { @@ -59,30 +55,30 @@ class PluginTest : KoinComponent { @Before fun setup() = runBlocking { - every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage() - storage.rootBackupDir?.deleteContents(context) - ?: error("Select a storage location in the app first!") + every { + mockedSettingsManager.getSafProperties() + } returns settingsManager.getSafProperties() + backend.removeAll() } @After fun tearDown() = runBlocking { - storage.rootBackupDir?.deleteContents(context) - Unit + backend.removeAll() } @Test fun testProviderPackageName() { - assertNotNull(storagePlugin.providerPackageName) + assertNotNull(backend.providerPackageName) } @Test fun testTest() = runBlocking(Dispatchers.IO) { - assertTrue(storagePlugin.test()) + assertTrue(backend.test()) } @Test fun testGetFreeSpace() = runBlocking(Dispatchers.IO) { - val freeBytes = storagePlugin.getFreeSpace() ?: error("no free space retrieved") + val freeBytes = backend.getFreeSpace() ?: error("no free space retrieved") assertTrue(freeBytes > 0) } @@ -96,52 +92,39 @@ class PluginTest : KoinComponent { @Test fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) { // no backups available initially - assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size) + assertEquals(0, backend.getAvailableBackups()?.toList()?.size) // prepare returned tokens requested when initializing device every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1) - // start new restore set and initialize device afterwards - storagePlugin.startNewRestoreSet(token) - storagePlugin.initializeDevice() - // write metadata (needed for backup to be recognized) - storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA) + backend.save(LegacyAppBackupFile.Metadata(token)) .writeAndClose(getRandomByteArray()) // one backup available now - assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size) + assertEquals(1, backend.getAvailableBackups()?.toList()?.size) // initializing again (with another restore set) does add a restore set - storagePlugin.startNewRestoreSet(token + 1) - storagePlugin.initializeDevice() - storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA) + backend.save(LegacyAppBackupFile.Metadata(token + 1)) .writeAndClose(getRandomByteArray()) - assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size) + assertEquals(2, backend.getAvailableBackups()?.toList()?.size) // initializing again (without new restore set) doesn't change number of restore sets - storagePlugin.initializeDevice() - storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA) + backend.save(LegacyAppBackupFile.Metadata(token + 1)) .writeAndClose(getRandomByteArray()) - assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size) - - // ensure that the new backup dir exist - assertTrue(storage.currentSetDir!!.exists()) + assertEquals(2, backend.getAvailableBackups()?.toList()?.size) } @Test fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) { every { mockedSettingsManager.getToken() } returns token - storagePlugin.startNewRestoreSet(token) - storagePlugin.initializeDevice() - // write metadata val metadata = getRandomByteArray() - storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata) + backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata) // get available backups, expect only one with our token and no error - var availableBackups = storagePlugin.getAvailableBackups()?.toList() + var availableBackups = backend.getAvailableBackups()?.toList() check(availableBackups != null) assertEquals(1, availableBackups.size) assertEquals(token, availableBackups[0].token) @@ -150,9 +133,8 @@ class PluginTest : KoinComponent { assertReadEquals(metadata, availableBackups[0].inputStreamRetriever()) // initializing again (without changing storage) keeps restore set with same token - storagePlugin.initializeDevice() - storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata) - availableBackups = storagePlugin.getAvailableBackups()?.toList() + backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata) + availableBackups = backend.getAvailableBackups()?.toList() check(availableBackups != null) assertEquals(1, availableBackups.size) assertEquals(token, availableBackups[0].token) @@ -169,7 +151,8 @@ class PluginTest : KoinComponent { // write random bytes as APK val apk1 = getRandomByteArray(1337 * 1024) - storagePlugin.getOutputStream(token, "${packageInfo.packageName}.apk").writeAndClose(apk1) + backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo.packageName}.apk")) + .writeAndClose(apk1) // assert that read APK bytes match what was written assertReadEquals( @@ -181,7 +164,7 @@ class PluginTest : KoinComponent { val suffix2 = getRandomBase64(23) val apk2 = getRandomByteArray(23 * 1024 * 1024) - storagePlugin.getOutputStream(token, "${packageInfo2.packageName}$suffix2.apk") + backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo2.packageName}$suffix2.apk")) .writeAndClose(apk2) // assert that read APK bytes match what was written @@ -199,42 +182,27 @@ class PluginTest : KoinComponent { val name1 = getRandomBase64() val name2 = getRandomBase64() - // no data available initially - assertFalse(storagePlugin.hasData(token, name1)) - assertFalse(storagePlugin.hasData(token, name2)) - // write full backup data val data = getRandomByteArray(5 * 1024 * 1024) - storagePlugin.getOutputStream(token, name1).writeAndClose(data) - - // data is available now, but only this token - assertTrue(storagePlugin.hasData(token, name1)) - assertFalse(storagePlugin.hasData(token + 1, name1)) + backend.save(LegacyAppBackupFile.Blob(token, name1)).writeAndClose(data) // restore data matches backed up data - assertReadEquals(data, storagePlugin.getInputStream(token, name1)) + assertReadEquals(data, backend.load(LegacyAppBackupFile.Blob(token, name1))) // write and check data for second package val data2 = getRandomByteArray(5 * 1024 * 1024) - storagePlugin.getOutputStream(token, name2).writeAndClose(data2) - assertTrue(storagePlugin.hasData(token, name2)) - assertReadEquals(data2, storagePlugin.getInputStream(token, name2)) + backend.save(LegacyAppBackupFile.Blob(token, name2)).writeAndClose(data2) + assertReadEquals(data2, backend.load(LegacyAppBackupFile.Blob(token, name2))) // remove data of first package again and ensure that no more data is found - storagePlugin.removeData(token, name1) - assertFalse(storagePlugin.hasData(token, name1)) - - // second package is still there - assertTrue(storagePlugin.hasData(token, name2)) + backend.remove(LegacyAppBackupFile.Blob(token, name1)) // ensure that it gets deleted as well - storagePlugin.removeData(token, name2) - assertFalse(storagePlugin.hasData(token, name2)) + backend.remove(LegacyAppBackupFile.Blob(token, name2)) } private fun initStorage(token: Long) = runBlocking { every { mockedSettingsManager.getToken() } returns token - storagePlugin.initializeDevice() } } diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt new file mode 100644 index 00000000..173246bf --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.backend.saf + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import com.stevesoltys.seedvault.settings.SettingsManager +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendTest +import org.calyxos.seedvault.core.backends.saf.SafBackend +import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SafBackendTest : BackendTest(), KoinComponent { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val settingsManager by inject() + private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage") + private val safProperties = SafProperties( + config = safStorage.config, + name = safStorage.name, + isUsb = safStorage.isUsb, + requiresNetwork = safStorage.requiresNetwork, + rootId = safStorage.rootId, + ) + override val plugin: Backend = SafBackend(context, safProperties, ".SeedvaultTest") + + @Test + fun `test write list read rename delete`(): Unit = runBlocking { + testWriteListReadRenameDelete() + } + + @Test + fun `test remove create write file`(): Unit = runBlocking { + testRemoveCreateWriteFile() + } +} diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt index 7de32560..3017a83d 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt @@ -23,7 +23,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() { confirmCode() } - if (settingsManager.getSafStorage() == null) { + if (settingsManager.getSafProperties() == null) { chooseStorageLocation() } else { changeBackupLocation() diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt deleted file mode 100644 index 4f08f52c..00000000 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.saf - -import android.database.ContentObserver -import android.database.Cursor -import android.net.Uri -import android.os.Bundle -import android.provider.DocumentsContract.EXTRA_LOADING -import androidx.documentfile.provider.DocumentFile -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.MediumTest -import androidx.test.platform.app.InstrumentationRegistry -import com.stevesoltys.seedvault.assertReadEquals -import com.stevesoltys.seedvault.coAssertThrows -import com.stevesoltys.seedvault.getRandomBase64 -import com.stevesoltys.seedvault.getRandomByteArray -import com.stevesoltys.seedvault.settings.SettingsManager -import com.stevesoltys.seedvault.writeAndClose -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.io.IOException -import kotlin.random.Random - -@RunWith(AndroidJUnit4::class) -@MediumTest -class DocumentsStorageTest : KoinComponent { - - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val settingsManager by inject() - private val storage = DocumentsStorage( - appContext = context, - settingsManager = settingsManager, - safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"), - ) - - private val filename = getRandomBase64() - private lateinit var file: DocumentFile - - @Before - fun setup() = runBlocking { - assertNotNull("Select a storage location in the app first!", storage.rootBackupDir) - file = storage.rootBackupDir?.createOrGetFile(context, filename) - ?: error("Could not create test file") - } - - @After - fun tearDown() { - file.delete() - } - - @Test - fun testWritingAndReadingFile() { - // write to output stream - val outputStream = storage.getOutputStream(file) - val content = ByteArray(1337).apply { Random.nextBytes(this) } - outputStream.write(content) - outputStream.flush() - outputStream.close() - - // read written data from input stream - val inputStream = storage.getInputStream(file) - val readContent = inputStream.readBytes() - inputStream.close() - assertArrayEquals(content, readContent) - - // write smaller content to same file - val outputStream2 = storage.getOutputStream(file) - val content2 = ByteArray(42).apply { Random.nextBytes(this) } - outputStream2.write(content2) - outputStream2.flush() - outputStream2.close() - - // read written data from input stream - val inputStream2 = storage.getInputStream(file) - val readContent2 = inputStream2.readBytes() - inputStream2.close() - assertArrayEquals(content2, readContent2) - } - - @Test - fun testFindFile() = runBlocking(Dispatchers.IO) { - val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!) - assertNotNull(foundFile) - assertEquals(filename, foundFile!!.name) - assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri) - } - - @Test - fun testCreateFile() { - // create test file - val dir = storage.rootBackupDir!! - val createdFile = dir.createFile("text", getRandomBase64()) - assertNotNull(createdFile) - assertNotNull(createdFile!!.name) - - // write some data into it - val data = getRandomByteArray() - context.contentResolver.openOutputStream(createdFile.uri)!!.writeAndClose(data) - - // data should still be there - assertReadEquals(data, context.contentResolver.openInputStream(createdFile.uri)) - - // delete again - createdFile.delete() - assertFalse(createdFile.exists()) - } - - @Test - fun testCreateTwoFiles() = runBlocking { - val mimeType = "application/octet-stream" - val dir = storage.rootBackupDir!! - - // create test file - val name1 = getRandomBase64(Random.nextInt(1, 10)) - val file1 = requireNotNull(dir.createFile(mimeType, name1)) - assertTrue(file1.exists()) - assertEquals(name1, file1.name) - assertEquals(0L, file1.length()) - - assertReadEquals(getRandomByteArray(0), context.contentResolver.openInputStream(file1.uri)) - - // write some data into it - val data1 = getRandomByteArray(5 * 1024 * 1024) - context.contentResolver.openOutputStream(file1.uri)!!.writeAndClose(data1) - assertEquals(data1.size.toLong(), file1.length()) - - // data should still be there - assertReadEquals(data1, context.contentResolver.openInputStream(file1.uri)) - - // create test file - val name2 = getRandomBase64(Random.nextInt(1, 10)) - val file2 = requireNotNull(dir.createFile(mimeType, name2)) - assertTrue(file2.exists()) - assertEquals(name2, file2.name) - - // write some data into it - val data2 = getRandomByteArray(12 * 1024 * 1024) - context.contentResolver.openOutputStream(file2.uri)!!.writeAndClose(data2) - assertEquals(data2.size.toLong(), file2.length()) - - // data should still be there - assertReadEquals(data2, context.contentResolver.openInputStream(file2.uri)) - - // delete files again - file1.delete() - file2.delete() - assertFalse(file1.exists()) - assertFalse(file2.exists()) - } - - @Test - fun testGetLoadedCursor() = runBlocking { - // empty cursor extras are like not loading, returns same cursor right away - val cursor1: Cursor = mockk() - every { cursor1.extras } returns Bundle() - assertEquals(cursor1, getLoadedCursor { cursor1 }) - - // explicitly not loading, returns same cursor right away - val cursor2: Cursor = mockk() - every { cursor2.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, false) } - assertEquals(cursor2, getLoadedCursor { cursor2 }) - - // loading cursor registers content observer, times out and closes cursor - val cursor3: Cursor = mockk() - every { cursor3.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) } - every { cursor3.registerContentObserver(any()) } just Runs - every { cursor3.close() } just Runs - coAssertThrows(TimeoutCancellationException::class.java) { - getLoadedCursor(1000) { cursor3 } - } - verify { cursor3.registerContentObserver(any()) } - verify { cursor3.close() } // ensure that cursor gets closed - - // loading cursor registers content observer, but re-query fails - val cursor4: Cursor = mockk() - val observer4 = slot() - val query: () -> Cursor? = { if (observer4.isCaptured) null else cursor4 } - every { cursor4.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) } - every { cursor4.registerContentObserver(capture(observer4)) } answers { - observer4.captured.onChange(false, Uri.parse("foo://bar")) - } - every { cursor4.close() } just Runs - coAssertThrows(IOException::class.java) { - getLoadedCursor(10_000, query) - } - assertTrue(observer4.isCaptured) - verify { cursor4.close() } // ensure that cursor gets closed - - // loading cursor registers content observer, re-queries and returns new result - val cursor5: Cursor = mockk() - val result5: Cursor = mockk() - val observer5 = slot() - val query5: () -> Cursor? = { if (observer5.isCaptured) result5 else cursor5 } - every { cursor5.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) } - every { cursor5.registerContentObserver(capture(observer5)) } answers { - observer5.captured.onChange(false, null) - } - every { cursor5.close() } just Runs - assertEquals(result5, getLoadedCursor(10_000, query5)) - assertTrue(observer5.isCaptured) - verify { cursor5.close() } // ensure that initial cursor got closed - } - -} diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt index 046b3024..2231ef0c 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt @@ -9,12 +9,12 @@ import android.content.pm.PackageInfo import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.settings.AppStatus import com.stevesoltys.seedvault.settings.SettingsManager import io.mockk.every import io.mockk.mockk +import org.calyxos.seedvault.core.backends.Backend import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -30,9 +30,9 @@ class PackageServiceTest : KoinComponent { private val settingsManager: SettingsManager by inject() - private val storagePluginManager: StoragePluginManager by inject() + private val backendManager: BackendManager by inject() - private val storagePlugin: StoragePlugin<*> get() = storagePluginManager.appPlugin + private val backend: Backend get() = backendManager.backend @Test fun testNotAllowedPackages() { @@ -65,6 +65,6 @@ class PackageServiceTest : KoinComponent { assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName)) // Should not backup storage provider - assertFalse(packageService.shouldIncludeAppInBackup(storagePlugin.providerPackageName!!)) + assertFalse(packageService.shouldIncludeAppInBackup(backend.providerPackageName!!)) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index aea38f1c..afc5bc74 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -20,13 +20,13 @@ import android.os.UserManager import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.WorkManager import com.google.android.material.color.DynamicColors +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf +import com.stevesoltys.seedvault.backend.webdav.storagePluginModuleWebDav import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.metadataModule -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf -import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.restoreUiModule import com.stevesoltys.seedvault.settings.AppListRetriever @@ -42,6 +42,7 @@ import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.workerModule +import org.calyxos.seedvault.core.backends.BackendFactory import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger @@ -61,7 +62,15 @@ open class App : Application() { private val appModule = module { single { SettingsManager(this@App) } single { BackupNotificationManager(this@App) } - single { StoragePluginManager(this@App, get(), get(), get()) } + single { BackendManager(this@App, get(), get()) } + single { + BackendFactory { + // uses context of the device's main user to be able to access USB storage + this@App.applicationContext.getStorageContext { + get().getSafProperties()?.isUsb == true + } + } + } single { BackupStateManager(this@App) } single { Clock() } factory { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } @@ -72,7 +81,7 @@ open class App : Application() { app = this@App, settingsManager = get(), keyManager = get(), - pluginManager = get(), + backendManager = get(), metadataManager = get(), appListRetriever = get(), storageBackup = get(), @@ -91,7 +100,7 @@ open class App : Application() { safHandler = get(), webDavHandler = get(), settingsManager = get(), - storagePluginManager = get(), + backendManager = get(), ) } viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) } @@ -146,7 +155,7 @@ open class App : Application() { private val settingsManager: SettingsManager by inject() private val metadataManager: MetadataManager by inject() private val backupManager: IBackupManager by inject() - private val pluginManager: StoragePluginManager by inject() + private val backendManager: BackendManager by inject() private val backupStateManager: BackupStateManager by inject() /** @@ -170,13 +179,13 @@ open class App : Application() { protected open fun migrateToOwnScheduling() { if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling // fix things for removable drive users who had a job scheduled here before - if (pluginManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext) + if (backendManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext) return } if (backupManager.currentTransport == TRANSPORT_ID) { backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) - if (backupManager.isBackupEnabled && !pluginManager.isOnRemovableDrive) { + if (backupManager.isBackupEnabled && !backendManager.isOnRemovableDrive) { AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE) } // cancel old D2D worker @@ -213,6 +222,10 @@ fun permitDiskReads(func: () -> T): T { } } +/** + * Hack to allow other profiles access to USB backend. + * @return the context of the device's main user, so use with great care! + */ @Suppress("MissingPermission") fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context { if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/backend/BackendExt.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/BackendExt.kt new file mode 100644 index 00000000..ffe16ba6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/BackendExt.kt @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.backend + +import android.util.Log +import at.bitfire.dav4jvm.exception.HttpException +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +suspend fun Backend.getMetadataOutputStream(token: Long): OutputStream { + return save(LegacyAppBackupFile.Metadata(token)) +} + +suspend fun Backend.getAvailableBackups(): Sequence? { + return try { + // get all restore set tokens in root folder that have a metadata file + val handles = ArrayList() + list(null, LegacyAppBackupFile.Metadata::class) { fileInfo -> + val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata + handles.add(handle) + } + val handleIterator = handles.iterator() + return generateSequence { + if (!handleIterator.hasNext()) return@generateSequence null // end sequence + val handle = handleIterator.next() + EncryptedMetadata(handle.token) { + load(handle) + } + } + } catch (e: Exception) { + Log.e("SafBackend", "Error getting available backups: ", e) + null + } +} + +fun Exception.isOutOfSpace(): Boolean { + return when (this) { + is IOException -> message?.contains("No space left on device") == true || + (cause as? HttpException)?.code == 507 + + is HttpException -> code == 507 + + else -> false + } +} + +class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream) diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt similarity index 51% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt index aa8ec43e..545339a5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/BackendManager.kt @@ -3,80 +3,68 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins +package com.stevesoltys.seedvault.backend import android.content.Context import android.util.Log import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.getStorageContext import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage -import com.stevesoltys.seedvault.plugins.saf.SafFactory -import com.stevesoltys.seedvault.plugins.webdav.WebDavFactory import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.StoragePluginType +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendFactory +import org.calyxos.seedvault.core.backends.BackendProperties +import org.calyxos.seedvault.core.backends.saf.SafBackend -class StoragePluginManager( +class BackendManager( private val context: Context, private val settingsManager: SettingsManager, - safFactory: SafFactory, - webDavFactory: WebDavFactory, + backendFactory: BackendFactory, ) { - private var mAppPlugin: StoragePlugin<*>? - private var mFilesPlugin: org.calyxos.backup.storage.api.StoragePlugin? - private var mStorageProperties: StorageProperties<*>? + private var mBackend: Backend? + private var mBackendProperties: BackendProperties<*>? - val appPlugin: StoragePlugin<*> + val backend: Backend @Synchronized get() { - return mAppPlugin ?: error("App plugin was loaded, but still null") + return mBackend ?: error("App plugin was loaded, but still null") } - val filesPlugin: org.calyxos.backup.storage.api.StoragePlugin + val backendProperties: BackendProperties<*>? @Synchronized get() { - return mFilesPlugin ?: error("Files plugin was loaded, but still null") + return mBackendProperties } - - val storageProperties: StorageProperties<*>? - @Synchronized - get() { - return mStorageProperties - } - val isOnRemovableDrive: Boolean get() = storageProperties?.isUsb == true + val isOnRemovableDrive: Boolean get() = backendProperties?.isUsb == true init { when (settingsManager.storagePluginType) { StoragePluginType.SAF -> { - val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved") - val documentsStorage = DocumentsStorage(context, settingsManager, safStorage) - mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage) - mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage) - mStorageProperties = safStorage + val safConfig = settingsManager.getSafProperties() ?: error("No SAF storage saved") + mBackend = backendFactory.createSafBackend(safConfig) + mBackendProperties = safConfig } StoragePluginType.WEB_DAV -> { val webDavProperties = settingsManager.webDavProperties ?: error("No WebDAV config saved") - mAppPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config) - mFilesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config) - mStorageProperties = webDavProperties + mBackend = backendFactory.createWebDavBackend(webDavProperties.config) + mBackendProperties = webDavProperties } null -> { - mAppPlugin = null - mFilesPlugin = null - mStorageProperties = null + mBackend = null + mBackendProperties = null } } } fun isValidAppPluginSet(): Boolean { - if (mAppPlugin == null || mFilesPlugin == null) return false - if (mAppPlugin is DocumentsProviderStoragePlugin) { - val storage = settingsManager.getSafStorage() ?: return false + if (mBackend == null) return false + if (mBackend is SafBackend) { + val storage = settingsManager.getSafProperties() ?: return false if (storage.isUsb) return true return permitDiskReads { storage.getDocumentFile(context).isDirectory @@ -86,20 +74,18 @@ class StoragePluginManager( } /** - * Changes the storage plugins and current [StorageProperties]. + * Changes the storage plugins and current [BackendProperties]. * * IMPORTANT: Do no call this while current plugins are being used, * e.g. while backup/restore operation is still running. */ fun changePlugins( - storageProperties: StorageProperties, - appPlugin: StoragePlugin, - filesPlugin: org.calyxos.backup.storage.api.StoragePlugin, + backend: Backend, + storageProperties: BackendProperties, ) { - settingsManager.setStoragePlugin(appPlugin) - mStorageProperties = storageProperties - mAppPlugin = appPlugin - mFilesPlugin = filesPlugin + settingsManager.setStorageBackend(backend) + mBackend = backend + mBackendProperties = storageProperties } /** @@ -112,7 +98,7 @@ class StoragePluginManager( */ @WorkerThread fun canDoBackupNow(): Boolean { - val storage = storageProperties ?: return false + val storage = backendProperties ?: return false return !isOnUnavailableUsb() && !storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork) } @@ -127,7 +113,7 @@ class StoragePluginManager( */ @WorkerThread fun isOnUnavailableUsb(): Boolean { - val storage = storageProperties ?: return false + val storage = backendProperties ?: return false val systemContext = context.getStorageContext { storage.isUsb } return storage.isUnavailableUsb(systemContext) } @@ -138,7 +124,7 @@ class StoragePluginManager( @WorkerThread suspend fun getFreeSpace(): Long? { return try { - appPlugin.getFreeSpace() + backend.getFreeSpace() } catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm Log.e("StoragePluginManager", "Error getting free space: ", e) null diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/LegacyStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/LegacyStoragePlugin.kt similarity index 97% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/LegacyStoragePlugin.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/LegacyStoragePlugin.kt index 2f7a4cba..3d030934 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/LegacyStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/LegacyStoragePlugin.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins +package com.stevesoltys.seedvault.backend import android.content.pm.PackageInfo import java.io.IOException diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsProviderLegacyPlugin.kt similarity index 97% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsProviderLegacyPlugin.kt index 5fdc0812..2d849887 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsProviderLegacyPlugin.kt @@ -3,13 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf import android.content.Context import android.content.pm.PackageInfo import androidx.annotation.WorkerThread import androidx.documentfile.provider.DocumentFile -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsProviderModule.kt similarity index 61% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsProviderModule.kt index 5f9b82a5..0689e18f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsProviderModule.kt @@ -3,15 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.settings.SettingsManager import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val storagePluginModuleSaf = module { - single { SafFactory(androidContext(), get(), get()) } single { SafHandler(androidContext(), get(), get(), get()) } @Suppress("Deprecation") @@ -19,8 +18,9 @@ val storagePluginModuleSaf = module { DocumentsProviderLegacyPlugin( context = androidContext(), storageGetter = { - val safStorage = get().getSafStorage() ?: error("No SAF storage") - DocumentsStorage(androidContext(), get(), safStorage) + val safProperties = get().getSafProperties() + ?: error("No SAF storage") + DocumentsStorage(androidContext(), safProperties) }, ) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsStorage.kt similarity index 66% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsStorage.kt index 67a60257..1f61dbd0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/DocumentsStorage.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf import android.content.ContentResolver import android.content.Context @@ -20,33 +20,29 @@ import android.util.Log import androidx.annotation.VisibleForTesting import androidx.documentfile.provider.DocumentFile import com.stevesoltys.seedvault.getStorageContext -import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout +import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT +import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile import java.io.IOException import java.io.InputStream import java.io.OutputStream import kotlin.coroutines.resume -const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup" - @Deprecated("") const val DIRECTORY_FULL_BACKUP = "full" @Deprecated("") const val DIRECTORY_KEY_VALUE_BACKUP = "kv" -const val FILE_BACKUP_METADATA = ".backup.metadata" -const val FILE_NO_MEDIA = ".nomedia" -const val MIME_TYPE = "application/octet-stream" private val TAG = DocumentsStorage::class.java.simpleName internal class DocumentsStorage( private val appContext: Context, - private val settingsManager: SettingsManager, - internal val safStorage: SafStorage, + internal val safStorage: SafProperties, ) { /** @@ -60,11 +56,7 @@ internal class DocumentsStorage( if (field == null) { val parent = safStorage.getDocumentFile(context) field = try { - parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply { - // create .nomedia file to prevent Android's MediaScanner - // from trying to index the backup - createOrGetFile(context, FILE_NO_MEDIA) - } + parent.createOrGetDirectory(context, DIRECTORY_ROOT) } catch (e: IOException) { Log.e(TAG, "Error creating root backup dir.", e) null @@ -73,41 +65,8 @@ internal class DocumentsStorage( field } - private var currentToken: Long? = null - get() { - if (field == null) field = settingsManager.getToken() - return field - } - - var currentSetDir: DocumentFile? = null - get() = runBlocking { - if (field == null) { - if (currentToken == 0L) return@runBlocking null - field = try { - rootBackupDir?.createOrGetDirectory(context, currentToken.toString()) - } catch (e: IOException) { - Log.e(TAG, "Error creating current restore set dir.", e) - null - } - } - field - } - private set - - /** - * Resets this storage abstraction, forcing it to re-fetch cached values on next access. - */ - fun reset(newToken: Long?) { - currentToken = newToken - rootBackupDir = null - currentSetDir = null - } - - fun getAuthority(): String? = safStorage.uri.authority - @Throws(IOException::class) - suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? { - if (token == currentToken) return currentSetDir + suspend fun getSetDir(token: Long): DocumentFile? { return rootBackupDir?.findFileBlocking(context, token.toString()) } @@ -147,33 +106,6 @@ internal class DocumentsStorage( } -/** - * Checks if a file exists and if not, creates it. - * - * If we were trying to create it right away, some providers create "filename (1)". - */ -@Throws(IOException::class) -internal suspend fun DocumentFile.createOrGetFile( - context: Context, - name: String, - mimeType: String = MIME_TYPE, -): DocumentFile { - return try { - findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply { - if (this.name != name) { - throw IOException("File named ${this.name}, but should be $name") - } - } ?: throw IOException("could not find nor create") - } catch (e: Exception) { - // SAF can throw all sorts of exceptions, so wrap it in IOException. - // E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument() - // when flash drive is not plugged-in: - // http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135 - if (e is IOException) throw e - else throw IOException(e) - } -} - /** * Checks if a directory already exists and if not, creates it. */ @@ -186,11 +118,6 @@ suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): D } ?: throw IOException() } -@Throws(IOException::class) -suspend fun DocumentFile.deleteContents(context: Context) { - for (file in listFilesBlocking(context)) file.delete() -} - fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { if (name != packageInfo.packageName) { throw AssertionError("Expected ${packageInfo.packageName}, but got $name") @@ -224,26 +151,6 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): List return result } -/** - * An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent. - * - * All other public ways to get a TreeDocumentFile only work from [Uri]s - * (e.g. [DocumentFile.fromTreeUri]) and always set parent to null. - * - * We have a test for this method to ensure CI will alert us when this reflection breaks. - * Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails. - */ -@VisibleForTesting -internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile { - @SuppressWarnings("MagicNumber") - val constructor = parent.javaClass.declaredConstructors.find { - it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3 - } - check(constructor != null) { "Could not find constructor for TreeDocumentFile" } - constructor.isAccessible = true - return constructor.newInstance(parent, context, uri) as DocumentFile -} - /** * Same as [DocumentFile.findFile] only that it re-queries when the first result was stale. * diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafHandler.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/SafHandler.kt similarity index 62% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafHandler.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/saf/SafHandler.kt index ec4554f7..b9db52f5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafHandler.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/SafHandler.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf import android.content.Context import android.content.Context.USB_SERVICE @@ -14,33 +14,42 @@ import android.net.Uri import android.util.Log import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.getAvailableBackups import com.stevesoltys.seedvault.isMassStorage -import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.storage.StorageOption +import org.calyxos.seedvault.core.backends.BackendFactory +import org.calyxos.seedvault.core.backends.saf.SafProperties import java.io.IOException private const val TAG = "SafHandler" internal class SafHandler( private val context: Context, - private val safFactory: SafFactory, + private val backendFactory: BackendFactory, private val settingsManager: SettingsManager, - private val storagePluginManager: StoragePluginManager, + private val backendManager: BackendManager, ) { - fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafStorage { + fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafProperties { // persist permission to access backup folder across reboots val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.takePersistableUriPermission(uri, takeFlags) - val name = if (safOption.isInternal()) { - "${safOption.title} (${context.getString(R.string.settings_backup_location_internal)})" - } else { - safOption.title - } - return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork, safOption.rootId) + return SafProperties( + config = uri, + name = if (safOption.isInternal()) { + val brackets = context.getString(R.string.settings_backup_location_internal) + "${safOption.title} ($brackets)" + } else { + safOption.title + }, + isUsb = safOption.isUsb, + requiresNetwork = safOption.requiresNetwork, + rootId = safOption.rootId, + ) } /** @@ -49,17 +58,16 @@ internal class SafHandler( */ @WorkerThread @Throws(IOException::class) - suspend fun hasAppBackup(safStorage: SafStorage): Boolean { - val storage = DocumentsStorage(context, settingsManager, safStorage) - val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage) + suspend fun hasAppBackup(safProperties: SafProperties): Boolean { + val appPlugin = backendFactory.createSafBackend(safProperties) val backups = appPlugin.getAvailableBackups() return backups != null && backups.iterator().hasNext() } - fun save(safStorage: SafStorage) { - settingsManager.setSafStorage(safStorage) + fun save(safProperties: SafProperties) { + settingsManager.setSafProperties(safProperties) - if (safStorage.isUsb) { + if (safProperties.isUsb) { Log.d(TAG, "Selected storage is a removable USB device.") val wasSaved = saveUsbDevice() // reset stored flash drive, if we did not update it @@ -67,7 +75,7 @@ internal class SafHandler( } else { settingsManager.setFlashDrive(null) } - Log.d(TAG, "New storage location saved: ${safStorage.uri}") + Log.d(TAG, "New storage location saved: ${safProperties.uri}") } private fun saveUsbDevice(): Boolean { @@ -84,12 +92,10 @@ internal class SafHandler( return false } - fun setPlugin(safStorage: SafStorage) { - val storage = DocumentsStorage(context, settingsManager, safStorage) - storagePluginManager.changePlugins( - storageProperties = safStorage, - appPlugin = safFactory.createAppStoragePlugin(safStorage, storage), - filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage), + fun setPlugin(safProperties: SafProperties) { + backendManager.changePlugins( + backend = backendFactory.createSafBackend(safProperties), + storageProperties = safProperties, ) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorageOptions.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/SafStorageOptions.kt similarity index 98% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorageOptions.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/saf/SafStorageOptions.kt index b20511c1..73fb3bf8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorageOptions.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/SafStorageOptions.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf import android.content.Context import android.content.Intent @@ -14,7 +14,7 @@ import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver.getIcon +import com.stevesoltys.seedvault.backend.saf.StorageRootResolver.getIcon import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5 import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/StorageRootResolver.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/StorageRootResolver.kt similarity index 99% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/StorageRootResolver.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/saf/StorageRootResolver.kt index 605fdf87..12dc1dc5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/StorageRootResolver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/saf/StorageRootResolver.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf import android.Manifest.permission.MANAGE_DOCUMENTS import android.content.Context diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/webdav/WebDavHandler.kt similarity index 73% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/webdav/WebDavHandler.kt index 667b7568..17d6894a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/webdav/WebDavHandler.kt @@ -3,17 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.webdav +package com.stevesoltys.seedvault.backend.webdav import android.content.Context import android.util.Log import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.getAvailableBackups import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import okhttp3.HttpUrl.Companion.toHttpUrl +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendFactory +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig +import org.calyxos.seedvault.core.backends.webdav.WebDavProperties import java.io.IOException internal sealed interface WebDavConfigState { @@ -21,7 +26,7 @@ internal sealed interface WebDavConfigState { object Checking : WebDavConfigState class Success( val properties: WebDavProperties, - val plugin: WebDavStoragePlugin, + val backend: Backend, ) : WebDavConfigState class Error(val e: Exception?) : WebDavConfigState @@ -31,9 +36,9 @@ private val TAG = WebDavHandler::class.java.simpleName internal class WebDavHandler( private val context: Context, - private val webDavFactory: WebDavFactory, + private val backendFactory: BackendFactory, private val settingsManager: SettingsManager, - private val storagePluginManager: StoragePluginManager, + private val backendManager: BackendManager, ) { companion object { @@ -51,11 +56,11 @@ internal class WebDavHandler( suspend fun onConfigReceived(config: WebDavConfig) { mConfigState.value = WebDavConfigState.Checking - val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin + val backend = backendFactory.createWebDavBackend(config) try { - if (plugin.test()) { + if (backend.test()) { val properties = createWebDavProperties(context, config) - mConfigState.value = WebDavConfigState.Success(properties, plugin) + mConfigState.value = WebDavConfigState.Success(properties, backend) } else { mConfigState.value = WebDavConfigState.Error(null) } @@ -75,8 +80,8 @@ internal class WebDavHandler( */ @WorkerThread @Throws(IOException::class) - suspend fun hasAppBackup(appPlugin: WebDavStoragePlugin): Boolean { - val backups = appPlugin.getAvailableBackups() + suspend fun hasAppBackup(backend: Backend): Boolean { + val backups = backend.getAvailableBackups() return backups != null && backups.iterator().hasNext() } @@ -84,11 +89,10 @@ internal class WebDavHandler( settingsManager.saveWebDavConfig(properties.config) } - fun setPlugin(properties: WebDavProperties, plugin: WebDavStoragePlugin) { - storagePluginManager.changePlugins( + fun setPlugin(properties: WebDavProperties, backend: Backend) { + backendManager.changePlugins( + backend = backend, storageProperties = properties, - appPlugin = plugin, - filesPlugin = webDavFactory.createFilesStoragePlugin(properties.config), ) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt b/app/src/main/java/com/stevesoltys/seedvault/backend/webdav/WebDavModule.kt similarity index 73% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt rename to app/src/main/java/com/stevesoltys/seedvault/backend/webdav/WebDavModule.kt index 75181659..f0fe8910 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/backend/webdav/WebDavModule.kt @@ -3,12 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.webdav +package com.stevesoltys.seedvault.backend.webdav import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val storagePluginModuleWebDav = module { - single { WebDavFactory(androidContext(), get()) } single { WebDavHandler(androidContext(), get(), get(), get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt index 613d566a..60bbcbbf 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt @@ -24,7 +24,7 @@ internal const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main" private const val KEY_ALGORITHM_BACKUP = "AES" private const val KEY_ALGORITHM_MAIN = "HmacSHA256" -interface KeyManager { +interface KeyManager : org.calyxos.seedvault.core.crypto.KeyManager { /** * Store a new backup key derived from the given [seed]. * @@ -57,14 +57,6 @@ interface KeyManager { * because the key can not leave the [KeyStore]'s hardware security module. */ fun getBackupKey(): SecretKey - - /** - * Returns the main key, so it can be used for deriving sub-keys. - * - * Note that any attempt to export the key will return null or an empty [ByteArray], - * because the key can not leave the [KeyStore]'s hardware security module. - */ - fun getMainKey(): SecretKey } internal class KeyManagerImpl( diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt deleted file mode 100644 index 939b21e4..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins - -import android.app.backup.RestoreSet -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream - -interface StoragePlugin { - - /** - * Returns true if the plugin is working, or false if it isn't. - * @throws Exception any kind of exception to provide more info on the error - */ - suspend fun test(): Boolean - - /** - * Retrieves the available storage space in bytes. - * @return the number of bytes available or null if the number is unknown. - * Returning a negative number or zero to indicate unknown is discouraged. - */ - suspend fun getFreeSpace(): Long? - - /** - * Start a new [RestoreSet] with the given token. - * - * This is typically followed by a call to [initializeDevice]. - */ - @Throws(IOException::class) - suspend fun startNewRestoreSet(token: Long) - - /** - * Initialize the storage for this device, erasing all stored data in the current [RestoreSet]. - */ - @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) - - /** - * 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 the package name of the app that provides the backend storage - * which is used for the current backup location. - * - * Plugins are advised to cache this as it will be requested frequently. - * - * @return null if no package name could be found - */ - val providerPackageName: String? - -} - -class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream) - -internal val tokenRegex = Regex("([0-9]{13})") // good until the year 2286 -internal val chunkFolderRegex = Regex("[a-f0-9]{2}") diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt deleted file mode 100644 index c2ba52e9..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt +++ /dev/null @@ -1,193 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.saf - -import android.content.Context -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Environment -import android.os.StatFs -import android.provider.DocumentsContract -import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES -import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID -import android.util.Log -import androidx.core.database.getIntOrNull -import androidx.documentfile.provider.DocumentFile -import com.stevesoltys.seedvault.getStorageContext -import com.stevesoltys.seedvault.plugins.EncryptedMetadata -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.chunkFolderRegex -import com.stevesoltys.seedvault.plugins.tokenRegex -import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE -import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE -import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream - -private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName - -internal class DocumentsProviderStoragePlugin( - private val appContext: Context, - private val storage: DocumentsStorage, -) : StoragePlugin { - - /** - * Attention: This context might be from a different user. Use with care. - */ - private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb } - - private val packageManager: PackageManager = appContext.packageManager - - override suspend fun test(): Boolean { - val dir = storage.rootBackupDir - return dir != null && dir.exists() - } - - override suspend fun getFreeSpace(): Long? { - val rootId = storage.safStorage.rootId ?: return null - val authority = storage.safStorage.uri.authority - // using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work - val rootUri = DocumentsContract.buildRootsUri(authority) - val projection = arrayOf(COLUMN_AVAILABLE_BYTES) - // query directly for our rootId - val bytesAvailable = context.contentResolver.query( - rootUri, projection, "$COLUMN_ROOT_ID=?", arrayOf(rootId), null - )?.use { c -> - if (!c.moveToNext()) return@use null // no results - val bytes = c.getIntOrNull(c.getColumnIndex(COLUMN_AVAILABLE_BYTES)) - if (bytes != null && bytes >= 0) return@use bytes.toLong() - else return@use null - } - // if we didn't get anything from SAF, try some known hacks - return if (bytesAvailable == null && authority == AUTHORITY_STORAGE) { - if (rootId == ROOT_ID_DEVICE) { - StatFs(Environment.getDataDirectory().absolutePath).availableBytes - } else if (storage.safStorage.isUsb) { - val documentId = storage.safStorage.uri.lastPathSegment ?: return null - StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes - } else null - } else bytesAvailable - } - - @Throws(IOException::class) - override suspend fun startNewRestoreSet(token: Long) { - // reset current storage - storage.reset(token) - } - - @Throws(IOException::class) - override suspend fun initializeDevice() { - // reset storage without new token, so folders get recreated - // otherwise stale DocumentFiles will hang around - storage.reset(null) - } - - @Throws(IOException::class) - 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") - } - - 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) - } - } - } - - override val providerPackageName: String? by lazy { - val authority = storage.getAuthority() ?: return@lazy null - val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null - providerInfo.packageName - } - -} - -class BackupSet(val token: Long, val metadataFile: DocumentFile) - -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 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(SNAPSHOT_EXT) -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafFactory.kt deleted file mode 100644 index af46f6aa..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafFactory.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.saf - -import android.content.Context -import android.net.Uri -import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.settings.SettingsManager -import com.stevesoltys.seedvault.storage.SeedvaultSafStoragePlugin - -class SafFactory( - private val context: Context, - private val keyManager: KeyManager, - private val settingsManager: SettingsManager, -) { - - internal fun createAppStoragePlugin( - safStorage: SafStorage, - documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage), - ): StoragePlugin { - return DocumentsProviderStoragePlugin(context, documentsStorage) - } - - internal fun createFilesStoragePlugin( - safStorage: SafStorage, - documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage), - ): org.calyxos.backup.storage.api.StoragePlugin { - return SeedvaultSafStoragePlugin(context, documentsStorage, keyManager) - } - -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt deleted file mode 100644 index f11728e1..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.webdav - -import android.annotation.SuppressLint -import android.content.Context -import android.provider.Settings -import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.plugins.StoragePlugin - -class WebDavFactory( - private val context: Context, - private val keyManager: KeyManager, -) { - - fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin { - return WebDavStoragePlugin(context, config) - } - - fun createFilesStoragePlugin( - config: WebDavConfig, - ): org.calyxos.backup.storage.api.StoragePlugin { - @SuppressLint("HardwareIds") - val androidId = - Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) - return com.stevesoltys.seedvault.storage.WebDavStoragePlugin( - keyManager = keyManager, - androidId = androidId, - webDavConfig = config, - ) - } - -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt deleted file mode 100644 index faec3f84..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt +++ /dev/null @@ -1,258 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.webdav - -import android.util.Log -import at.bitfire.dav4jvm.BasicDigestAuthHandler -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.MultiResponseCallback -import at.bitfire.dav4jvm.Property -import at.bitfire.dav4jvm.PropertyFactory -import at.bitfire.dav4jvm.PropertyRegistry -import at.bitfire.dav4jvm.Response -import at.bitfire.dav4jvm.Response.HrefRelation.SELF -import at.bitfire.dav4jvm.exception.HttpException -import at.bitfire.dav4jvm.property.webdav.DisplayName -import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV -import at.bitfire.dav4jvm.property.webdav.ResourceType -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import okhttp3.ConnectionSpec -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.RequestBody -import okio.BufferedSink -import org.xmlpull.v1.XmlPullParser -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -const val DEBUG_LOG = true -const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup" - -@OptIn(DelicateCoroutinesApi::class) -internal abstract class WebDavStorage( - webDavConfig: WebDavConfig, - root: String = DIRECTORY_ROOT, -) { - - companion object { - val TAG: String = WebDavStorage::class.java.simpleName - } - - private val authHandler = BasicDigestAuthHandler( - domain = null, // Optional, to only authenticate against hosts with this domain. - username = webDavConfig.username, - password = webDavConfig.password, - ) - protected val okHttpClient = OkHttpClient.Builder() - .followRedirects(false) - .authenticator(authHandler) - .addNetworkInterceptor(authHandler) - .connectTimeout(30, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .readTimeout(240, TimeUnit.SECONDS) - .pingInterval(45, TimeUnit.SECONDS) - .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) - .retryOnConnectionFailure(true) - .build() - - protected val baseUrl = webDavConfig.url - protected val url = "${webDavConfig.url}/$root" - - init { - PropertyRegistry.register(GetLastModified.Factory) - } - - @Throws(IOException::class) - protected suspend fun getOutputStream(location: HttpUrl): OutputStream { - val davCollection = DavCollection(okHttpClient, location) - - val pipedInputStream = PipedInputStream() - val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream) - - val body = object : RequestBody() { - override fun isOneShot(): Boolean = true - override fun contentType() = "application/octet-stream".toMediaType() - override fun writeTo(sink: BufferedSink) { - pipedInputStream.use { inputStream -> - sink.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - } - } - val deferred = GlobalScope.async(Dispatchers.IO) { - davCollection.put(body) { response -> - debugLog { "getOutputStream($location) = $response" } - } - } - pipedOutputStream.doOnClose { - runBlocking { // blocking i/o wait - deferred.await() - } - } - return pipedOutputStream - } - - @Throws(IOException::class) - protected fun getInputStream(location: HttpUrl): InputStream { - val davCollection = DavCollection(okHttpClient, location) - - val response = davCollection.get(accept = "", headers = null) - debugLog { "getInputStream($location) = $response" } - if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}") - return response.body?.byteStream() ?: throw IOException() - } - - /** - * Tries to do [DavCollection.propfind] with a depth of `2` which is not in RFC4918. - * Since `infinity` isn't supported by nginx either, - * we fallback to iterating over all folders found with depth `1` - * and do another PROPFIND on those, passing the given [callback]. - */ - protected fun DavCollection.propfindDepthTwo(callback: MultiResponseCallback) { - try { - propfind( - depth = 2, // this isn't defined in RFC4918 - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - callback = callback, - ) - } catch (e: HttpException) { - if (e.isUnsupportedPropfind()) { - Log.i(TAG, "Got ${e.response}, trying two depth=1 PROPFINDs...") - propfindFakeTwo(callback) - } else { - throw e - } - } - } - - private fun DavCollection.propfindFakeTwo(callback: MultiResponseCallback) { - propfind( - depth = 1, - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - ) { response, relation -> - debugLog { "propFindFakeTwo() = $response" } - // This callback will be called for everything in the folder - callback.onResponse(response, relation) - if (relation != SELF && response.isFolder()) { - DavCollection(okHttpClient, response.href).propfind( - depth = 1, - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - callback = callback, - ) - } - } - } - - protected fun HttpException.isUnsupportedPropfind(): Boolean { - // nginx returns 400 for depth=2 - if (code == 400) { - return true - } - // lighttpd returns 403 with error as if we used infinity - if (code == 403 && responseBody?.contains("propfind-finite-depth") == true) { - return true - } - return false - } - - protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response { - return try { - suspendCoroutine { cont -> - mkCol(xmlBody) { response -> - cont.resume(response) - } - } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - protected inline fun debugLog(block: () -> String) { - if (DEBUG_LOG) Log.d(TAG, block()) - } - - protected fun Response.isFolder(): Boolean { - return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true - } - - private class PipedCloseActionOutputStream( - inputStream: PipedInputStream, - ) : PipedOutputStream(inputStream) { - - private var onClose: (() -> Unit)? = null - - override fun write(b: Int) { - try { - super.write(b) - } catch (e: Exception) { - try { - onClose?.invoke() - } catch (closeException: Exception) { - e.addSuppressed(closeException) - } - throw e - } - } - - override fun write(b: ByteArray?, off: Int, len: Int) { - try { - super.write(b, off, len) - } catch (e: Exception) { - try { - onClose?.invoke() - } catch (closeException: Exception) { - e.addSuppressed(closeException) - } - throw e - } - } - - @Throws(IOException::class) - override fun close() { - super.close() - try { - onClose?.invoke() - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - fun doOnClose(function: () -> Unit) { - this.onClose = function - } - } - -} - -/** - * A fake version of [at.bitfire.dav4jvm.property.webdav.GetLastModified] which we register - * so we don't need to depend on `org.apache.commons.lang3` which is used for date parsing. - */ -class GetLastModified : Property { - companion object { - @JvmField - val NAME = Property.Name(NS_WEBDAV, "getlastmodified") - } - - object Factory : PropertyFactory { - override fun getName() = NAME - override fun create(parser: XmlPullParser): GetLastModified? = null - } -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt deleted file mode 100644 index dc7e7289..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.webdav - -import android.content.Context -import android.util.Log -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.Response.HrefRelation.SELF -import at.bitfire.dav4jvm.exception.HttpException -import at.bitfire.dav4jvm.exception.NotFoundException -import at.bitfire.dav4jvm.property.webdav.DisplayName -import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes -import at.bitfire.dav4jvm.property.webdav.ResourceType -import com.stevesoltys.seedvault.plugins.EncryptedMetadata -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.chunkFolderRegex -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA -import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA -import com.stevesoltys.seedvault.plugins.tokenRegex -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -internal class WebDavStoragePlugin( - context: Context, - webDavConfig: WebDavConfig, - root: String = DIRECTORY_ROOT, -) : WebDavStorage(webDavConfig, root), StoragePlugin { - - override suspend fun test(): Boolean { - val location = (if (baseUrl.endsWith('/')) baseUrl else "$baseUrl/").toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - val webDavSupported = suspendCoroutine { cont -> - davCollection.options { davCapabilities, response -> - debugLog { "test() = $davCapabilities $response" } - if (davCapabilities.contains("1")) cont.resume(true) - else if (davCapabilities.contains("2")) cont.resume(true) - else if (davCapabilities.contains("3")) cont.resume(true) - else cont.resume(false) - } - } - return webDavSupported - } - - override suspend fun getFreeSpace(): Long? { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - val availableBytes = suspendCoroutine { cont -> - davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ -> - debugLog { "getFreeSpace() = $response" } - val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes - val availableBytes = quota?.quotaAvailableBytes ?: -1 - if (availableBytes > 0) { - cont.resume(availableBytes) - } else { - cont.resume(null) - } - } - } - return availableBytes - } - - @Throws(IOException::class) - override suspend fun startNewRestoreSet(token: Long) { - val location = "$url/$token/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - val response = davCollection.createFolder() - debugLog { "startNewRestoreSet($token) = $response" } - } - - @Throws(IOException::class) - override suspend fun initializeDevice() { - // TODO does it make sense to delete anything - // when [startNewRestoreSet] is always called first? Maybe unify both calls? - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - davCollection.head { response -> - debugLog { "Root exists: $response" } - } - } catch (e: NotFoundException) { - val response = davCollection.createFolder() - debugLog { "initializeDevice() = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - @Throws(IOException::class) - override suspend fun hasData(token: Long, name: String): Boolean { - val location = "$url/$token/$name".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - return try { - val response = suspendCoroutine { cont -> - davCollection.head { response -> - cont.resume(response) - } - } - debugLog { "hasData($token, $name) = $response" } - response.isSuccessful - } catch (e: NotFoundException) { - debugLog { "hasData($token, $name) = $e" } - false - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - @Throws(IOException::class) - override suspend fun getOutputStream(token: Long, name: String): OutputStream { - val location = "$url/$token/$name".toHttpUrl() - return try { - getOutputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting OutputStream for $token and $name: ", e) - } - } - - @Throws(IOException::class) - override suspend fun getInputStream(token: Long, name: String): InputStream { - val location = "$url/$token/$name".toHttpUrl() - return try { - getInputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting InputStream for $token and $name: ", e) - } - } - - @Throws(IOException::class) - override suspend fun removeData(token: Long, name: String) { - val location = "$url/$token/$name".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - val response = suspendCoroutine { cont -> - davCollection.delete { response -> - cont.resume(response) - } - } - debugLog { "removeData($token, $name) = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - override suspend fun getAvailableBackups(): Sequence? { - return try { - doGetAvailableBackups() - } catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm - Log.e(TAG, "Error getting available backups: ", e) - null - } - } - - private suspend fun doGetAvailableBackups(): Sequence { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - // get all restore set tokens in root folder - val tokens = ArrayList() - try { - davCollection.propfind( - depth = 2, - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - ) { response, relation -> - debugLog { "getAvailableBackups() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2 && - response.hrefName() == FILE_BACKUP_METADATA - ) { - val tokenName = response.href.pathSegments[response.href.pathSegments.size - 2] - getTokenOrNull(tokenName)?.let { token -> - tokens.add(token) - } - } - } - } catch (e: HttpException) { - if (e.isUnsupportedPropfind()) getBackupTokenWithDepthOne(davCollection, tokens) - else throw e - } - val tokenIterator = tokens.iterator() - return generateSequence { - if (!tokenIterator.hasNext()) return@generateSequence null // end sequence - val token = tokenIterator.next() - EncryptedMetadata(token) { - getInputStream(token, FILE_BACKUP_METADATA) - } - } - } - - private fun getBackupTokenWithDepthOne(davCollection: DavCollection, tokens: ArrayList) { - davCollection.propfind( - depth = 1, - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - ) { response, relation -> - debugLog { "getBackupTokenWithDepthOne() = $response" } - - // we are only interested in sub-folders, skip rest - if (relation == SELF || !response.isFolder()) return@propfind - - val token = getTokenOrNull(response.hrefName()) ?: return@propfind - val tokenUrl = response.href.newBuilder() - .addPathSegment(FILE_BACKUP_METADATA) - .build() - // check if .backup.metadata file exists using HEAD request, - // because some servers (e.g. nginx don't list hidden files with PROPFIND) - try { - DavCollection(okHttpClient, tokenUrl).head { - debugLog { "getBackupTokenWithDepthOne() = $response" } - tokens.add(token) - } - } catch (e: Exception) { - // just log exception and continue, we want to find all files that are there - Log.e(TAG, "Error retrieving $tokenUrl: ", e) - } - } - } - - private fun getTokenOrNull(name: String): Long? { - val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name) - if (looksLikeToken) { - return try { - name.toLong() - } catch (e: NumberFormatException) { - throw AssertionError(e) // regex must be wrong - } - } - if (isUnexpectedFile(name)) { - Log.w(TAG, "Found invalid backup set folder: $name") - } - return null - } - - private fun isUnexpectedFile(name: String): Boolean { - return name != FILE_NO_MEDIA && - !chunkFolderRegex.matches(name) && - !name.endsWith(SNAPSHOT_EXT) - } - - override val providerPackageName: String = context.packageName // 100% built-in plugin - -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt index 8d8bb01f..2372018c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt @@ -25,7 +25,7 @@ import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageState -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.restore.install.isInstalled import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.TRANSPORT_ID @@ -56,7 +56,7 @@ internal class AppDataRestoreManager( private val backupManager: IBackupManager, private val settingsManager: SettingsManager, private val restoreCoordinator: RestoreCoordinator, - private val storagePluginManager: StoragePluginManager, + private val backendManager: BackendManager, ) { private var session: IRestoreSession? = null @@ -101,7 +101,7 @@ internal class AppDataRestoreManager( return } - val providerPackageName = storagePluginManager.appPlugin.providerPackageName + val providerPackageName = backendManager.backend.providerPackageName val observer = RestoreObserver( restoreCoordinator = restoreCoordinator, restorableBackup = restorableBackup, diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt index a60c76b6..22a0b4ee 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt @@ -14,10 +14,9 @@ import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM import com.stevesoltys.seedvault.ui.systemData -import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS import com.stevesoltys.seedvault.worker.IconManager import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -25,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.util.Locale internal class SelectedAppsState( @@ -37,7 +37,7 @@ private val TAG = AppSelectionManager::class.simpleName internal class AppSelectionManager( private val context: Context, - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, private val iconManager: IconManager, private val coroutineScope: CoroutineScope, private val workDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -88,10 +88,10 @@ internal class AppSelectionManager( SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false) // download icons coroutineScope.launch(workDispatcher) { - val plugin = pluginManager.appPlugin + val backend = backendManager.backend val token = restorableBackup.token val packagesWithIcons = try { - plugin.getInputStream(token, FILE_BACKUP_ICONS).use { + backend.load(LegacyAppBackupFile.IconsFile(token)).use { iconManager.downloadIcons(restorableBackup.version, token, it) } } catch (e: Exception) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreUiModule.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreUiModule.kt index 41abcddf..e1f5a420 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreUiModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreUiModule.kt @@ -23,7 +23,7 @@ val restoreUiModule = module { apkRestore = get(), iconManager = get(), storageBackup = get(), - pluginManager = get(), + backendManager = get(), fileSelectionManager = get(), ) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index ccaca295..3abfda9c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -18,7 +18,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES @@ -65,19 +65,19 @@ internal class RestoreViewModel( private val apkRestore: ApkRestore, private val iconManager: IconManager, storageBackup: StorageBackup, - pluginManager: StoragePluginManager, + backendManager: BackendManager, override val fileSelectionManager: FileSelectionManager, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, -) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager), +) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager), RestorableBackupClickListener, SnapshotViewModel { override val isRestoreOperation = true var isSetupWizard = false private val appSelectionManager = - AppSelectionManager(app, pluginManager, iconManager, viewModelScope) + AppSelectionManager(app, backendManager, iconManager, viewModelScope) private val appDataRestoreManager = AppDataRestoreManager( - app, backupManager, settingsManager, restoreCoordinator, pluginManager + app, backupManager, settingsManager, restoreCoordinator, backendManager ) private val mDisplayFragment = MutableLiveEvent() diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index d3570a97..ab600960 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -17,9 +17,8 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestoreService import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED @@ -34,6 +33,8 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.File import java.io.IOException import java.util.Locale @@ -44,7 +45,7 @@ internal class ApkRestore( private val context: Context, private val backupManager: IBackupManager, private val backupStateManager: BackupStateManager, - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin, private val crypto: Crypto, @@ -54,7 +55,7 @@ internal class ApkRestore( ) { private val pm = context.packageManager - private val storagePlugin get() = pluginManager.appPlugin + private val backend get() = backendManager.backend private val mInstallResult = MutableStateFlow(InstallResult()) val installResult = mInstallResult.asStateFlow() @@ -65,7 +66,7 @@ internal class ApkRestore( val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) -> // We need to exclude the DocumentsProvider used to retrieve backup data. // Otherwise, it gets killed when we install it, terminating our restoration. - if (packageName == storagePlugin.providerPackageName) return@mapNotNull null + if (packageName == backend.providerPackageName) return@mapNotNull null // The @pm@ package needs to be included in [backup], but can't be installed like an app if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null // we don't filter out apps without APK, so the user can manually install them @@ -236,7 +237,7 @@ internal class ApkRestore( } /** - * Retrieves APK splits from [StoragePlugin] and caches them locally. + * Retrieves APK splits from [Backend] and caches them locally. * * @throws SecurityException if a split has an unexpected SHA-256 hash. * @return a list of all APKs that need to be installed @@ -274,7 +275,7 @@ internal class ApkRestore( } /** - * Retrieves an APK from the [StoragePlugin] and caches it locally + * Retrieves an APK from the [Backend] and caches it locally * while calculating its SHA-256 hash. * * @return a [Pair] of the cached [File] and SHA-256 hash. @@ -294,7 +295,7 @@ internal class ApkRestore( legacyStoragePlugin.getApkInputStream(token, packageName, suffix) } else { val name = crypto.getNameForApk(salt, packageName, suffix) - storagePlugin.getInputStream(token, name) + backend.load(LegacyAppBackupFile.Blob(token, name)) } val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream()) return Pair(cachedApk, sha256) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt index bc0fb203..387e440c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt @@ -17,7 +17,7 @@ import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.settings.preference.M3ListPreference import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -27,7 +27,7 @@ class SchedulingFragment : PreferenceFragmentCompat(), private val viewModel: SettingsViewModel by sharedViewModel() private val settingsManager: SettingsManager by inject() - private val storagePluginManager: StoragePluginManager by inject() + private val backendManager: BackendManager by inject() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { permitDiskReads { @@ -39,7 +39,7 @@ class SchedulingFragment : PreferenceFragmentCompat(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val storage = storagePluginManager.storageProperties + val storage = backendManager.backendProperties if (storage?.isUsb == true) { findPreference("scheduling_category_conditions")?.isEnabled = false } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index e93f6201..04aad46f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -25,12 +25,12 @@ import androidx.work.WorkInfo import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.StorageProperties import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.toRelativeTime +import org.calyxos.seedvault.core.backends.BackendProperties import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel import java.util.concurrent.TimeUnit @@ -40,7 +40,7 @@ private val TAG = SettingsFragment::class.java.name class SettingsFragment : PreferenceFragmentCompat() { private val viewModel: SettingsViewModel by sharedViewModel() - private val storagePluginManager: StoragePluginManager by inject() + private val backendManager: BackendManager by inject() private val backupStateManager: BackupStateManager by inject() private val backupManager: IBackupManager by inject() private val notificationManager: BackupNotificationManager by inject() @@ -57,8 +57,8 @@ class SettingsFragment : PreferenceFragmentCompat() { private var menuBackupNow: MenuItem? = null private var menuRestore: MenuItem? = null - private val storageProperties: StorageProperties<*>? - get() = storagePluginManager.storageProperties + private val backendProperties: BackendProperties<*>? + get() = backendManager.backendProperties override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { permitDiskReads { @@ -270,7 +270,7 @@ class SettingsFragment : PreferenceFragmentCompat() { activity?.contentResolver?.let { autoRestore.isChecked = backupStateManager.isAutoRestoreEnabled } - val storage = this.storageProperties + val storage = this.backendProperties if (storage?.isUsb == true) { autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" + getString(R.string.settings_auto_restore_summary_usb, storage.name) @@ -282,7 +282,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun setBackupLocationSummary() { // get name of storage location backupLocation.summary = - storageProperties?.name ?: getString(R.string.settings_backup_location_none) + backendProperties?.name ?: getString(R.string.settings_backup_location_none) } private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) { @@ -301,7 +301,7 @@ class SettingsFragment : PreferenceFragmentCompat() { * says that nothing is scheduled which can happen when backup destination is on flash drive. */ private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) { - if (storageProperties?.isUsb == true) { + if (backendProperties?.isUsb == true) { backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb) return } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index f62ce688..baac5c8d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -11,15 +11,15 @@ import android.hardware.usb.UsbDevice import android.net.Uri import androidx.annotation.UiThread import androidx.preference.PreferenceManager +import com.stevesoltys.seedvault.backend.webdav.WebDavHandler.Companion.createWebDavProperties import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin -import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig -import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties -import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties -import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.transport.backup.BackupCoordinator +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.saf.SafBackend +import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.calyxos.seedvault.core.backends.webdav.WebDavBackend +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig +import org.calyxos.seedvault.core.backends.webdav.WebDavProperties import java.util.concurrent.ConcurrentSkipListSet internal const val PREF_KEY_TOKEN = "token" @@ -128,10 +128,10 @@ class SettingsManager(private val context: Context) { } } - fun setStoragePlugin(plugin: StoragePlugin<*>) { + fun setStorageBackend(plugin: Backend) { val value = when (plugin) { - is DocumentsProviderStoragePlugin -> StoragePluginType.SAF - is WebDavStoragePlugin -> StoragePluginType.WEB_DAV + is SafBackend -> StoragePluginType.SAF + is WebDavBackend -> StoragePluginType.WEB_DAV else -> error("Unsupported plugin: ${plugin::class.java.simpleName}") }.name prefs.edit() @@ -139,17 +139,17 @@ class SettingsManager(private val context: Context) { .apply() } - fun setSafStorage(safStorage: SafStorage) { + fun setSafProperties(safProperties: SafProperties) { prefs.edit() - .putString(PREF_KEY_STORAGE_URI, safStorage.uri.toString()) - .putString(PREF_KEY_STORAGE_ROOT_ID, safStorage.rootId) - .putString(PREF_KEY_STORAGE_NAME, safStorage.name) - .putBoolean(PREF_KEY_STORAGE_IS_USB, safStorage.isUsb) - .putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, safStorage.requiresNetwork) + .putString(PREF_KEY_STORAGE_URI, safProperties.uri.toString()) + .putString(PREF_KEY_STORAGE_ROOT_ID, safProperties.rootId) + .putString(PREF_KEY_STORAGE_NAME, safProperties.name) + .putBoolean(PREF_KEY_STORAGE_IS_USB, safProperties.isUsb) + .putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, safProperties.requiresNetwork) .apply() } - fun getSafStorage(): SafStorage? { + fun getSafProperties(): SafProperties? { val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null val uri = Uri.parse(uriStr) val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) @@ -157,7 +157,7 @@ class SettingsManager(private val context: Context) { val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false) val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false) val rootId = prefs.getString(PREF_KEY_STORAGE_ROOT_ID, null) - return SafStorage(uri, name, isUsb, requiresNetwork, rootId) + return SafProperties(uri, name, isUsb, requiresNetwork, rootId) } fun setFlashDrive(usb: FlashDrive?) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index f361f80e..2c1de79d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -40,8 +40,7 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.SafStorage +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP @@ -59,6 +58,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.backup.BackupJobService +import org.calyxos.seedvault.core.backends.saf.SafProperties import java.io.IOException import java.lang.Runtime.getRuntime import java.util.concurrent.TimeUnit.HOURS @@ -70,14 +70,14 @@ internal class SettingsViewModel( app: Application, settingsManager: SettingsManager, keyManager: KeyManager, - pluginManager: StoragePluginManager, + backendManager: BackendManager, private val metadataManager: MetadataManager, private val appListRetriever: AppListRetriever, private val storageBackup: StorageBackup, private val backupManager: IBackupManager, private val backupInitializer: BackupInitializer, backupStateManager: BackupStateManager, -) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager) { +) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager) { private val contentResolver = app.contentResolver private val connectivityManager: ConnectivityManager? = @@ -158,7 +158,7 @@ internal class SettingsViewModel( } override fun onStorageLocationChanged() { - val storage = pluginManager.storageProperties ?: return + val storage = backendManager.backendProperties ?: return Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb})") if (storage.isUsb) { @@ -177,33 +177,33 @@ internal class SettingsViewModel( private fun onBackupRunningStateChanged() { if (isBackupRunning.value) mBackupPossible.postValue(false) else viewModelScope.launch(Dispatchers.IO) { - val canDo = !isBackupRunning.value && !pluginManager.isOnUnavailableUsb() + val canDo = !isBackupRunning.value && !backendManager.isOnUnavailableUsb() mBackupPossible.postValue(canDo) } } private fun onStoragePropertiesChanged() { - val storage = pluginManager.storageProperties ?: return + val properties = backendManager.backendProperties ?: return Log.d(TAG, "onStoragePropertiesChanged") - if (storage is SafStorage) { + if (properties is SafProperties) { // register storage observer try { contentResolver.unregisterContentObserver(storageObserver) - contentResolver.registerContentObserver(storage.uri, false, storageObserver) + contentResolver.registerContentObserver(properties.uri, false, storageObserver) } catch (e: SecurityException) { // This can happen if the app providing the storage was uninstalled. // validLocationIsSet() gets called elsewhere // and prompts for a new storage location. - Log.e(TAG, "Error registering content observer for ${storage.uri}", e) + Log.e(TAG, "Error registering content observer for ${properties.uri}", e) } } // register network observer if needed - if (networkCallback.registered && !storage.requiresNetwork) { + if (networkCallback.registered && !properties.requiresNetwork) { connectivityManager?.unregisterNetworkCallback(networkCallback) networkCallback.registered = false - } else if (!networkCallback.registered && storage.requiresNetwork) { + } else if (!networkCallback.registered && properties.requiresNetwork) { // TODO we may want to warn the user when they start a backup on a metered connection val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) @@ -232,7 +232,7 @@ internal class SettingsViewModel( i.putExtra(EXTRA_START_APP_BACKUP, isAppBackupEnabled) startForegroundService(app, i) } else if (isAppBackupEnabled) { - AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive) + AppBackupWorker.scheduleNow(app, reschedule = !backendManager.isOnRemovableDrive) } } } @@ -313,14 +313,14 @@ internal class SettingsViewModel( fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) { // disable framework scheduling, because another transport may have enabled it backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) - if (!pluginManager.isOnRemovableDrive && backupManager.isBackupEnabled) { + if (!backendManager.isOnRemovableDrive && backupManager.isBackupEnabled) { AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy) } } fun scheduleFilesBackup() { - if (!pluginManager.isOnRemovableDrive && settingsManager.isStorageBackupEnabled()) { - val requiresNetwork = pluginManager.storageProperties?.requiresNetwork == true + if (!backendManager.isOnRemovableDrive && settingsManager.isStorageBackupEnabled()) { + val requiresNetwork = backendManager.backendProperties?.requiresNetwork == true BackupJobService.scheduleJob( context = app, jobServiceClass = StorageBackupJobService::class.java, diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt deleted file mode 100644 index ac4c95ac..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.storage - -import android.content.Context -import androidx.documentfile.provider.DocumentFile -import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.getStorageContext -import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage -import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin -import javax.crypto.SecretKey - -internal class SeedvaultSafStoragePlugin( - private val appContext: Context, - private val storage: DocumentsStorage, - private val keyManager: KeyManager, -) : SafStoragePlugin(appContext) { - /** - * Attention: This context might be from a different user. Use with care. - */ - override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb } - override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set") - - override fun getMasterKey(): SecretKey = keyManager.getMainKey() - override fun hasMasterKey(): Boolean = keyManager.hasMainKey() -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt index 8675483a..aa5799f6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt @@ -6,7 +6,7 @@ package com.stevesoltys.seedvault.storage import android.content.Intent -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.worker.AppBackupWorker import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -44,7 +44,7 @@ internal class StorageBackupService : BackupService() { } override val storageBackup: StorageBackup by inject() - private val storagePluginManager: StoragePluginManager by inject() + private val backendManager: BackendManager by inject() // use lazy delegate because context isn't available during construction time override val backupObserver: BackupObserver by lazy { @@ -63,7 +63,7 @@ internal class StorageBackupService : BackupService() { override fun onBackupFinished(intent: Intent, success: Boolean) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { - val isUsb = storagePluginManager.storageProperties?.isUsb ?: false + val isUsb = backendManager.backendProperties?.isUsb ?: false AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt index ff5e0b1d..72bc7c0b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt @@ -5,10 +5,11 @@ package com.stevesoltys.seedvault.storage -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.backend.BackendManager import org.calyxos.backup.storage.api.StorageBackup import org.koin.dsl.module val storageModule = module { - single { StorageBackup(get(), { get().filesPlugin }) } + single { StorageBackup(get(), { get().backend }, get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt deleted file mode 100644 index 28241dee..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt +++ /dev/null @@ -1,290 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.storage - -import android.util.Log -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.Response.HrefRelation.SELF -import at.bitfire.dav4jvm.exception.NotFoundException -import at.bitfire.dav4jvm.property.webdav.DisplayName -import at.bitfire.dav4jvm.property.webdav.ResourceType -import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.plugins.chunkFolderRegex -import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT -import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig -import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.calyxos.backup.storage.api.StoragePlugin -import org.calyxos.backup.storage.api.StoredSnapshot -import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT -import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex -import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex -import org.koin.core.time.measureDuration -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import javax.crypto.SecretKey -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -internal class WebDavStoragePlugin( - private val keyManager: KeyManager, - /** - * The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) - */ - androidId: String, - webDavConfig: WebDavConfig, - root: String = DIRECTORY_ROOT, -) : WebDavStorage(webDavConfig, root), StoragePlugin { - - /** - * The folder name is our user ID plus .sv extension (for SeedVault). - * The user or `androidId` is unique to each combination of app-signing key, user, and device - * so we don't leak anything by not hashing this and can use it as is. - */ - private val folder: String = "$androidId.sv" - - @Throws(IOException::class) - override suspend fun init() { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - davCollection.head { response -> - debugLog { "Root exists: $response" } - } - } catch (e: NotFoundException) { - val response = davCollection.createFolder() - debugLog { "init() = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - @Throws(IOException::class) - override suspend fun getAvailableChunkIds(): List { - val location = "$url/$folder/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - debugLog { "getAvailableChunkIds($location)" } - - val expectedChunkFolders = (0x00..0xff).map { - Integer.toHexString(it).padStart(2, '0') - }.toHashSet() - val chunkIds = ArrayList() - try { - val duration = measureDuration { - davCollection.propfindDepthTwo { response, relation -> - debugLog { "getAvailableChunkIds() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && response.isFolder()) { - val name = response.hrefName() - if (chunkFolderRegex.matches(name)) { - expectedChunkFolders.remove(name) - } - } else if (relation != SELF && response.href.pathSize >= 2) { - val folderName = - response.href.pathSegments[response.href.pathSegments.size - 2] - if (folderName != folder && chunkFolderRegex.matches(folderName)) { - val name = response.hrefName() - if (chunkRegex.matches(name)) chunkIds.add(name) - } - } - } - } - Log.i(TAG, "Retrieving chunks took $duration") - } catch (e: NotFoundException) { - debugLog { "Folder not found: $location" } - davCollection.createFolder() - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error populating chunk folders: ", e) - } - Log.i(TAG, "Got ${chunkIds.size} available chunks") - createMissingChunkFolders(expectedChunkFolders) - return chunkIds - } - - @Throws(IOException::class) - private suspend fun createMissingChunkFolders( - missingChunkFolders: Set, - ) { - val s = missingChunkFolders.size - for ((i, chunkFolderName) in missingChunkFolders.withIndex()) { - val location = "$url/$folder/$chunkFolderName/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - val response = davCollection.createFolder() - debugLog { "Created missing folder $chunkFolderName (${i + 1}/$s) $response" } - } - } - - override fun getMasterKey(): SecretKey = keyManager.getMainKey() - override fun hasMasterKey(): Boolean = keyManager.hasMainKey() - - @Throws(IOException::class) - override suspend fun getChunkOutputStream(chunkId: String): OutputStream { - val chunkFolderName = chunkId.substring(0, 2) - val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() - debugLog { "getChunkOutputStream($location) for $chunkId" } - return try { - getOutputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting OutputStream for $chunkId: ", e) - } - } - - @Throws(IOException::class) - override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { - val location = "$url/$folder/$timestamp$SNAPSHOT_EXT".toHttpUrl() - debugLog { "getBackupSnapshotOutputStream($location)" } - return try { - getOutputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting OutputStream for $timestamp$SNAPSHOT_EXT: ", e) - } - } - - /************************* Restore *******************************/ - - @Throws(IOException::class) - override suspend fun getBackupSnapshotsForRestore(): List { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - debugLog { "getBackupSnapshotsForRestore($location)" } - - val snapshots = ArrayList() - try { - davCollection.propfindDepthTwo { response, relation -> - debugLog { "getBackupSnapshotsForRestore() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) { - val name = response.hrefName() - val match = snapshotRegex.matchEntire(name) - if (match != null) { - val timestamp = match.groupValues[1].toLong() - val folderName = - response.href.pathSegments[response.href.pathSegments.size - 2] - val storedSnapshot = StoredSnapshot(folderName, timestamp) - snapshots.add(storedSnapshot) - } - } - } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting snapshots for restore: ", e) - } - return snapshots - } - - @Throws(IOException::class) - override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream { - val timestamp = storedSnapshot.timestamp - val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() - debugLog { "getBackupSnapshotInputStream($location)" } - return try { - getInputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting InputStream for $storedSnapshot: ", e) - } - } - - @Throws(IOException::class) - override suspend fun getChunkInputStream( - snapshot: StoredSnapshot, - chunkId: String, - ): InputStream { - val chunkFolderName = chunkId.substring(0, 2) - val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl() - debugLog { "getChunkInputStream($location) for $chunkId" } - return try { - getInputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting InputStream for $chunkFolderName/$chunkId: ", e) - } - } - - /************************* Pruning *******************************/ - - @Throws(IOException::class) - override suspend fun getCurrentBackupSnapshots(): List { - val location = "$url/$folder/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - debugLog { "getCurrentBackupSnapshots($location)" } - - val snapshots = ArrayList() - try { - val duration = measureDuration { - davCollection.propfind( - depth = 1, - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - ) { response, relation -> - debugLog { "getCurrentBackupSnapshots() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && !response.isFolder()) { - val match = snapshotRegex.matchEntire(response.hrefName()) - if (match != null) { - val timestamp = match.groupValues[1].toLong() - val storedSnapshot = StoredSnapshot(folder, timestamp) - snapshots.add(storedSnapshot) - } - } - } - } - Log.i(TAG, "getCurrentBackupSnapshots took $duration") - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting current snapshots: ", e) - } - Log.i(TAG, "Got ${snapshots.size} snapshots.") - return snapshots - } - - @Throws(IOException::class) - override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) { - val timestamp = storedSnapshot.timestamp - Log.d(TAG, "Deleting snapshot $timestamp") - - val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - val response = suspendCoroutine { cont -> - davCollection.delete { response -> - cont.resume(response) - } - } - debugLog { "deleteBackupSnapshot() = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - - @Throws(IOException::class) - override suspend fun deleteChunks(chunkIds: List) { - chunkIds.forEach { chunkId -> - val chunkFolderName = chunkId.substring(0, 2) - val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - val response = suspendCoroutine { cont -> - davCollection.delete { response -> - cont.resume(response) - } - } - debugLog { "deleteChunks($chunkId) = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } - } - } -} 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 510428d4..bf6bce13 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 @@ -29,14 +29,12 @@ import com.stevesoltys.seedvault.metadata.PackageState 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.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.isOutOfSpace -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.getMetadataOutputStream +import com.stevesoltys.seedvault.backend.isOutOfSpace 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 @@ -64,7 +62,7 @@ private class CoordinatorState( @WorkerThread internal class BackupCoordinator( private val context: Context, - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, private val kv: KVBackup, private val full: FullBackup, private val clock: Clock, @@ -74,7 +72,7 @@ internal class BackupCoordinator( private val nm: BackupNotificationManager, ) { - private val plugin get() = pluginManager.appPlugin + private val backend get() = backendManager.backend private val state = CoordinatorState( calledInitialize = false, calledClearBackupData = false, @@ -97,7 +95,6 @@ internal class BackupCoordinator( val token = clock.time() Log.i(TAG, "Starting new RestoreSet with token $token...") settingsManager.setNewToken(token) - plugin.startNewRestoreSet(token) Log.d(TAG, "Resetting backup metadata...") metadataManager.onDeviceInitialization(token) } @@ -125,7 +122,6 @@ internal class BackupCoordinator( // instead of simply deleting the current one startNewRestoreSet() Log.i(TAG, "Initialize Device!") - plugin.initializeDevice() // [finishBackup] will only be called when we return [TRANSPORT_OK] here // so we remember that we initialized successfully state.calledInitialize = true @@ -133,7 +129,7 @@ internal class BackupCoordinator( } catch (e: Exception) { Log.e(TAG, "Error initializing device", e) // Show error notification if we needed init or were ready for backups - if (metadataManager.requiresInit || pluginManager.canDoBackupNow()) nm.onBackupError() + if (metadataManager.requiresInit || backendManager.canDoBackupNow()) nm.onBackupError() TRANSPORT_ERROR } @@ -371,7 +367,7 @@ internal class BackupCoordinator( if (result == TRANSPORT_OK) { val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER // call onPackageBackedUp for @pm@ only if we can do backups right now - if (isNormalBackup || pluginManager.canDoBackupNow()) { + if (isNormalBackup || backendManager.canDoBackupNow()) { try { onPackageBackedUp(packageInfo, BackupType.KV, size) } catch (e: Exception) { @@ -410,7 +406,8 @@ internal class BackupCoordinator( } private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) { - plugin.getMetadataOutputStream().use { + val token = settingsManager.getToken() ?: error("no token") + backend.getMetadataOutputStream(token).use { metadataManager.onPackageBackedUp(packageInfo, type, size, it) } } @@ -418,7 +415,8 @@ internal class BackupCoordinator( private suspend fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) { val packageName = packageInfo.packageName try { - plugin.getMetadataOutputStream().use { + val token = settingsManager.getToken() ?: error("no token") + backend.getMetadataOutputStream(token).use { metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it, type) } } catch (e: IOException) { @@ -430,7 +428,7 @@ internal class BackupCoordinator( val longBackoff = DAYS.toMillis(30) // back off if there's no storage set - val storage = pluginManager.storageProperties ?: return longBackoff + val storage = backendManager.backendProperties ?: return longBackoff return when { // back off if storage is removable and not available right now storage.isUnavailableUsb(context) -> longBackoff @@ -443,12 +441,4 @@ internal class BackupCoordinator( else -> 0L } } - - private suspend fun StoragePlugin<*>.getMetadataOutputStream( - token: Long? = null, - ): OutputStream { - val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") - return getOutputStream(t, FILE_BACKUP_METADATA) - } - } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 2fd9a7d1..bc18e0cb 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -16,13 +16,13 @@ val backupModule = module { context = androidContext(), backupManager = get(), settingsManager = get(), - pluginManager = get(), + backendManager = get(), ) } single { KvDbManagerImpl(androidContext()) } single { KVBackup( - pluginManager = get(), + backendManager = get(), settingsManager = get(), nm = get(), inputFactory = get(), @@ -32,7 +32,7 @@ val backupModule = module { } single { FullBackup( - pluginManager = get(), + backendManager = get(), settingsManager = get(), nm = get(), inputFactory = get(), @@ -42,7 +42,7 @@ val backupModule = module { single { BackupCoordinator( context = androidContext(), - pluginManager = get(), + backendManager = get(), kv = get(), full = get(), clock = get(), diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt index eed8acab..65f8da7c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt @@ -16,10 +16,11 @@ import android.util.Log import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForFull -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.isOutOfSpace +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.isOutOfSpace import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.Closeable import java.io.EOFException import java.io.IOException @@ -46,14 +47,14 @@ private val TAG = FullBackup::class.java.simpleName @Suppress("BlockingMethodInNonBlockingContext") internal class FullBackup( - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, private val settingsManager: SettingsManager, private val nm: BackupNotificationManager, private val inputFactory: InputFactory, private val crypto: Crypto, ) { - private val plugin get() = pluginManager.appPlugin + private val backend get() = backendManager.backend private var state: FullBackupState? = null fun hasState() = state != null @@ -128,7 +129,7 @@ internal class FullBackup( val name = crypto.getNameForPackage(salt, packageName) // get OutputStream to write backup data into val outputStream = try { - plugin.getOutputStream(token, name) + backend.save(LegacyAppBackupFile.Blob(token, name)) } catch (e: IOException) { "Error getting OutputStream for full backup of $packageName".let { Log.e(TAG, it, e) @@ -186,7 +187,7 @@ internal class FullBackup( @Throws(IOException::class) suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) { val name = crypto.getNameForPackage(salt, packageInfo.packageName) - plugin.removeData(token, name) + backend.remove(LegacyAppBackupFile.Blob(token, name)) } suspend fun cancelFullBackup(token: Long, salt: String, ignoreApp: Boolean) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt index 069670ef..acdfc855 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt @@ -15,13 +15,14 @@ import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.isOutOfSpace import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.isOutOfSpace import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.IOException import java.util.zip.GZIPOutputStream @@ -39,7 +40,7 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong() private val TAG = KVBackup::class.java.simpleName internal class KVBackup( - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, private val settingsManager: SettingsManager, private val nm: BackupNotificationManager, private val inputFactory: InputFactory, @@ -47,7 +48,7 @@ internal class KVBackup( private val dbManager: KvDbManager, ) { - private val plugin get() = pluginManager.appPlugin + private val backend get() = backendManager.backend private var state: KVBackupState? = null fun hasState() = state != null @@ -146,7 +147,7 @@ internal class KVBackup( // K/V backups (typically starting with package manager metadata - @pm@) // are scheduled with JobInfo.Builder#setOverrideDeadline() // and thus do not respect backoff. - pluginManager.canDoBackupNow() + backendManager.canDoBackupNow() } else { // all other packages always need upload true @@ -207,7 +208,7 @@ internal class KVBackup( suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) { Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}") val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName) - plugin.removeData(token, name) + backend.remove(LegacyAppBackupFile.Blob(token, name)) if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException() } @@ -254,7 +255,8 @@ internal class KVBackup( db.vacuum() db.close() - plugin.getOutputStream(token, name).use { outputStream -> + val handle = LegacyAppBackupFile.Blob(token, name) + backend.save(handle).use { outputStream -> outputStream.write(ByteArray(1) { VERSION }) val ad = getADForKV(VERSION, packageName) crypto.newEncryptingStream(outputStream, ad).use { encryptedStream -> diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt index c1fb8618..d409842e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt @@ -27,9 +27,9 @@ import android.util.Log import android.util.Log.INFO import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.settings.SettingsManager +import org.calyxos.seedvault.core.backends.Backend private val TAG = PackageService::class.java.simpleName @@ -43,12 +43,12 @@ internal class PackageService( private val context: Context, private val backupManager: IBackupManager, private val settingsManager: SettingsManager, - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, ) { private val packageManager: PackageManager = context.packageManager private val myUserId = UserHandle.myUserId() - private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin + private val backend: Backend get() = backendManager.backend val eligiblePackages: List @WorkerThread @@ -182,7 +182,7 @@ internal class PackageService( // We need to explicitly exclude DocumentsProvider and Seedvault. // Otherwise, they get killed while backing them up, terminating our backup. val excludedPackages = setOf( - plugin.providerPackageName, + backend.providerPackageName, context.packageName ) @@ -225,7 +225,7 @@ internal class PackageService( */ private fun PackageInfo.doesNotGetBackedUp(): Boolean { if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true - if (packageName == plugin.providerPackageName) return true + if (packageName == backend.providerPackageName) return true return !allowsBackup() || isStopped() } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt index f0ccb4f3..ec563165 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt @@ -17,9 +17,10 @@ import com.stevesoltys.seedvault.header.HeaderReader import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.getADForFull -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import libcore.io.IoUtils.closeQuietly +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.EOFException import java.io.IOException import java.io.InputStream @@ -38,7 +39,7 @@ private class FullRestoreState( private val TAG = FullRestore::class.java.simpleName internal class FullRestore( - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, @Suppress("Deprecation") private val legacyPlugin: LegacyStoragePlugin, private val outputFactory: OutputFactory, @@ -46,7 +47,7 @@ internal class FullRestore( private val crypto: Crypto, ) { - private val plugin get() = pluginManager.appPlugin + private val backend get() = backendManager.backend private var state: FullRestoreState? = null fun hasState() = state != null @@ -114,7 +115,8 @@ internal class FullRestore( crypto.decryptHeader(inputStream, version, packageName) state.inputStream = inputStream } else { - val inputStream = plugin.getInputStream(state.token, state.name) + val handle = LegacyAppBackupFile.Blob(state.token, state.name) + val inputStream = backend.load(handle) val version = headerReader.readVersion(inputStream, state.version) val ad = getADForFull(version, packageName) state.inputStream = crypto.newDecryptingStream(inputStream, ad) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt index b6020bda..78069d8a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt @@ -20,11 +20,12 @@ import com.stevesoltys.seedvault.header.HeaderReader import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KvDbManager import libcore.io.IoUtils.closeQuietly +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.IOException import java.security.GeneralSecurityException import java.util.zip.GZIPInputStream @@ -44,7 +45,7 @@ private class KVRestoreState( private val TAG = KVRestore::class.java.simpleName internal class KVRestore( - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, @Suppress("Deprecation") private val legacyPlugin: LegacyStoragePlugin, private val outputFactory: OutputFactory, @@ -53,7 +54,7 @@ internal class KVRestore( private val dbManager: KvDbManager, ) { - private val plugin get() = pluginManager.appPlugin + private val backend get() = backendManager.backend private var state: KVRestoreState? = null /** @@ -156,7 +157,8 @@ internal class KVRestore( @Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class) private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb { val packageName = state.packageInfo.packageName - plugin.getInputStream(state.token, state.name).use { inputStream -> + val handle = LegacyAppBackupFile.Blob(state.token, state.name) + backend.load(handle).use { inputStream -> headerReader.readVersion(inputStream, state.version) val ad = getADForKV(VERSION, packageName) crypto.newDecryptingStream(inputStream, ad).use { decryptedStream -> diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt index 666abe03..a166ad1e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt @@ -25,12 +25,13 @@ import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.DecryptionFailedException import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataReader -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.getAvailableBackups import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import org.calyxos.seedvault.core.backends.Backend import java.io.IOException /** @@ -61,19 +62,19 @@ internal class RestoreCoordinator( private val settingsManager: SettingsManager, private val metadataManager: MetadataManager, private val notificationManager: BackupNotificationManager, - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, private val kv: KVRestore, private val full: FullRestore, private val metadataReader: MetadataReader, ) { - private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin + private val backend: Backend get() = backendManager.backend private var state: RestoreCoordinatorState? = null private var backupMetadata: BackupMetadata? = null private val failedPackages = ArrayList() suspend fun getAvailableMetadata(): Map? { - val availableBackups = plugin.getAvailableBackups() ?: return null + val availableBackups = backend.getAvailableBackups() ?: return null val metadataMap = HashMap() for (encryptedMetadata in availableBackups) { try { @@ -175,7 +176,7 @@ internal class RestoreCoordinator( // check if we even have a backup of that app if (metadataManager.getPackageMetadata(pmPackageName) != null) { // remind user to plug in storage device - val storageName = pluginManager.storageProperties?.name + val storageName = backendManager.backendProperties?.name ?: context.getString(R.string.settings_backup_location_none) notificationManager.onRemovableStorageNotAvailableForRestore( pmPackageName, @@ -234,48 +235,36 @@ internal class RestoreCoordinator( if (version == 0.toByte()) return nextRestorePackageV0(state, packageInfo) val packageName = packageInfo.packageName - val type = try { - when (state.backupMetadata.packageMetadataMap[packageName]?.backupType) { - BackupType.KV -> { - val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName) - if (plugin.hasData(state.token, name)) { - Log.i(TAG, "Found K/V data for $packageName.") - kv.initializeState( - version = version, - token = state.token, - name = name, - packageInfo = packageInfo, - autoRestorePackageInfo = state.autoRestorePackageInfo - ) - state.currentPackage = packageName - TYPE_KEY_VALUE - } else throw IOException("No data found for $packageName. Skipping.") - } - - BackupType.FULL -> { - val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName) - if (plugin.hasData(state.token, name)) { - Log.i(TAG, "Found full backup data for $packageName.") - full.initializeState(version, state.token, name, packageInfo) - state.currentPackage = packageName - TYPE_FULL_STREAM - } else throw IOException("No data found for $packageName. Skipping...") - } - - null -> { - Log.i(TAG, "No backup type found for $packageName. Skipping...") - state.backupMetadata.packageMetadataMap[packageName]?.backupType?.let { s -> - Log.w(TAG, "State was ${s.name}") - } - failedPackages.add(packageName) - return nextRestorePackage() - } + val type = when (state.backupMetadata.packageMetadataMap[packageName]?.backupType) { + BackupType.KV -> { + val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName) + kv.initializeState( + version = version, + token = state.token, + name = name, + packageInfo = packageInfo, + autoRestorePackageInfo = state.autoRestorePackageInfo + ) + state.currentPackage = packageName + TYPE_KEY_VALUE + } + + BackupType.FULL -> { + val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName) + full.initializeState(version, state.token, name, packageInfo) + state.currentPackage = packageName + TYPE_FULL_STREAM + } + + null -> { + Log.i(TAG, "No backup type found for $packageName. Skipping...") + state.backupMetadata.packageMetadataMap[packageName]?.backupType?.let { s -> + Log.w(TAG, "State was ${s.name}") + } + failedPackages.add(packageName) + // don't return null and cause abort here, but try next package + return nextRestorePackage() } - } catch (e: IOException) { - Log.e(TAG, "Error finding restore data for $packageName.", e) - failedPackages.add(packageName) - // don't return null and cause abort here, but try next package - return nextRestorePackage() } return RestoreDescription(packageName, type) } @@ -370,7 +359,7 @@ internal class RestoreCoordinator( fun isFailedPackage(packageName: String) = packageName in failedPackages private fun isStorageRemovableAndNotAvailable(): Boolean { - val storage = pluginManager.storageProperties ?: return false + val storage = backendManager.backendProperties ?: return false return storage.isUnavailableUsb(context) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt index 505f9944..a7858eeb 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt @@ -8,14 +8,14 @@ package com.stevesoltys.seedvault.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.settings.SettingsManager abstract class RequireProvisioningViewModel( protected val app: Application, protected val settingsManager: SettingsManager, protected val keyManager: KeyManager, - protected val pluginManager: StoragePluginManager, + protected val backendManager: BackendManager, ) : AndroidViewModel(app) { abstract val isRestoreOperation: Boolean @@ -24,7 +24,7 @@ abstract class RequireProvisioningViewModel( internal val chooseBackupLocation: LiveEvent get() = mChooseBackupLocation internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) - internal fun validLocationIsSet() = pluginManager.isValidAppPluginSet() + internal fun validLocationIsSet() = backendManager.isValidAppPluginSet() internal fun recoveryCodeIsSet() = keyManager.hasBackupKey() diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index 8e71d9cb..bacd0518 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -13,12 +13,10 @@ import android.util.Log import androidx.lifecycle.viewModelScope import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.SafHandler -import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler -import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties -import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.saf.SafHandler +import com.stevesoltys.seedvault.backend.webdav.WebDavHandler +import org.calyxos.seedvault.core.backends.webdav.WebDavProperties import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.transport.backup.BackupInitializer @@ -27,6 +25,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.backup.BackupJobService +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.saf.SafProperties import java.io.IOException import java.util.concurrent.TimeUnit @@ -40,15 +40,15 @@ internal class BackupStorageViewModel( safHandler: SafHandler, webDavHandler: WebDavHandler, settingsManager: SettingsManager, - storagePluginManager: StoragePluginManager, -) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) { + backendManager: BackendManager, +) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, backendManager) { override val isRestoreOperation = false - override fun onSafUriSet(safStorage: SafStorage) { - safHandler.save(safStorage) - safHandler.setPlugin(safStorage) - if (safStorage.isUsb) { + override fun onSafUriSet(safProperties: SafProperties) { + safHandler.save(safProperties) + safHandler.setPlugin(safProperties) + if (safProperties.isUsb) { // disable storage backup if new storage is on USB cancelBackupWorkers() } else { @@ -56,12 +56,12 @@ internal class BackupStorageViewModel( // also to update the network requirement of the new storage scheduleBackupWorkers() } - onStorageLocationSet(safStorage.isUsb) + onStorageLocationSet(safProperties.isUsb) } - override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + override fun onWebDavConfigSet(properties: WebDavProperties, backend: Backend) { webdavHandler.save(properties) - webdavHandler.setPlugin(properties, plugin) + webdavHandler.setPlugin(properties, backend) scheduleBackupWorkers() onStorageLocationSet(isUsb = false) } @@ -100,7 +100,7 @@ internal class BackupStorageViewModel( } private fun scheduleBackupWorkers() { - val storage = storagePluginManager.storageProperties ?: error("no storage available") + val storage = backendManager.backendProperties ?: error("no storage available") // disable framework scheduling, because another transport may have enabled it backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) if (!storage.isUsb) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt index b92607ec..54d77431 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt @@ -9,16 +9,16 @@ import android.app.Application import android.util.Log import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT -import com.stevesoltys.seedvault.plugins.saf.SafHandler -import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler -import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties -import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.saf.SafHandler +import com.stevesoltys.seedvault.backend.webdav.WebDavHandler import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT +import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.calyxos.seedvault.core.backends.webdav.WebDavProperties import java.io.IOException private val TAG = RestoreStorageViewModel::class.java.simpleName @@ -28,25 +28,25 @@ internal class RestoreStorageViewModel( safHandler: SafHandler, webDavHandler: WebDavHandler, settingsManager: SettingsManager, - storagePluginManager: StoragePluginManager, -) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) { + backendManager: BackendManager, +) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, backendManager) { override val isRestoreOperation = true - override fun onSafUriSet(safStorage: SafStorage) { + override fun onSafUriSet(safProperties: SafProperties) { viewModelScope.launch(Dispatchers.IO) { val hasBackup = try { - safHandler.hasAppBackup(safStorage) + safHandler.hasAppBackup(safProperties) } catch (e: IOException) { - Log.e(TAG, "Error reading URI: ${safStorage.uri}", e) + Log.e(TAG, "Error reading URI: ${safProperties.uri}", e) false } if (hasBackup) { - safHandler.save(safStorage) - safHandler.setPlugin(safStorage) + safHandler.save(safProperties) + safHandler.setPlugin(safProperties) mLocationChecked.postEvent(LocationResult()) } else { - Log.w(TAG, "Location was rejected: ${safStorage.uri}") + Log.w(TAG, "Location was rejected: ${safProperties.uri}") // notify the UI that the location was invalid val errorMsg = @@ -56,17 +56,17 @@ internal class RestoreStorageViewModel( } } - override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + override fun onWebDavConfigSet(properties: WebDavProperties, backend: Backend) { viewModelScope.launch(Dispatchers.IO) { val hasBackup = try { - webdavHandler.hasAppBackup(plugin) + webdavHandler.hasAppBackup(backend) } catch (e: IOException) { Log.e(TAG, "Error reading: ${properties.config.url}", e) false } if (hasBackup) { webdavHandler.save(properties) - webdavHandler.setPlugin(properties, plugin) + webdavHandler.setPlugin(properties, backend) mLocationChecked.postEvent(LocationResult()) } else { Log.w(TAG, "Location was rejected: ${properties.config.url}") diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt index 1e0b78e1..f3f2244a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt @@ -18,7 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTre import androidx.annotation.CallSuper import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver +import com.stevesoltys.seedvault.backend.saf.StorageRootResolver import com.stevesoltys.seedvault.ui.BackupActivity import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt index 043f235e..d13ed72b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt @@ -18,8 +18,8 @@ import android.provider.DocumentsContract.PROVIDER_INTERFACE import android.provider.DocumentsContract.buildRootsUri import android.util.Log import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.saf.SafStorageOptions -import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver +import com.stevesoltys.seedvault.backend.saf.SafStorageOptions +import com.stevesoltys.seedvault.backend.saf.StorageRootResolver import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption private val TAG = StorageOptionFetcher::class.java.simpleName diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt index 61d0f2e7..79a843fc 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt @@ -13,26 +13,26 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.SafHandler -import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig -import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler -import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties -import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.saf.SafHandler +import com.stevesoltys.seedvault.backend.webdav.WebDavHandler +import org.calyxos.seedvault.core.backends.webdav.WebDavProperties import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig internal abstract class StorageViewModel( private val app: Application, protected val safHandler: SafHandler, protected val webdavHandler: WebDavHandler, protected val settingsManager: SettingsManager, - protected val storagePluginManager: StoragePluginManager, + protected val backendManager: BackendManager, ) : AndroidViewModel(app), RemovableStorageListener { private val mStorageOptions = MutableLiveData>() @@ -49,7 +49,7 @@ internal abstract class StorageViewModel( internal var isSetupWizard: Boolean = false internal val hasStorageSet: Boolean - get() = storagePluginManager.storageProperties != null + get() = backendManager.backendProperties != null abstract val isRestoreOperation: Boolean internal fun loadStorageRoots() { @@ -88,8 +88,8 @@ internal abstract class StorageViewModel( onSafUriSet(safStorage) } - abstract fun onSafUriSet(safStorage: SafStorage) - abstract fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) + abstract fun onSafUriSet(safProperties: SafProperties) + abstract fun onWebDavConfigSet(properties: WebDavProperties, backend: Backend) override fun onCleared() { storageOptionFetcher.setRemovableStorageListener(null) @@ -107,9 +107,9 @@ internal abstract class StorageViewModel( fun resetWebDavConfig() = webdavHandler.resetConfigState() @UiThread - fun onWebDavConfigSuccess(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + fun onWebDavConfigSuccess(properties: WebDavProperties, backend: Backend) { mLocationSet.setEvent(true) - onWebDavConfigSet(properties, plugin) + onWebDavConfigSet(properties, backend) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt index a302a8d5..c5aade29 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt @@ -22,7 +22,7 @@ import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.LENGTH_LONG import com.google.android.material.textfield.TextInputEditText import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.webdav.WebDavConfigState +import com.stevesoltys.seedvault.backend.webdav.WebDavConfigState import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.getSharedViewModel @@ -111,7 +111,7 @@ class WebDavConfigFragment : Fragment(), View.OnClickListener { } is WebDavConfigState.Success -> { - viewModel.onWebDavConfigSuccess(state.properties, state.plugin) + viewModel.onWebDavConfigSuccess(state.properties, state.backend) } is WebDavConfigState.Error -> { diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt index 0e5d48d6..b661a181 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -11,18 +11,17 @@ import android.util.Log import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.isOutOfSpace -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.getMetadataOutputStream +import com.stevesoltys.seedvault.backend.isOutOfSpace import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.isStopped import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.getAppName import kotlinx.coroutines.delay +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.IOException -import java.io.OutputStream internal class ApkBackupManager( private val context: Context, @@ -31,7 +30,7 @@ internal class ApkBackupManager( private val packageService: PackageService, private val iconManager: IconManager, private val apkBackup: ApkBackup, - private val pluginManager: StoragePluginManager, + private val backendManager: BackendManager, private val nm: BackupNotificationManager, ) { @@ -55,7 +54,8 @@ internal class ApkBackupManager( keepTrying { // upload all local changes only at the end, // so we don't have to re-upload the metadata - pluginManager.appPlugin.getMetadataOutputStream().use { outputStream -> + val token = settingsManager.getToken() ?: error("no token") + backendManager.backend.getMetadataOutputStream(token).use { outputStream -> metadataManager.uploadMetadata(outputStream) } } @@ -101,7 +101,8 @@ internal class ApkBackupManager( private suspend fun uploadIcons() { try { val token = settingsManager.getToken() ?: throw IOException("no current token") - pluginManager.appPlugin.getOutputStream(token, FILE_BACKUP_ICONS).use { + val handle = LegacyAppBackupFile.IconsFile(token) + backendManager.backend.save(handle).use { iconManager.uploadIcons(token, it) } } catch (e: IOException) { @@ -119,7 +120,7 @@ internal class ApkBackupManager( return try { apkBackup.backupApkIfNecessary(packageInfo) { name -> val token = settingsManager.getToken() ?: throw IOException("no current token") - pluginManager.appPlugin.getOutputStream(token, name) + backendManager.backend.save(LegacyAppBackupFile.Blob(token, name)) }?.let { packageMetadata -> metadataManager.onApkBackedUp(packageInfo, packageMetadata) true @@ -143,11 +144,4 @@ internal class ApkBackupManager( } } } - - private suspend fun StoragePlugin<*>.getMetadataOutputStream( - token: Long? = null, - ): OutputStream { - val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") - return getOutputStream(t, FILE_BACKUP_METADATA) - } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index e1a7426f..b7041901 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -22,7 +22,7 @@ import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER @@ -101,7 +101,7 @@ class AppBackupWorker( private val backupRequester: BackupRequester by inject() private val settingsManager: SettingsManager by inject() private val apkBackupManager: ApkBackupManager by inject() - private val storagePluginManager: StoragePluginManager by inject() + private val backendManager: BackendManager by inject() private val nm: BackupNotificationManager by inject() override suspend fun doWork(): Result { @@ -111,7 +111,7 @@ class AppBackupWorker( } catch (e: Exception) { Log.e(TAG, "Error while running setForeground: ", e) } - val freeSpace = storagePluginManager.getFreeSpace() + val freeSpace = backendManager.getFreeSpace() if (freeSpace != null && freeSpace < MIN_FREE_SPACE) { nm.onInsufficientSpaceError() return Result.failure() diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index 8c3d0c3e..dea0b941 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -39,7 +39,7 @@ val workerModule = module { packageService = get(), apkBackup = get(), iconManager = get(), - pluginManager = get(), + backendManager = get(), nm = get() ) } diff --git a/app/src/main/resources/simplelogger.properties b/app/src/main/resources/simplelogger.properties new file mode 100644 index 00000000..beb56b2e --- /dev/null +++ b/app/src/main/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel=debug diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt index 78e9333a..305bbc6c 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt @@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.metadata.metadataModule -import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf +import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.PackageService diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt b/app/src/test/java/com/stevesoltys/seedvault/backend/saf/DocumentFileTest.kt similarity index 94% rename from app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt rename to app/src/test/java/com/stevesoltys/seedvault/backend/saf/DocumentFileTest.kt index b29e82ce..da2eb86f 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/backend/saf/DocumentFileTest.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package com.stevesoltys.seedvault.backend.saf import android.content.Context import android.content.pm.PackageManager @@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.stevesoltys.seedvault.TestApp import io.mockk.every import io.mockk.mockk +import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt deleted file mode 100644 index a13ef9db..00000000 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.saf - -import androidx.documentfile.provider.DocumentFile -import com.stevesoltys.seedvault.transport.backup.BackupTest -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkStatic -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Test - -internal class StoragePluginTest : BackupTest() { - - private val storage = mockk() - - private val plugin = DocumentsProviderStoragePlugin(context, storage) - - private val setDir: DocumentFile = mockk() - private val backupFile: DocumentFile = mockk() - - init { - // to mock extension functions on DocumentFile - mockkStatic("com.stevesoltys.seedvault.plugins.saf.DocumentsStorageKt") - } - - @Test - fun `test startNewRestoreSet`() = runBlocking { - every { storage.reset(token) } just Runs - every { storage getProperty "rootBackupDir" } returns setDir - - plugin.startNewRestoreSet(token) - } - - @Test - fun `test initializeDevice`() = runBlocking { - // get current set dir and for that the current token - every { storage getProperty "currentToken" } returns token - every { settingsManager.getToken() } returns token - every { storage getProperty "safStorage" } returns null // just to check if isUsb - coEvery { storage.getSetDir(token) } returns setDir - // delete contents of current set dir - coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile) - every { backupFile.delete() } returns true - // reset storage - every { storage.reset(null) } just Runs - // create new set dir - every { storage getProperty "currentSetDir" } returns setDir - - plugin.initializeDevice() - } - -} diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt deleted file mode 100644 index c5389a4a..00000000 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.plugins.webdav - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.stevesoltys.seedvault.TestApp -import com.stevesoltys.seedvault.getRandomByteArray -import com.stevesoltys.seedvault.getRandomString -import com.stevesoltys.seedvault.plugins.EncryptedMetadata -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA -import com.stevesoltys.seedvault.transport.TransportTest -import kotlinx.coroutines.runBlocking -import org.junit.Test -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Assertions.fail -import org.junit.jupiter.api.assertThrows -import org.junit.runner.RunWith -import org.robolectric.annotation.Config -import java.io.IOException -import kotlin.random.Random - -@RunWith(AndroidJUnit4::class) -@Config( - sdk = [34], // TODO: Drop once robolectric supports 35 - application = TestApp::class -) -internal class WebDavStoragePluginTest : TransportTest() { - - private val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig()) - - @Test - fun `test self-test`() = runBlocking { - assertTrue(plugin.test()) - - val plugin2 = WebDavStoragePlugin(context, WebDavConfig("https://github.com/", "", "")) - val e = assertThrows { - assertFalse(plugin2.test()) - } - println(e) - } - - @Test - fun `test getting free space`() = runBlocking { - val freeBytes = plugin.getFreeSpace() ?: fail() - assertTrue(freeBytes > 0) - } - - @Test - fun `test restore sets and reading+writing`() = runBlocking { - val token = System.currentTimeMillis() - val metadata = getRandomByteArray() - - // need to initialize, to have root .SeedVaultAndroidBackup folder - plugin.initializeDevice() - plugin.startNewRestoreSet(token) - - // initially, we don't have any backups - assertEquals(emptySet(), plugin.getAvailableBackups()?.toSet()) - - // and no data - assertFalse(plugin.hasData(token, FILE_BACKUP_METADATA)) - - // write out the metadata file - plugin.getOutputStream(token, FILE_BACKUP_METADATA).use { - it.write(metadata) - } - - // now we have data - assertTrue(plugin.hasData(token, FILE_BACKUP_METADATA)) - - try { - // now we have one backup matching our token - val backups = plugin.getAvailableBackups()?.toSet() ?: fail() - assertEquals(1, backups.size) - assertEquals(token, backups.first().token) - - // read back written data - assertArrayEquals( - metadata, - plugin.getInputStream(token, FILE_BACKUP_METADATA).use { it.readAllBytes() }, - ) - - // it has data now - assertTrue(plugin.hasData(token, FILE_BACKUP_METADATA)) - } finally { - // remove data at the end, so consecutive test runs pass - plugin.removeData(token, FILE_BACKUP_METADATA) - } - } - - @Test - fun `test streams for non-existent data`() = runBlocking { - val token = Random.nextLong(System.currentTimeMillis(), 9999999999999) - val file = getRandomString() - - assertFalse(plugin.hasData(token, file)) - - assertThrows { - plugin.getOutputStream(token, file).use { it.write(getRandomByteArray()) } - } - - assertThrows { - plugin.getInputStream(token, file).use { - it.readAllBytes() - } - } - Unit - } - - @Test - fun `test missing root dir`() = runBlocking { - val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig(), getRandomString()) - - assertNull(plugin.getAvailableBackups()) - - assertFalse(plugin.hasData(42L, "foo")) - - assertThrows { - plugin.removeData(42L, "foo") - } - Unit - } - -} diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt index d6c85b57..4a51fdd0 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt @@ -13,13 +13,11 @@ import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM -import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS import com.stevesoltys.seedvault.worker.IconManager import io.mockk.coEvery import io.mockk.every @@ -28,6 +26,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -47,7 +47,7 @@ import kotlin.random.Random ) internal class AppSelectionManagerTest : TransportTest() { - private val storagePluginManager: StoragePluginManager = mockk() + private val backendManager: BackendManager = mockk() private val iconManager: IconManager = mockk() private val testDispatcher = UnconfinedTestDispatcher() private val scope = TestScope(testDispatcher) @@ -63,7 +63,7 @@ internal class AppSelectionManagerTest : TransportTest() { private val appSelectionManager = AppSelectionManager( context = context, - pluginManager = storagePluginManager, + backendManager = backendManager, iconManager = iconManager, coroutineScope = scope, workDispatcher = testDispatcher, @@ -221,10 +221,10 @@ internal class AppSelectionManagerTest : TransportTest() { @Test fun `test icon loading fails`() = scope.runTest { - val appPlugin: StoragePlugin<*> = mockk() - every { storagePluginManager.appPlugin } returns appPlugin + val backend: Backend = mockk() + every { backendManager.backend } returns backend coEvery { - appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS) + backend.load(LegacyAppBackupFile.IconsFile(backupMetadata.token)) } throws IOException() appSelectionManager.selectedAppsFlow.test { @@ -427,11 +427,11 @@ internal class AppSelectionManagerTest : TransportTest() { } private fun expectIconLoading(icons: Set = setOf(packageName1, packageName2)) { - val appPlugin: StoragePlugin<*> = mockk() + val backend: Backend = mockk() val inputStream = ByteArrayInputStream(Random.nextBytes(42)) - every { storagePluginManager.appPlugin } returns appPlugin + every { backendManager.backend } returns backend coEvery { - appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS) + backend.load(LegacyAppBackupFile.IconsFile(backupMetadata.token)) } returns inputStream every { iconManager.downloadIcons(backupMetadata.version, backupMetadata.token, inputStream) diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt index 12c5761c..2dde0168 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -20,9 +20,8 @@ import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED @@ -36,6 +35,8 @@ import io.mockk.mockkStatic import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -59,13 +60,13 @@ internal class ApkBackupRestoreTest : TransportTest() { every { packageManager } returns pm } - private val storagePluginManager: StoragePluginManager = mockk() + private val backendManager: BackendManager = mockk() private val backupManager: IBackupManager = mockk() private val backupStateManager: BackupStateManager = mockk() @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin = mockk() - private val storagePlugin: StoragePlugin<*> = mockk() + private val backend: Backend = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val apkInstaller: ApkInstaller = mockk() private val installRestriction: InstallRestriction = mockk() @@ -75,7 +76,7 @@ internal class ApkBackupRestoreTest : TransportTest() { context = strictContext, backupManager = backupManager, backupStateManager = backupStateManager, - pluginManager = storagePluginManager, + backendManager = backendManager, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, splitCompatChecker = splitCompatChecker, @@ -111,7 +112,7 @@ internal class ApkBackupRestoreTest : TransportTest() { init { mockkStatic(PackageUtils::class) - every { storagePluginManager.appPlugin } returns storagePlugin + every { backendManager.backend } returns backend } @Test @@ -147,7 +148,7 @@ internal class ApkBackupRestoreTest : TransportTest() { every { metadataManager.salt } returns salt every { crypto.getNameForApk(salt, packageName) } returns name every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter) @@ -164,7 +165,7 @@ internal class ApkBackupRestoreTest : TransportTest() { every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns tmpFile every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { storagePlugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(LegacyAppBackupFile.Blob(token, name)) } returns inputStream every { pm.getPackageArchiveInfo(capture(apkPath), any()) } returns packageInfo every { applicationInfo.loadIcon(pm) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName @@ -172,7 +173,9 @@ internal class ApkBackupRestoreTest : TransportTest() { splitCompatChecker.isCompatible(metadata.deviceName, listOf(splitName)) } returns true every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName)) + } returns splitInputStream val resultMap = mapOf( packageName to ApkInstallResult( packageName, diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index 808723d2..bf353aff 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -24,9 +24,8 @@ import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP @@ -44,6 +43,8 @@ import io.mockk.mockkStatic import io.mockk.verifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -66,8 +67,8 @@ internal class ApkRestoreTest : TransportTest() { } private val backupManager: IBackupManager = mockk() private val backupStateManager: BackupStateManager = mockk() - private val storagePluginManager: StoragePluginManager = mockk() - private val storagePlugin: StoragePlugin<*> = mockk() + private val backendManager: BackendManager = mockk() + private val backend: Backend = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val apkInstaller: ApkInstaller = mockk() @@ -77,7 +78,7 @@ internal class ApkRestoreTest : TransportTest() { context = strictContext, backupManager = backupManager, backupStateManager = backupStateManager, - pluginManager = storagePluginManager, + backendManager = backendManager, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, splitCompatChecker = splitCompatChecker, @@ -108,7 +109,7 @@ internal class ApkRestoreTest : TransportTest() { // as we don't do strict signature checking, we can use a relaxed mock packageInfo.signingInfo = mockk(relaxed = true) - every { storagePluginManager.appPlugin } returns storagePlugin + every { backendManager.backend } returns backend // related to starting/stopping service every { strictContext.packageName } returns "org.foo.bar" @@ -128,8 +129,8 @@ internal class ApkRestoreTest : TransportTest() { every { backupStateManager.isAutoRestoreEnabled } returns false every { strictContext.cacheDir } returns File(tmpDir.toString()) every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream - every { storagePlugin.providerPackageName } returns storageProviderPackageName + coEvery { backend.load(handle) } returns apkInputStream + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -151,7 +152,7 @@ internal class ApkRestoreTest : TransportTest() { every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() apkRestore.installResult.test { @@ -177,7 +178,7 @@ internal class ApkRestoreTest : TransportTest() { every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName val packageInfo: PackageInfo = mockk() every { pm.getPackageInfo(packageName, any()) } returns packageInfo @@ -202,9 +203,9 @@ internal class ApkRestoreTest : TransportTest() { every { backupStateManager.isAutoRestoreEnabled } returns false every { strictContext.cacheDir } returns File(tmpDir.toString()) every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream + coEvery { backend.load(handle) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -222,7 +223,7 @@ internal class ApkRestoreTest : TransportTest() { coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) } throws SecurityException() - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -249,7 +250,7 @@ internal class ApkRestoreTest : TransportTest() { coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) } returns installResult - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -285,7 +286,7 @@ internal class ApkRestoreTest : TransportTest() { coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) } returns installResult - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -300,7 +301,7 @@ internal class ApkRestoreTest : TransportTest() { mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName every { pm.getPackageInfo(packageName, any()) } returns packageInfo every { packageInfo.signingInfo.getSignatures() } returns packageMetadata.signatures!! every { @@ -329,7 +330,7 @@ internal class ApkRestoreTest : TransportTest() { mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName every { pm.getPackageInfo(packageName, any()) } returns packageInfo every { packageInfo.signingInfo.getSignatures() } returns packageMetadata.signatures!! every { packageInfo.longVersionCode } returns packageMetadata.version!! - 1 @@ -369,7 +370,7 @@ internal class ApkRestoreTest : TransportTest() { mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName every { pm.getPackageInfo(packageName, any()) } returns packageInfo every { packageInfo.signingInfo.getSignatures() } returns listOf("foobar") @@ -401,7 +402,7 @@ internal class ApkRestoreTest : TransportTest() { every { backupStateManager.isAutoRestoreEnabled } returns false every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() cacheBaseApkAndGetInfo(tmpDir) - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName if (willFail) { every { @@ -476,7 +477,7 @@ internal class ApkRestoreTest : TransportTest() { every { splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name)) } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -502,9 +503,9 @@ internal class ApkRestoreTest : TransportTest() { every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName coEvery { - storagePlugin.getInputStream(token, suffixName) + backend.load(LegacyAppBackupFile.Blob(token, suffixName)) } returns ByteArrayInputStream(getRandomByteArray()) - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -531,8 +532,10 @@ internal class ApkRestoreTest : TransportTest() { every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException() - every { storagePlugin.providerPackageName } returns storageProviderPackageName + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName)) + } throws IOException() + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -573,10 +576,14 @@ internal class ApkRestoreTest : TransportTest() { val suffixName1 = getRandomString() val suffixName2 = getRandomString() every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1 - coEvery { storagePlugin.getInputStream(token, suffixName1) } returns split1InputStream + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName1)) + } returns split1InputStream every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2 - coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream - every { storagePlugin.providerPackageName } returns storageProviderPackageName + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName2)) + } returns split2InputStream + every { backend.providerPackageName } returns storageProviderPackageName val resultMap = mapOf( packageName to ApkInstallResult( @@ -602,7 +609,7 @@ internal class ApkRestoreTest : TransportTest() { every { backupStateManager.isAutoRestoreEnabled } returns false // set the storage provider package name to match our current package name, // and ensure that the current package is therefore skipped. - every { storagePlugin.providerPackageName } returns packageName + every { backend.providerPackageName } returns packageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -627,7 +634,7 @@ internal class ApkRestoreTest : TransportTest() { every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -656,7 +663,7 @@ internal class ApkRestoreTest : TransportTest() { every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns true - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName every { backupManager.setAutoRestore(false) } just Runs every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name @@ -680,7 +687,7 @@ internal class ApkRestoreTest : TransportTest() { @Test fun `no apks get installed when blocked by policy`() = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns false - every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backend.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { awaitItem() // initial empty state @@ -703,7 +710,7 @@ internal class ApkRestoreTest : TransportTest() { private fun cacheBaseApkAndGetInfo(tmpDir: Path) { every { strictContext.cacheDir } returns File(tmpDir.toString()) every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream + coEvery { backend.load(handle) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { applicationInfo.loadIcon(pm) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName diff --git a/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt deleted file mode 100644 index 0118a092..00000000 --- a/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.stevesoltys.seedvault.storage - -import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.getRandomByteArray -import com.stevesoltys.seedvault.getRandomString -import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig -import com.stevesoltys.seedvault.transport.backup.BackupTest -import io.mockk.mockk -import kotlinx.coroutines.runBlocking -import org.calyxos.backup.storage.api.StoredSnapshot -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.jupiter.api.assertThrows -import java.io.IOException - -internal class WebDavStoragePluginTest : BackupTest() { - - private val keyManager: KeyManager = mockk() - private val plugin = WebDavStoragePlugin(keyManager, "foo", WebDavTestConfig.getConfig()) - - private val snapshot = StoredSnapshot("foo.sv", System.currentTimeMillis()) - - @Test - fun `test chunks`() = runBlocking { - val chunkId1 = getRandomByteArray(32).toHexString() - val chunkBytes1 = getRandomByteArray() - - // init to create root folder - plugin.init() - - // first we don't have any chunks - assertEquals(emptyList(), plugin.getAvailableChunkIds()) - - // we write out chunk1 - plugin.getChunkOutputStream(chunkId1).use { - it.write(chunkBytes1) - } - - try { - // now we have the ID of chunk1 - assertEquals(listOf(chunkId1), plugin.getAvailableChunkIds()) - - // reading chunk1 matches what we wrote - assertArrayEquals( - chunkBytes1, - plugin.getChunkInputStream(snapshot, chunkId1).readAllBytes(), - ) - } finally { - // delete chunk again - plugin.deleteChunks(listOf(chunkId1)) - } - } - - @Test - fun `test snapshots`() = runBlocking { - val snapshotBytes = getRandomByteArray() - - // init to create root folder - plugin.init() - - // first we don't have any snapshots - assertEquals(emptyList(), plugin.getCurrentBackupSnapshots()) - assertEquals(emptyList(), plugin.getBackupSnapshotsForRestore()) - - // now write one snapshot - plugin.getBackupSnapshotOutputStream(snapshot.timestamp).use { - it.write(snapshotBytes) - } - - try { - // now we have that one snapshot - assertEquals(listOf(snapshot), plugin.getCurrentBackupSnapshots()) - assertEquals(listOf(snapshot), plugin.getBackupSnapshotsForRestore()) - - // read back written snapshot - assertArrayEquals( - snapshotBytes, - plugin.getBackupSnapshotInputStream(snapshot).readAllBytes(), - ) - - // other device writes another snapshot - val otherPlugin = WebDavStoragePlugin(keyManager, "bar", WebDavTestConfig.getConfig()) - val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis()) - val otherSnapshotBytes = getRandomByteArray() - assertEquals(emptyList(), otherPlugin.getAvailableChunkIds()) - otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use { - it.write(otherSnapshotBytes) - } - try { - // now that initial one snapshot is still the only current, but restore has both - assertEquals(listOf(snapshot), plugin.getCurrentBackupSnapshots()) - assertEquals( - setOf(snapshot, otherSnapshot), - plugin.getBackupSnapshotsForRestore().toSet(), // set to avoid sorting issues - ) - } finally { - plugin.deleteBackupSnapshot(otherSnapshot) - } - } finally { - plugin.deleteBackupSnapshot(snapshot) - } - } - - @Test - fun `test missing root dir`() = runBlocking { - val plugin = WebDavStoragePlugin( - keyManager = keyManager, - androidId = "foo", - webDavConfig = WebDavTestConfig.getConfig(), - root = getRandomString(), - ) - - assertThrows { - plugin.getCurrentBackupSnapshots() - } - assertThrows { - plugin.getBackupSnapshotsForRestore() - } - assertThrows { - plugin.getAvailableChunkIds() - } - assertThrows { - plugin.deleteChunks(listOf("foo")) - } - assertThrows { - plugin.deleteBackupSnapshot(snapshot) - } - assertThrows { - plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close() - } - assertThrows { - plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() } - } - assertThrows { - plugin.getChunkOutputStream("foo").close() - } - assertThrows { - plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() } - } - Unit - } - -} - -private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } 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 222fed1e..84c6fb61 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -20,10 +20,8 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.FullBackup import com.stevesoltys.seedvault.transport.backup.InputFactory @@ -44,6 +42,8 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.fail @@ -63,13 +63,13 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val metadataReader = MetadataReaderImpl(cryptoImpl) private val notificationManager = mockk() private val dbManager = TestKvDbManager() - private val storagePluginManager: StoragePluginManager = mockk() + private val backendManager: BackendManager = mockk() @Suppress("Deprecation") private val legacyPlugin = mockk() - private val backupPlugin = mockk>() + private val backend = mockk() private val kvBackup = KVBackup( - pluginManager = storagePluginManager, + backendManager = backendManager, settingsManager = settingsManager, nm = notificationManager, inputFactory = inputFactory, @@ -77,7 +77,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { dbManager = dbManager, ) private val fullBackup = FullBackup( - pluginManager = storagePluginManager, + backendManager = backendManager, settingsManager = settingsManager, nm = notificationManager, inputFactory = inputFactory, @@ -87,7 +87,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val packageService: PackageService = mockk() private val backup = BackupCoordinator( context, - storagePluginManager, + backendManager, kvBackup, fullBackup, clock, @@ -98,7 +98,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { ) private val kvRestore = KVRestore( - storagePluginManager, + backendManager, legacyPlugin, outputFactory, headerReader, @@ -106,14 +106,14 @@ internal class CoordinatorIntegrationTest : TransportTest() { dbManager ) private val fullRestore = - FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl) + FullRestore(backendManager, legacyPlugin, outputFactory, headerReader, cryptoImpl) private val restore = RestoreCoordinator( context, crypto, settingsManager, metadataManager, notificationManager, - storagePluginManager, + backendManager, kvRestore, fullRestore, metadataReader @@ -132,7 +132,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName) init { - every { storagePluginManager.appPlugin } returns backupPlugin + every { backendManager.backend } returns backend } @Test @@ -161,7 +161,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata coEvery { - backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) + backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.onApkBackedUp(packageInfo, packageMetadata) @@ -179,7 +179,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) // upload DB - coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream + coEvery { + backend.save(LegacyAppBackupFile.Blob(token, realName)) + } returns bOutputStream // finish K/V backup assertEquals(TRANSPORT_OK, backup.finishBackup()) @@ -190,7 +192,6 @@ internal class CoordinatorIntegrationTest : TransportTest() { // find data for K/V backup every { crypto.getNameForPackage(metadata.salt, packageInfo.packageName) } returns name - coEvery { backupPlugin.hasData(token, name) } returns true val restoreDescription = restore.nextRestorePackage() ?: fail() assertEquals(packageInfo.packageName, restoreDescription.packageName) @@ -199,7 +200,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { // restore finds the backed up key and writes the decrypted value val backupDataOutput = mockk() val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) - coEvery { backupPlugin.getInputStream(token, name) } returns rInputStream + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, name)) + } returns rInputStream every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size @@ -238,7 +241,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null every { settingsManager.getToken() } returns token coEvery { - backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) + backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.onPackageBackedUp( @@ -253,7 +256,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) // upload DB - coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream + coEvery { + backend.save(LegacyAppBackupFile.Blob(token, realName)) + } returns bOutputStream // finish K/V backup assertEquals(TRANSPORT_OK, backup.finishBackup()) @@ -264,7 +269,6 @@ internal class CoordinatorIntegrationTest : TransportTest() { // find data for K/V backup every { crypto.getNameForPackage(metadata.salt, packageInfo.packageName) } returns name - coEvery { backupPlugin.hasData(token, name) } returns true val restoreDescription = restore.nextRestorePackage() ?: fail() assertEquals(packageInfo.packageName, restoreDescription.packageName) @@ -273,7 +277,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { // restore finds the backed up key and writes the decrypted value val backupDataOutput = mockk() val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) - coEvery { backupPlugin.getInputStream(token, name) } returns rInputStream + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, name)) + } returns rInputStream every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size @@ -296,14 +302,16 @@ internal class CoordinatorIntegrationTest : TransportTest() { // return streams from plugin and app data val bOutputStream = ByteArrayOutputStream() val bInputStream = ByteArrayInputStream(appData) - coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream + coEvery { + backend.save(LegacyAppBackupFile.Blob(token, realName)) + } returns bOutputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { settingsManager.isQuotaUnlimited() } returns false coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata every { settingsManager.getToken() } returns token every { metadataManager.salt } returns salt coEvery { - backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) + backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.onApkBackedUp(packageInfo, packageMetadata) } just Runs every { @@ -327,7 +335,6 @@ internal class CoordinatorIntegrationTest : TransportTest() { // finds data for full backup every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { backupPlugin.hasData(token, name) } returns true val restoreDescription = restore.nextRestorePackage() ?: fail() assertEquals(packageInfo.packageName, restoreDescription.packageName) @@ -336,7 +343,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { // reverse the backup streams into restore input val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rOutputStream = ByteArrayOutputStream() - coEvery { backupPlugin.getInputStream(token, name) } returns rInputStream + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, name)) + } returns rInputStream every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream // restore data diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt index 56406985..ee0e99d7 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt @@ -28,6 +28,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD import kotlin.random.Random @@ -73,6 +74,7 @@ internal abstract class TransportTest { protected val name = getRandomString(12) protected val name2 = getRandomString(23) protected val storageProviderPackageName = getRandomString(23) + protected val handle = LegacyAppBackupFile.Blob(token, name) init { mockkStatic(Log::class) 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 f07bd307..1f9c04ac 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 @@ -20,10 +20,7 @@ import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA -import com.stevesoltys.seedvault.plugins.saf.SafStorage +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.Runs @@ -33,6 +30,9 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.backends.saf.SafProperties import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.io.IOException @@ -41,7 +41,7 @@ import kotlin.random.Random internal class BackupCoordinatorTest : BackupTest() { - private val pluginManager = mockk() + private val backendManager = mockk() private val kv = mockk() private val full = mockk() private val apkBackup = mockk() @@ -50,7 +50,7 @@ internal class BackupCoordinatorTest : BackupTest() { private val backup = BackupCoordinator( context = context, - pluginManager = pluginManager, + backendManager = backendManager, kv = kv, full = full, clock = clock, @@ -60,11 +60,11 @@ internal class BackupCoordinatorTest : BackupTest() { nm = notificationManager, ) - private val plugin = mockk>() + private val backend = mockk() private val metadataOutputStream = mockk() private val fileDescriptor: ParcelFileDescriptor = mockk() private val packageMetadata: PackageMetadata = mockk() - private val safStorage = SafStorage( + private val safProperties = SafProperties( config = Uri.EMPTY, name = getRandomString(), isUsb = false, @@ -73,13 +73,12 @@ internal class BackupCoordinatorTest : BackupTest() { ) init { - every { pluginManager.appPlugin } returns plugin + every { backendManager.backend } returns backend } @Test fun `device initialization succeeds and delegates to plugin`() = runBlocking { expectStartNewRestoreSet() - coEvery { plugin.initializeDevice() } just Runs every { kv.hasState() } returns false every { full.hasState() } returns false @@ -87,10 +86,9 @@ internal class BackupCoordinatorTest : BackupTest() { assertEquals(TRANSPORT_OK, backup.finishBackup()) } - private suspend fun expectStartNewRestoreSet() { + private fun expectStartNewRestoreSet() { every { clock.time() } returns token every { settingsManager.setNewToken(token) } just Runs - coEvery { plugin.startNewRestoreSet(token) } just Runs every { metadataManager.onDeviceInitialization(token) } just Runs } @@ -98,10 +96,11 @@ internal class BackupCoordinatorTest : BackupTest() { fun `error notification when device initialization fails`() = runBlocking { val maybeTrue = Random.nextBoolean() - expectStartNewRestoreSet() - coEvery { plugin.initializeDevice() } throws IOException() + every { clock.time() } returns token + every { settingsManager.setNewToken(token) } just Runs + every { metadataManager.onDeviceInitialization(token) } throws IOException() every { metadataManager.requiresInit } returns maybeTrue - every { pluginManager.canDoBackupNow() } returns !maybeTrue + every { backendManager.canDoBackupNow() } returns !maybeTrue every { notificationManager.onBackupError() } just Runs assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) @@ -117,10 +116,11 @@ internal class BackupCoordinatorTest : BackupTest() { @Test fun `no error notification when device initialization fails when no backup possible`() = runBlocking { - expectStartNewRestoreSet() - coEvery { plugin.initializeDevice() } throws IOException() + every { clock.time() } returns token + every { settingsManager.setNewToken(token) } just Runs + every { metadataManager.onDeviceInitialization(token) } throws IOException() every { metadataManager.requiresInit } returns false - every { pluginManager.canDoBackupNow() } returns false + every { backendManager.canDoBackupNow() } returns false assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) @@ -136,13 +136,12 @@ internal class BackupCoordinatorTest : BackupTest() { fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking { val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } - every { pluginManager.canDoBackupNow() } returns true + every { backendManager.canDoBackupNow() } returns true every { metadataManager.requiresInit } returns true // start new restore set every { clock.time() } returns token + 1 every { settingsManager.setNewToken(token + 1) } just Runs - coEvery { plugin.startNewRestoreSet(token + 1) } just Runs every { metadataManager.onDeviceInitialization(token + 1) } just Runs every { data.close() } just Runs @@ -210,7 +209,7 @@ internal class BackupCoordinatorTest : BackupTest() { every { kv.getCurrentPackage() } returns packageInfo coEvery { kv.finishBackup() } returns TRANSPORT_OK every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { kv.getCurrentSize() } returns size every { metadataManager.onPackageBackedUp( @@ -235,7 +234,7 @@ internal class BackupCoordinatorTest : BackupTest() { every { kv.getCurrentSize() } returns 42L coEvery { kv.finishBackup() } returns TRANSPORT_OK - every { pluginManager.canDoBackupNow() } returns false + every { backendManager.canDoBackupNow() } returns false assertEquals(TRANSPORT_OK, backup.finishBackup()) } @@ -250,7 +249,7 @@ internal class BackupCoordinatorTest : BackupTest() { every { full.getCurrentPackage() } returns packageInfo every { full.finishBackup() } returns result every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { full.getCurrentSize() } returns size every { metadataManager.onPackageBackedUp( @@ -301,7 +300,7 @@ internal class BackupCoordinatorTest : BackupTest() { ) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs - every { pluginManager.storageProperties } returns safStorage + every { backendManager.backendProperties } returns safProperties every { settingsManager.useMeteredNetwork } returns false every { metadataOutputStream.close() } just Runs @@ -351,7 +350,7 @@ internal class BackupCoordinatorTest : BackupTest() { ) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs - every { pluginManager.storageProperties } returns safStorage + every { backendManager.backendProperties } returns safProperties every { settingsManager.useMeteredNetwork } returns false every { metadataOutputStream.close() } just Runs @@ -385,7 +384,7 @@ internal class BackupCoordinatorTest : BackupTest() { private fun expectApkBackupAndMetadataWrite() { coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt index b509d18e..816c496d 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt @@ -11,8 +11,7 @@ import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForFull -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.Runs import io.mockk.coEvery @@ -20,6 +19,8 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -30,11 +31,11 @@ import kotlin.random.Random internal class FullBackupTest : BackupTest() { - private val storagePluginManager: StoragePluginManager = mockk() - private val plugin = mockk>() + private val backendManager: BackendManager = mockk() + private val backend = mockk() private val notificationManager = mockk() private val backup = FullBackup( - pluginManager = storagePluginManager, + backendManager = backendManager, settingsManager = settingsManager, nm = notificationManager, inputFactory = inputFactory, @@ -46,7 +47,7 @@ internal class FullBackupTest : BackupTest() { private val ad = getADForFull(VERSION, packageInfo.packageName) init { - every { storagePluginManager.appPlugin } returns plugin + every { backendManager.backend } returns backend } @Test @@ -167,7 +168,7 @@ internal class FullBackupTest : BackupTest() { every { settingsManager.isQuotaUnlimited() } returns false every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { plugin.getOutputStream(token, name) } throws IOException() + coEvery { backend.save(handle) } throws IOException() expectClearState() assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt)) @@ -184,7 +185,7 @@ internal class FullBackupTest : BackupTest() { every { settingsManager.isQuotaUnlimited() } returns false every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { plugin.getOutputStream(token, name) } returns outputStream + coEvery { backend.save(handle) } returns outputStream every { inputFactory.getInputStream(data) } returns inputStream every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException() expectClearState() @@ -240,7 +241,7 @@ internal class FullBackupTest : BackupTest() { @Test fun `clearBackupData delegates to plugin`() = runBlocking { every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { plugin.removeData(token, name) } just Runs + coEvery { backend.remove(handle) } just Runs backup.clearBackupData(packageInfo, token, salt) } @@ -251,7 +252,7 @@ internal class FullBackupTest : BackupTest() { expectInitializeOutputStream() expectClearState() every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { plugin.removeData(token, name) } just Runs + coEvery { backend.remove(handle) } just Runs assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt)) assertTrue(backup.hasState()) @@ -265,7 +266,7 @@ internal class FullBackupTest : BackupTest() { expectInitializeOutputStream() expectClearState() every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { plugin.removeData(token, name) } throws IOException() + coEvery { backend.remove(handle) } throws IOException() assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt)) assertTrue(backup.hasState()) @@ -336,7 +337,9 @@ internal class FullBackupTest : BackupTest() { private fun expectInitializeOutputStream() { every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name - coEvery { plugin.getOutputStream(token, name) } returns outputStream + coEvery { + backend.save(LegacyAppBackupFile.Blob(token, name)) + } returns outputStream every { outputStream.write(ByteArray(1) { VERSION }) } just Runs } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt index 9468bb9e..95bfbe90 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt @@ -17,8 +17,7 @@ import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.CapturingSlot import io.mockk.Runs @@ -29,6 +28,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -39,13 +39,13 @@ import kotlin.random.Random internal class KVBackupTest : BackupTest() { - private val pluginManager = mockk() + private val backendManager = mockk() private val notificationManager = mockk() private val dataInput = mockk() private val dbManager = mockk() private val backup = KVBackup( - pluginManager = pluginManager, + backendManager = backendManager, settingsManager = settingsManager, nm = notificationManager, inputFactory = inputFactory, @@ -54,7 +54,7 @@ internal class KVBackupTest : BackupTest() { ) private val db = mockk() - private val plugin = mockk>() + private val backend = mockk() private val packageName = packageInfo.packageName private val key = getRandomString(MAX_KEY_LENGTH_SIZE) private val dataValue = Random.nextBytes(23) @@ -62,7 +62,7 @@ internal class KVBackupTest : BackupTest() { private val inputStream = ByteArrayInputStream(dbBytes) init { - every { pluginManager.appPlugin } returns plugin + every { backendManager.backend } returns backend } @Test @@ -96,7 +96,7 @@ internal class KVBackupTest : BackupTest() { @Test fun `non-incremental backup with data clears old data first`() = runBlocking { singleRecordBackup(true) - coEvery { plugin.removeData(token, name) } just Runs + coEvery { backend.remove(handle) } just Runs every { dbManager.deleteDb(packageName) } returns true assertEquals( @@ -112,7 +112,7 @@ internal class KVBackupTest : BackupTest() { fun `ignoring exception when clearing data when non-incremental backup has data`() = runBlocking { singleRecordBackup(true) - coEvery { plugin.removeData(token, name) } throws IOException() + coEvery { backend.remove(handle) } throws IOException() assertEquals( TRANSPORT_OK, @@ -210,7 +210,7 @@ internal class KVBackupTest : BackupTest() { every { db.vacuum() } just Runs every { db.close() } just Runs - coEvery { plugin.getOutputStream(token, name) } returns outputStream + coEvery { backend.save(handle) } returns outputStream every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException() every { outputStream.close() } just Runs assertEquals(TRANSPORT_ERROR, backup.finishBackup()) @@ -230,7 +230,7 @@ internal class KVBackupTest : BackupTest() { every { db.vacuum() } just Runs every { db.close() } just Runs - coEvery { plugin.getOutputStream(token, name) } returns outputStream + coEvery { backend.save(handle) } returns outputStream every { outputStream.write(ByteArray(1) { VERSION }) } just Runs val ad = getADForKV(VERSION, packageInfo.packageName) every { crypto.newEncryptingStream(outputStream, ad) } returns encryptedOutputStream @@ -250,7 +250,7 @@ internal class KVBackupTest : BackupTest() { every { dbManager.existsDb(pmPackageInfo.packageName) } returns false every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name every { dbManager.getDb(pmPackageInfo.packageName) } returns db - every { pluginManager.canDoBackupNow() } returns false + every { backendManager.canDoBackupNow() } returns false every { db.put(key, dataValue) } just Runs getDataInput(listOf(true, false)) @@ -264,7 +264,7 @@ internal class KVBackupTest : BackupTest() { assertFalse(backup.hasState()) coVerify(exactly = 0) { - plugin.getOutputStream(token, name) + backend.save(handle) } } @@ -301,7 +301,7 @@ internal class KVBackupTest : BackupTest() { every { db.vacuum() } just Runs every { db.close() } just Runs - coEvery { plugin.getOutputStream(token, name) } returns outputStream + coEvery { backend.save(handle) } returns outputStream every { outputStream.write(ByteArray(1) { VERSION }) } just Runs val ad = getADForKV(VERSION, packageInfo.packageName) every { crypto.newEncryptingStream(outputStream, ad) } returns encryptedOutputStream diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt index bc3e3266..c176d78c 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt @@ -16,9 +16,8 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.getADForFull -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import io.mockk.CapturingSlot import io.mockk.Runs import io.mockk.coEvery @@ -26,6 +25,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -39,11 +39,11 @@ import kotlin.random.Random internal class FullRestoreTest : RestoreTest() { - private val storagePluginManager: StoragePluginManager = mockk() - private val plugin = mockk>() + private val backendManager: BackendManager = mockk() + private val backend = mockk() private val legacyPlugin = mockk() private val restore = FullRestore( - pluginManager = storagePluginManager, + backendManager = backendManager, legacyPlugin = legacyPlugin, outputFactory = outputFactory, headerReader = headerReader, @@ -55,7 +55,7 @@ internal class FullRestoreTest : RestoreTest() { private val ad = getADForFull(VERSION, packageInfo.packageName) init { - every { storagePluginManager.appPlugin } returns plugin + every { backendManager.backend } returns backend } @Test @@ -90,7 +90,7 @@ internal class FullRestoreTest : RestoreTest() { fun `getting InputStream for package when getting first chunk throws`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } throws IOException() + coEvery { backend.load(handle) } throws IOException() every { fileDescriptor.close() } just Runs assertEquals( @@ -103,7 +103,7 @@ internal class FullRestoreTest : RestoreTest() { fun `reading version header when getting first chunk throws`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } throws IOException() every { fileDescriptor.close() } just Runs @@ -117,7 +117,7 @@ internal class FullRestoreTest : RestoreTest() { fun `reading unsupported version when getting first chunk`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } throws UnsupportedVersionException(unsupportedVersion) @@ -133,7 +133,7 @@ internal class FullRestoreTest : RestoreTest() { fun `getting decrypted stream when getting first chunk throws`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } throws IOException() every { fileDescriptor.close() } just Runs @@ -149,7 +149,7 @@ internal class FullRestoreTest : RestoreTest() { runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } throws GeneralSecurityException() every { fileDescriptor.close() } just Runs @@ -197,7 +197,7 @@ internal class FullRestoreTest : RestoreTest() { fun `unexpected version aborts with error`() = runBlocking { restore.initializeState(Byte.MAX_VALUE, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, Byte.MAX_VALUE) } throws GeneralSecurityException() @@ -215,7 +215,7 @@ internal class FullRestoreTest : RestoreTest() { val decryptedInputStream = ByteArrayInputStream(encryptedBytes) restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } returns decryptedInputStream every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream @@ -248,7 +248,7 @@ internal class FullRestoreTest : RestoreTest() { } private fun initInputStream() { - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } returns decryptedInputStream } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt index 018d2b2d..4e3caf9a 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt @@ -15,9 +15,8 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KvDbManager import io.mockk.Runs @@ -29,6 +28,7 @@ import io.mockk.mockkStatic import io.mockk.verify import io.mockk.verifyAll import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.io.ByteArrayInputStream @@ -41,14 +41,14 @@ import kotlin.random.Random internal class KVRestoreTest : RestoreTest() { - private val storagePluginManager: StoragePluginManager = mockk() - private val plugin = mockk>() + private val backendManager: BackendManager = mockk() + private val backend = mockk() @Suppress("DEPRECATION") private val legacyPlugin = mockk() private val dbManager = mockk() private val output = mockk() private val restore = KVRestore( - pluginManager = storagePluginManager, + backendManager = backendManager, legacyPlugin = legacyPlugin, outputFactory = outputFactory, headerReader = headerReader, @@ -74,7 +74,7 @@ internal class KVRestoreTest : RestoreTest() { // for InputStream#readBytes() mockkStatic("kotlin.io.ByteStreamsKt") - every { storagePluginManager.appPlugin } returns plugin + every { backendManager.backend } returns backend } @Test @@ -88,7 +88,7 @@ internal class KVRestoreTest : RestoreTest() { fun `unexpected version aborts with error`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } throws UnsupportedVersionException(Byte.MAX_VALUE) @@ -103,7 +103,7 @@ internal class KVRestoreTest : RestoreTest() { fun `newDecryptingStream throws`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } throws GeneralSecurityException() every { dbManager.deleteDb(packageInfo.packageName, true) } returns true @@ -121,7 +121,7 @@ internal class KVRestoreTest : RestoreTest() { fun `writeEntityHeader throws`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } returns decryptInputStream every { @@ -146,7 +146,7 @@ internal class KVRestoreTest : RestoreTest() { fun `two records get restored`() = runBlocking { restore.initializeState(VERSION, token, name, packageInfo) - coEvery { plugin.getInputStream(token, name) } returns inputStream + coEvery { backend.load(handle) } returns inputStream every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { crypto.newDecryptingStream(inputStream, ad) } returns decryptInputStream every { diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt index decae7bd..f187ad00 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt @@ -13,16 +13,15 @@ import android.app.backup.RestoreDescription.TYPE_FULL_STREAM import android.app.backup.RestoreDescription.TYPE_KEY_VALUE import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.EncryptedMetadata +import com.stevesoltys.seedvault.backend.getAvailableBackups import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.plugins.EncryptedMetadata -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.Runs @@ -30,8 +29,11 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.verify import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.saf.SafProperties import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertThrows @@ -44,8 +46,8 @@ import kotlin.random.Random internal class RestoreCoordinatorTest : TransportTest() { private val notificationManager: BackupNotificationManager = mockk() - private val storagePluginManager: StoragePluginManager = mockk() - private val plugin = mockk>() + private val backendManager: BackendManager = mockk() + private val backend = mockk() private val kv = mockk() private val full = mockk() private val metadataReader = mockk() @@ -56,14 +58,14 @@ internal class RestoreCoordinatorTest : TransportTest() { settingsManager = settingsManager, metadataManager = metadataManager, notificationManager = notificationManager, - pluginManager = storagePluginManager, + backendManager = backendManager, kv = kv, full = full, metadataReader = metadataReader, ) private val inputStream = mockk() - private val safStorage: SafStorage = mockk() + private val safStorage: SafProperties = mockk() private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" } private val packageInfoArray = arrayOf(packageInfo) private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2) @@ -78,14 +80,15 @@ internal class RestoreCoordinatorTest : TransportTest() { metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata(backupType = BackupType.FULL) - every { storagePluginManager.appPlugin } returns plugin + mockkStatic("com.stevesoltys.seedvault.backend.BackendExtKt") + every { backendManager.backend } returns backend } @Test fun `getAvailableRestoreSets() builds set from plugin response`() = runBlocking { val encryptedMetadata = EncryptedMetadata(token) { inputStream } - coEvery { plugin.getAvailableBackups() } returns sequenceOf( + coEvery { backend.getAvailableBackups() } returns sequenceOf( encryptedMetadata, EncryptedMetadata(token + 1) { inputStream } ) @@ -123,7 +126,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() fetches metadata if missing`() = runBlocking { - coEvery { plugin.getAvailableBackups() } returns sequenceOf( + coEvery { backend.getAvailableBackups() } returns sequenceOf( EncryptedMetadata(token) { inputStream }, EncryptedMetadata(token + 1) { inputStream } ) @@ -136,7 +139,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() errors if metadata is not matching token`() = runBlocking { - coEvery { plugin.getAvailableBackups() } returns sequenceOf( + coEvery { backend.getAvailableBackups() } returns sequenceOf( EncryptedMetadata(token + 42) { inputStream } ) every { metadataReader.readMetadata(inputStream, token + 42) } returns metadata @@ -172,7 +175,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() optimized auto-restore with removed storage shows notification`() = runBlocking { - every { storagePluginManager.storageProperties } returns safStorage + every { backendManager.backendProperties } returns safStorage every { safStorage.isUnavailableUsb(context) } returns true every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L) every { safStorage.name } returns storageName @@ -196,7 +199,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() optimized auto-restore with available storage shows no notification`() = runBlocking { - every { storagePluginManager.storageProperties } returns safStorage + every { backendManager.backendProperties } returns safStorage every { safStorage.isUnavailableUsb(context) } returns false restore.beforeStartRestore(metadata) @@ -212,7 +215,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() with removed storage shows no notification`() = runBlocking { - every { storagePluginManager.storageProperties } returns safStorage + every { backendManager.backendProperties } returns safStorage every { safStorage.isUnavailableUsb(context) } returns true every { metadataManager.getPackageMetadata(packageName) } returns null @@ -239,7 +242,6 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.startRestore(token, packageInfoArray) every { crypto.getNameForPackage(metadata.salt, packageName) } returns name - coEvery { plugin.hasData(token, name) } returns true every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs val expected = RestoreDescription(packageName, TYPE_KEY_VALUE) @@ -273,19 +275,6 @@ internal class RestoreCoordinatorTest : TransportTest() { assertEquals(expected, restore.nextRestorePackage()) } - @Test - fun `nextRestorePackage() returns NO_MORE_PACKAGES if data not found`() = runBlocking { - restore.beforeStartRestore(metadata) - restore.startRestore(token, packageInfoArray2) - - every { crypto.getNameForPackage(metadata.salt, packageName) } returns name - coEvery { plugin.hasData(token, name) } returns false - every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2 - coEvery { plugin.hasData(token, name2) } returns false - - assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) - } - @Test fun `nextRestorePackage() tries next package if one has no backup type()`() = runBlocking { metadata.packageMetadataMap[packageName] = @@ -294,7 +283,6 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.startRestore(token, packageInfoArray2) every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2 - coEvery { plugin.hasData(token, name2) } returns true every { full.initializeState(VERSION, token, name2, packageInfo2) } just Runs val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) @@ -309,14 +297,12 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.startRestore(token, packageInfoArray2) every { crypto.getNameForPackage(metadata.salt, packageName) } returns name - coEvery { plugin.hasData(token, name) } returns true every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) assertEquals(expected, restore.nextRestorePackage()) every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2 - coEvery { plugin.hasData(token, name2) } returns true every { full.initializeState(VERSION, token, name2, packageInfo2) } just Runs val expected2 = @@ -359,19 +345,6 @@ internal class RestoreCoordinatorTest : TransportTest() { assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) } - @Test - fun `when plugin#hasData() throws, it tries next package`() = runBlocking { - restore.beforeStartRestore(metadata) - restore.startRestore(token, packageInfoArray2) - - every { crypto.getNameForPackage(metadata.salt, packageName) } returns name - coEvery { plugin.hasData(token, name) } returns false - every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2 - coEvery { plugin.hasData(token, name2) } throws IOException() - - assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) - } - @Test @Suppress("deprecation") fun `v0 when full#hasDataForPackage() throws, it tries next package`() = runBlocking { diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt index 9e8baecf..9bdab3a6 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt @@ -18,9 +18,8 @@ import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.header.HeaderReaderImpl import com.stevesoltys.seedvault.metadata.MetadataReaderImpl -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.toByteArrayFromHex import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.backup.KvDbManager @@ -30,6 +29,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verifyOrder import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.fail @@ -54,13 +54,13 @@ internal class RestoreV0IntegrationTest : TransportTest() { private val dbManager = mockk() private val metadataReader = MetadataReaderImpl(cryptoImpl) private val notificationManager = mockk() - private val storagePluginManager: StoragePluginManager = mockk() + private val backendManager: BackendManager = mockk() @Suppress("Deprecation") private val legacyPlugin = mockk() - private val backupPlugin = mockk>() + private val backend = mockk() private val kvRestore = KVRestore( - pluginManager = storagePluginManager, + backendManager = backendManager, legacyPlugin = legacyPlugin, outputFactory = outputFactory, headerReader = headerReader, @@ -68,14 +68,14 @@ internal class RestoreV0IntegrationTest : TransportTest() { dbManager = dbManager, ) private val fullRestore = - FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl) + FullRestore(backendManager, legacyPlugin, outputFactory, headerReader, cryptoImpl) private val restore = RestoreCoordinator( context = context, crypto = crypto, settingsManager = settingsManager, metadataManager = metadataManager, notificationManager = notificationManager, - pluginManager = storagePluginManager, + backendManager = backendManager, kv = kvRestore, full = fullRestore, metadataReader = metadataReader, @@ -123,7 +123,7 @@ internal class RestoreV0IntegrationTest : TransportTest() { private val key264 = key2.encodeBase64() init { - every { storagePluginManager.appPlugin } returns backupPlugin + every { backendManager.backend } returns backend } @Test diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt index 5dafa04b..20ad8d6a 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -14,9 +14,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED -import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePluginManager -import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager @@ -30,6 +28,8 @@ import io.mockk.mockk import io.mockk.verify import io.mockk.verifyAll import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Test import java.io.ByteArrayOutputStream import java.io.IOException @@ -40,8 +40,8 @@ internal class ApkBackupManagerTest : TransportTest() { private val packageService: PackageService = mockk() private val apkBackup: ApkBackup = mockk() private val iconManager: IconManager = mockk() - private val storagePluginManager: StoragePluginManager = mockk() - private val plugin: StoragePlugin<*> = mockk() + private val backendManager: BackendManager = mockk() + private val backend: Backend = mockk() private val nm: BackupNotificationManager = mockk() private val apkBackupManager = ApkBackupManager( @@ -51,7 +51,7 @@ internal class ApkBackupManagerTest : TransportTest() { packageService = packageService, apkBackup = apkBackup, iconManager = iconManager, - pluginManager = storagePluginManager, + backendManager = backendManager, nm = nm, ) @@ -59,7 +59,7 @@ internal class ApkBackupManagerTest : TransportTest() { private val packageMetadata: PackageMetadata = mockk() init { - every { storagePluginManager.appPlugin } returns plugin + every { backendManager.backend } returns backend } @Test @@ -258,7 +258,7 @@ internal class ApkBackupManagerTest : TransportTest() { // final upload every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.uploadMetadata(metadataOutputStream) } throws IOException() andThenThrows SecurityException() andThenJust Runs @@ -277,7 +277,7 @@ internal class ApkBackupManagerTest : TransportTest() { private suspend fun expectUploadIcons() { every { settingsManager.getToken() } returns token val stream = ByteArrayOutputStream() - coEvery { plugin.getOutputStream(token, FILE_BACKUP_ICONS) } returns stream + coEvery { backend.save(LegacyAppBackupFile.IconsFile(token)) } returns stream every { iconManager.uploadIcons(token, stream) } just Runs } @@ -288,7 +288,7 @@ internal class ApkBackupManagerTest : TransportTest() { private fun expectFinalUpload() { every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.uploadMetadata(metadataOutputStream) } just Runs every { metadataOutputStream.close() } just Runs } diff --git a/app/src/test/resources/simplelogger.properties b/app/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..df3774e5 --- /dev/null +++ b/app/src/test/resources/simplelogger.properties @@ -0,0 +1 @@ +#org.slf4j.simpleLogger.defaultLogLevel=debug diff --git a/build.gradle.kts b/build.gradle.kts index ae47a822..2d2cc46e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,21 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.dokka) apply false alias(libs.plugins.jlleitschuh.ktlint) apply false + alias(libs.plugins.jetbrains.kotlin.jvm) apply false +} + +val aospLibs by extra { + fileTree("$rootDir/libs/aosp") { + // For more information about this module: + // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507 + // framework_intermediates/classes-header.jar works for gradle build as well, + // but not unit tests, so we use the actual classes (without updatable modules). + // + // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar + include("android.jar") + // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar + include("libcore.jar") + } } subprojects { diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/Android.bp b/core/Android.bp new file mode 100644 index 00000000..6922e7f5 --- /dev/null +++ b/core/Android.bp @@ -0,0 +1,35 @@ +// +// SPDX-FileCopyrightText: 2021 The Calyx Institute +// SPDX-License-Identifier: Apache-2.0 +// + +android_library { + name: "seedvault-lib-core", + sdk_version: "current", + srcs: [ + "src/main/java/**/*.kt", + "src/main/java/**/*.java", + ], + exclude_srcs: [ + "src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt", + ], + static_libs: [ + "androidx.core_core-ktx", + "androidx.documentfile_documentfile", + "kotlinx-coroutines-android", + "kotlinx-coroutines-core", + "seedvault-lib-kotlin-logging-jvm", + "seedvault-lib-slf4j-api", + // WebDAV + "seedvault-lib-dav4jvm", + "seedvault-lib-okhttp", + "okio-lib", + ], + manifest: "src/main/AndroidManifest.xml", + optimize: { + enabled: false, + }, + kotlincflags: [ + "-opt-in=kotlin.RequiresOptIn", + ], +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 00000000..daed7a8f --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +android { + namespace = "org.calyxos.seedvault.core" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + languageVersion = "1.8" + freeCompilerArgs += listOf( + "-opt-in=kotlin.RequiresOptIn", + "-Xexplicit-api=strict" + ) + } +} + +dependencies { + val aospLibs: FileTree by rootProject.extra + compileOnly(aospLibs) + compileOnly(kotlin("test")) + implementation(libs.bundles.kotlin) + implementation(libs.bundles.coroutines) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.core.ktx) + implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar")) + implementation(libs.squareup.okio) + implementation(libs.kotlin.logging) + implementation(libs.slf4j.api) + + testImplementation(kotlin("test")) + testImplementation("org.ogce:xpp3:1.1.6") + testImplementation("org.slf4j:slf4j-simple:2.0.3") +} diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6099552b --- /dev/null +++ b/core/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/core/src/main/java/org/calyxos/seedvault/core/ByteArrayUtils.kt b/core/src/main/java/org/calyxos/seedvault/core/ByteArrayUtils.kt new file mode 100644 index 00000000..4ce2c4e7 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/ByteArrayUtils.kt @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2021 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core + +public fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } + +public fun String.toByteArrayFromHex(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt new file mode 100644 index 00000000..03c711ca --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends + +import androidx.annotation.VisibleForTesting +import java.io.InputStream +import java.io.OutputStream +import kotlin.reflect.KClass + +public interface Backend { + + /** + * Returns true if the plugin is working, or false if it isn't. + * @throws Exception any kind of exception to provide more info on the error + */ + public suspend fun test(): Boolean + + /** + * Retrieves the available storage space in bytes. + * @return the number of bytes available or null if the number is unknown. + * Returning a negative number or zero to indicate unknown is discouraged. + */ + public suspend fun getFreeSpace(): Long? + + public suspend fun save(handle: FileHandle): OutputStream + + public suspend fun load(handle: FileHandle): InputStream + + public suspend fun list( + topLevelFolder: TopLevelFolder?, + vararg fileTypes: KClass, + callback: (FileInfo) -> Unit, + ) + + public suspend fun remove(handle: FileHandle) + + public suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) + + @VisibleForTesting + public suspend fun removeAll() + + /** + * Returns the package name of the app that provides the storage backend + * which is used for the current backup location. + * + * Backends are advised to cache this as it will be requested frequently. + * + * @return null if no package name could be found + */ + public val providerPackageName: String? +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt new file mode 100644 index 00000000..f05bcb54 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends + +import android.content.Context +import org.calyxos.seedvault.core.backends.saf.SafBackend +import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.calyxos.seedvault.core.backends.webdav.WebDavBackend +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig + +public class BackendFactory( + private val contextGetter: () -> Context, +) { + public fun createSafBackend(config: SafProperties): Backend = + SafBackend(contextGetter(), config) + + public fun createWebDavBackend(config: WebDavConfig): Backend = WebDavBackend(config) +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/StorageProperties.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendProperties.kt similarity index 73% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/StorageProperties.kt rename to core/src/main/java/org/calyxos/seedvault/core/backends/BackendProperties.kt index 5fa3cfd4..1b31136d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/StorageProperties.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendProperties.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins +package org.calyxos.seedvault.core.backends import android.content.Context import android.net.ConnectivityManager @@ -12,20 +12,20 @@ import androidx.annotation.WorkerThread import at.bitfire.dav4jvm.exception.HttpException import java.io.IOException -abstract class StorageProperties { - abstract val config: T - abstract val name: String - abstract val isUsb: Boolean - abstract val requiresNetwork: Boolean +public abstract class BackendProperties { + public abstract val config: T + public abstract val name: String + public abstract val isUsb: Boolean + public abstract val requiresNetwork: Boolean @WorkerThread - abstract fun isUnavailableUsb(context: Context): Boolean + public abstract fun isUnavailableUsb(context: Context): Boolean /** * Returns true if this is storage that requires network access, * but it isn't available right now. */ - fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { + public fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { return requiresNetwork && !hasUnmeteredInternet(context, allowMetered) } @@ -37,7 +37,7 @@ abstract class StorageProperties { } } -fun Exception.isOutOfSpace(): Boolean { +public fun Exception.isOutOfSpace(): Boolean { return when (this) { is IOException -> message?.contains("No space left on device") == true || (cause as? HttpException)?.code == 507 diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt new file mode 100644 index 00000000..1fd40ee9 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends + +import androidx.annotation.VisibleForTesting +import org.calyxos.seedvault.core.toHexString +import kotlin.random.Random +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +@VisibleForTesting +public abstract class BackendTest { + + public abstract val plugin: Backend + + protected suspend fun testWriteListReadRenameDelete() { + plugin.removeAll() + + val androidId = "0123456789abcdef" + val now = System.currentTimeMillis() + val bytes1 = Random.nextBytes(1337) + val bytes2 = Random.nextBytes(1337 * 8) + plugin.save(LegacyAppBackupFile.Metadata(now)).use { + it.write(bytes1) + } + + plugin.save(FileBackupFileType.Snapshot(androidId, now)).use { + it.write(bytes2) + } + + var metadata: LegacyAppBackupFile.Metadata? = null + var snapshot: FileBackupFileType.Snapshot? = null + plugin.list( + null, + FileBackupFileType.Snapshot::class, + FileBackupFileType.Blob::class, + LegacyAppBackupFile.Metadata::class, + ) { fileInfo -> + val handle = fileInfo.fileHandle + if (handle is LegacyAppBackupFile.Metadata && handle.token == now) { + metadata = handle + } else if (handle is FileBackupFileType.Snapshot && handle.time == now) { + snapshot = handle + } + } + assertNotNull(metadata) + assertNotNull(snapshot) + + assertContentEquals(bytes1, plugin.load(metadata as FileHandle).readAllBytes()) + assertContentEquals(bytes2, plugin.load(snapshot as FileHandle).readAllBytes()) + + val blobName = Random.nextBytes(32).toHexString() + var blob: FileBackupFileType.Blob? = null + val bytes3 = Random.nextBytes(1337 * 16) + plugin.save(FileBackupFileType.Blob(androidId, blobName)).use { + it.write(bytes3) + } + plugin.list( + null, + FileBackupFileType.Snapshot::class, + FileBackupFileType.Blob::class, + LegacyAppBackupFile.Metadata::class, + ) { fileInfo -> + val handle = fileInfo.fileHandle + if (handle is FileBackupFileType.Blob && handle.name == blobName) { + blob = handle + } + } + assertNotNull(blob) + assertContentEquals(bytes3, plugin.load(blob as FileHandle).readAllBytes()) + + // try listing with top-level folder, should find two files of FileBackupFileType in there + var numFiles = 0 + plugin.list( + snapshot!!.topLevelFolder, + FileBackupFileType.Snapshot::class, + FileBackupFileType.Blob::class, + LegacyAppBackupFile.Metadata::class, + ) { numFiles++ } + assertEquals(2, numFiles) + + plugin.remove(snapshot as FileHandle) + + // rename snapshots + val snapshotNewFolder = TopLevelFolder("a123456789abcdef.sv") + plugin.rename(snapshot!!.topLevelFolder, snapshotNewFolder) + + // rename to existing folder should fail + val e = assertFailsWith { + plugin.rename(snapshotNewFolder, metadata!!.topLevelFolder) + } + println(e) + + plugin.remove(metadata!!.topLevelFolder) + plugin.remove(snapshotNewFolder) + } + + protected suspend fun testRemoveCreateWriteFile() { + val now = System.currentTimeMillis() + val blob = LegacyAppBackupFile.Blob(now, Random.nextBytes(32).toHexString()) + val bytes = Random.nextBytes(2342) + + plugin.remove(blob) + try { + plugin.save(blob).use { + it.write(bytes) + } + assertContentEquals(bytes, plugin.load(blob as FileHandle).readAllBytes()) + } finally { + plugin.remove(blob) + } + } + +} diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/PluginConstants.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/Constants.kt similarity index 61% rename from storage/lib/src/main/java/org/calyxos/backup/storage/plugin/PluginConstants.kt rename to core/src/main/java/org/calyxos/seedvault/core/backends/Constants.kt index 98db4974..d006b0da 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/PluginConstants.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/Constants.kt @@ -3,10 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.calyxos.backup.storage.plugin +package org.calyxos.seedvault.core.backends -public object PluginConstants { +public object Constants { + public const val DIRECTORY_ROOT: String = ".SeedVaultAndroidBackup" + internal const val FILE_BACKUP_METADATA = ".backup.metadata" + internal const val FILE_BACKUP_ICONS = ".backup.icons" + public val tokenRegex: Regex = Regex("([0-9]{13})") // good until the year 2286 public const val SNAPSHOT_EXT: String = ".SeedSnap" public val folderRegex: Regex = Regex("^[a-f0-9]{16}\\.sv$") public val chunkFolderRegex: Regex = Regex("[a-f0-9]{2}") diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt new file mode 100644 index 00000000..6c2a1240 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends + +import org.calyxos.seedvault.core.backends.Constants.FILE_BACKUP_ICONS +import org.calyxos.seedvault.core.backends.Constants.FILE_BACKUP_METADATA +import org.calyxos.seedvault.core.backends.Constants.SNAPSHOT_EXT + +public sealed class FileHandle { + public abstract val name: String + + /** + * The relative path relative to the storage root without prepended or trailing slash (/). + */ + public abstract val relativePath: String +} + +public data class TopLevelFolder(override val name: String) : FileHandle() { + override val relativePath: String = name + + public companion object { + public fun fromAndroidId(androidId: String): TopLevelFolder { + return TopLevelFolder("$androidId.sv") + } + } +} + +public sealed class LegacyAppBackupFile : FileHandle() { + public abstract val token: Long + public val topLevelFolder: TopLevelFolder get() = TopLevelFolder(token.toString()) + override val relativePath: String get() = "$token/$name" + + public data class Metadata(override val token: Long) : LegacyAppBackupFile() { + override val name: String = FILE_BACKUP_METADATA + } + + public data class IconsFile(override val token: Long) : LegacyAppBackupFile() { + override val name: String = FILE_BACKUP_ICONS + } + + public data class Blob( + override val token: Long, + override val name: String, + ) : LegacyAppBackupFile() +} + +public sealed class FileBackupFileType : FileHandle() { + public abstract val androidId: String + + /** + * The folder name is our user ID plus .sv extension (for SeedVault). + * The user or `androidId` is unique to each combination of app-signing key, user, and device + * so we don't leak anything by not hashing this and can use it as is. + */ + public val topLevelFolder: TopLevelFolder get() = TopLevelFolder("$androidId.sv") + + public data class Blob( + override val androidId: String, + override val name: String, + ) : FileBackupFileType() { + override val relativePath: String get() = "$androidId.sv/${name.substring(0, 2)}/$name" + } + + public data class Snapshot( + override val androidId: String, + val time: Long, + ) : FileBackupFileType() { + override val name: String = "$time$SNAPSHOT_EXT" + override val relativePath: String get() = "$androidId.sv/$name" + } +} + +public data class FileInfo( + val fileHandle: FileHandle, + val size: Long, +) diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/saf/DocumentFileCache.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/DocumentFileCache.kt new file mode 100644 index 00000000..9adf0da8 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/DocumentFileCache.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.saf + +import android.content.Context +import androidx.documentfile.provider.DocumentFile +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.FileHandle +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.backends.TopLevelFolder +import java.util.concurrent.ConcurrentHashMap + +internal class DocumentFileCache( + private val context: Context, + private val baseFile: DocumentFile, + private val root: String, +) { + + private val cache = ConcurrentHashMap() + + internal suspend fun getRootFile(): DocumentFile { + return cache.getOrPut(root) { + baseFile.getOrCreateDirectory(context, root) + } + } + + internal suspend fun getOrCreateFile(fh: FileHandle): DocumentFile = when (fh) { + is TopLevelFolder -> cache.getOrPut("$root/${fh.relativePath}") { + getRootFile().getOrCreateDirectory(context, fh.name) + } + + is LegacyAppBackupFile -> cache.getOrPut("$root/${fh.relativePath}") { + getOrCreateFile(fh.topLevelFolder).getOrCreateFile(context, fh.name) + } + + is FileBackupFileType.Blob -> { + val subFolderName = fh.name.substring(0, 2) + cache.getOrPut("$root/${fh.topLevelFolder.name}/$subFolderName") { + getOrCreateFile(fh.topLevelFolder).getOrCreateDirectory(context, subFolderName) + }.getOrCreateFile(context, fh.name) + } + + is FileBackupFileType.Snapshot -> { + getOrCreateFile(fh.topLevelFolder).getOrCreateFile(context, fh.name) + } + } + + internal suspend fun getFile(fh: FileHandle): DocumentFile? = when (fh) { + is TopLevelFolder -> cache.getOrElse("$root/${fh.relativePath}") { + getRootFile().findFileBlocking(context, fh.name) + } + + is LegacyAppBackupFile -> cache.getOrElse("$root/${fh.relativePath}") { + getFile(fh.topLevelFolder)?.findFileBlocking(context, fh.name) + } + + is FileBackupFileType.Blob -> { + val subFolderName = fh.name.substring(0, 2) + cache.getOrElse("$root/${fh.topLevelFolder.name}/$subFolderName") { + getFile(fh.topLevelFolder)?.findFileBlocking(context, subFolderName) + }?.findFileBlocking(context, fh.name) + } + + is FileBackupFileType.Snapshot -> { + getFile(fh.topLevelFolder)?.findFileBlocking(context, fh.name) + } + } + + internal fun removeFromCache(fh: FileHandle) { + cache.remove("$root/${fh.relativePath}") + } + + internal fun clearAll() { + cache.clear() + } +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt new file mode 100644 index 00000000..028c521e --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.saf + +import android.content.Context +import android.os.Environment +import android.os.StatFs +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES +import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID +import android.provider.DocumentsContract.renameDocument +import androidx.core.database.getIntOrNull +import androidx.documentfile.provider.DocumentFile +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT +import org.calyxos.seedvault.core.backends.Constants.FILE_BACKUP_METADATA +import org.calyxos.seedvault.core.backends.Constants.chunkFolderRegex +import org.calyxos.seedvault.core.backends.Constants.chunkRegex +import org.calyxos.seedvault.core.backends.Constants.folderRegex +import org.calyxos.seedvault.core.backends.Constants.snapshotRegex +import org.calyxos.seedvault.core.backends.Constants.tokenRegex +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.FileHandle +import org.calyxos.seedvault.core.backends.FileInfo +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.backends.TopLevelFolder +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlin.reflect.KClass + +internal const val AUTHORITY_STORAGE = "com.android.externalstorage.documents" +internal const val ROOT_ID_DEVICE = "primary" + +private const val DEBUG_LOG = true + +public class SafBackend( + private val context: Context, + private val safProperties: SafProperties, + root: String = DIRECTORY_ROOT, +) : Backend { + + private val log = KotlinLogging.logger {} + + private val cache = DocumentFileCache(context, safProperties.getDocumentFile(context), root) + + override suspend fun test(): Boolean { + log.debugLog { "test()" } + return cache.getRootFile().isDirectory + } + + override suspend fun getFreeSpace(): Long? { + log.debugLog { "getFreeSpace()" } + val rootId = safProperties.rootId ?: return null + val authority = safProperties.uri.authority + // using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work + val rootUri = DocumentsContract.buildRootsUri(authority) + val projection = arrayOf(COLUMN_AVAILABLE_BYTES) + // query directly for our rootId + val bytesAvailable = context.contentResolver.query( + rootUri, projection, "$COLUMN_ROOT_ID=?", arrayOf(rootId), null + )?.use { c -> + if (!c.moveToNext()) return@use null // no results + val bytes = c.getIntOrNull(c.getColumnIndex(COLUMN_AVAILABLE_BYTES)) + if (bytes != null && bytes >= 0) return@use bytes.toLong() + else return@use null + } + // if we didn't get anything from SAF, try some known hacks + return if (bytesAvailable == null && authority == AUTHORITY_STORAGE) { + if (rootId == ROOT_ID_DEVICE) { + StatFs(Environment.getDataDirectory().absolutePath).availableBytes + } else if (safProperties.isUsb) { + val documentId = safProperties.uri.lastPathSegment ?: return null + StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes + } else null + } else bytesAvailable + } + + override suspend fun save(handle: FileHandle): OutputStream { + log.debugLog { "save($handle)" } + val file = cache.getOrCreateFile(handle) + return file.getOutputStream(context.contentResolver) + } + + override suspend fun load(handle: FileHandle): InputStream { + log.debugLog { "load($handle)" } + val file = cache.getOrCreateFile(handle) + return file.getInputStream(context.contentResolver) + } + + override suspend fun list( + topLevelFolder: TopLevelFolder?, + vararg fileTypes: KClass, + callback: (FileInfo) -> Unit, + ) { + if (TopLevelFolder::class in fileTypes) throw UnsupportedOperationException() + if (LegacyAppBackupFile::class in fileTypes) throw UnsupportedOperationException() + if (LegacyAppBackupFile.IconsFile::class in fileTypes) throw UnsupportedOperationException() + if (LegacyAppBackupFile.Blob::class in fileTypes) throw UnsupportedOperationException() + + log.debugLog { "list($topLevelFolder, $fileTypes)" } + + val folder = if (topLevelFolder == null) { + cache.getRootFile() + } else { + cache.getOrCreateFile(topLevelFolder) + } + // limit depth based on wanted types and if top-level folder is given + var depth = if (FileBackupFileType.Blob::class in fileTypes) 3 else 2 + if (topLevelFolder != null) depth -= 1 + + folder.listFilesRecursive(depth) { file -> + if (!file.isFile) return@listFilesRecursive + val parentName = file.parentFile?.name ?: return@listFilesRecursive + val name = file.name ?: return@listFilesRecursive + if (LegacyAppBackupFile.Metadata::class in fileTypes && name == FILE_BACKUP_METADATA && + parentName.matches(tokenRegex) + ) { + val metadata = LegacyAppBackupFile.Metadata(parentName.toLong()) + callback(FileInfo(metadata, file.length())) + } + if (FileBackupFileType.Snapshot::class in fileTypes || + FileBackupFileType::class in fileTypes + ) { + val match = snapshotRegex.matchEntire(name) + if (match != null) { + val snapshot = FileBackupFileType.Snapshot( + androidId = parentName.substringBefore('.'), + time = match.groupValues[1].toLong(), + ) + callback(FileInfo(snapshot, file.length())) + } + } + if ((FileBackupFileType.Blob::class in fileTypes || + FileBackupFileType::class in fileTypes) + ) { + val androidIdSv = file.parentFile?.parentFile?.name ?: "" + if (folderRegex.matches(androidIdSv) && chunkFolderRegex.matches(parentName)) { + if (chunkRegex.matches(name)) { + val blob = FileBackupFileType.Blob( + androidId = androidIdSv.substringBefore('.'), + name = name, + ) + callback(FileInfo(blob, file.length())) + } + } + } + } + } + + private suspend fun DocumentFile.listFilesRecursive( + depth: Int, + callback: (DocumentFile) -> Unit, + ) { + if (depth <= 0) return + listFilesBlocking(context).forEach { file -> + callback(file) + if (file.isDirectory) file.listFilesRecursive(depth - 1, callback) + } + } + + override suspend fun remove(handle: FileHandle) { + log.debugLog { "remove($handle)" } + cache.getFile(handle)?.let { file -> + if (!file.delete()) throw IOException("could not delete ${handle.relativePath}") + cache.removeFromCache(handle) + } + } + + override suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) { + log.debugLog { "rename($from, ${to.name})" } + val fromFile = cache.getOrCreateFile(from) + // don't use fromFile.renameTo(to.name) as that creates "${to.name} (1)" + val newUri = renameDocument(context.contentResolver, fromFile.uri, to.name) + ?: throw IOException("could not rename ${from.relativePath}") + val toFile = DocumentFile.fromTreeUri(context, newUri) + ?: throw IOException("renamed URI invalid: $newUri") + if (toFile.name != to.name) { + toFile.delete() + throw IOException("renamed to ${toFile.name}, but expected ${to.name}") + } + } + + override suspend fun removeAll() { + log.debugLog { "removeAll()" } + try { + cache.getRootFile().listFilesBlocking(context).forEach { file -> + log.debugLog { " remove ${file.uri}" } + file.delete() + } + } finally { + cache.clearAll() + } + } + + override val providerPackageName: String? by lazy { + log.debugLog { "providerPackageName" } + val authority = safProperties.uri.authority ?: return@lazy null + val providerInfo = context.packageManager.resolveContentProvider(authority, 0) + ?: return@lazy null + log.debugLog { " ${providerInfo.packageName}" } + providerInfo.packageName + } + +} + +private inline fun KLogger.debugLog(crossinline block: () -> String) { + if (DEBUG_LOG) debug { block() } +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafHelper.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafHelper.kt new file mode 100644 index 00000000..46682e45 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafHelper.kt @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2021 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.saf + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Context +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID +import android.provider.DocumentsContract.EXTRA_LOADING +import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree +import android.provider.DocumentsContract.buildDocumentUriUsingTree +import android.provider.DocumentsContract.getDocumentId +import android.util.Log +import androidx.annotation.VisibleForTesting +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import org.calyxos.seedvault.core.backends.Constants.MIME_TYPE +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.resume + +private const val TAG = "SafHelper" + +@Throws(IOException::class) +public fun DocumentFile.getInputStream(contentResolver: ContentResolver): InputStream { + return uri.openInputStream(contentResolver) +} + +@Throws(IOException::class) +public fun DocumentFile.getOutputStream(contentResolver: ContentResolver): OutputStream { + return uri.openOutputStream(contentResolver) +} + +/** + * Checks if a file exists and if not, creates it. + * + * If we were trying to create it right away, some providers create "filename (1)". + */ +@Throws(IOException::class) +internal suspend fun DocumentFile.getOrCreateFile(context: Context, name: String): DocumentFile { + return try { + findFileBlocking(context, name) ?: createFileOrThrow(name, MIME_TYPE) + } catch (e: Exception) { + // SAF can throw all sorts of exceptions, so wrap it in IOException. + // E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument() + // when flash drive is not plugged-in: + // http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135 + if (e is IOException) throw e + else throw IOException(e) + } +} + +@Throws(IOException::class) +internal fun DocumentFile.createFileOrThrow( + name: String, + mimeType: String = MIME_TYPE, +): DocumentFile { + val file = createFile(mimeType, name) ?: throw IOException("Unable to create file: $name") + if (file.name != name) { + file.delete() + if (file.name == null) { // this happens when file existed already + // try to find the original file we were looking for + val foundFile = findFile(name) + if (foundFile?.name == name) return foundFile + } + throw IOException("Wanted to create $name, but got ${file.name}") + } + return file +} + +/** + * Checks if a directory already exists and if not, creates it. + */ +@Throws(IOException::class) +public suspend fun DocumentFile.getOrCreateDirectory(context: Context, name: String): DocumentFile { + return findFileBlocking(context, name) ?: createDirectoryOrThrow(name) +} + +@Throws(IOException::class) +public fun DocumentFile.createDirectoryOrThrow(name: String): DocumentFile { + val directory = createDirectory(name) + ?: throw IOException("Unable to create directory: $name") + if (directory.name != name) { + directory.delete() + throw IOException("Wanted to directory $name, but got ${directory.name}") + } + return directory +} + +/** + * Works like [DocumentFile.listFiles] except + * that it waits until the DocumentProvider has a result. + * This prevents getting an empty list even though there are children to be listed. + */ +@Throws(IOException::class) +public suspend fun DocumentFile.listFilesBlocking(context: Context): List { + val resolver = context.contentResolver + val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) + val projection = arrayOf(COLUMN_DOCUMENT_ID) + val result = ArrayList() + + try { + getLoadedCursor { + resolver.query(childrenUri, projection, null, null, null) + } + } catch (e: TimeoutCancellationException) { + throw IOException(e) + }.use { cursor -> + while (cursor.moveToNext()) { + val documentId = cursor.getString(0) + val documentUri = buildDocumentUriUsingTree(uri, documentId) + result.add(getTreeDocumentFile(this, context, documentUri)) + } + } + return result +} + +/** + * An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent. + * + * All other public ways to get a TreeDocumentFile only work from [Uri]s + * (e.g. [DocumentFile.fromTreeUri]) and always set parent to null. + * + * We have a test for this method to ensure CI will alert us when this reflection breaks. + * Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails. + */ +@VisibleForTesting +@SuppressLint("CheckedExceptions") +public fun getTreeDocumentFile( + parent: DocumentFile, + context: Context, + uri: Uri, +): DocumentFile { + @SuppressWarnings("MagicNumber") + val constructor = parent.javaClass.declaredConstructors.find { + it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3 + } + check(constructor != null) { "Could not find constructor for TreeDocumentFile" } + constructor.isAccessible = true + return constructor.newInstance(parent, context, uri) as DocumentFile +} + +/** + * Same as [DocumentFile.findFile] only that it re-queries when the first result was stale. + * + * Most documents providers including Nextcloud are listing the full directory content + * when querying for a specific file in a directory, + * so there is no point in trying to optimize the query by not listing all children. + */ +public suspend fun DocumentFile.findFileBlocking( + context: Context, + displayName: String, +): DocumentFile? { + val files = try { + listFilesBlocking(context) + } catch (e: IOException) { + Log.e(TAG, "Error finding file blocking", e) + return null + } + for (doc in files) { + if (displayName == doc.name) return doc + } + return null +} + +/** + * Returns a cursor for the given query while ensuring that the cursor was loaded. + * + * When the SAF backend is a cloud storage provider (e.g. Nextcloud), + * it can happen that the query returns an outdated (e.g. empty) cursor + * which will only be updated in response to this query. + * + * See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html + * + * This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver] + * registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]). + * If the cursor is not loading, it will be returned right away. + * + * @param timeout an optional time-out in milliseconds + * @throws TimeoutCancellationException if there was no result before the time-out + * @throws IOException if the query returns null + */ +@VisibleForTesting +@Throws(IOException::class, TimeoutCancellationException::class) +public suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?): Cursor = + withTimeout(timeout) { + suspendCancellableCoroutine { cont -> + val cursor = query() ?: throw IOException() + cont.invokeOnCancellation { cursor.close() } + val loading = cursor.extras.getBoolean(EXTRA_LOADING, false) + if (loading) { + Log.d(TAG, "Wait for children to get loaded...") + cursor.registerContentObserver(object : ContentObserver(null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + Log.d(TAG, "Children loaded. Continue...") + cursor.close() + val newCursor = query() + if (newCursor == null) { + cont.cancel(IOException("query returned no results")) + } else cont.resume(newCursor) + } + }) + } else { + // not loading, return cursor right away + cont.resume(cursor) + } + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafProperties.kt similarity index 70% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt rename to core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafProperties.kt index dae9410b..2da86c37 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafProperties.kt @@ -3,16 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.saf +package org.calyxos.seedvault.core.backends.saf import android.content.Context import android.net.Uri import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID import androidx.annotation.WorkerThread import androidx.documentfile.provider.DocumentFile -import com.stevesoltys.seedvault.plugins.StorageProperties +import org.calyxos.seedvault.core.backends.BackendProperties -data class SafStorage( +public data class SafProperties( override val config: Uri, override val name: String, override val isUsb: Boolean, @@ -22,12 +22,13 @@ data class SafStorage( * This is only nullable for historic reasons, because we didn't always store it. */ val rootId: String?, -) : StorageProperties() { +) : BackendProperties() { - val uri: Uri = config + public val uri: Uri = config - fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, config) - ?: throw AssertionError("Should only happen on API < 21.") + public fun getDocumentFile(context: Context): DocumentFile = + DocumentFile.fromTreeUri(context, config) + ?: throw AssertionError("Should only happen on API < 21.") /** * Returns true if this is USB storage that is not available, false otherwise. diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/saf/UriUtils.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/UriUtils.kt new file mode 100644 index 00000000..07d0d3e4 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/UriUtils.kt @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2021 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.saf + +import android.content.ContentResolver +import android.net.Uri +import android.provider.MediaStore +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +public fun Uri.getDocumentPath(): String? { + return lastPathSegment?.split(':')?.getOrNull(1) +} + +public fun Uri.getVolume(): String? { + val volume = lastPathSegment?.split(':')?.getOrNull(0) + return if (volume == "primary") MediaStore.VOLUME_EXTERNAL_PRIMARY else volume +} + +@Throws(IOException::class) +public fun Uri.openInputStream(contentResolver: ContentResolver): InputStream { + return try { + contentResolver.openInputStream(this) + } catch (e: IllegalArgumentException) { + // This is necessary, because contrary to the documentation, files that have been deleted + // after we retrieved their Uri, will throw an IllegalArgumentException + throw IOException(e) + } ?: throw IOException("Stream for $this returned null") +} + +@Throws(IOException::class) +public fun Uri.openOutputStream(contentResolver: ContentResolver): OutputStream { + return try { + contentResolver.openOutputStream(this, "wt") + } catch (e: IllegalArgumentException) { + // This is necessary, because contrary to the documentation, files that have been deleted + // after we retrieved their Uri, will throw an IllegalArgumentException + throw IOException(e) + } ?: throw IOException("Stream for $this returned null") +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/GetLastModified.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/GetLastModified.kt new file mode 100644 index 00000000..46c78f58 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/GetLastModified.kt @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.webdav + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV +import org.xmlpull.v1.XmlPullParser + +/** + * A fake version of [at.bitfire.dav4jvm.property.webdav.GetLastModified] which we register + * so we don't need to depend on `org.apache.commons.lang3` which is used for date parsing. + */ +internal class GetLastModified : Property { + companion object { + @JvmField + val NAME = Property.Name(NS_WEBDAV, "getlastmodified") + } + + object Factory : PropertyFactory { + override fun getName() = NAME + override fun create(parser: XmlPullParser): GetLastModified? = null + } +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/PipedCloseActionOutputStream.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/PipedCloseActionOutputStream.kt new file mode 100644 index 00000000..377a8c3e --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/PipedCloseActionOutputStream.kt @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.webdav + +import java.io.IOException +import java.io.PipedInputStream +import java.io.PipedOutputStream + +internal class PipedCloseActionOutputStream( + inputStream: PipedInputStream, +) : PipedOutputStream(inputStream) { + + private var onClose: (() -> Unit)? = null + + override fun write(b: Int) { + try { + super.write(b) + } catch (e: Exception) { + try { + onClose?.invoke() + } catch (closeException: Exception) { + e.addSuppressed(closeException) + } + throw e + } + } + + override fun write(b: ByteArray, off: Int, len: Int) { + try { + super.write(b, off, len) + } catch (e: Exception) { + try { + onClose?.invoke() + } catch (closeException: Exception) { + e.addSuppressed(closeException) + } + throw e + } + } + + @Throws(IOException::class) + override fun close() { + super.close() + try { + onClose?.invoke() + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + fun doOnClose(function: () -> Unit) { + this.onClose = function + } +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt new file mode 100644 index 00000000..ee24b895 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt @@ -0,0 +1,345 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.webdav + +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.PropertyRegistry +import at.bitfire.dav4jvm.Response.HrefRelation.SELF +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okio.BufferedSink +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT +import org.calyxos.seedvault.core.backends.Constants.FILE_BACKUP_METADATA +import org.calyxos.seedvault.core.backends.Constants.chunkFolderRegex +import org.calyxos.seedvault.core.backends.Constants.chunkRegex +import org.calyxos.seedvault.core.backends.Constants.folderRegex +import org.calyxos.seedvault.core.backends.Constants.snapshotRegex +import org.calyxos.seedvault.core.backends.Constants.tokenRegex +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.FileHandle +import org.calyxos.seedvault.core.backends.FileInfo +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.backends.TopLevelFolder +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.PipedInputStream +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.reflect.KClass + +private const val DEBUG_LOG = true + +@OptIn(DelicateCoroutinesApi::class) +public class WebDavBackend( + webDavConfig: WebDavConfig, + root: String = DIRECTORY_ROOT, +) : Backend { + + private val log = KotlinLogging.logger {} + + private val authHandler = BasicDigestAuthHandler( + domain = null, // Optional, to only authenticate against hosts with this domain. + username = webDavConfig.username, + password = webDavConfig.password, + ) + private val okHttpClient = OkHttpClient.Builder() + .followRedirects(false) + .authenticator(authHandler) + .addNetworkInterceptor(authHandler) + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(240, TimeUnit.SECONDS) + .pingInterval(45, TimeUnit.SECONDS) + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) + .retryOnConnectionFailure(true) + .build() + + private val baseUrl = webDavConfig.url.trimEnd('/') + private val url = "$baseUrl/$root" + private val folders = mutableSetOf() // cache for existing/created folders + + init { + PropertyRegistry.register(GetLastModified.Factory) + } + + override suspend fun test(): Boolean { + val location = "$baseUrl/".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val hasCaps = suspendCoroutine { cont -> + davCollection.options { davCapabilities, response -> + log.debugLog { "test() = $davCapabilities $response" } + if (davCapabilities.contains("1")) cont.resume(true) + else if (davCapabilities.contains("2")) cont.resume(true) + else if (davCapabilities.contains("3")) cont.resume(true) + else cont.resume(false) + } + } + if (!hasCaps) return false + + val rootCollection = DavCollection(okHttpClient, "$url/foo".toHttpUrl()) + rootCollection.ensureFoldersExist(log, folders) // only considers parents, so foo isn't used + return true + } + + override suspend fun getFreeSpace(): Long? { + val location = "$url/".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val availableBytes = suspendCoroutine { cont -> + davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ -> + log.debugLog { "getFreeSpace() = $response" } + val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes + val availableBytes = quota?.quotaAvailableBytes ?: -1 + if (availableBytes > 0) { + cont.resume(availableBytes) + } else { + cont.resume(null) + } + } + } + return availableBytes + } + + override suspend fun save(handle: FileHandle): OutputStream { + val location = handle.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + davCollection.ensureFoldersExist(log, folders) + + val pipedInputStream = PipedInputStream() + val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream) + + val body = object : RequestBody() { + override fun isOneShot(): Boolean = true + override fun contentType() = "application/octet-stream".toMediaType() + override fun writeTo(sink: BufferedSink) { + pipedInputStream.use { inputStream -> + sink.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + } + val deferred = GlobalScope.async(Dispatchers.IO) { + davCollection.put(body) { response -> + log.debugLog { "save($location) = $response" } + } + } + pipedOutputStream.doOnClose { + runBlocking { // blocking i/o wait + deferred.await() + } + } + return pipedOutputStream + } + + override suspend fun load(handle: FileHandle): InputStream { + val location = handle.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val response = try { + davCollection.get(accept = "", headers = null) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error loading $location", e) + } + log.debugLog { "load($location) = $response" } + if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}") + return response.body?.byteStream() ?: throw IOException("Body was null for $location") + } + + override suspend fun list( + topLevelFolder: TopLevelFolder?, + vararg fileTypes: KClass, + callback: (FileInfo) -> Unit, + ) { + if (TopLevelFolder::class in fileTypes) throw UnsupportedOperationException() + if (LegacyAppBackupFile::class in fileTypes) throw UnsupportedOperationException() + if (LegacyAppBackupFile.IconsFile::class in fileTypes) throw UnsupportedOperationException() + if (LegacyAppBackupFile.Blob::class in fileTypes) throw UnsupportedOperationException() + + // limit depth based on wanted types and if top-level folder is given + var depth = if (FileBackupFileType.Blob::class in fileTypes) 3 else 2 + if (topLevelFolder != null) depth -= 1 + + val location = if (topLevelFolder == null) { + "$url/".toHttpUrl() + } else { + "$url/${topLevelFolder.name}/".toHttpUrl() + } + val davCollection = DavCollection(okHttpClient, location) + val tokenFolders = mutableSetOf() + try { + davCollection.propfindDepthInfinity(depth) { response, relation -> + log.debugLog { "list() = $response" } + + // work around nginx's inability to find files starting with . + if (relation != SELF && LegacyAppBackupFile.Metadata::class in fileTypes && + response.isFolder() && response.hrefName().matches(tokenRegex) + ) { + tokenFolders.add(response.href) + } + if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) { + val name = response.hrefName() + val parentName = response.href.pathSegments[response.href.pathSegments.size - 2] + + if (LegacyAppBackupFile.Metadata::class in fileTypes) { + if (name == FILE_BACKUP_METADATA && parentName.matches(tokenRegex)) { + val metadata = LegacyAppBackupFile.Metadata(parentName.toLong()) + val size = response.properties.contentLength() + callback(FileInfo(metadata, size)) + // we can find .backup.metadata files, so no need for nginx workaround + tokenFolders.clear() + } + } + if (FileBackupFileType.Snapshot::class in fileTypes || + FileBackupFileType::class in fileTypes + ) { + val match = snapshotRegex.matchEntire(name) + if (match != null) { + val size = response.properties.contentLength() + val snapshot = FileBackupFileType.Snapshot( + androidId = parentName.substringBefore('.'), + time = match.groupValues[1].toLong(), + ) + callback(FileInfo(snapshot, size)) + } + } + if ((FileBackupFileType.Blob::class in fileTypes || + FileBackupFileType::class in fileTypes) && response.href.pathSize >= 3 + ) { + val androidIdSv = + response.href.pathSegments[response.href.pathSegments.size - 3] + if (folderRegex.matches(androidIdSv) && + chunkFolderRegex.matches(parentName) + ) { + if (chunkRegex.matches(name)) { + val blob = FileBackupFileType.Blob( + androidId = androidIdSv.substringBefore('.'), + name = name, + ) + val size = response.properties.contentLength() + callback(FileInfo(blob, size)) + } + } + } + } + } + } catch (e: NotFoundException) { + log.warn(e) { "$location not found" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error listing $location", e) + } + // direct query for .backup.metadata as nginx doesn't support listing hidden files + tokenFolders.forEach { url -> + val metadataLocation = url.newBuilder().addPathSegment(FILE_BACKUP_METADATA).build() + try { + DavCollection(okHttpClient, metadataLocation).head { response -> + log.debugLog { "head($metadataLocation) = $response" } + val token = url.pathSegments.last { it.isNotBlank() }.toLong() + val metadata = LegacyAppBackupFile.Metadata(token) + val size = response.headers["content-length"]?.toLong() + ?: error("no content length") + callback(FileInfo(metadata, size)) + } + } catch (e: Exception) { + log.warn { "No $FILE_BACKUP_METADATA found in $url: $e" } + } + } + } + + override suspend fun remove(handle: FileHandle) { + val location = handle.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + log.debugLog { "remove($handle)" } + + try { + val response = suspendCoroutine { cont -> + davCollection.delete { response -> + cont.resume(response) + } + } + log.debugLog { "remove($location) = $response" } + } catch (e: Exception) { + when (e) { + is NotFoundException -> log.info { "Not found: $location" } + is IOException -> throw e + else -> throw IOException(e) + } + } + } + + /** + * Renames [from] to [to]. + * + * @throws HttpException if [to] already exists + * * nginx code 412 + * * lighttp code 207 + * * dufs code 500 + */ + @Throws(HttpException::class) + override suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) { + val location = "$url/${from.name}/".toHttpUrl() + val toUrl = "$url/${to.name}/".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + try { + davCollection.move(toUrl, false) { response -> + log.debugLog { "rename(${from.name}, ${to.name}) = $response" } + } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error renaming $location to ${to.name}", e) + } + } + + override suspend fun removeAll() { + val location = "$url/".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + try { + davCollection.delete { response -> + log.debugLog { "removeAll() = $response" } + } + } catch (e: NotFoundException) { + log.info { "Not found: $location" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error removing all at $location", e) + } + } + + override val providerPackageName: String? = null // 100% built-in plugin + + private fun FileHandle.toHttpUrl(): HttpUrl = when (this) { + // careful with trailing slashes, use only for folders/collections + is TopLevelFolder -> "$url/$name/".toHttpUrl() + else -> "$url/$relativePath".toHttpUrl() + } + +} + +internal inline fun KLogger.debugLog(crossinline block: () -> String) { + if (DEBUG_LOG) debug { block() } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavConfig.kt similarity index 67% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt rename to core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavConfig.kt index b95ff71b..a2d2c180 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavConfig.kt @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.webdav +package org.calyxos.seedvault.core.backends.webdav -data class WebDavConfig( +public data class WebDavConfig( val url: String, val username: String, val password: String, diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavHelper.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavHelper.kt new file mode 100644 index 00000000..8e594bce --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavHelper.kt @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.webdav + +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.Response.HrefRelation.SELF +import at.bitfire.dav4jvm.ResponseCallback +import at.bitfire.dav4jvm.exception.ConflictException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.GetContentLength +import at.bitfire.dav4jvm.property.webdav.ResourceType +import io.github.oshai.kotlinlogging.KLogger +import okhttp3.HttpUrl + +/** + * Tries to do [DavCollection.propfind] with a depth of `-1`. + * Since `infinity` isn't supported by nginx either, + * we fallback to iterating over all folders found with depth `1` + * and do another PROPFIND on those, passing the given [callback]. + * + * @param maxDepth in case we need to fallback to recursive propfinds, we only go that far down. + */ +internal fun DavCollection.propfindDepthInfinity(maxDepth: Int, callback: MultiResponseCallback) { + try { + propfind( + depth = -1, + reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME, GetContentLength.NAME), + callback = callback, + ) + } catch (e: HttpException) { + if (e.isUnsupportedPropfind()) { + log.info { "Got ${e.response}, trying recursive depth=1 PROPFINDs..." } + propfindFakeInfinity(maxDepth, callback) + } else { + throw e + } + } +} + +internal fun DavCollection.propfindFakeInfinity(depth: Int, callback: MultiResponseCallback) { + if (depth <= 0) return + propfind( + depth = 1, + reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME, GetContentLength.NAME), + ) { response, relation -> + // This callback will be called for everything in the folder + callback.onResponse(response, relation) + if (relation != SELF && response.isFolder()) { + DavCollection(httpClient, response.href).propfindFakeInfinity(depth - 1, callback) + } + } +} + +internal fun DavCollection.mkColCreateMissing(callback: ResponseCallback) { + try { + mkCol(null) { response -> + callback.onResponse(response) + } + } catch (e: ConflictException) { + log.warning { "Error creating $location: $e" } + if (location.pathSize <= 1) throw e + val newLocation = location.newBuilder() + .removePathSegment(location.pathSize - 1) + .build() + DavCollection(httpClient, newLocation).mkColCreateMissing(callback) + // re-run original command to create parent collection + mkCol(null) { response -> + callback.onResponse(response) + } + } +} + +internal fun DavCollection.ensureFoldersExist(log: KLogger, folders: MutableSet) { + if (location.pathSize <= 2) return + val parent = location.newBuilder() + .removePathSegment(location.pathSize - 1) + .build() + if (parent in folders) return + val parentCollection = DavCollection(httpClient, parent) + try { + parentCollection.head { response -> + log.debugLog { "head($parent) = $response" } + folders.add(parent) + } + } catch (e: NotFoundException) { + log.debugLog { "$parent not found, creating..." } + parentCollection.mkColCreateMissing { response -> + log.debugLog { "mkColCreateMissing($parent) = $response" } + folders.add(parent) + } + } +} + +private fun HttpException.isUnsupportedPropfind(): Boolean { + // nginx is not including 'propfind-finite-depth' in body, so just relay on code + return code == 403 || code == 400 // dufs returns 400 +} + +internal fun List.contentLength(): Long { + // crash intentionally, if this isn't in the list + return filterIsInstance()[0].contentLength +} + +internal fun Response.isFolder(): Boolean { + return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavProperties.kt similarity index 65% rename from app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt rename to core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavProperties.kt index 29ea84a9..4d47fb37 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavProperties.kt @@ -3,15 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.webdav +package org.calyxos.seedvault.core.backends.webdav import android.content.Context -import com.stevesoltys.seedvault.plugins.StorageProperties +import org.calyxos.seedvault.core.backends.BackendProperties -data class WebDavProperties( +public data class WebDavProperties( override val config: WebDavConfig, override val name: String, -) : StorageProperties() { +) : BackendProperties() { override val isUsb: Boolean = false override val requiresNetwork: Boolean = true override fun isUnavailableUsb(context: Context): Boolean = false diff --git a/core/src/main/java/org/calyxos/seedvault/core/crypto/KeyManager.kt b/core/src/main/java/org/calyxos/seedvault/core/crypto/KeyManager.kt new file mode 100644 index 00000000..ec15fa31 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/crypto/KeyManager.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.crypto + +import java.security.KeyStore +import javax.crypto.SecretKey + +public interface KeyManager { + /** + * Returns the main key, so it can be used for deriving sub-keys. + * + * Note that any attempt to export the key will return null or an empty [ByteArray], + * because the key can not leave the [KeyStore]'s hardware security module. + */ + public fun getMainKey(): SecretKey +} diff --git a/core/src/test/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackendTest.kt b/core/src/test/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackendTest.kt new file mode 100644 index 00000000..8b366c82 --- /dev/null +++ b/core/src/test/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackendTest.kt @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends.webdav + +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendTest +import kotlin.test.Test + +public class WebDavBackendTest : BackendTest() { + override val plugin: Backend = WebDavBackend(WebDavTestConfig.getConfig(), ".SeedvaultTest") + + @Test + public fun `test write, list, read, rename, delete`(): Unit = runBlocking { + testWriteListReadRenameDelete() + } + + @Test + public fun `test remove, create, write file`(): Unit = runBlocking { + testRemoveCreateWriteFile() + } +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt b/core/src/test/java/org/calyxos/seedvault/core/backends/webdav/WebDavTestConfig.kt similarity index 80% rename from app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt rename to core/src/test/java/org/calyxos/seedvault/core/backends/webdav/WebDavTestConfig.kt index e7f5c674..b22558f0 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt +++ b/core/src/test/java/org/calyxos/seedvault/core/backends/webdav/WebDavTestConfig.kt @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.plugins.webdav +package org.calyxos.seedvault.core.backends.webdav import org.junit.Assume.assumeFalse -import org.junit.jupiter.api.Assertions.fail +import kotlin.test.fail -object WebDavTestConfig { +internal object WebDavTestConfig { fun getConfig(): WebDavConfig { assumeFalse(System.getenv("NEXTCLOUD_URL").isNullOrEmpty()) diff --git a/core/src/test/resources/simplelogger.properties b/core/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..e0f0d79d --- /dev/null +++ b/core/src/test/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel=trace diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e1efe7a..e4c40d10 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,10 @@ dokka = "1.9.20" # Dokka has no releases after 1.9.20 # Lint versions lint-rules = { strictly = "0.1.0" } +# Logging libs (check versions at /libs) +logging = { strictly = "6.0.3" } +slf4j-api = { strictly = "2.0.16" } + # Google versions # https://android.googlesource.com/platform/external/protobuf/+/refs/tags/android-15.0.0_r1/java/pom.xml#7 protobuf = { strictly = "3.21.12" } @@ -74,6 +78,9 @@ androidx-documentfile = { strictly = "1.1.0-alpha01" } # 1.1.0-alpha02 in AOSP b # https://android.googlesource.com/platform/prebuilts/sdk/+/android-15.0.0_r1/current/androidx/m2repository/androidx/work/work-runtime-ktx?autodive=0 androidx-work-runtime = { strictly = "2.10.0-alpha02" } +# https://android.googlesource.com/platform/external/okio/+/refs/tags/android-14.0.0_r53/CHANGELOG.md +squareup-okio = { strictly = "3.7.0" } + [libraries] # Kotlin standard dependencies kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -105,6 +112,10 @@ androidx-documentfile = { module = "androidx.documentfile:documentfile", version androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work-runtime" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +squareup-okio = { module= "com.squareup.okio:okio", version.ref = "squareup-okio" } +kotlin-logging = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "logging" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" } + [bundles] kotlin = ["kotlin-stdlib", "kotlin-stdlib-jdk8", "kotlin-stdlib-common"] coroutines = ["kotlinx-coroutines-core-jvm", "kotlinx-coroutines-android"] @@ -115,5 +126,6 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jlleitschuh-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/libs/Android.bp b/libs/Android.bp index 33f51101..8a9c8e65 100644 --- a/libs/Android.bp +++ b/libs/Android.bp @@ -8,3 +8,15 @@ java_import { jars: ["kotlin-bip39-jvm-1.0.6.jar"], sdk_version: "current", } + +java_import { + name: "seedvault-lib-kotlin-logging-jvm", + jars: ["kotlin-logging-jvm-6.0.3.jar"], + sdk_version: "current", +} + +java_import { + name: "seedvault-lib-slf4j-api", + jars: ["slf4j-api-2.0.16.jar"], + sdk_version: "current", +} diff --git a/app/libs/android.jar b/libs/aosp/android.jar similarity index 100% rename from app/libs/android.jar rename to libs/aosp/android.jar diff --git a/app/libs/libcore.jar b/libs/aosp/libcore.jar similarity index 100% rename from app/libs/libcore.jar rename to libs/aosp/libcore.jar diff --git a/libs/dav4jvm/Android.bp b/libs/dav4jvm/Android.bp index 66092662..ea70b04b 100644 --- a/libs/dav4jvm/Android.bp +++ b/libs/dav4jvm/Android.bp @@ -14,9 +14,3 @@ java_import { jars: ["okhttp-4.12.0.jar"], sdk_version: "current", } - -java_import { - name: "seedvault-lib-okio", - jars: ["okio-jvm-3.7.0.jar"], - sdk_version: "current", -} diff --git a/libs/dav4jvm/okio-jvm-3.7.0.jar b/libs/dav4jvm/okio-jvm-3.7.0.jar deleted file mode 100644 index 8da081a9..00000000 Binary files a/libs/dav4jvm/okio-jvm-3.7.0.jar and /dev/null differ diff --git a/libs/kotlin-logging-jvm-6.0.3.jar b/libs/kotlin-logging-jvm-6.0.3.jar new file mode 100644 index 00000000..b9f78839 Binary files /dev/null and b/libs/kotlin-logging-jvm-6.0.3.jar differ diff --git a/libs/slf4j-api-2.0.16.jar b/libs/slf4j-api-2.0.16.jar new file mode 100644 index 00000000..cbb5448d Binary files /dev/null and b/libs/slf4j-api-2.0.16.jar differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 2350388d..812a36f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ dependencyResolutionManagement { } rootProject.name = "Seedvault" +include(":core") include(":app") include(":contactsbackup") include(":storage:lib") diff --git a/storage/demo/build.gradle.kts b/storage/demo/build.gradle.kts index baa45faf..2317cae5 100644 --- a/storage/demo/build.gradle.kts +++ b/storage/demo/build.gradle.kts @@ -69,6 +69,7 @@ android { } dependencies { + implementation(project(":core")) implementation(project(":storage:lib")) implementation(libs.bundles.kotlin) diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt index 5582b4ca..da045dca 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt @@ -9,7 +9,8 @@ import android.app.Application import android.os.StrictMode import android.os.StrictMode.VmPolicy import android.util.Log -import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin +import de.grobox.storagebackuptester.crypto.KeyManager +import de.grobox.storagebackuptester.plugin.TestSafBackend import de.grobox.storagebackuptester.settings.SettingsManager import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.ui.restore.FileSelectionManager @@ -18,8 +19,8 @@ class App : Application() { val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) } val storageBackup: StorageBackup by lazy { - val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() } - StorageBackup(this, { plugin }) + val plugin = TestSafBackend(this) { settingsManager.getBackupLocation() } + StorageBackup(this, { plugin }, KeyManager) } val fileSelectionManager: FileSelectionManager get() = FileSelectionManager() diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt index c5619784..3b9347db 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainActivity.kt @@ -24,7 +24,7 @@ class MainActivity : AppCompatActivity() { KeyManager.storeMasterKey() - if (!KeyManager.hasMasterKey()) { + if (!KeyManager.hasMainKey()) { Log.e("TEST", "storing new key") KeyManager.storeMasterKey() } else { diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt index 613aa51d..3a0e077a 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/crypto/KeyManager.kt @@ -14,7 +14,7 @@ import java.security.KeyStore import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec -object KeyManager { +object KeyManager: org.calyxos.seedvault.core.crypto.KeyManager { private const val KEY_SIZE = 256 internal const val KEY_SIZE_BYTES = KEY_SIZE / 8 @@ -42,9 +42,9 @@ object KeyManager { keyStore.setEntry(KEY_ALIAS_MASTER, ksEntry, getKeyProtection()) } - fun hasMasterKey(): Boolean = keyStore.containsAlias(KEY_ALIAS_MASTER) + fun hasMainKey(): Boolean = keyStore.containsAlias(KEY_ALIAS_MASTER) - fun getMasterKey(): SecretKey { + override fun getMainKey(): SecretKey { val ksEntry = keyStore.getEntry(KEY_ALIAS_MASTER, null) as KeyStore.SecretKeyEntry return ksEntry.secretKey } diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt new file mode 100644 index 00000000..514c58db --- /dev/null +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2021 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.grobox.storagebackuptester.plugin + +import android.content.Context +import android.net.Uri +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileHandle +import org.calyxos.seedvault.core.backends.FileInfo +import org.calyxos.seedvault.core.backends.TopLevelFolder +import org.calyxos.seedvault.core.backends.saf.SafBackend +import org.calyxos.seedvault.core.backends.saf.SafProperties +import java.io.InputStream +import java.io.OutputStream +import kotlin.reflect.KClass + +class TestSafBackend( + private val appContext: Context, + private val getLocationUri: () -> Uri?, +) : Backend { + + private val safProperties + get() = SafProperties( + config = getLocationUri() ?: error("no uri"), + name = "foo", + isUsb = false, + requiresNetwork = false, + rootId = "bar", + ) + private val delegate: SafBackend get() = SafBackend(appContext, safProperties) + + private val nullStream = object : OutputStream() { + override fun write(b: Int) { + // oops + } + } + + override suspend fun test(): Boolean = delegate.test() + + override suspend fun getFreeSpace(): Long? = delegate.getFreeSpace() + + override suspend fun save(handle: FileHandle): OutputStream { + if (getLocationUri() == null) return nullStream + return delegate.save(handle) + } + + override suspend fun load(handle: FileHandle): InputStream { + return delegate.load(handle) + } + + override suspend fun list( + topLevelFolder: TopLevelFolder?, + vararg fileTypes: KClass, + callback: (FileInfo) -> Unit, + ) = delegate.list(topLevelFolder, *fileTypes, callback = callback) + + override suspend fun remove(handle: FileHandle) = delegate.remove(handle) + + override suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) { + delegate.rename(from, to) + } + + override suspend fun removeAll() = delegate.removeAll() + + override val providerPackageName: String? get() = delegate.providerPackageName + +} diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt deleted file mode 100644 index 10c41f9e..00000000 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package de.grobox.storagebackuptester.plugin - -import android.content.Context -import android.net.Uri -import androidx.documentfile.provider.DocumentFile -import de.grobox.storagebackuptester.crypto.KeyManager -import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin -import java.io.IOException -import java.io.OutputStream -import javax.crypto.SecretKey - -@Suppress("BlockingMethodInNonBlockingContext") -class TestSafStoragePlugin( - appContext: Context, - private val getLocationUri: () -> Uri?, -) : SafStoragePlugin(appContext) { - - override val context = appContext - override val root: DocumentFile? - get() { - val uri = getLocationUri() ?: return null - return DocumentFile.fromTreeUri(context, uri) ?: error("No doc file from tree Uri") - } - - private val nullStream = object : OutputStream() { - override fun write(b: Int) { - // oops - } - } - - override fun getMasterKey(): SecretKey { - return KeyManager.getMasterKey() - } - - override fun hasMasterKey(): Boolean { - return KeyManager.hasMasterKey() - } - - @Throws(IOException::class) - override suspend fun getChunkOutputStream(chunkId: String): OutputStream { - if (getLocationUri() == null) return nullStream - return super.getChunkOutputStream(chunkId) - } - - override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { - if (root == null) return nullStream - return super.getBackupSnapshotOutputStream(timestamp) - } - -} diff --git a/storage/lib/Android.bp b/storage/lib/Android.bp index d28d5c87..2cac46ca 100644 --- a/storage/lib/Android.bp +++ b/storage/lib/Android.bp @@ -19,6 +19,7 @@ android_library { local_include_dirs: ["src/main/proto"], }, static_libs: [ + "seedvault-lib-core", "seedvault-lib-tink-android", "libprotobuf-java-lite", "androidx.core_core-ktx", diff --git a/storage/lib/build.gradle.kts b/storage/lib/build.gradle.kts index 9d1ba5d3..5ada1faa 100644 --- a/storage/lib/build.gradle.kts +++ b/storage/lib/build.gradle.kts @@ -81,6 +81,7 @@ android { } dependencies { + implementation(project(":core")) implementation(libs.bundles.kotlin) implementation(libs.androidx.core) implementation(libs.androidx.fragment) diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/ByteArrayUtils.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/ByteArrayUtils.kt deleted file mode 100644 index 2cabdd65..00000000 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/ByteArrayUtils.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.calyxos.backup.storage - -internal fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } - -internal fun String.toByteArrayFromHex() = chunked(2).map { it.toInt(16).toByte() }.toByteArray() diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/SnapshotRetriever.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/SnapshotRetriever.kt new file mode 100644 index 00000000..31442596 --- /dev/null +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/SnapshotRetriever.kt @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.backup.storage + +import com.google.protobuf.InvalidProtocolBufferException +import org.calyxos.backup.storage.api.StoredSnapshot +import org.calyxos.backup.storage.backup.BackupSnapshot +import org.calyxos.backup.storage.crypto.StreamCrypto +import org.calyxos.backup.storage.restore.readVersion +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder +import java.io.IOException +import java.security.GeneralSecurityException + +internal class SnapshotRetriever( + private val backendGetter: () -> Backend, + private val streamCrypto: StreamCrypto = StreamCrypto, +) { + + @Throws( + IOException::class, + GeneralSecurityException::class, + InvalidProtocolBufferException::class, + ) + suspend fun getSnapshot(streamKey: ByteArray, storedSnapshot: StoredSnapshot): BackupSnapshot { + return backendGetter().load(storedSnapshot.snapshotHandle).use { inputStream -> + val version = inputStream.readVersion() + val timestamp = storedSnapshot.timestamp + val ad = streamCrypto.getAssociatedDataForSnapshot(timestamp, version.toByte()) + streamCrypto.newDecryptingStream(streamKey, inputStream, ad).use { decryptedStream -> + BackupSnapshot.parseFrom(decryptedStream) + } + } + } + +} + +@Throws(IOException::class) +internal suspend fun Backend.getCurrentBackupSnapshots(androidId: String): List { + val topLevelFolder = TopLevelFolder("$androidId.sv") + val snapshots = ArrayList() + list(topLevelFolder, FileBackupFileType.Snapshot::class) { fileInfo -> + val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot + val folderName = handle.topLevelFolder.name + val timestamp = handle.time + val storedSnapshot = StoredSnapshot(folderName, timestamp) + snapshots.add(storedSnapshot) + } + return snapshots +} + +@Throws(IOException::class) +internal suspend fun Backend.getBackupSnapshotsForRestore(): List { + val snapshots = ArrayList() + list(null, FileBackupFileType.Snapshot::class) { fileInfo -> + val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot + val folderName = handle.topLevelFolder.name + val timestamp = handle.time + val storedSnapshot = StoredSnapshot(folderName, timestamp) + snapshots.add(storedSnapshot) + } + return snapshots +} diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/UriUtils.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/UriUtils.kt index 2162a010..773c7a16 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/UriUtils.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/UriUtils.kt @@ -5,50 +5,14 @@ package org.calyxos.backup.storage -import android.content.ContentResolver import android.net.Uri -import android.provider.MediaStore import org.calyxos.backup.storage.api.MediaType import org.calyxos.backup.storage.api.mediaItems import org.calyxos.backup.storage.backup.BackupMediaFile import org.calyxos.backup.storage.db.StoredUri -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream internal fun Uri.toStoredUri(): StoredUri = StoredUri(this) -internal fun Uri.getDocumentPath(): String? { - return lastPathSegment?.split(':')?.getOrNull(1) -} - -internal fun Uri.getVolume(): String? { - val volume = lastPathSegment?.split(':')?.getOrNull(0) - return if (volume == "primary") MediaStore.VOLUME_EXTERNAL_PRIMARY else volume -} - -@Throws(IOException::class) -public fun Uri.openInputStream(contentResolver: ContentResolver): InputStream { - return try { - contentResolver.openInputStream(this) - } catch (e: IllegalArgumentException) { - // This is necessary, because contrary to the documentation, files that have been deleted - // after we retrieved their Uri, will throw an IllegalArgumentException - throw IOException(e) - } ?: throw IOException("Stream for $this returned null") -} - -@Throws(IOException::class) -public fun Uri.openOutputStream(contentResolver: ContentResolver): OutputStream { - return try { - contentResolver.openOutputStream(this, "wt") - } catch (e: IllegalArgumentException) { - // This is necessary, because contrary to the documentation, files that have been deleted - // after we retrieved their Uri, will throw an IllegalArgumentException - throw IOException(e) - } ?: throw IOException("Stream for $this returned null") -} - internal fun Uri.getMediaType(): MediaType? { val str = toString() for (item in mediaItems) { diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/api/BackupContentType.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/api/BackupContentType.kt index 04d1cb38..21df9889 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/api/BackupContentType.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/api/BackupContentType.kt @@ -11,7 +11,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.calyxos.backup.storage.R import org.calyxos.backup.storage.backup.BackupMediaFile -import org.calyxos.backup.storage.getDocumentPath +import org.calyxos.seedvault.core.backends.saf.getDocumentPath // hidden in DocumentsContract public const val EXTERNAL_STORAGE_PROVIDER_AUTHORITY: String = @@ -38,7 +38,7 @@ public sealed class BackupContentType( public object Custom : BackupContentType(R.drawable.ic_folder) { public fun getName(uri: Uri): String { val path = uri.getDocumentPath()!! - return if (path.isBlank()) "/" else path + return path.ifBlank { "/" } } } } diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt index a0135283..78622445 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt @@ -6,6 +6,8 @@ package org.calyxos.backup.storage.api import org.calyxos.backup.storage.backup.BackupSnapshot +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType public data class SnapshotItem( public val storedSnapshot: StoredSnapshot, @@ -21,7 +23,7 @@ public sealed class SnapshotResult { public data class StoredSnapshot( /** - * The unique ID of the current device/user combination chosen by the [StoragePlugin]. + * The unique ID of the current device/user combination chosen by the [Backend]. * It may include an '.sv' extension. */ public val userId: String, @@ -29,7 +31,14 @@ public data class StoredSnapshot( * The timestamp identifying a snapshot of the [userId]. */ public val timestamp: Long, -) +) { + public val androidId: String = userId.substringBefore(".sv") + public val snapshotHandle: FileBackupFileType.Snapshot + get() = FileBackupFileType.Snapshot( + androidId = androidId, + time = timestamp, + ) +} /** * Defines which backup snapshots should be retained when pruning backups. diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt index d2ed1855..bedd5dcf 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt @@ -5,10 +5,13 @@ package org.calyxos.backup.storage.api +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.provider.DocumentsContract.isTreeUri import android.provider.MediaStore +import android.provider.Settings +import android.provider.Settings.Secure.ANDROID_ID import android.util.Log import androidx.annotation.WorkerThread import androidx.room.Room @@ -16,13 +19,13 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import org.calyxos.backup.storage.SnapshotRetriever import org.calyxos.backup.storage.backup.Backup import org.calyxos.backup.storage.backup.BackupSnapshot import org.calyxos.backup.storage.backup.ChunksCacheRepopulater import org.calyxos.backup.storage.db.Db -import org.calyxos.backup.storage.getDocumentPath +import org.calyxos.backup.storage.getCurrentBackupSnapshots import org.calyxos.backup.storage.getMediaType -import org.calyxos.backup.storage.plugin.SnapshotRetriever import org.calyxos.backup.storage.prune.Pruner import org.calyxos.backup.storage.prune.RetentionManager import org.calyxos.backup.storage.restore.FileRestore @@ -31,6 +34,10 @@ import org.calyxos.backup.storage.scanner.DocumentScanner import org.calyxos.backup.storage.scanner.FileScanner import org.calyxos.backup.storage.scanner.MediaScanner import org.calyxos.backup.storage.toStoredUri +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.saf.getDocumentPath +import org.calyxos.seedvault.core.crypto.KeyManager import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean @@ -38,7 +45,8 @@ private const val TAG = "StorageBackup" public class StorageBackup( private val context: Context, - private val pluginGetter: () -> StoragePlugin, + private val pluginGetter: () -> Backend, + private val keyManager: KeyManager, private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { @@ -48,19 +56,38 @@ public class StorageBackup( } private val uriStore by lazy { db.getUriStore() } + @SuppressLint("HardwareIds") + private val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) + private val mediaScanner by lazy { MediaScanner(context) } private val snapshotRetriever = SnapshotRetriever(pluginGetter) - private val chunksCacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever) + private val chunksCacheRepopulater = ChunksCacheRepopulater( + db = db, + storagePlugin = pluginGetter, + androidId = androidId, + snapshotRetriever = snapshotRetriever, + ) private val backup by lazy { val documentScanner = DocumentScanner(context) val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner) - Backup(context, db, fileScanner, pluginGetter, chunksCacheRepopulater) + Backup( + context = context, + db = db, + fileScanner = fileScanner, + backendGetter = pluginGetter, + androidId = androidId, + keyManager = keyManager, + cacheRepopulater = chunksCacheRepopulater + ) } private val restore by lazy { - Restore(context, pluginGetter, snapshotRetriever, FileRestore(context, mediaScanner)) + val fileRestore = FileRestore(context, mediaScanner) + Restore(context, pluginGetter, keyManager, snapshotRetriever, fileRestore) } private val retention = RetentionManager(context) - private val pruner by lazy { Pruner(db, retention, pluginGetter, snapshotRetriever) } + private val pruner by lazy { + Pruner(db, retention, pluginGetter, androidId, keyManager, snapshotRetriever) + } private val backupRunning = AtomicBoolean(false) private val restoreRunning = AtomicBoolean(false) @@ -108,7 +135,6 @@ public class StorageBackup( * (see [deleteAllSnapshots]) as well as clears local cache (see [clearCache]). */ public suspend fun init() { - pluginGetter().init() deleteAllSnapshots() clearCache() } @@ -118,13 +144,14 @@ public class StorageBackup( * (potentially encrypted with an old key) laying around. * Using a storage location with existing data is not supported. * Using the same root folder for storage on different devices or user profiles is fine though - * as the [StoragePlugin] should isolate storage per [StoredSnapshot.userId]. + * as the [Backend] should isolate storage per [StoredSnapshot.userId]. */ public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) { try { - pluginGetter().getCurrentBackupSnapshots().forEach { + pluginGetter().getCurrentBackupSnapshots(androidId).forEach { + val handle = FileBackupFileType.Snapshot(androidId, it.timestamp) try { - pluginGetter().deleteBackupSnapshot(it) + pluginGetter().remove(handle) } catch (e: IOException) { Log.e(TAG, "Error deleting snapshot $it", e) } diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/api/StoragePlugin.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/api/StoragePlugin.kt index a796b881..0a6677f7 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/api/StoragePlugin.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/api/StoragePlugin.kt @@ -8,8 +8,6 @@ package org.calyxos.backup.storage.api import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.security.KeyStore -import javax.crypto.SecretKey public interface StoragePlugin { @@ -28,16 +26,6 @@ public interface StoragePlugin { @Throws(IOException::class) public suspend fun getAvailableChunkIds(): List - /** - * Returns a [SecretKey] for HmacSHA256, ideally stored in the [KeyStore]. - */ - public fun getMasterKey(): SecretKey - - /** - * Returns true if the key for [getMasterKey] exists, false otherwise. - */ - public fun hasMasterKey(): Boolean - @Throws(IOException::class) public suspend fun getChunkOutputStream(chunkId: String): OutputStream @@ -48,8 +36,7 @@ public interface StoragePlugin { /** * Returns *all* [StoredSnapshot]s that are available on storage - * independent of user ID and whether they can be decrypted - * with the key returned by [getMasterKey]. + * independent of user ID and whether they can be decrypted with the main key. */ @Throws(IOException::class) public suspend fun getBackupSnapshotsForRestore(): List diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt index 545353ab..ad8548b4 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt @@ -12,13 +12,16 @@ import android.os.Build import android.text.format.Formatter import android.util.Log import org.calyxos.backup.storage.api.BackupObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.crypto.ChunkCrypto import org.calyxos.backup.storage.crypto.StreamCrypto import org.calyxos.backup.storage.db.Db import org.calyxos.backup.storage.measure import org.calyxos.backup.storage.scanner.FileScanner import org.calyxos.backup.storage.scanner.FileScannerResult +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder +import org.calyxos.seedvault.core.crypto.KeyManager import java.io.IOException import java.security.GeneralSecurityException import kotlin.time.Duration @@ -41,7 +44,9 @@ internal class Backup( private val context: Context, private val db: Db, private val fileScanner: FileScanner, - private val storagePluginGetter: () -> StoragePlugin, + private val backendGetter: () -> Backend, + private val androidId: String, + keyManager: KeyManager, private val cacheRepopulater: ChunksCacheRepopulater, chunkSizeMax: Int = CHUNK_SIZE_MAX, private val streamCrypto: StreamCrypto = StreamCrypto, @@ -55,21 +60,22 @@ internal class Backup( } private val contentResolver = context.contentResolver - private val storagePlugin get() = storagePluginGetter() + private val backend get() = backendGetter() private val filesCache = db.getFilesCache() private val chunksCache = db.getChunksCache() private val mac = try { - ChunkCrypto.getMac(ChunkCrypto.deriveChunkIdKey(storagePlugin.getMasterKey())) + ChunkCrypto.getMac(ChunkCrypto.deriveChunkIdKey(keyManager.getMainKey())) } catch (e: GeneralSecurityException) { throw AssertionError(e) } private val streamKey = try { - streamCrypto.deriveStreamKey(storagePlugin.getMasterKey()) + streamCrypto.deriveStreamKey(keyManager.getMainKey()) } catch (e: GeneralSecurityException) { throw AssertionError(e) } - private val chunkWriter = ChunkWriter(streamCrypto, streamKey, chunksCache, storagePlugin) + private val chunkWriter = + ChunkWriter(streamCrypto, streamKey, chunksCache, backendGetter, androidId) private val hasMediaAccessPerm = context.checkSelfPermission(ACCESS_MEDIA_LOCATION) == PERMISSION_GRANTED private val fileBackup = FileBackup( @@ -93,7 +99,12 @@ internal class Backup( try { // get available chunks, so we do not need to rely solely on local cache // for checking if a chunk already exists on storage - val availableChunkIds = storagePlugin.getAvailableChunkIds().toHashSet() + val chunkIds = ArrayList() + val topLevelFolder = TopLevelFolder.fromAndroidId(androidId) + backend.list(topLevelFolder, FileBackupFileType.Blob::class) { fileInfo -> + chunkIds.add(fileInfo.fileHandle.name) + } + val availableChunkIds = chunkIds.toHashSet() if (!chunksCache.areAllAvailableChunksCached(db, availableChunkIds)) { cacheRepopulater.repopulate(streamKey, availableChunkIds) } @@ -152,7 +163,8 @@ internal class Backup( .setTimeStart(startTime) .setTimeEnd(endTime) .build() - storagePlugin.getBackupSnapshotOutputStream(startTime).use { outputStream -> + val fileHandle = FileBackupFileType.Snapshot(androidId, startTime) + backend.save(fileHandle).use { outputStream -> outputStream.write(VERSION.toInt()) val ad = streamCrypto.getAssociatedDataForSnapshot(startTime) streamCrypto.newEncryptingStream(streamKey, outputStream, ad) diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt index dd81b526..1424ed30 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt @@ -6,10 +6,11 @@ package org.calyxos.backup.storage.backup import android.util.Log -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.backup.Backup.Companion.VERSION import org.calyxos.backup.storage.crypto.StreamCrypto import org.calyxos.backup.storage.db.ChunksCache +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream @@ -30,10 +31,12 @@ internal class ChunkWriter( private val streamCrypto: StreamCrypto, private val streamKey: ByteArray, private val chunksCache: ChunksCache, - private val storagePlugin: StoragePlugin, + private val backendGetter: () -> Backend, + private val androidId: String, private val bufferSize: Int = DEFAULT_BUFFER_SIZE, ) { + private val backend get() = backendGetter() private val buffer = ByteArray(bufferSize) @Throws(IOException::class, GeneralSecurityException::class) @@ -68,7 +71,8 @@ internal class ChunkWriter( @Throws(IOException::class, GeneralSecurityException::class) private suspend fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) { - storagePlugin.getChunkOutputStream(chunkId).use { chunkStream -> + val handle = FileBackupFileType.Blob(androidId, chunkId) + backend.save(handle).use { chunkStream -> chunkStream.write(VERSION.toInt()) val ad = streamCrypto.getAssociatedDataForChunk(chunkId) streamCrypto.newEncryptingStream(streamKey, chunkStream, ad).use { encryptingStream -> diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Chunker.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Chunker.kt index 4a31343a..4a52f0b9 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Chunker.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Chunker.kt @@ -6,7 +6,7 @@ package org.calyxos.backup.storage.backup import org.calyxos.backup.storage.db.CachedChunk -import org.calyxos.backup.storage.toHexString +import org.calyxos.seedvault.core.toHexString import java.io.IOException import java.io.InputStream import javax.crypto.Mac diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt index b29ca369..2edfd5cb 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt @@ -6,22 +6,24 @@ package org.calyxos.backup.storage.backup import android.util.Log -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.db.CachedChunk import org.calyxos.backup.storage.db.Db import org.calyxos.backup.storage.measure -import org.calyxos.backup.storage.plugin.SnapshotRetriever +import org.calyxos.backup.storage.SnapshotRetriever +import org.calyxos.backup.storage.getCurrentBackupSnapshots +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType import java.io.IOException import java.security.GeneralSecurityException import kotlin.time.DurationUnit.MILLISECONDS -import kotlin.time.ExperimentalTime import kotlin.time.toDuration private const val TAG = "ChunksCacheRepopulater" internal class ChunksCacheRepopulater( private val db: Db, - private val storagePlugin: () -> StoragePlugin, + private val storagePlugin: () -> Backend, + private val androidId: String, private val snapshotRetriever: SnapshotRetriever, ) { @@ -36,20 +38,20 @@ internal class ChunksCacheRepopulater( } @Throws(IOException::class) - @OptIn(ExperimentalTime::class) private suspend fun repopulateInternal( streamKey: ByteArray, availableChunkIds: HashSet, ) { val start = System.currentTimeMillis() - val snapshots = storagePlugin().getCurrentBackupSnapshots().mapNotNull { storedSnapshot -> - try { - snapshotRetriever.getSnapshot(streamKey, storedSnapshot) - } catch (e: GeneralSecurityException) { - Log.w(TAG, "Error fetching snapshot $storedSnapshot", e) - null + val snapshots = + storagePlugin().getCurrentBackupSnapshots(androidId).mapNotNull { storedSnapshot -> + try { + snapshotRetriever.getSnapshot(streamKey, storedSnapshot) + } catch (e: GeneralSecurityException) { + Log.w(TAG, "Error fetching snapshot $storedSnapshot", e) + null + } } - } val snapshotDuration = (System.currentTimeMillis() - start).toDuration(MILLISECONDS) Log.i(TAG, "Retrieving and parsing all snapshots took $snapshotDuration") @@ -60,9 +62,12 @@ internal class ChunksCacheRepopulater( Log.i(TAG, "Repopulating chunks cache took $repopulateDuration") // delete chunks that are not references by any snapshot anymore - val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id }) + val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id }.toSet()) val deletionDuration = measure { - storagePlugin().deleteChunks(chunksToDelete.toList()) + chunksToDelete.forEach { chunkId -> + val handle = FileBackupFileType.Blob(androidId, chunkId) + storagePlugin().remove(handle) + } } Log.i(TAG, "Deleting ${chunksToDelete.size} chunks took $deletionDuration") } diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/FileBackup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/FileBackup.kt index f9f516e7..6845e412 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/FileBackup.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/FileBackup.kt @@ -13,7 +13,7 @@ import org.calyxos.backup.storage.content.DocFile import org.calyxos.backup.storage.content.MediaFile import org.calyxos.backup.storage.db.CachedFile import org.calyxos.backup.storage.db.FilesCache -import org.calyxos.backup.storage.openInputStream +import org.calyxos.seedvault.core.backends.saf.openInputStream import java.io.IOException import java.security.GeneralSecurityException diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/SmallFileBackup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/SmallFileBackup.kt index 3d6d2cc3..a1b7b493 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/SmallFileBackup.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/SmallFileBackup.kt @@ -13,11 +13,10 @@ import org.calyxos.backup.storage.content.DocFile import org.calyxos.backup.storage.content.MediaFile import org.calyxos.backup.storage.db.CachedFile import org.calyxos.backup.storage.db.FilesCache -import org.calyxos.backup.storage.openInputStream +import org.calyxos.seedvault.core.backends.saf.openInputStream import java.io.IOException import java.security.GeneralSecurityException -@Suppress("BlockingMethodInNonBlockingContext") internal class SmallFileBackup( private val contentResolver: ContentResolver, private val filesCache: FilesCache, diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt index 61532dd5..295ac82e 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt @@ -7,7 +7,7 @@ package org.calyxos.backup.storage.backup import org.calyxos.backup.storage.content.ContentFile import org.calyxos.backup.storage.db.CachedChunk -import org.calyxos.backup.storage.toHexString +import org.calyxos.seedvault.core.toHexString import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/crypto/StreamCrypto.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/crypto/StreamCrypto.kt index ff4891cd..ad9f350c 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/crypto/StreamCrypto.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/crypto/StreamCrypto.kt @@ -9,7 +9,7 @@ import com.google.crypto.tink.subtle.AesGcmHkdfStreaming import org.calyxos.backup.storage.backup.Backup.Companion.VERSION import org.calyxos.backup.storage.crypto.Hkdf.ALGORITHM_HMAC import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES -import org.calyxos.backup.storage.toByteArrayFromHex +import org.calyxos.seedvault.core.toByteArrayFromHex import java.io.IOException import java.io.InputStream import java.io.OutputStream diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt deleted file mode 100644 index 0eb63764..00000000 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.calyxos.backup.storage.plugin - -import com.google.protobuf.InvalidProtocolBufferException -import org.calyxos.backup.storage.api.StoragePlugin -import org.calyxos.backup.storage.api.StoredSnapshot -import org.calyxos.backup.storage.backup.BackupSnapshot -import org.calyxos.backup.storage.crypto.StreamCrypto -import org.calyxos.backup.storage.restore.readVersion -import java.io.IOException -import java.security.GeneralSecurityException - -internal class SnapshotRetriever( - private val storagePlugin: () -> StoragePlugin, - private val streamCrypto: StreamCrypto = StreamCrypto, -) { - - @Throws( - IOException::class, - GeneralSecurityException::class, - InvalidProtocolBufferException::class, - ) - suspend fun getSnapshot(streamKey: ByteArray, storedSnapshot: StoredSnapshot): BackupSnapshot { - return storagePlugin().getBackupSnapshotInputStream(storedSnapshot).use { inputStream -> - val version = inputStream.readVersion() - val timestamp = storedSnapshot.timestamp - val ad = streamCrypto.getAssociatedDataForSnapshot(timestamp, version.toByte()) - streamCrypto.newDecryptingStream(streamKey, inputStream, ad).use { decryptedStream -> - BackupSnapshot.parseFrom(decryptedStream) - } - } - } - -} diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/DocumentFileExt.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/DocumentFileExt.kt deleted file mode 100644 index fada3eea..00000000 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/DocumentFileExt.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -@file:Suppress("BlockingMethodInNonBlockingContext") - -package org.calyxos.backup.storage.plugin.saf - -import android.annotation.SuppressLint -import android.content.ContentResolver -import android.content.Context -import android.database.ContentObserver -import android.database.Cursor -import android.net.Uri -import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID -import android.provider.DocumentsContract.EXTRA_LOADING -import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree -import android.provider.DocumentsContract.buildDocumentUriUsingTree -import android.provider.DocumentsContract.getDocumentId -import android.util.Log -import androidx.annotation.VisibleForTesting -import androidx.documentfile.provider.DocumentFile -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import org.calyxos.backup.storage.openInputStream -import org.calyxos.backup.storage.openOutputStream -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import kotlin.coroutines.resume - -public object DocumentFileExt { - - private const val TAG = "DocumentFileExt" - - @Throws(IOException::class) - public fun DocumentFile.getInputStream(contentResolver: ContentResolver): InputStream { - return uri.openInputStream(contentResolver) - } - - @Throws(IOException::class) - public fun DocumentFile.getOutputStream(contentResolver: ContentResolver): OutputStream { - return uri.openOutputStream(contentResolver) - } - - @Throws(IOException::class) - public fun DocumentFile.createDirectoryOrThrow(name: String): DocumentFile { - val directory = createDirectory(name) - ?: throw IOException("Unable to create directory: $name") - if (directory.name != name) { - directory.delete() - throw IOException("Wanted to directory $name, but got ${directory.name}") - } - return directory - } - - @Throws(IOException::class) - public fun DocumentFile.createFileOrThrow(name: String, mimeType: String): DocumentFile { - val file = createFile(mimeType, name) ?: throw IOException("Unable to create file: $name") - if (file.name != name) { - file.delete() - if (file.name == null) { // this happens when file existed already - // try to find the original file we were looking for - val foundFile = findFile(name) - if (foundFile?.name == name) return foundFile - } - throw IOException("Wanted to create $name, but got ${file.name}") - } - return file - } - - /** - * Works like [DocumentFile.listFiles] except - * that it waits until the DocumentProvider has a result. - * This prevents getting an empty list even though there are children to be listed. - */ - @Throws(IOException::class) - public suspend fun DocumentFile.listFilesBlocking(context: Context): List { - val resolver = context.contentResolver - val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) - val projection = arrayOf(COLUMN_DOCUMENT_ID) - val result = ArrayList() - - try { - getLoadedCursor { - resolver.query(childrenUri, projection, null, null, null) - } - } catch (e: TimeoutCancellationException) { - throw IOException(e) - }.use { cursor -> - while (cursor.moveToNext()) { - val documentId = cursor.getString(0) - val documentUri = buildDocumentUriUsingTree(uri, documentId) - result.add(getTreeDocumentFile(this, context, documentUri)) - } - } - return result - } - - /** - * An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent. - * - * All other public ways to get a TreeDocumentFile only work from [Uri]s - * (e.g. [DocumentFile.fromTreeUri]) and always set parent to null. - * - * We have a test for this method to ensure CI will alert us when this reflection breaks. - * Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails. - */ - @VisibleForTesting - @SuppressLint("CheckedExceptions") - public fun getTreeDocumentFile( - parent: DocumentFile, - context: Context, - uri: Uri, - ): DocumentFile { - @SuppressWarnings("MagicNumber") - val constructor = parent.javaClass.declaredConstructors.find { - it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3 - } - check(constructor != null) { "Could not find constructor for TreeDocumentFile" } - constructor.isAccessible = true - return constructor.newInstance(parent, context, uri) as DocumentFile - } - - /** - * Same as [DocumentFile.findFile] only that it re-queries when the first result was stale. - * - * Most documents providers including Nextcloud are listing the full directory content - * when querying for a specific file in a directory, - * so there is no point in trying to optimize the query by not listing all children. - */ - public suspend fun DocumentFile.findFileBlocking( - context: Context, - displayName: String, - ): DocumentFile? { - val files = try { - listFilesBlocking(context) - } catch (e: IOException) { - Log.e(TAG, "Error finding file blocking", e) - return null - } - for (doc in files) { - if (displayName == doc.name) return doc - } - return null - } - - /** - * Returns a cursor for the given query while ensuring that the cursor was loaded. - * - * When the SAF backend is a cloud storage provider (e.g. Nextcloud), - * it can happen that the query returns an outdated (e.g. empty) cursor - * which will only be updated in response to this query. - * - * See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html - * - * This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver] - * registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]). - * If the cursor is not loading, it will be returned right away. - * - * @param timeout an optional time-out in milliseconds - * @throws TimeoutCancellationException if there was no result before the time-out - * @throws IOException if the query returns null - */ - @VisibleForTesting - @Throws(IOException::class, TimeoutCancellationException::class) - public suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?): Cursor = - withTimeout(timeout) { - suspendCancellableCoroutine { cont -> - val cursor = query() ?: throw IOException() - cont.invokeOnCancellation { cursor.close() } - val loading = cursor.extras.getBoolean(EXTRA_LOADING, false) - if (loading) { - Log.d(TAG, "Wait for children to get loaded...") - cursor.registerContentObserver(object : ContentObserver(null) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - Log.d(TAG, "Children loaded. Continue...") - cursor.close() - val newCursor = query() - if (newCursor == null) { - cont.cancel(IOException("query returned no results")) - } else cont.resume(newCursor) - } - }) - } else { - // not loading, return cursor right away - cont.resume(cursor) - } - } - } - -} diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafCache.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafCache.kt deleted file mode 100644 index 993dcc4c..00000000 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafCache.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.calyxos.backup.storage.plugin.saf - -import androidx.documentfile.provider.DocumentFile -import org.calyxos.backup.storage.api.StoredSnapshot -import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT - -/** - * Accessing files and attributes via SAF is costly. - * This class caches them to speed up SAF related operations. - */ -internal class SafCache { - - /** - * The folder for the current user ID (here "${ANDROID_ID}.sv"). - */ - var currentFolder: DocumentFile? = null - - /** - * Folders containing chunks for backups of the current user ID. - */ - val backupChunkFolders = HashMap(CHUNK_FOLDER_COUNT) - - /** - * Folders containing chunks for restore of a chosen [StoredSnapshot]. - */ - val restoreChunkFolders = HashMap(CHUNK_FOLDER_COUNT) - - /** - * Files for each [StoredSnapshot]. - */ - val snapshotFiles = HashMap() - -} diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt deleted file mode 100644 index 1d152f01..00000000 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt +++ /dev/null @@ -1,291 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The Calyx Institute - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.calyxos.backup.storage.plugin.saf - -import android.annotation.SuppressLint -import android.content.Context -import android.provider.Settings -import android.provider.Settings.Secure.ANDROID_ID -import android.util.Log -import androidx.documentfile.provider.DocumentFile -import org.calyxos.backup.storage.api.StoragePlugin -import org.calyxos.backup.storage.api.StoredSnapshot -import org.calyxos.backup.storage.measure -import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT -import org.calyxos.backup.storage.plugin.PluginConstants.MIME_TYPE -import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT -import org.calyxos.backup.storage.plugin.PluginConstants.chunkFolderRegex -import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex -import org.calyxos.backup.storage.plugin.PluginConstants.folderRegex -import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex -import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createDirectoryOrThrow -import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createFileOrThrow -import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.findFileBlocking -import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.getInputStream -import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.getOutputStream -import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.listFilesBlocking -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import kotlin.time.ExperimentalTime - -private const val TAG = "SafStoragePlugin" - -/** - * @param appContext application context provided by the storage module - */ -@Suppress("BlockingMethodInNonBlockingContext") -public abstract class SafStoragePlugin( - private val appContext: Context, -) : StoragePlugin { - /** - * Attention: This context could be unexpected. E.g. the system user's application context, - * in the case of USB storage, if INTERACT_ACROSS_USERS_FULL permission is granted. - * Use [appContext], if you need the context of the current app and user - * and [context] for all file access. - */ - protected abstract val context: Context - protected abstract val root: DocumentFile? - private val cache = SafCache() - - private val folder: DocumentFile? - get() { - val root = this.root ?: return null - if (cache.currentFolder != null) return cache.currentFolder - - @SuppressLint("HardwareIds") - // This is unique to each combination of app-signing key, user, and device - // so we don't leak anything by not hashing this and can use it as is. - // Note: Use [appContext] here to not get the wrong ID for a different user. - val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID) - // the folder name is our user ID - val folderName = "$androidId.sv" - cache.currentFolder = try { - root.findFile(folderName) ?: root.createDirectoryOrThrow(folderName) - } catch (e: IOException) { - Log.e(TAG, "Error creating storage folder $folderName") - null - } - return cache.currentFolder - } - - private fun timestampToSnapshot(timestamp: Long): String { - return "$timestamp$SNAPSHOT_EXT" - } - - override suspend fun init() { - // no-op as we are getting [root] created from super class - } - - @Throws(IOException::class) - override suspend fun getAvailableChunkIds(): List { - val folder = folder ?: return emptyList() - val chunkIds = ArrayList() - populateChunkFolders(folder, cache.backupChunkFolders) { file, name -> - if (chunkFolderRegex.matches(name)) { - chunkIds.addAll(getChunksFromFolder(file)) - } - } - Log.i(TAG, "Got ${chunkIds.size} available chunks") - return chunkIds - } - - /** - * Goes through all files in the given [folder] and performs the optional [fileOp] on them. - * Afterwards, it creates missing chunk folders, as needed. - * Chunk folders will get cached in the given [chunkFolders] for faster access. - */ - @Throws(IOException::class) - @OptIn(ExperimentalTime::class) - private suspend fun populateChunkFolders( - folder: DocumentFile, - chunkFolders: HashMap, - fileOp: ((DocumentFile, String) -> Unit)? = null, - ) { - val expectedChunkFolders = (0x00..0xff).map { - Integer.toHexString(it).padStart(2, '0') - }.toHashSet() - val duration = measure { - for (file in folder.listFilesBlocking(context)) { - val name = file.name ?: continue - if (chunkFolderRegex.matches(name)) { - chunkFolders[name] = file - expectedChunkFolders.remove(name) - } - fileOp?.invoke(file, name) - } - } - Log.i(TAG, "Retrieving chunk folders took $duration") - createMissingChunkFolders(folder, chunkFolders, expectedChunkFolders) - } - - @Throws(IOException::class) - private fun getChunksFromFolder(chunkFolder: DocumentFile): List { - val chunkFiles = try { - chunkFolder.listFiles() - } catch (e: UnsupportedOperationException) { - // can happen if this wasn't a directory after all - throw IOException(e) - } - return chunkFiles.mapNotNull { chunkFile -> - val name = chunkFile.name ?: return@mapNotNull null - if (chunkRegex.matches(name)) name else null - } - } - - @Throws(IOException::class) - @OptIn(ExperimentalTime::class) - private fun createMissingChunkFolders( - root: DocumentFile, - chunkFolders: HashMap, - expectedChunkFolders: Set, - ) { - val s = expectedChunkFolders.size - val duration = measure { - for ((i, chunkFolderName) in expectedChunkFolders.withIndex()) { - val file = root.createDirectoryOrThrow(chunkFolderName) - chunkFolders[chunkFolderName] = file - Log.d(TAG, "Created missing folder $chunkFolderName (${i + 1}/$s)") - } - if (chunkFolders.size != 256) { - throw IOException("Only have ${chunkFolders.size} chunk folders.") - } - } - if (s > 0) Log.i(TAG, "Creating $s missing chunk folders took $duration") - } - - @Throws(IOException::class) - override suspend fun getChunkOutputStream(chunkId: String): OutputStream { - val chunkFolderName = chunkId.substring(0, 2) - val chunkFolder = - cache.backupChunkFolders[chunkFolderName] ?: error("No folder for chunk $chunkId") - // TODO should we check if it exists first? - val chunkFile = chunkFolder.createFileOrThrow(chunkId, MIME_TYPE) - return chunkFile.getOutputStream(context.contentResolver) - } - - @Throws(IOException::class) - override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { - val folder = folder ?: throw IOException() - val name = timestampToSnapshot(timestamp) - // TODO should we check if it exists first? - val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE) - return snapshotFile.getOutputStream(context.contentResolver) - } - - /************************* Restore *******************************/ - - @Throws(IOException::class) - override suspend fun getBackupSnapshotsForRestore(): List { - val snapshots = ArrayList() - - root?.listFilesBlocking(context)?.forEach { folder -> - val folderName = folder.name ?: "" - if (!folderRegex.matches(folderName)) return@forEach - - Log.i(TAG, "Checking $folderName for snapshots...") - for (file in folder.listFilesBlocking(context)) { - val name = file.name ?: continue - val match = snapshotRegex.matchEntire(name) - if (match != null) { - val timestamp = match.groupValues[1].toLong() - val storedSnapshot = StoredSnapshot(folderName, timestamp) - snapshots.add(storedSnapshot) - cache.snapshotFiles[storedSnapshot] = file - } - } - } - Log.i(TAG, "Got ${snapshots.size} snapshots while populating chunk folders") - return snapshots - } - - @Throws(IOException::class) - override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream { - val timestamp = storedSnapshot.timestamp - val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) { - getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp)) - } ?: throw IOException("Could not get file for snapshot $timestamp") - return snapshotFile.getInputStream(context.contentResolver) - } - - @Throws(IOException::class) - override suspend fun getChunkInputStream( - snapshot: StoredSnapshot, - chunkId: String, - ): InputStream { - if (cache.restoreChunkFolders.size < CHUNK_FOLDER_COUNT) { - populateChunkFolders(getFolder(snapshot), cache.restoreChunkFolders) - } - val chunkFolderName = chunkId.substring(0, 2) - val chunkFolder = cache.restoreChunkFolders[chunkFolderName] - ?: throw IOException("No folder for chunk $chunkId") - val chunkFile = chunkFolder.findFileBlocking(context, chunkId) - ?: throw IOException("No chunk $chunkId") - return chunkFile.getInputStream(context.contentResolver) - } - - @Throws(IOException::class) - private suspend fun getFolder(storedSnapshot: StoredSnapshot): DocumentFile { - // not cached, because used in several places only once and - // [getBackupSnapshotInputStream] uses snapshot files cache and - // [getChunkInputStream] uses restore chunk folders cache - return root?.findFileBlocking(context, storedSnapshot.userId) - ?: throw IOException("Could not find snapshot $storedSnapshot") - } - - /************************* Pruning *******************************/ - - @Throws(IOException::class) - override suspend fun getCurrentBackupSnapshots(): List { - val folder = folder ?: return emptyList() - val folderName = folder.name ?: error("Folder suddenly has no more name") - val snapshots = ArrayList() - - populateChunkFolders(folder, cache.backupChunkFolders) { file, name -> - val match = snapshotRegex.matchEntire(name) - if (match != null) { - val timestamp = match.groupValues[1].toLong() - val storedSnapshot = StoredSnapshot(folderName, timestamp) - snapshots.add(storedSnapshot) - cache.snapshotFiles[storedSnapshot] = file - } - } - Log.i(TAG, "Got ${snapshots.size} snapshots while populating chunk folders") - return snapshots - } - - @Throws(IOException::class) - override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) { - val timestamp = storedSnapshot.timestamp - Log.d(TAG, "Deleting snapshot $timestamp") - val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) { - getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp)) - } ?: throw IOException("Could not get file for snapshot $timestamp") - if (!snapshotFile.delete()) throw IOException("Could not delete snapshot $timestamp") - cache.snapshotFiles.remove(storedSnapshot) - } - - @Throws(IOException::class) - override suspend fun deleteChunks(chunkIds: List) { - if (cache.backupChunkFolders.size < CHUNK_FOLDER_COUNT) { - val folder = folder ?: throw IOException("Could not get current folder in root") - populateChunkFolders(folder, cache.backupChunkFolders) - } - for (chunkId in chunkIds) { - Log.d(TAG, "Deleting chunk $chunkId") - val chunkFolderName = chunkId.substring(0, 2) - val chunkFolder = cache.backupChunkFolders[chunkFolderName] - ?: throw IOException("No folder for chunk $chunkId") - val chunkFile = chunkFolder.findFileBlocking(context, chunkId) - if (chunkFile == null) { - Log.w(TAG, "Could not find $chunkId") - } else { - if (!chunkFile.delete()) throw IOException("Could not delete chunk $chunkId") - } - } - } - -} diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt index c6c91f35..90f94013 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt @@ -7,39 +7,42 @@ package org.calyxos.backup.storage.prune import android.util.Log import org.calyxos.backup.storage.api.BackupObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.crypto.StreamCrypto import org.calyxos.backup.storage.db.Db import org.calyxos.backup.storage.measure -import org.calyxos.backup.storage.plugin.SnapshotRetriever +import org.calyxos.backup.storage.SnapshotRetriever +import org.calyxos.backup.storage.getCurrentBackupSnapshots +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.crypto.KeyManager import java.io.IOException import java.security.GeneralSecurityException -import kotlin.time.ExperimentalTime private val TAG = Pruner::class.java.simpleName internal class Pruner( private val db: Db, private val retentionManager: RetentionManager, - private val storagePluginGetter: () -> StoragePlugin, + private val storagePluginGetter: () -> Backend, + private val androidId: String, + keyManager: KeyManager, private val snapshotRetriever: SnapshotRetriever, streamCrypto: StreamCrypto = StreamCrypto, ) { - private val storagePlugin get() = storagePluginGetter() + private val backend get() = storagePluginGetter() private val chunksCache = db.getChunksCache() private val streamKey = try { - streamCrypto.deriveStreamKey(storagePlugin.getMasterKey()) + streamCrypto.deriveStreamKey(keyManager.getMainKey()) } catch (e: GeneralSecurityException) { throw AssertionError(e) } - @OptIn(ExperimentalTime::class) @Throws(IOException::class) suspend fun prune(backupObserver: BackupObserver?) { val duration = measure { - val storedSnapshots = storagePlugin.getCurrentBackupSnapshots() + val storedSnapshots = backend.getCurrentBackupSnapshots(androidId) val toDelete = retentionManager.getSnapshotsToDelete(storedSnapshots) backupObserver?.onPruneStart(toDelete.map { it.timestamp }) for (snapshot in toDelete) { @@ -64,7 +67,7 @@ internal class Pruner( val chunks = HashSet() snapshot.mediaFilesList.forEach { chunks.addAll(it.chunkIdsList) } snapshot.documentFilesList.forEach { chunks.addAll(it.chunkIdsList) } - storagePlugin.deleteBackupSnapshot(storedSnapshot) + backend.remove(storedSnapshot.snapshotHandle) db.applyInParts(chunks) { chunksCache.decrementRefCount(it) } @@ -78,7 +81,9 @@ internal class Pruner( it.id } backupObserver?.onPruneSnapshot(storedSnapshot.timestamp, chunkIdsToDelete.size, size) - storagePlugin.deleteChunks(chunkIdsToDelete) + chunkIdsToDelete.forEach { chunkId -> + backend.remove(FileBackupFileType.Blob(androidId, chunkId)) + } chunksCache.deleteChunks(cachedChunksToDelete) } diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt index 221aed56..73fd164c 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt @@ -6,22 +6,23 @@ package org.calyxos.backup.storage.restore import org.calyxos.backup.storage.api.RestoreObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.crypto.StreamCrypto +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType.Blob import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.security.GeneralSecurityException internal abstract class AbstractChunkRestore( - private val storagePluginGetter: () -> StoragePlugin, + private val backendGetter: () -> Backend, private val fileRestore: FileRestore, private val streamCrypto: StreamCrypto, private val streamKey: ByteArray, ) { - private val storagePlugin get() = storagePluginGetter() + private val backend get() = backendGetter() @Throws(IOException::class, GeneralSecurityException::class) protected suspend fun getAndDecryptChunk( @@ -30,7 +31,7 @@ internal abstract class AbstractChunkRestore( chunkId: String, streamReader: suspend (InputStream) -> Unit, ) { - storagePlugin.getChunkInputStream(storedSnapshot, chunkId).use { inputStream -> + backend.load(Blob(storedSnapshot.androidId, chunkId)).use { inputStream -> inputStream.readVersion(version) val ad = streamCrypto.getAssociatedDataForChunk(chunkId, version.toByte()) streamCrypto.newDecryptingStream(streamKey, inputStream, ad).use { decryptedStream -> diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/FileRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/FileRestore.kt index facf90c3..f4963b48 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/FileRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/FileRestore.kt @@ -15,8 +15,8 @@ import android.util.Log import org.calyxos.backup.storage.api.MediaType import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.backup.BackupMediaFile -import org.calyxos.backup.storage.openOutputStream import org.calyxos.backup.storage.scanner.MediaScanner +import org.calyxos.seedvault.core.backends.saf.openOutputStream import java.io.File import java.io.IOException import java.io.OutputStream diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt index 2b5f8e6d..4ca90c46 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt @@ -8,9 +8,9 @@ package org.calyxos.backup.storage.restore import android.content.Context import android.util.Log import org.calyxos.backup.storage.api.RestoreObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.crypto.StreamCrypto +import org.calyxos.seedvault.core.backends.Backend import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -24,11 +24,11 @@ private const val TAG = "MultiChunkRestore" @Suppress("BlockingMethodInNonBlockingContext") internal class MultiChunkRestore( private val context: Context, - storagePlugin: () -> StoragePlugin, + backendGetter: () -> Backend, fileRestore: FileRestore, streamCrypto: StreamCrypto, streamKey: ByteArray, -) : AbstractChunkRestore(storagePlugin, fileRestore, streamCrypto, streamKey) { +) : AbstractChunkRestore(backendGetter, fileRestore, streamCrypto, streamKey) { suspend fun restore( version: Int, diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt index 13e6609d..caebc431 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt @@ -12,13 +12,15 @@ import kotlinx.coroutines.flow.flow import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.SnapshotResult -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.backup.Backup import org.calyxos.backup.storage.backup.BackupSnapshot import org.calyxos.backup.storage.crypto.StreamCrypto import org.calyxos.backup.storage.measure -import org.calyxos.backup.storage.plugin.SnapshotRetriever +import org.calyxos.backup.storage.SnapshotRetriever +import org.calyxos.backup.storage.getBackupSnapshotsForRestore +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.crypto.KeyManager import java.io.IOException import java.io.InputStream import java.security.GeneralSecurityException @@ -27,19 +29,20 @@ private const val TAG = "Restore" internal class Restore( context: Context, - private val storagePluginGetter: () -> StoragePlugin, + private val backendGetter: () -> Backend, + private val keyManager: KeyManager, private val snapshotRetriever: SnapshotRetriever, fileRestore: FileRestore, streamCrypto: StreamCrypto = StreamCrypto, ) { - private val storagePlugin get() = storagePluginGetter() + private val backend get() = backendGetter() private val streamKey by lazy { - // This class might get instantiated before the StoragePlugin had time to provide the key + // This class might get instantiated before the Backend had time to provide the key // so we need to get it lazily here to prevent crashes. We can still crash later, // if the plugin is not providing a key as it should when performing calls into this class. try { - streamCrypto.deriveStreamKey(storagePlugin.getMasterKey()) + streamCrypto.deriveStreamKey(keyManager.getMainKey()) } catch (e: GeneralSecurityException) { throw AssertionError(e) } @@ -47,13 +50,13 @@ internal class Restore( // lazily instantiate these, so they don't try to get the streamKey too early private val zipChunkRestore by lazy { - ZipChunkRestore(storagePluginGetter, fileRestore, streamCrypto, streamKey) + ZipChunkRestore(backendGetter, fileRestore, streamCrypto, streamKey) } private val singleChunkRestore by lazy { - SingleChunkRestore(storagePluginGetter, fileRestore, streamCrypto, streamKey) + SingleChunkRestore(backendGetter, fileRestore, streamCrypto, streamKey) } private val multiChunkRestore by lazy { - MultiChunkRestore(context, storagePluginGetter, fileRestore, streamCrypto, streamKey) + MultiChunkRestore(context, backendGetter, fileRestore, streamCrypto, streamKey) } fun getBackupSnapshots(): Flow = flow { @@ -61,7 +64,7 @@ internal class Restore( val time = measure { val list = try { // get all available backups, they may not be usable - storagePlugin.getBackupSnapshotsForRestore().sortedByDescending { storedSnapshot -> + backend.getBackupSnapshotsForRestore().sortedByDescending { storedSnapshot -> storedSnapshot.timestamp }.map { storedSnapshot -> // as long as snapshot is null, it can't be used for restore diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt index a9fa530b..d22fc520 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt @@ -7,18 +7,18 @@ package org.calyxos.backup.storage.restore import android.util.Log import org.calyxos.backup.storage.api.RestoreObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.crypto.StreamCrypto +import org.calyxos.seedvault.core.backends.Backend private const val TAG = "SingleChunkRestore" internal class SingleChunkRestore( - storagePlugin: () -> StoragePlugin, + backendGetter: () -> Backend, fileRestore: FileRestore, streamCrypto: StreamCrypto, streamKey: ByteArray, -) : AbstractChunkRestore(storagePlugin, fileRestore, streamCrypto, streamKey) { +) : AbstractChunkRestore(backendGetter, fileRestore, streamCrypto, streamKey) { suspend fun restore( version: Int, diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt index 608668da..b64a17b1 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt @@ -7,9 +7,9 @@ package org.calyxos.backup.storage.restore import android.util.Log import org.calyxos.backup.storage.api.RestoreObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.crypto.StreamCrypto +import org.calyxos.seedvault.core.backends.Backend import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -18,11 +18,11 @@ import java.util.zip.ZipInputStream private const val TAG = "ZipChunkRestore" internal class ZipChunkRestore( - storagePlugin: () -> StoragePlugin, + backendGetter: () -> Backend, fileRestore: FileRestore, streamCrypto: StreamCrypto, streamKey: ByteArray, -) : AbstractChunkRestore(storagePlugin, fileRestore, streamCrypto, streamKey) { +) : AbstractChunkRestore(backendGetter, fileRestore, streamCrypto, streamKey) { /** * Assumes that files in [zipChunks] are sorted by zipIndex with no duplicate indices. diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/DocumentScanner.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/DocumentScanner.kt index 85e93718..aee3b0ae 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/DocumentScanner.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/DocumentScanner.kt @@ -14,8 +14,8 @@ import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull import org.calyxos.backup.storage.api.BackupFile import org.calyxos.backup.storage.content.DocFile -import org.calyxos.backup.storage.getDocumentPath -import org.calyxos.backup.storage.getVolume +import org.calyxos.seedvault.core.backends.saf.getDocumentPath +import org.calyxos.seedvault.core.backends.saf.getVolume public class DocumentScanner(context: Context) { diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt index dd53e98c..f5310b19 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt @@ -14,6 +14,7 @@ import android.text.format.Formatter import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -22,7 +23,6 @@ import io.mockk.slot import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import org.calyxos.backup.storage.api.SnapshotResult -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.backup.Backup import org.calyxos.backup.storage.backup.Backup.Companion.CHUNK_SIZE_MAX @@ -40,12 +40,15 @@ import org.calyxos.backup.storage.db.CachedFile import org.calyxos.backup.storage.db.ChunksCache import org.calyxos.backup.storage.db.Db import org.calyxos.backup.storage.db.FilesCache -import org.calyxos.backup.storage.plugin.SnapshotRetriever import org.calyxos.backup.storage.restore.FileRestore import org.calyxos.backup.storage.restore.RestorableFile import org.calyxos.backup.storage.restore.Restore import org.calyxos.backup.storage.scanner.FileScanner import org.calyxos.backup.storage.scanner.FileScannerResult +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType.Blob +import org.calyxos.seedvault.core.backends.FileBackupFileType.Snapshot +import org.calyxos.seedvault.core.crypto.KeyManager import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -70,24 +73,26 @@ internal class BackupRestoreTest { private val contentResolver: ContentResolver = mockk() private val fileScanner: FileScanner = mockk() - private val pluginGetter: () -> StoragePlugin = mockk() - private val plugin: StoragePlugin = mockk() + private val backendGetter: () -> Backend = mockk() + private val androidId: String = getRandomString() + private val keyManager: KeyManager = mockk() + private val backend: Backend = mockk() private val fileRestore: FileRestore = mockk() - private val snapshotRetriever = SnapshotRetriever(pluginGetter) + private val snapshotRetriever = SnapshotRetriever(backendGetter) private val cacheRepopulater: ChunksCacheRepopulater = mockk() init { mockLog() - + mockkStatic("org.calyxos.backup.storage.SnapshotRetrieverKt") mockkStatic(Formatter::class) every { Formatter.formatShortFileSize(any(), any()) } returns "" mockkStatic("org.calyxos.backup.storage.UriUtilsKt") - every { pluginGetter() } returns plugin + every { backendGetter() } returns backend every { db.getFilesCache() } returns filesCache every { db.getChunksCache() } returns chunksCache - every { plugin.getMasterKey() } returns SecretKeySpec( + every { keyManager.getMainKey() } returns SecretKeySpec( "This is a backup key for testing".toByteArray(), 0, KEY_SIZE_BYTES, ALGORITHM_HMAC ) @@ -95,11 +100,13 @@ internal class BackupRestoreTest { every { context.contentResolver } returns contentResolver } - private val restore = Restore(context, pluginGetter, snapshotRetriever, fileRestore) + private val restore = + Restore(context, backendGetter, keyManager, snapshotRetriever, fileRestore) @Test fun testZipAndSingleRandom(): Unit = runBlocking { - val backup = Backup(context, db, fileScanner, pluginGetter, cacheRepopulater) + val backup = + Backup(context, db, fileScanner, backendGetter, androidId, keyManager, cacheRepopulater) val smallFileMBytes = Random.nextBytes(Random.nextInt(SMALL_FILE_SIZE_MAX)) val smallFileM = getRandomMediaFile(smallFileMBytes.size) @@ -117,12 +124,12 @@ internal class BackupRestoreTest { val zipChunkOutputStream = ByteArrayOutputStream() val mOutputStream = ByteArrayOutputStream() val dOutputStream = ByteArrayOutputStream() - val snapshotTimestamp = slot() + val snapshotHandle = slot() val snapshotOutputStream = ByteArrayOutputStream() // provide files and empty cache val availableChunks = emptyList() - coEvery { plugin.getAvailableChunkIds() } returns availableChunks + coEvery { backend.list(any(), Blob::class, callback = any()) } just Runs every { chunksCache.areAllAvailableChunksCached(db, availableChunks.toHashSet()) } returns true @@ -150,16 +157,14 @@ internal class BackupRestoreTest { } returns ByteArrayInputStream(fileDBytes) andThen ByteArrayInputStream(fileDBytes) // output streams and caching - coEvery { plugin.getChunkOutputStream(any()) } returnsMany listOf( + coEvery { backend.save(any()) } returnsMany listOf( zipChunkOutputStream, mOutputStream, dOutputStream ) every { chunksCache.insert(any()) } just Runs every { filesCache.upsert(capture(cachedFiles)) } just Runs // snapshot writing - coEvery { - plugin.getBackupSnapshotOutputStream(capture(snapshotTimestamp)) - } returns snapshotOutputStream + coEvery { backend.save(capture(snapshotHandle)) } returns snapshotOutputStream every { db.applyInParts(any(), any()) } just Runs backup.runBackup(null) @@ -179,16 +184,16 @@ internal class BackupRestoreTest { // RESTORE - val storedSnapshot = StoredSnapshot("test", snapshotTimestamp.captured) + val storedSnapshot = StoredSnapshot("$androidId.sv", snapshotHandle.captured.time) val smallFileMOutputStream = ByteArrayOutputStream() val smallFileDOutputStream = ByteArrayOutputStream() val fileMOutputStream = ByteArrayOutputStream() val fileDOutputStream = ByteArrayOutputStream() - coEvery { plugin.getBackupSnapshotsForRestore() } returns listOf(storedSnapshot) + coEvery { backend.getBackupSnapshotsForRestore() } returns listOf(storedSnapshot) coEvery { - plugin.getBackupSnapshotInputStream(storedSnapshot) + backend.load(storedSnapshot.snapshotHandle) } returns ByteArrayInputStream(snapshotOutputStream.toByteArray()) // retrieve snapshots @@ -198,21 +203,21 @@ internal class BackupRestoreTest { assertEquals(2, snapshotResultList.size) val snapshots = (snapshotResultList[1] as SnapshotResult.Success).snapshots assertEquals(1, snapshots.size) - assertEquals(snapshotTimestamp.captured, snapshots[0].time) + assertEquals(snapshotHandle.captured.time, snapshots[0].time) val snapshot = snapshots[0].snapshot ?: error("snapshot was null") assertEquals(2, snapshot.mediaFilesList.size) assertEquals(2, snapshot.documentFilesList.size) // pipe chunks back in coEvery { - plugin.getChunkInputStream(storedSnapshot, cachedFiles[0].chunks[0]) + backend.load(Blob(androidId, cachedFiles[0].chunks[0])) } returns ByteArrayInputStream(zipChunkOutputStream.toByteArray()) // cachedFiles[0].chunks[1] is in previous zipChunk coEvery { - plugin.getChunkInputStream(storedSnapshot, cachedFiles[2].chunks[0]) + backend.load(Blob(androidId, cachedFiles[2].chunks[0])) } returns ByteArrayInputStream(mOutputStream.toByteArray()) coEvery { - plugin.getChunkInputStream(storedSnapshot, cachedFiles[3].chunks[0]) + backend.load(Blob(androidId, cachedFiles[3].chunks[0])) } returns ByteArrayInputStream(dOutputStream.toByteArray()) // provide file output streams for restore @@ -236,7 +241,16 @@ internal class BackupRestoreTest { @Test fun testMultiChunks(): Unit = runBlocking { - val backup = Backup(context, db, fileScanner, pluginGetter, cacheRepopulater, 4) + val backup = Backup( + context = context, + db = db, + fileScanner = fileScanner, + backendGetter = backendGetter, + androidId = androidId, + keyManager = keyManager, + cacheRepopulater = cacheRepopulater, + chunkSizeMax = 4, + ) val chunk1 = byteArrayOf(0x00, 0x01, 0x02, 0x03) val chunk2 = byteArrayOf(0x04, 0x05, 0x06, 0x07) @@ -248,7 +262,7 @@ internal class BackupRestoreTest { val file2 = getRandomDocFile(file2Bytes.size) val file1OutputStream = ByteArrayOutputStream() val file2OutputStream = ByteArrayOutputStream() - val snapshotTimestamp = slot() + val snapshotHandle = slot() val snapshotOutputStream = ByteArrayOutputStream() val scannedFiles = FileScannerResult( @@ -259,7 +273,7 @@ internal class BackupRestoreTest { // provide files and empty cache val availableChunks = emptyList() - coEvery { plugin.getAvailableChunkIds() } returns availableChunks + coEvery { backend.list(any(), Blob::class, callback = any()) } just Runs every { chunksCache.areAllAvailableChunksCached(db, availableChunks.toHashSet()) } returns true @@ -297,26 +311,38 @@ internal class BackupRestoreTest { // output streams for deterministic chunks val id040f32 = ByteArrayOutputStream() coEvery { - plugin.getChunkOutputStream( - "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3" + backend.save( + Blob( + androidId = androidId, + name = "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3", + ) ) } returns id040f32 val id901fbc = ByteArrayOutputStream() coEvery { - plugin.getChunkOutputStream( - "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29" + backend.save( + Blob( + androidId = androidId, + name = "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29", + ) ) } returns id901fbc val id5adea3 = ByteArrayOutputStream() coEvery { - plugin.getChunkOutputStream( - "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d" + backend.save( + Blob( + androidId = androidId, + name = "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d", + ) ) } returns id5adea3 val id40d00c = ByteArrayOutputStream() coEvery { - plugin.getChunkOutputStream( - "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67" + backend.save( + Blob( + androidId = androidId, + name = "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67", + ) ) } returns id40d00c @@ -324,32 +350,46 @@ internal class BackupRestoreTest { every { filesCache.upsert(capture(cachedFiles)) } just Runs // snapshot writing - coEvery { - plugin.getBackupSnapshotOutputStream(capture(snapshotTimestamp)) - } returns snapshotOutputStream + coEvery { backend.save(capture(snapshotHandle)) } returns snapshotOutputStream every { db.applyInParts(any(), any()) } just Runs backup.runBackup(null) // chunks were only written to storage once coVerify(exactly = 1) { - plugin.getChunkOutputStream( - "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3") - plugin.getChunkOutputStream( - "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29") - plugin.getChunkOutputStream( - "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d") - plugin.getChunkOutputStream( - "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67") + backend.save( + Blob( + androidId = androidId, + name = "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3", + ) + ) + backend.save( + Blob( + androidId = androidId, + name = "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29", + ) + ) + backend.save( + Blob( + androidId = androidId, + name = "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d", + ) + ) + backend.save( + Blob( + androidId = androidId, + name = "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67", + ) + ) } // RESTORE - val storedSnapshot = StoredSnapshot("test", snapshotTimestamp.captured) + val storedSnapshot = StoredSnapshot("$androidId.sv", snapshotHandle.captured.time) - coEvery { plugin.getBackupSnapshotsForRestore() } returns listOf(storedSnapshot) + coEvery { backend.getBackupSnapshotsForRestore() } returns listOf(storedSnapshot) coEvery { - plugin.getBackupSnapshotInputStream(storedSnapshot) + backend.load(storedSnapshot.snapshotHandle) } returns ByteArrayInputStream(snapshotOutputStream.toByteArray()) // retrieve snapshots @@ -363,27 +403,35 @@ internal class BackupRestoreTest { // pipe chunks back in coEvery { - plugin.getChunkInputStream( - storedSnapshot, - "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3" + backend.load( + Blob( + androidId = androidId, + name = "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3", + ) ) } returns ByteArrayInputStream(id040f32.toByteArray()) coEvery { - plugin.getChunkInputStream( - storedSnapshot, - "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29" + backend.load( + Blob( + androidId = androidId, + name = "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29", + ) ) } returns ByteArrayInputStream(id901fbc.toByteArray()) coEvery { - plugin.getChunkInputStream( - storedSnapshot, - "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d" + backend.load( + Blob( + androidId = androidId, + name = "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d", + ) ) } returns ByteArrayInputStream(id5adea3.toByteArray()) coEvery { - plugin.getChunkInputStream( - storedSnapshot, - "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67" + backend.load( + Blob( + androidId = androidId, + name = "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67", + ) ) } returns ByteArrayInputStream(id40d00c.toByteArray()) @@ -401,25 +449,66 @@ internal class BackupRestoreTest { // chunks were only read from storage once coVerify(exactly = 1) { - plugin.getChunkInputStream( - storedSnapshot, - "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3" + backend.load( + Blob( + androidId = androidId, + name = "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3", + ) ) - plugin.getChunkInputStream( - storedSnapshot, - "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29" + backend.load( + Blob( + androidId = androidId, + name = "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29", + ) ) - plugin.getChunkInputStream( - storedSnapshot, - "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d" + backend.load( + Blob( + androidId = androidId, + name = "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d", + ) ) - plugin.getChunkInputStream( - storedSnapshot, - "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67" + backend.load( + Blob( + androidId = androidId, + name = "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67", + ) ) } } + @Test + fun testBackupUpdatesBackend(): Unit = runBlocking { + val backendGetterNew: () -> Backend = mockk() + val backend1: Backend = mockk() + val backend2: Backend = mockk() + val backup = Backup( + context = context, + db = db, + fileScanner = fileScanner, + backendGetter = backendGetterNew, + androidId = androidId, + keyManager = keyManager, + cacheRepopulater = cacheRepopulater, + ) + every { backendGetterNew() } returnsMany listOf(backend1, backend2) + + coEvery { backend1.list(any(), Blob::class, callback = any()) } just Runs + every { chunksCache.areAllAvailableChunksCached(db, emptySet()) } returns true + every { fileScanner.getFiles() } returns FileScannerResult(emptyList(), emptyList()) + every { filesCache.getByUri(any()) } returns null // nothing is cached, all is new + + backup.runBackup(null) + + // second run uses new backend + coEvery { backend2.list(any(), Blob::class, callback = any()) } just Runs + backup.runBackup(null) + + coVerifyOrder { + backend1.list(any(), Blob::class, callback = any()) + backend2.list(any(), Blob::class, callback = any()) + } + } + private fun getRandomMediaFile(size: Int) = MediaFile( uri = mockk(), dir = getRandomString(), diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunkWriterTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunkWriterTest.kt index 5e2d8489..8dd886ac 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunkWriterTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunkWriterTest.kt @@ -12,13 +12,15 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.runBlocking -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.backup.Backup.Companion.VERSION import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES import org.calyxos.backup.storage.crypto.StreamCrypto import org.calyxos.backup.storage.db.ChunksCache +import org.calyxos.backup.storage.getRandomString import org.calyxos.backup.storage.mockLog -import org.calyxos.backup.storage.toHexString +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType.Blob +import org.calyxos.seedvault.core.toHexString import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Test @@ -30,13 +32,21 @@ internal class ChunkWriterTest { private val streamCrypto: StreamCrypto = mockk() private val chunksCache: ChunksCache = mockk() - private val storagePlugin: StoragePlugin = mockk() + private val backendGetter: () -> Backend = mockk() + private val backend: Backend = mockk() + private val androidId: String = getRandomString() private val streamKey: ByteArray = Random.nextBytes(KEY_SIZE_BYTES) private val ad1: ByteArray = Random.nextBytes(34) private val ad2: ByteArray = Random.nextBytes(34) private val ad3: ByteArray = Random.nextBytes(34) - private val chunkWriter = - ChunkWriter(streamCrypto, streamKey, chunksCache, storagePlugin, Random.nextInt(1, 42)) + private val chunkWriter = ChunkWriter( + streamCrypto = streamCrypto, + streamKey = streamKey, + chunksCache = chunksCache, + backendGetter = backendGetter, + androidId = androidId, + bufferSize = Random.nextInt(1, 42), + ) private val chunkId1 = Random.nextBytes(KEY_SIZE_BYTES).toHexString() private val chunkId2 = Random.nextBytes(KEY_SIZE_BYTES).toHexString() @@ -44,6 +54,7 @@ internal class ChunkWriterTest { init { mockLog() + every { backendGetter() } returns backend } @Test @@ -66,9 +77,9 @@ internal class ChunkWriterTest { every { chunksCache.get(chunkId3) } returns null // get the output streams for the chunks - coEvery { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output - coEvery { storagePlugin.getChunkOutputStream(chunkId2) } returns chunk2Output - coEvery { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output + coEvery { backend.save(Blob(androidId, chunkId1)) } returns chunk1Output + coEvery { backend.save(Blob(androidId, chunkId2)) } returns chunk2Output + coEvery { backend.save(Blob(androidId, chunkId3)) } returns chunk3Output // get AD every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1 @@ -122,7 +133,7 @@ internal class ChunkWriterTest { every { chunksCache.get(chunkId3) } returns null // get and wrap the output stream for chunk that is missing - coEvery { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output + coEvery { backend.save(Blob(androidId, chunkId1)) } returns chunk1Output every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1 every { streamCrypto.newEncryptingStream(streamKey, chunk1Output, bytes(34)) @@ -132,7 +143,7 @@ internal class ChunkWriterTest { every { chunksCache.insert(chunks[0].toCachedChunk()) } just Runs // get and wrap the output stream for chunk that isn't cached - coEvery { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output + coEvery { backend.save(Blob(androidId, chunkId3)) } returns chunk3Output every { streamCrypto.getAssociatedDataForChunk(chunkId3) } returns ad3 every { streamCrypto.newEncryptingStream(streamKey, chunk3Output, bytes(34)) @@ -175,8 +186,8 @@ internal class ChunkWriterTest { every { chunksCache.get(chunkId3) } returns null // get the output streams for the chunks - coEvery { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output - coEvery { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output + coEvery { backend.save(Blob(androidId, chunkId1)) } returns chunk1Output + coEvery { backend.save(Blob(androidId, chunkId3)) } returns chunk3Output // get AD every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1 diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt index eb668d3b..17b656ed 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt @@ -11,16 +11,19 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot import kotlinx.coroutines.runBlocking -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.db.CachedChunk import org.calyxos.backup.storage.db.ChunksCache import org.calyxos.backup.storage.db.Db import org.calyxos.backup.storage.getRandomString import org.calyxos.backup.storage.mockLog -import org.calyxos.backup.storage.plugin.SnapshotRetriever +import org.calyxos.backup.storage.SnapshotRetriever +import org.calyxos.backup.storage.getCurrentBackupSnapshots +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType.Blob import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -30,15 +33,22 @@ internal class ChunksCacheRepopulaterTest { private val db: Db = mockk() private val chunksCache: ChunksCache = mockk() - private val pluginGetter: () -> StoragePlugin = mockk() - private val plugin: StoragePlugin = mockk() + private val backendGetter: () -> Backend = mockk() + private val androidId: String = getRandomString() + private val backend: Backend = mockk() private val snapshotRetriever: SnapshotRetriever = mockk() private val streamKey = "This is a backup key for testing".toByteArray() - private val cacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever) + private val cacheRepopulater = ChunksCacheRepopulater( + db = db, + storagePlugin = backendGetter, + androidId = androidId, + snapshotRetriever = snapshotRetriever, + ) init { mockLog() - every { pluginGetter() } returns plugin + mockkStatic("org.calyxos.backup.storage.SnapshotRetrieverKt") + every { backendGetter() } returns backend every { db.getChunksCache() } returns chunksCache } @@ -73,7 +83,7 @@ internal class ChunksCacheRepopulaterTest { ) // chunk3 is not referenced and should get deleted val cachedChunksSlot = slot>() - coEvery { plugin.getCurrentBackupSnapshots() } returns storedSnapshots + coEvery { backend.getCurrentBackupSnapshots(androidId) } returns storedSnapshots coEvery { snapshotRetriever.getSnapshot(streamKey, storedSnapshot1) } returns snapshot1 @@ -81,14 +91,14 @@ internal class ChunksCacheRepopulaterTest { snapshotRetriever.getSnapshot(streamKey, storedSnapshot2) } returns snapshot2 every { chunksCache.clearAndRepopulate(db, capture(cachedChunksSlot)) } just Runs - coEvery { plugin.deleteChunks(listOf(chunk3)) } just Runs + coEvery { backend.remove(Blob(androidId, chunk3)) } just Runs cacheRepopulater.repopulate(streamKey, availableChunkIds) assertTrue(cachedChunksSlot.isCaptured) assertEquals(cachedChunks.toSet(), cachedChunksSlot.captured.toSet()) - coVerify { plugin.deleteChunks(listOf(chunk3)) } + coVerify { backend.remove(Blob(androidId, chunk3)) } } } diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt index ef7a07d6..3c46f07c 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt @@ -14,7 +14,6 @@ import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.calyxos.backup.storage.api.BackupObserver -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES import org.calyxos.backup.storage.crypto.StreamCrypto import org.calyxos.backup.storage.db.CachedChunk @@ -23,7 +22,8 @@ import org.calyxos.backup.storage.db.FilesCache import org.calyxos.backup.storage.getRandomDocFile import org.calyxos.backup.storage.getRandomString import org.calyxos.backup.storage.mockLog -import org.calyxos.backup.storage.toHexString +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.toHexString import org.junit.Assert.assertEquals import org.junit.Test import java.io.ByteArrayInputStream @@ -39,13 +39,16 @@ internal class SmallFileBackupIntegrationTest { private val filesCache: FilesCache = mockk() private val mac: Mac = mockk() private val chunksCache: ChunksCache = mockk() - private val storagePlugin: StoragePlugin = mockk() + private val backendGetter: () -> Backend = mockk() + private val backend: Backend = mockk() + private val androidId: String = getRandomString() private val chunkWriter = ChunkWriter( streamCrypto = StreamCrypto, streamKey = Random.nextBytes(KEY_SIZE_BYTES), chunksCache = chunksCache, - storagePlugin = storagePlugin, + backendGetter = backendGetter, + androidId = androidId, ) private val zipChunker = ZipChunker( mac = mac, @@ -56,6 +59,7 @@ internal class SmallFileBackupIntegrationTest { init { mockLog() + every { backendGetter() } returns backend } /** @@ -91,7 +95,7 @@ internal class SmallFileBackupIntegrationTest { every { mac.doFinal(any()) } returns chunkId every { chunksCache.get(any()) } returns null - coEvery { storagePlugin.getChunkOutputStream(any()) } returns outputStream2 + coEvery { backend.save(any()) } returns outputStream2 every { chunksCache.insert(match { cachedChunk -> cachedChunk.id == chunkId.toHexString() && diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ZipChunkerTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ZipChunkerTest.kt index 353012cf..78d1d07d 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ZipChunkerTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ZipChunkerTest.kt @@ -13,7 +13,7 @@ import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.calyxos.backup.storage.getRandomDocFile import org.calyxos.backup.storage.getRandomString -import org.calyxos.backup.storage.toHexString +import org.calyxos.seedvault.core.toHexString import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt index 7da12a29..23d65edb 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt @@ -10,9 +10,9 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot import kotlinx.coroutines.runBlocking -import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.backup.BackupDocumentFile import org.calyxos.backup.storage.backup.BackupMediaFile @@ -25,7 +25,11 @@ import org.calyxos.backup.storage.db.ChunksCache import org.calyxos.backup.storage.db.Db import org.calyxos.backup.storage.getRandomString import org.calyxos.backup.storage.mockLog -import org.calyxos.backup.storage.plugin.SnapshotRetriever +import org.calyxos.backup.storage.SnapshotRetriever +import org.calyxos.backup.storage.getCurrentBackupSnapshots +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileBackupFileType.Blob +import org.calyxos.seedvault.core.crypto.KeyManager import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -36,8 +40,10 @@ internal class PrunerTest { private val db: Db = mockk() private val chunksCache: ChunksCache = mockk() - private val pluginGetter: () -> StoragePlugin = mockk() - private val plugin: StoragePlugin = mockk() + private val backendGetter: () -> Backend = mockk() + private val androidId: String = getRandomString() + private val keyManager: KeyManager = mockk() + private val backend: Backend = mockk() private val snapshotRetriever: SnapshotRetriever = mockk() private val retentionManager: RetentionManager = mockk() private val streamCrypto: StreamCrypto = mockk() @@ -46,13 +52,22 @@ internal class PrunerTest { init { mockLog(false) - every { pluginGetter() } returns plugin + mockkStatic("org.calyxos.backup.storage.SnapshotRetrieverKt") + every { backendGetter() } returns backend every { db.getChunksCache() } returns chunksCache - every { plugin.getMasterKey() } returns masterKey + every { keyManager.getMainKey() } returns masterKey every { streamCrypto.deriveStreamKey(masterKey) } returns streamKey } - private val pruner = Pruner(db, retentionManager, pluginGetter, snapshotRetriever, streamCrypto) + private val pruner = Pruner( + db = db, + retentionManager = retentionManager, + storagePluginGetter = backendGetter, + androidId = androidId, + keyManager = keyManager, + snapshotRetriever = snapshotRetriever, + streamCrypto = streamCrypto, + ) @Test fun test() = runBlocking { @@ -81,12 +96,12 @@ internal class PrunerTest { val actualChunks2 = slot>() val cachedChunk3 = CachedChunk(chunk3, 0, 0) - coEvery { plugin.getCurrentBackupSnapshots() } returns storedSnapshots + coEvery { backend.getCurrentBackupSnapshots(androidId) } returns storedSnapshots every { retentionManager.getSnapshotsToDelete(storedSnapshots) } returns listOf(storedSnapshot1) coEvery { snapshotRetriever.getSnapshot(streamKey, storedSnapshot1) } returns snapshot1 - coEvery { plugin.deleteBackupSnapshot(storedSnapshot1) } just Runs + coEvery { backend.remove(storedSnapshot1.snapshotHandle) } just Runs every { db.applyInParts(capture(actualChunks), captureLambda()) } answers { @@ -94,7 +109,7 @@ internal class PrunerTest { } every { chunksCache.decrementRefCount(capture(actualChunks2)) } just Runs every { chunksCache.getUnreferencedChunks() } returns listOf(cachedChunk3) - coEvery { plugin.deleteChunks(listOf(chunk3)) } just Runs + coEvery { backend.remove(Blob(androidId, chunk3)) } just Runs every { chunksCache.deleteChunks(listOf(cachedChunk3)) } just Runs pruner.prune(null)