Merge pull request #734 from grote/unified-backends

Unify Storage Backends
This commit is contained in:
Torsten Grote 2024-09-19 13:52:34 -03:00 committed by GitHub
commit cf7953edf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
155 changed files with 2856 additions and 3443 deletions

View file

@ -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",

View file

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

View file

@ -56,7 +56,7 @@ class KoinInstrumentationTestApp : App() {
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
backendManager = get(),
fileSelectionManager = get(),
)
)

View file

@ -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<Uri> = 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()
}
}

View file

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

View file

@ -23,7 +23,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
confirmCode()
}
if (settingsManager.getSafStorage() == null) {
if (settingsManager.getSafProperties() == null) {
chooseStorageLocation()
} else {
changeBackupLocation()

View file

@ -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<SettingsManager>()
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<ContentObserver>()
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<ContentObserver>()
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
}
}

View file

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

View file

@ -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<SettingsManager>().getSafProperties()?.isUsb == true
}
}
}
single { BackupStateManager(this@App) }
single { Clock() }
factory<IBackupManager> { 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 <T> 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()) {

View file

@ -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<EncryptedMetadata>? {
return try {
// get all restore set tokens in root folder that have a metadata file
val handles = ArrayList<LegacyAppBackupFile.Metadata>()
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)

View file

@ -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 <T> changePlugins(
storageProperties: StorageProperties<T>,
appPlugin: StoragePlugin<T>,
filesPlugin: org.calyxos.backup.storage.api.StoragePlugin,
backend: Backend,
storageProperties: BackendProperties<T>,
) {
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

View file

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

View file

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

View file

@ -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<SettingsManager>().getSafStorage() ?: error("No SAF storage")
DocumentsStorage(androidContext(), get(), safStorage)
val safProperties = get<SettingsManager>().getSafProperties()
?: error("No SAF storage")
DocumentsStorage(androidContext(), safProperties)
},
)
}

View file

@ -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<DocumentFile>
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.
*

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T> {
/**
* 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<EncryptedMetadata>?
/**
* 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}")

View file

@ -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<Uri> {
/**
* 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<EncryptedMetadata>? {
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<BackupSet> {
val backupSets = ArrayList<BackupSet>()
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)
}

View file

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

View file

@ -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<WebDavConfig> {
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,
)
}
}

View file

@ -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 <DAV:propfind-finite-depth/> 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
}
}

View file

@ -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<WebDavConfig> {
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<EncryptedMetadata>? {
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<EncryptedMetadata> {
val location = "$url/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
// get all restore set tokens in root folder
val tokens = ArrayList<Long>()
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<Long>) {
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
}

View file

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

View file

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

View file

@ -23,7 +23,7 @@ val restoreUiModule = module {
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
backendManager = get(),
fileSelectionManager = get(),
)
}

View file

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

View file

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

View file

@ -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<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<StoragePluginManager>().filesPlugin }) }
single { StorageBackup(get(), { get<BackendManager>().backend }, get<KeyManager>()) }
}

View file

@ -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<String> {
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<String>()
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<String>,
) {
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<StoredSnapshot> {
val location = "$url/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getBackupSnapshotsForRestore($location)" }
val snapshots = ArrayList<StoredSnapshot>()
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<StoredSnapshot> {
val location = "$url/$folder/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getCurrentBackupSnapshots($location)" }
val snapshots = ArrayList<StoredSnapshot>()
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<String>) {
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)
}
}
}
}

View file

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

View file

@ -16,13 +16,13 @@ val backupModule = module {
context = androidContext(),
backupManager = get(),
settingsManager = get(),
pluginManager = get(),
backendManager = get(),
)
}
single<KvDbManager> { 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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String>()
suspend fun getAvailableMetadata(): Map<Long, BackupMetadata>? {
val availableBackups = plugin.getAvailableBackups() ?: return null
val availableBackups = backend.getAvailableBackups() ?: return null
val metadataMap = HashMap<Long, BackupMetadata>()
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)
}

View file

@ -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<Boolean> get() = mChooseBackupLocation
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
internal fun validLocationIsSet() = pluginManager.isValidAppPluginSet()
internal fun validLocationIsSet() = backendManager.isValidAppPluginSet()
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()

View file

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

View file

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

View file

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

View file

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

View file

@ -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<List<StorageOption>>()
@ -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)
}
}

View file

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

View file

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

View file

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

View file

@ -39,7 +39,7 @@ val workerModule = module {
packageService = get(),
apkBackup = get(),
iconManager = get(),
pluginManager = get(),
backendManager = get(),
nm = get()
)
}

View file

@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel=debug

View file

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

View file

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

View file

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

View file

@ -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<Exception> {
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<EncryptedMetadata>(), 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<IOException> {
plugin.getOutputStream(token, file).use { it.write(getRandomByteArray()) }
}
assertThrows<IOException> {
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<IOException> {
plugin.removeData(42L, "foo")
}
Unit
}
}

View file

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

View file

@ -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<Int>()) } 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<Int>()) } 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,

View file

@ -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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } returns packageInfo
every { applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName

View file

@ -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<String>(), 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<StoredSnapshot>(), plugin.getCurrentBackupSnapshots())
assertEquals(emptyList<StoredSnapshot>(), 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<String>(), 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<IOException> {
plugin.getCurrentBackupSnapshots()
}
assertThrows<IOException> {
plugin.getBackupSnapshotsForRestore()
}
assertThrows<IOException> {
plugin.getAvailableChunkIds()
}
assertThrows<IOException> {
plugin.deleteChunks(listOf("foo"))
}
assertThrows<IOException> {
plugin.deleteBackupSnapshot(snapshot)
}
assertThrows<IOException> {
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close()
}
assertThrows<IOException> {
plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() }
}
assertThrows<IOException> {
plugin.getChunkOutputStream("foo").close()
}
assertThrows<IOException> {
plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() }
}
Unit
}
}
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }

View file

@ -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<BackupNotificationManager>()
private val dbManager = TestKvDbManager()
private val storagePluginManager: StoragePluginManager = mockk()
private val backendManager: BackendManager = mockk()
@Suppress("Deprecation")
private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val backupPlugin = mockk<StoragePlugin<*>>()
private val backend = mockk<Backend>()
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<BackupDataOutput>()
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<BackupDataOutput>()
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

View file

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

View file

@ -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<StoragePluginManager>()
private val backendManager = mockk<BackendManager>()
private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>()
private val apkBackup = mockk<ApkBackup>()
@ -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<StoragePlugin<*>>()
private val backend = mockk<Backend>()
private val metadataOutputStream = mockk<OutputStream>()
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
}

View file

@ -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<StoragePlugin<*>>()
private val backendManager: BackendManager = mockk()
private val backend = mockk<Backend>()
private val notificationManager = mockk<BackupNotificationManager>()
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
}

View file

@ -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<StoragePluginManager>()
private val backendManager = mockk<BackendManager>()
private val notificationManager = mockk<BackupNotificationManager>()
private val dataInput = mockk<BackupDataInput>()
private val dbManager = mockk<KvDbManager>()
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<KVDb>()
private val plugin = mockk<StoragePlugin<*>>()
private val backend = mockk<Backend>()
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

View file

@ -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<StoragePlugin<*>>()
private val backendManager: BackendManager = mockk()
private val backend = mockk<Backend>()
private val legacyPlugin = mockk<LegacyStoragePlugin>()
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
}

View file

@ -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<StoragePlugin<*>>()
private val backendManager: BackendManager = mockk()
private val backend = mockk<Backend>()
@Suppress("DEPRECATION")
private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val dbManager = mockk<KvDbManager>()
private val output = mockk<BackupDataOutput>()
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 {

View file

@ -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<StoragePlugin<*>>()
private val backendManager: BackendManager = mockk()
private val backend = mockk<Backend>()
private val kv = mockk<KVRestore>()
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
@ -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<InputStream>()
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 {

View file

@ -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<KvDbManager>()
private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>()
private val storagePluginManager: StoragePluginManager = mockk()
private val backendManager: BackendManager = mockk()
@Suppress("Deprecation")
private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val backupPlugin = mockk<StoragePlugin<*>>()
private val backend = mockk<Backend>()
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

View file

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

View file

@ -0,0 +1 @@
#org.slf4j.simpleLogger.defaultLogLevel=debug

View file

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

1
core/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

35
core/Android.bp Normal file
View file

@ -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",
],
}

52
core/build.gradle.kts Normal file
View file

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

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2021 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.calyxos.seedvault.core">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View file

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

View file

@ -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<out FileHandle>,
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?
}

View file

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

View file

@ -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<T> {
abstract val config: T
abstract val name: String
abstract val isUsb: Boolean
abstract val requiresNetwork: Boolean
public abstract class BackendProperties<T> {
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<T> {
}
}
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

View file

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

View file

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

View file

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

View file

@ -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<String, DocumentFile>()
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()
}
}

View file

@ -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<out FileHandle>,
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() }
}

View file

@ -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<DocumentFile> {
val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID)
val result = ArrayList<DocumentFile>()
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)
}
}
}

View file

@ -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<Uri>() {
) : BackendProperties<Uri>() {
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.

View file

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

View file

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

View file

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

View file

@ -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<HttpUrl>() // 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<out FileHandle>,
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<HttpUrl>()
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() }
}

View file

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

View file

@ -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<HttpUrl>) {
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<Property>.contentLength(): Long {
// crash intentionally, if this isn't in the list
return filterIsInstance<GetContentLength>()[0].contentLength
}
internal fun Response.isFolder(): Boolean {
return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true
}

Some files were not shown because too many files have changed in this diff Show more