Merge pull request #734 from grote/unified-backends
Unify Storage Backends
This commit is contained in:
commit
cf7953edf7
155 changed files with 2856 additions and 3443 deletions
|
@ -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",
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ class KoinInstrumentationTestApp : App() {
|
|||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
pluginManager = get(),
|
||||
backendManager = get(),
|
||||
fileSelectionManager = get(),
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -23,7 +23,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
|||
confirmCode()
|
||||
}
|
||||
|
||||
if (settingsManager.getSafStorage() == null) {
|
||||
if (settingsManager.getSafProperties() == null) {
|
||||
chooseStorageLocation()
|
||||
} else {
|
||||
changeBackupLocation()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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!!))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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.
|
||||
*
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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}")
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -23,7 +23,7 @@ val restoreUiModule = module {
|
|||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
pluginManager = get(),
|
||||
backendManager = get(),
|
||||
fileSelectionManager = get(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>()) }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -39,7 +39,7 @@ val workerModule = module {
|
|||
packageService = get(),
|
||||
apkBackup = get(),
|
||||
iconManager = get(),
|
||||
pluginManager = get(),
|
||||
backendManager = get(),
|
||||
nm = get()
|
||||
)
|
||||
}
|
||||
|
|
1
app/src/main/resources/simplelogger.properties
Normal file
1
app/src/main/resources/simplelogger.properties
Normal file
|
@ -0,0 +1 @@
|
|||
org.slf4j.simpleLogger.defaultLogLevel=debug
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
1
app/src/test/resources/simplelogger.properties
Normal file
1
app/src/test/resources/simplelogger.properties
Normal file
|
@ -0,0 +1 @@
|
|||
#org.slf4j.simpleLogger.defaultLogLevel=debug
|
|
@ -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
1
core/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
35
core/Android.bp
Normal file
35
core/Android.bp
Normal 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
52
core/build.gradle.kts
Normal 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")
|
||||
}
|
9
core/src/main/AndroidManifest.xml
Normal file
9
core/src/main/AndroidManifest.xml
Normal 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>
|
|
@ -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()
|
|
@ -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?
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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}")
|
|
@ -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,
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
}
|
|
@ -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,
|
|
@ -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
Loading…
Reference in a new issue