Add core gradle module with unified storage backends

This commit is contained in:
Torsten Grote 2024-07-25 18:55:01 -03:00
parent fa56a74ad7
commit 9e56384cb2
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
28 changed files with 1474 additions and 13 deletions

View file

@ -106,19 +106,7 @@ android {
} }
dependencies { dependencies {
val aospLibs: FileTree by rootProject.extra
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")
}
compileOnly(aospLibs) compileOnly(aospLibs)
/** /**
@ -149,6 +137,7 @@ dependencies {
/** /**
* Storage Dependencies * Storage Dependencies
*/ */
implementation(project(":core"))
implementation(project(":storage:lib")) implementation(project(":storage:lib"))
/** /**
@ -188,6 +177,7 @@ dependencies {
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
androidTestImplementation(aospLibs) androidTestImplementation(aospLibs)
androidTestImplementation(kotlin("test"))
androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:runner:1.4.0")
androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("androidx.test:rules:1.4.0")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.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.SafConfig
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>()
override val plugin: Backend
get() {
val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage")
val safConfig = SafConfig(
config = safStorage.config,
name = safStorage.name,
isUsb = safStorage.isUsb,
requiresNetwork = safStorage.requiresNetwork,
rootId = safStorage.rootId,
)
return SafBackend(context, safConfig, ".SeedvaultTest")
}
@Test
fun test(): Unit = runBlocking {
testWriteListReadRenameDelete()
}
}

View file

@ -13,6 +13,21 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.jetbrains.dokka) apply false alias(libs.plugins.jetbrains.dokka) apply false
alias(libs.plugins.jlleitschuh.ktlint) apply false alias(libs.plugins.jlleitschuh.ktlint) apply false
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
}
val aospLibs by extra {
fileTree("$rootDir/app/libs") { // TODO move libs to root
// 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 { subprojects {

1
core/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

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

@ -0,0 +1,51 @@
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("org.ogce:xpp3:1.1.6")
compileOnly(kotlin("test"))
implementation(libs.bundles.kotlin)
implementation(libs.bundles.coroutines)
implementation(libs.androidx.documentfile)
// implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("okio-jvm-3.7.0.jar"))
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
implementation("org.slf4j:slf4j-simple:2.0.3")
testImplementation(kotlin("test"))
testImplementation("org.ogce:xpp3:1.1.6")
}

View file

@ -0,0 +1,8 @@
<?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">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2021 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core
internal fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
internal fun String.toByteArrayFromHex() = chunked(2).map { it.toInt(16).toByte() }.toByteArray()

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
import android.content.Context
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.UserManager
/**
* 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")
public fun Context.getBackendContext(isUsbStorage: () -> Boolean): Context {
if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) {
UserManager.get(this).getProfileParent(user)
?.let { parent -> return createContextAsUser(parent, 0) }
}
return this
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends
import okio.BufferedSink
import okio.BufferedSource
import kotlin.reflect.KClass
public interface Backend {
public suspend fun save(handle: FileHandle): BufferedSink
public suspend fun load(handle: FileHandle): BufferedSource
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)
// TODO really all?
public suspend fun removeAll()
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends
import android.annotation.WorkerThread
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import at.bitfire.dav4jvm.exception.HttpException
import java.io.IOException
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
public abstract fun isUnavailableUsb(context: Context): Boolean
/**
* Returns true if this is storage that requires network access,
* but it isn't available right now.
*/
public fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
}
private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val isMetered = cm.isActiveNetworkMetered
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return capabilities.hasCapability(NET_CAPABILITY_INTERNET) && (allowMetered || !isMetered)
}
}
public 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
}
}

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.exception.HttpException
import org.calyxos.seedvault.core.toHexString
import org.junit.Assert.assertArrayEquals
import kotlin.random.Random
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.fail
@VisibleForTesting
public abstract class BackendTest {
public abstract val plugin: Backend
protected suspend fun testWriteListReadRenameDelete() {
try {
plugin.removeAll()
} catch (e: HttpException) {
if (e.code != 404) fail(e.message, e)
}
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("0123456789abcdef", 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)
assertArrayEquals(bytes1, plugin.load(metadata as FileHandle).readByteArray())
assertArrayEquals(bytes2, plugin.load(snapshot as FileHandle).readByteArray())
val blobName = Random.nextBytes(32).toHexString()
var blob: FileBackupFileType.Blob? = null
val bytes3 = Random.nextBytes(1337 * 16)
plugin.save(FileBackupFileType.Blob("0123456789abcdef", 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)
assertArrayEquals(bytes3, plugin.load(blob as FileHandle).readByteArray())
// 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("0123456789abcdee.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)
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends
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}")
public val chunkRegex: Regex = Regex("[a-f0-9]{64}")
public val snapshotRegex: Regex = Regex("([0-9]{13})\\.SeedSnap") // good until the year 2286
public const val MIME_TYPE: String = "application/octet-stream"
public const val CHUNK_FOLDER_COUNT: Int = 256
}

View file

@ -0,0 +1,67 @@
/*
* 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 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
public val topLevelFolder: TopLevelFolder get() = TopLevelFolder("$androidId.sv")
public data class Blob(
override val androidId: String,
override val name: String,
) : FileBackupFileType() {
override val relativePath: String get() = "$androidId.sv/${name.substring(0, 2)}/$name"
}
public data class Snapshot(
override val androidId: String,
val time: Long,
) : FileBackupFileType() {
override val name: String = "$time$SNAPSHOT_EXT"
override val relativePath: String get() = "$androidId.sv/$name"
}
}
public data class FileInfo(
val fileHandle: FileHandle,
val size: Long,
)

View file

@ -0,0 +1,49 @@
/*
* 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
internal class DocumentFileCache(
private val context: Context,
private val baseFile: DocumentFile,
private val root: String,
) {
private val cache = mutableMapOf<String, DocumentFile>()
internal suspend fun getRootFile(): DocumentFile {
return cache.getOrPut(root) {
baseFile.getOrCreateDirectory(context, root)
}
}
internal suspend fun getFile(fh: FileHandle): DocumentFile = when (fh) {
is TopLevelFolder -> cache.getOrPut("$root/${fh.relativePath}") {
getRootFile().getOrCreateDirectory(context, fh.name)
}
is LegacyAppBackupFile -> cache.getOrPut("$root/${fh.relativePath}") {
getFile(fh.topLevelFolder).getOrCreateFile(context, fh.name)
}
is FileBackupFileType.Blob -> {
val subFolderName = fh.name.substring(0, 2)
cache.getOrPut("$root/${fh.topLevelFolder.name}/$subFolderName") {
getFile(fh.topLevelFolder).getOrCreateDirectory(context, subFolderName)
}.getOrCreateFile(context, fh.name)
}
is FileBackupFileType.Snapshot -> {
getFile(fh.topLevelFolder).getOrCreateFile(context, fh.name)
}
}
}

View file

@ -0,0 +1,151 @@
/*
* 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.provider.DocumentsContract.renameDocument
import androidx.documentfile.provider.DocumentFile
import io.github.oshai.kotlinlogging.KotlinLogging
import okio.BufferedSink
import okio.BufferedSource
import okio.buffer
import okio.sink
import okio.source
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 org.calyxos.seedvault.core.getBackendContext
import java.io.IOException
import kotlin.reflect.KClass
public class SafBackend(
private val appContext: Context,
private val safConfig: SafConfig,
root: String = DIRECTORY_ROOT,
) : Backend {
private val log = KotlinLogging.logger {}
/**
* Attention: This context might be from a different user. Use with care.
*/
private val context: Context get() = appContext.getBackendContext { safConfig.isUsb }
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
override suspend fun save(handle: FileHandle): BufferedSink {
val file = cache.getFile(handle)
return file.getOutputStream(context.contentResolver).sink().buffer()
}
override suspend fun load(handle: FileHandle): BufferedSource {
val file = cache.getFile(handle)
return file.getInputStream(context.contentResolver).source().buffer()
}
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()
val folder = if (topLevelFolder == null) {
cache.getRootFile()
} else {
cache.getFile(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) {
val file = cache.getFile(handle)
if (!file.delete()) throw IOException("could not delete ${handle.relativePath}")
}
override suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) {
val fromFile = cache.getFile(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() {
cache.getRootFile().listFilesBlocking(context).forEach {
it.delete()
}
}
}

View file

@ -0,0 +1,42 @@
/*
* 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.net.Uri
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import org.calyxos.seedvault.core.backends.BackendProperties
public data class SafConfig(
override val config: Uri,
override val name: String,
override val isUsb: Boolean,
override val requiresNetwork: Boolean,
/**
* The [COLUMN_ROOT_ID] for the [uri].
* This is only nullable for historic reasons, because we didn't always store it.
*/
val rootId: String?,
) : BackendProperties<Uri>() {
internal val uri: Uri = config
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.
*
* Must be run off UI thread (ideally I/O).
*/
@WorkerThread
override fun isUnavailableUsb(context: Context): Boolean {
return isUsb && !getDocumentFile(context).isDirectory
}
}

View file

@ -0,0 +1,217 @@
/*
* SPDX-FileCopyrightText: 2021 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.saf
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.EXTRA_LOADING
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.getDocumentId
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.calyxos.seedvault.core.backends.Constants.MIME_TYPE
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import kotlin.coroutines.resume
private const val TAG = "SafHelper"
@Throws(IOException::class)
public fun DocumentFile.getInputStream(contentResolver: ContentResolver): InputStream {
return uri.openInputStream(contentResolver)
}
@Throws(IOException::class)
public fun DocumentFile.getOutputStream(contentResolver: ContentResolver): OutputStream {
return uri.openOutputStream(contentResolver)
}
/**
* Checks if a file exists and if not, creates it.
*
* If we were trying to create it right away, some providers create "filename (1)".
*/
@Throws(IOException::class)
internal suspend fun DocumentFile.getOrCreateFile(context: Context, name: String): DocumentFile {
return try {
findFileBlocking(context, name) ?: createFileOrThrow(name, MIME_TYPE)
} catch (e: Exception) {
// SAF can throw all sorts of exceptions, so wrap it in IOException.
// E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument()
// when flash drive is not plugged-in:
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
internal fun DocumentFile.createFileOrThrow(
name: String,
mimeType: String = MIME_TYPE,
): DocumentFile {
val file = createFile(mimeType, name) ?: throw IOException("Unable to create file: $name")
if (file.name != name) {
file.delete()
if (file.name == null) { // this happens when file existed already
// try to find the original file we were looking for
val foundFile = findFile(name)
if (foundFile?.name == name) return foundFile
}
throw IOException("Wanted to create $name, but got ${file.name}")
}
return file
}
/**
* Checks if a directory already exists and if not, creates it.
*/
@Throws(IOException::class)
public suspend fun DocumentFile.getOrCreateDirectory(context: Context, name: String): DocumentFile {
return findFileBlocking(context, name) ?: createDirectoryOrThrow(name)
}
@Throws(IOException::class)
public fun DocumentFile.createDirectoryOrThrow(name: String): DocumentFile {
val directory = createDirectory(name)
?: throw IOException("Unable to create directory: $name")
if (directory.name != name) {
directory.delete()
throw IOException("Wanted to directory $name, but got ${directory.name}")
}
return directory
}
/**
* Works like [DocumentFile.listFiles] except
* that it waits until the DocumentProvider has a result.
* This prevents getting an empty list even though there are children to be listed.
*/
@Throws(IOException::class)
public suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID)
val result = ArrayList<DocumentFile>()
try {
getLoadedCursor {
resolver.query(childrenUri, projection, null, null, null)
}
} catch (e: TimeoutCancellationException) {
throw IOException(e)
}.use { cursor ->
while (cursor.moveToNext()) {
val documentId = cursor.getString(0)
val documentUri = buildDocumentUriUsingTree(uri, documentId)
result.add(getTreeDocumentFile(this, context, documentUri))
}
}
return result
}
/**
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
*
* All other public ways to get a TreeDocumentFile only work from [Uri]s
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
*
* We have a test for this method to ensure CI will alert us when this reflection breaks.
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
*/
@VisibleForTesting
@SuppressLint("CheckedExceptions")
public fun getTreeDocumentFile(
parent: DocumentFile,
context: Context,
uri: Uri,
): DocumentFile {
@SuppressWarnings("MagicNumber")
val constructor = parent.javaClass.declaredConstructors.find {
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
}
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
constructor.isAccessible = true
return constructor.newInstance(parent, context, uri) as DocumentFile
}
/**
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
*
* Most documents providers including Nextcloud are listing the full directory content
* when querying for a specific file in a directory,
* so there is no point in trying to optimize the query by not listing all children.
*/
public suspend fun DocumentFile.findFileBlocking(
context: Context,
displayName: String,
): DocumentFile? {
val files = try {
listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error finding file blocking", e)
return null
}
for (doc in files) {
if (displayName == doc.name) return doc
}
return null
}
/**
* Returns a cursor for the given query while ensuring that the cursor was loaded.
*
* When the SAF backend is a cloud storage provider (e.g. Nextcloud),
* it can happen that the query returns an outdated (e.g. empty) cursor
* which will only be updated in response to this query.
*
* See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html
*
* This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver]
* registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]).
* If the cursor is not loading, it will be returned right away.
*
* @param timeout an optional time-out in milliseconds
* @throws TimeoutCancellationException if there was no result before the time-out
* @throws IOException if the query returns null
*/
@VisibleForTesting
@Throws(IOException::class, TimeoutCancellationException::class)
public suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?): Cursor =
withTimeout(timeout) {
suspendCancellableCoroutine { cont ->
val cursor = query() ?: throw IOException()
cont.invokeOnCancellation { cursor.close() }
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
cursor.close()
val newCursor = query()
if (newCursor == null) {
cont.cancel(IOException("query returned no results"))
} else cont.resume(newCursor)
}
})
} else {
// not loading, return cursor right away
cont.resume(cursor)
}
}
}

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2021 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.saf
import android.content.ContentResolver
import android.net.Uri
import android.provider.MediaStore
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
public fun Uri.getDocumentPath(): String? {
return lastPathSegment?.split(':')?.getOrNull(1)
}
public fun Uri.getVolume(): String? {
val volume = lastPathSegment?.split(':')?.getOrNull(0)
return if (volume == "primary") MediaStore.VOLUME_EXTERNAL_PRIMARY else volume
}
@Throws(IOException::class)
public fun Uri.openInputStream(contentResolver: ContentResolver): InputStream {
return try {
contentResolver.openInputStream(this)
} catch (e: IllegalArgumentException) {
// This is necessary, because contrary to the documentation, files that have been deleted
// after we retrieved their Uri, will throw an IllegalArgumentException
throw IOException(e)
} ?: throw IOException("Stream for $this returned null")
}
@Throws(IOException::class)
public fun Uri.openOutputStream(contentResolver: ContentResolver): OutputStream {
return try {
contentResolver.openOutputStream(this, "wt")
} catch (e: IllegalArgumentException) {
// This is necessary, because contrary to the documentation, files that have been deleted
// after we retrieved their Uri, will throw an IllegalArgumentException
throw IOException(e)
} ?: throw IOException("Stream for $this returned null")
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.webdav
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
import org.xmlpull.v1.XmlPullParser
/**
* A fake version of [at.bitfire.dav4jvm.property.webdav.GetLastModified] which we register
* so we don't need to depend on `org.apache.commons.lang3` which is used for date parsing.
*/
internal class GetLastModified : Property {
companion object {
@JvmField
val NAME = Property.Name(NS_WEBDAV, "getlastmodified")
}
object Factory : PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): GetLastModified? = null
}
}

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.webdav
import java.io.IOException
import java.io.PipedInputStream
import java.io.PipedOutputStream
internal class PipedCloseActionOutputStream(
inputStream: PipedInputStream,
) : PipedOutputStream(inputStream) {
private var onClose: (() -> Unit)? = null
override fun write(b: Int) {
try {
super.write(b)
} catch (e: Exception) {
try {
onClose?.invoke()
} catch (closeException: Exception) {
e.addSuppressed(closeException)
}
throw e
}
}
override fun write(b: ByteArray, off: Int, len: Int) {
try {
super.write(b, off, len)
} catch (e: Exception) {
try {
onClose?.invoke()
} catch (closeException: Exception) {
e.addSuppressed(closeException)
}
throw e
}
}
@Throws(IOException::class)
override fun close() {
super.close()
try {
onClose?.invoke()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
fun doOnClose(function: () -> Unit) {
this.onClose = function
}
}

View file

@ -0,0 +1,281 @@
/*
* 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 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 okio.BufferedSource
import okio.buffer
import okio.sink
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.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)
internal 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 url = "${webDavConfig.url}/$root"
private val folders = mutableSetOf<HttpUrl>() // cache for existing/created folders
init {
PropertyRegistry.register(GetLastModified.Factory)
}
override suspend fun save(handle: FileHandle): BufferedSink {
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.sink().buffer()
}
override suspend fun load(handle: FileHandle): BufferedSource {
val location = handle.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.get(accept = "", headers = null)
log.debugLog { "load($location) = $response" }
if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}")
return response.body?.source() ?: throw IOException()
}
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>()
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))
}
}
}
}
}
// 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)
davCollection.move(toUrl, false) { response ->
log.debugLog { "rename(${from.name}, ${to.name}) = $response" }
}
}
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" }
}
}
private fun FileHandle.toHttpUrl(): HttpUrl = when (this) {
// careful with trailing slashes, use only for folders/collections
is TopLevelFolder -> "$url/$name/".toHttpUrl()
else -> "$url/$relativePath".toHttpUrl()
}
}
internal inline fun KLogger.debugLog(crossinline block: () -> String) {
if (DEBUG_LOG) debug { block() }
}

View file

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.webdav
public data class WebDavConfig(
val url: String,
val username: String,
val password: String,
)

View file

@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.webdav
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.ResponseCallback
import at.bitfire.dav4jvm.exception.ConflictException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GetContentLength
import at.bitfire.dav4jvm.property.webdav.ResourceType
import io.github.oshai.kotlinlogging.KLogger
import okhttp3.HttpUrl
/**
* Tries to do [DavCollection.propfind] with a depth of `-1`.
* Since `infinity` isn't supported by nginx either,
* we fallback to iterating over all folders found with depth `1`
* and do another PROPFIND on those, passing the given [callback].
*
* @param maxDepth in case we need to fallback to recursive propfinds, we only go that far down.
*/
internal fun DavCollection.propfindDepthInfinity(maxDepth: Int, callback: MultiResponseCallback) {
try {
propfind(
depth = -1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME, GetContentLength.NAME),
callback = callback,
)
} catch (e: HttpException) {
if (e.isUnsupportedPropfind()) {
log.info { "Got ${e.response}, trying recursive depth=1 PROPFINDs..." }
propfindFakeInfinity(maxDepth, callback)
} else {
throw e
}
}
}
internal fun DavCollection.propfindFakeInfinity(depth: Int, callback: MultiResponseCallback) {
if (depth <= 0) return
propfind(
depth = 1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME, GetContentLength.NAME),
) { response, relation ->
// This callback will be called for everything in the folder
callback.onResponse(response, relation)
if (relation != SELF && response.isFolder()) {
DavCollection(httpClient, response.href).propfindFakeInfinity(depth - 1, callback)
}
}
}
internal fun DavCollection.mkColCreateMissing(callback: ResponseCallback) {
try {
mkCol(null) { response ->
callback.onResponse(response)
}
} catch (e: ConflictException) {
log.warning { "Error creating $location: $e" }
if (location.pathSize <= 1) throw e
val newLocation = location.newBuilder()
.removePathSegment(location.pathSize - 1)
.build()
DavCollection(httpClient, newLocation).mkColCreateMissing(callback)
// re-run original command to create parent collection
mkCol(null) { response ->
callback.onResponse(response)
}
}
}
internal fun DavCollection.ensureFoldersExist(log: KLogger, folders: MutableSet<HttpUrl>) {
if (location.pathSize <= 2) return
val parent = location.newBuilder()
.removePathSegment(location.pathSize - 1)
.build()
if (parent in folders) return
val parentCollection = DavCollection(httpClient, parent)
try {
parentCollection.head { response ->
log.debugLog { "head($parent) = $response" }
folders.add(parent)
}
} catch (e: NotFoundException) {
log.debugLog { "$parent not found, creating..." }
parentCollection.mkColCreateMissing { response ->
log.debugLog { "mkColCreateMissing($parent) = $response" }
folders.add(parent)
}
}
}
private fun HttpException.isUnsupportedPropfind(): Boolean {
// nginx is not including 'propfind-finite-depth' in body, so just relay on code
return code == 403 || code == 400 // dufs returns 400
}
internal fun List<Property>.contentLength(): Long {
// crash intentionally, if this isn't in the list
return filterIsInstance<GetContentLength>()[0].contentLength
}
internal fun Response.isFolder(): Boolean {
return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true
}

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.webdav
import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.BackendTest
import kotlin.test.Test
public class WebDavBackendTest : BackendTest() {
override val plugin: Backend = WebDavBackend(WebDavTestConfig.getConfig(), ".SeedvaultTest")
@Test
public fun `test write, list, read, rename, delete`(): Unit = runBlocking {
testWriteListReadRenameDelete()
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.backends.webdav
import org.junit.Assume.assumeFalse
import kotlin.test.fail
internal object WebDavTestConfig {
fun getConfig(): WebDavConfig {
assumeFalse(System.getenv("NEXTCLOUD_URL").isNullOrEmpty())
return WebDavConfig(
url = System.getenv("NEXTCLOUD_URL") ?: fail(),
username = System.getenv("NEXTCLOUD_USER") ?: fail(),
password = System.getenv("NEXTCLOUD_PASS") ?: fail(),
)
}
}

View file

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

View file

@ -115,5 +115,6 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug
google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
jlleitschuh-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } jlleitschuh-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }

View file

@ -21,6 +21,7 @@ dependencyResolutionManagement {
} }
rootProject.name = "Seedvault" rootProject.name = "Seedvault"
include(":core")
include(":app") include(":app")
include(":contactsbackup") include(":contactsbackup")
include(":storage:lib") include(":storage:lib")