Initial implementation of WebDavStoragePlugin
This commit is contained in:
parent
cc8d3079d2
commit
870d1617d2
16 changed files with 481 additions and 5 deletions
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
@ -35,6 +35,10 @@ jobs:
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
NEXTCLOUD_URL: ${{ vars.NEXTCLOUD_URL }}
|
||||||
|
NEXTCLOUD_USER: ${{ secrets.NEXTCLOUD_USER }}
|
||||||
|
NEXTCLOUD_PASS: ${{ secrets.NEXTCLOUD_PASS }}
|
||||||
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
|
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
|
||||||
|
|
||||||
- name: Upload APKs
|
- name: Upload APKs
|
||||||
|
|
|
@ -44,6 +44,10 @@ android_app {
|
||||||
"seedvault-lib-koin-android",
|
"seedvault-lib-koin-android",
|
||||||
// bip39
|
// bip39
|
||||||
"seedvault-lib-kotlin-bip39",
|
"seedvault-lib-kotlin-bip39",
|
||||||
|
// WebDAV
|
||||||
|
"seedvault-lib-dav4jvm",
|
||||||
|
"seedvault-lib-okhttp",
|
||||||
|
"seedvault-lib-okio",
|
||||||
],
|
],
|
||||||
manifest: "app/src/main/AndroidManifest.xml",
|
manifest: "app/src/main/AndroidManifest.xml",
|
||||||
|
|
||||||
|
|
|
@ -159,6 +159,9 @@ dependencies {
|
||||||
|
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
||||||
|
|
||||||
|
// dav4jvm - later versions of okhttp need kotlin > 1.9.0
|
||||||
|
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Dependencies (do not concern the AOSP build)
|
* Test Dependencies (do not concern the AOSP build)
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
<!-- This is needed to check for internet access when backup is stored on network storage -->
|
<!-- This is needed to check for internet access when backup is stored on network storage -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Used for internal WebDAV plugin -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<!-- This is needed to inform users about backup status and errors -->
|
<!-- This is needed to inform users about backup status and errors -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
|
|
@ -75,4 +75,7 @@ interface StoragePlugin {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class EncryptedMetadata(val token: Long, val inputStreamRetriever: () -> InputStream)
|
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}")
|
||||||
|
|
|
@ -7,6 +7,8 @@ import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
|
||||||
|
import com.stevesoltys.seedvault.plugins.tokenRegex
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
import com.stevesoltys.seedvault.settings.Storage
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -137,9 +139,6 @@ internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<B
|
||||||
return backupSets
|
return backupSets
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
|
|
||||||
private val chunkFolderRegex = Regex("[a-f0-9]{2}")
|
|
||||||
|
|
||||||
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
|
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
|
||||||
val looksLikeToken = name != null && tokenRegex.matches(name)
|
val looksLikeToken = name != null && tokenRegex.matches(name)
|
||||||
// check for isDirectory only if we already have a valid token (causes DB query)
|
// check for isDirectory only if we already have a valid token (causes DB query)
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
data class WebDavConfig(
|
||||||
|
val url: String,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
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.DocumentsStorage
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val webDavModule = module {
|
||||||
|
// TODO PluginManager should create the plugin on demand
|
||||||
|
single<StoragePlugin> { WebDavStoragePlugin(androidContext(), WebDavConfig("", "", "")) }
|
||||||
|
|
||||||
|
single { DocumentsStorage(androidContext(), get()) }
|
||||||
|
@Suppress("Deprecation")
|
||||||
|
single<LegacyStoragePlugin> { DocumentsProviderLegacyPlugin(androidContext(), get()) }
|
||||||
|
}
|
|
@ -0,0 +1,297 @@
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||||
|
import at.bitfire.dav4jvm.DavCollection
|
||||||
|
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
|
||||||
|
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||||
|
import at.bitfire.dav4jvm.property.DisplayName
|
||||||
|
import at.bitfire.dav4jvm.property.ResourceType
|
||||||
|
import at.bitfire.dav4jvm.property.ResourceType.Companion.COLLECTION
|
||||||
|
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 com.stevesoltys.seedvault.settings.Storage
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.io.PipedInputStream
|
||||||
|
import java.io.PipedOutputStream
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
private val TAG = WebDavStoragePlugin::class.java.simpleName
|
||||||
|
const val DEBUG_LOG = true
|
||||||
|
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
internal class WebDavStoragePlugin(
|
||||||
|
context: Context,
|
||||||
|
webDavConfig: WebDavConfig,
|
||||||
|
) : StoragePlugin {
|
||||||
|
|
||||||
|
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)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val url = "${webDavConfig.url}/$DIRECTORY_ROOT"
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
|
try {
|
||||||
|
val location = "$url/$token".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val response = suspendCoroutine { cont ->
|
||||||
|
davCollection.mkCol(null) { response ->
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog { "startNewRestoreSet($token) = $response" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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?
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
return try {
|
||||||
|
doGetOutputStream(token, name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException("Error getting OutputStream for $token and $name: ", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private suspend fun doGetOutputStream(token: Long, name: String): OutputStream {
|
||||||
|
val location = "$url/$token/$name".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val pipedInputStream = PipedInputStream()
|
||||||
|
val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream)
|
||||||
|
|
||||||
|
val body = object : RequestBody() {
|
||||||
|
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($token, $name) = $response" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pipedOutputStream.doOnClose {
|
||||||
|
runBlocking { // blocking i/o wait
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pipedOutputStream
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
||||||
|
return try {
|
||||||
|
doGetInputStream(token, name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException("Error getting InputStream for $token and $name: ", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun doGetInputStream(token: Long, name: String): InputStream {
|
||||||
|
val location = "$url/$token/$name".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val pipedInputStream = PipedInputStream()
|
||||||
|
val pipedOutputStream = PipedOutputStream(pipedInputStream)
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
davCollection.get(accept = "", headers = null) { response ->
|
||||||
|
val inputStream = response.body?.byteStream()
|
||||||
|
?: throw IOException("No response body")
|
||||||
|
debugLog { "getInputStream($token, $name) = $response" }
|
||||||
|
pipedOutputStream.use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pipedInputStream
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun hasBackup(storage: Storage): Boolean {
|
||||||
|
// TODO this requires refactoring
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||||
|
return try {
|
||||||
|
doGetAvailableBackups()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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(".SeedSnap")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.isFolder(): Boolean {
|
||||||
|
return this[ResourceType::class.java]?.types?.contains(COLLECTION) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
override val providerPackageName: String = context.packageName // 100% built-in plugin
|
||||||
|
|
||||||
|
private class PipedCloseActionOutputStream(
|
||||||
|
inputStream: PipedInputStream,
|
||||||
|
) : PipedOutputStream(inputStream) {
|
||||||
|
|
||||||
|
private var onClose: (() -> Unit)? = null
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun debugLog(block: () -> String) {
|
||||||
|
if (DEBUG_LOG) Log.d(TAG, block())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
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.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 = [33], // robolectric does not support 34, yet
|
||||||
|
application = TestApp::class
|
||||||
|
)
|
||||||
|
internal class WebDavStoragePluginTest : TransportTest() {
|
||||||
|
|
||||||
|
private val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test restore sets and reading+writing`() = runBlocking {
|
||||||
|
val token = System.currentTimeMillis()
|
||||||
|
val metadata = getRandomByteArray()
|
||||||
|
|
||||||
|
// initially, we don't have any backups
|
||||||
|
assertEquals(emptySet<EncryptedMetadata>(), plugin.getAvailableBackups()?.toSet())
|
||||||
|
|
||||||
|
// and no data
|
||||||
|
assertFalse(plugin.hasData(token, FILE_BACKUP_METADATA))
|
||||||
|
|
||||||
|
// start a new restore set, initialize it and write out the metadata file
|
||||||
|
plugin.startNewRestoreSet(token)
|
||||||
|
plugin.initializeDevice()
|
||||||
|
plugin.getOutputStream(token, FILE_BACKUP_METADATA).use {
|
||||||
|
it.write(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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
import org.junit.Assume.assumeFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.fail
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.slot
|
||||||
import org.junit.jupiter.api.TestInstance
|
import org.junit.jupiter.api.TestInstance
|
||||||
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
|
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
@ -70,8 +71,14 @@ internal abstract class TransportTest {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
mockkStatic(Log::class)
|
mockkStatic(Log::class)
|
||||||
|
val logTagSlot = slot<String>()
|
||||||
|
val logMsgSlot = slot<String>()
|
||||||
every { Log.v(any(), any()) } returns 0
|
every { Log.v(any(), any()) } returns 0
|
||||||
every { Log.d(any(), any()) } returns 0
|
every { Log.d(capture(logTagSlot), capture(logMsgSlot)) } answers {
|
||||||
|
println("${logTagSlot.captured} - ${logMsgSlot.captured}")
|
||||||
|
0
|
||||||
|
}
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
every { Log.i(any(), any()) } returns 0
|
every { Log.i(any(), any()) } returns 0
|
||||||
every { Log.w(any(), ofType(String::class)) } returns 0
|
every { Log.w(any(), ofType(String::class)) } returns 0
|
||||||
every { Log.w(any(), ofType(String::class), any()) } returns 0
|
every { Log.w(any(), ofType(String::class), any()) } returns 0
|
||||||
|
|
17
libs/dav4jvm/Android.bp
Normal file
17
libs/dav4jvm/Android.bp
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
java_import {
|
||||||
|
name: "seedvault-lib-dav4jvm",
|
||||||
|
jars: ["dav4jvm-2.2.1.jar"],
|
||||||
|
sdk_version: "current",
|
||||||
|
}
|
||||||
|
|
||||||
|
java_import {
|
||||||
|
name: "seedvault-lib-okhttp",
|
||||||
|
jars: ["okhttp-4.11.0.jar"],
|
||||||
|
sdk_version: "current",
|
||||||
|
}
|
||||||
|
|
||||||
|
java_import {
|
||||||
|
name: "seedvault-lib-okio",
|
||||||
|
jars: ["okio-jvm-3.7.0.jar"],
|
||||||
|
sdk_version: "current",
|
||||||
|
}
|
BIN
libs/dav4jvm/dav4jvm-2.2.1.jar
Normal file
BIN
libs/dav4jvm/dav4jvm-2.2.1.jar
Normal file
Binary file not shown.
BIN
libs/dav4jvm/okhttp-4.11.0.jar
Normal file
BIN
libs/dav4jvm/okhttp-4.11.0.jar
Normal file
Binary file not shown.
BIN
libs/dav4jvm/okio-jvm-3.7.0.jar
Normal file
BIN
libs/dav4jvm/okio-jvm-3.7.0.jar
Normal file
Binary file not shown.
Loading…
Reference in a new issue