Use new WebDavBackend in WebDavStoragePlugin
This commit is contained in:
parent
099e0ba6d5
commit
8c05ccc39d
18 changed files with 255 additions and 534 deletions
|
@ -1,12 +0,0 @@
|
||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
|
@ -9,13 +9,14 @@ import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
|
|
||||||
class WebDavFactory(
|
class WebDavFactory(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> {
|
fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> {
|
||||||
return WebDavStoragePlugin(context, config)
|
return WebDavStoragePlugin(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createFilesStoragePlugin(
|
fun createFilesStoragePlugin(
|
||||||
|
|
|
@ -14,6 +14,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
internal sealed interface WebDavConfigState {
|
internal sealed interface WebDavConfigState {
|
||||||
|
|
|
@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.stevesoltys.seedvault.plugins.StorageProperties
|
import com.stevesoltys.seedvault.plugins.StorageProperties
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
|
|
||||||
data class WebDavProperties(
|
data class WebDavProperties(
|
||||||
override val config: WebDavConfig,
|
override val config: WebDavConfig,
|
||||||
|
|
|
@ -29,6 +29,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okio.BufferedSink
|
import okio.BufferedSink
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
|
@ -5,233 +5,95 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.plugins.webdav
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
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.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
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_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA
|
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
|
||||||
import com.stevesoltys.seedvault.plugins.tokenRegex
|
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import org.calyxos.seedvault.core.backends.webdav.WebDavBackend
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
internal class WebDavStoragePlugin(
|
internal class WebDavStoragePlugin(
|
||||||
context: Context,
|
|
||||||
webDavConfig: WebDavConfig,
|
webDavConfig: WebDavConfig,
|
||||||
root: String = DIRECTORY_ROOT,
|
root: String = DIRECTORY_ROOT,
|
||||||
) : WebDavStorage(webDavConfig, root), StoragePlugin<WebDavConfig> {
|
) : WebDavStorage(webDavConfig, root), StoragePlugin<WebDavConfig> {
|
||||||
|
|
||||||
override suspend fun test(): Boolean {
|
private val delegate = WebDavBackend(webDavConfig, root)
|
||||||
val location = (if (baseUrl.endsWith('/')) baseUrl else "$baseUrl/").toHttpUrl()
|
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
|
||||||
|
|
||||||
val webDavSupported = suspendCoroutine { cont ->
|
override suspend fun test(): Boolean {
|
||||||
davCollection.options { davCapabilities, response ->
|
return delegate.test()
|
||||||
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? {
|
override suspend fun getFreeSpace(): Long? {
|
||||||
val location = "$url/".toHttpUrl()
|
return delegate.getFreeSpace()
|
||||||
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)
|
@Throws(IOException::class)
|
||||||
override suspend fun startNewRestoreSet(token: Long) {
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
val location = "$url/$token/".toHttpUrl()
|
// no-op
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
|
||||||
|
|
||||||
val response = davCollection.createFolder()
|
|
||||||
debugLog { "startNewRestoreSet($token) = $response" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun initializeDevice() {
|
override suspend fun initializeDevice() {
|
||||||
// TODO does it make sense to delete anything
|
// no-op
|
||||||
// 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)
|
@Throws(IOException::class)
|
||||||
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
|
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
|
||||||
val location = "$url/$token/$name".toHttpUrl()
|
val handle = when (name) {
|
||||||
return try {
|
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
|
||||||
getOutputStream(location)
|
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
|
||||||
} catch (e: Exception) {
|
else -> LegacyAppBackupFile.Blob(token, name)
|
||||||
if (e is IOException) throw e
|
|
||||||
else throw IOException("Error getting OutputStream for $token and $name: ", e)
|
|
||||||
}
|
}
|
||||||
|
return delegate.save(handle).outputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
||||||
val location = "$url/$token/$name".toHttpUrl()
|
val handle = when (name) {
|
||||||
return try {
|
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
|
||||||
getInputStream(location)
|
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
|
||||||
} catch (e: Exception) {
|
else -> LegacyAppBackupFile.Blob(token, name)
|
||||||
if (e is IOException) throw e
|
|
||||||
else throw IOException("Error getting InputStream for $token and $name: ", e)
|
|
||||||
}
|
}
|
||||||
|
return delegate.load(handle).inputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun removeData(token: Long, name: String) {
|
override suspend fun removeData(token: Long, name: String) {
|
||||||
val location = "$url/$token/$name".toHttpUrl()
|
val handle = when (name) {
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
|
||||||
|
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
|
||||||
try {
|
else -> LegacyAppBackupFile.Blob(token, name)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
delegate.remove(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||||
return try {
|
return try {
|
||||||
doGetAvailableBackups()
|
// get all restore set tokens in root folder that have a metadata file
|
||||||
|
val tokens = ArrayList<Long>()
|
||||||
|
delegate.list(null, LegacyAppBackupFile.Metadata::class) { fileInfo ->
|
||||||
|
val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata
|
||||||
|
tokens.add(handle.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
|
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
|
||||||
Log.e(TAG, "Error getting available backups: ", e)
|
Log.e(TAG, "Error getting available backups: ", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun doGetAvailableBackups(): Sequence<EncryptedMetadata> {
|
override val providerPackageName: String? = null // 100% built-in plugin
|
||||||
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
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,11 +15,11 @@ import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
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.WebDavHandler.Companion.createWebDavProperties
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import java.util.concurrent.ConcurrentSkipListSet
|
import java.util.concurrent.ConcurrentSkipListSet
|
||||||
|
|
||||||
internal const val PREF_KEY_TOKEN = "token"
|
internal const val PREF_KEY_TOKEN = "token"
|
||||||
|
|
|
@ -5,187 +5,75 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.storage
|
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.plugins.chunkFolderRegex
|
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT
|
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.StoragePlugin
|
||||||
import org.calyxos.backup.storage.api.StoredSnapshot
|
import org.calyxos.backup.storage.api.StoredSnapshot
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
|
import org.calyxos.seedvault.core.backends.FileBackupFileType
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex
|
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex
|
import org.calyxos.seedvault.core.backends.webdav.WebDavBackend
|
||||||
import org.koin.core.time.measureDuration
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
internal class WebDavStoragePlugin(
|
internal class WebDavStoragePlugin(
|
||||||
/**
|
/**
|
||||||
* The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
* The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
*/
|
*/
|
||||||
androidId: String,
|
private val androidId: String,
|
||||||
webDavConfig: WebDavConfig,
|
webDavConfig: WebDavConfig,
|
||||||
root: String = DIRECTORY_ROOT,
|
root: String = DIRECTORY_ROOT,
|
||||||
) : WebDavStorage(webDavConfig, root), StoragePlugin {
|
) : StoragePlugin {
|
||||||
|
|
||||||
/**
|
private val topLevelFolder = TopLevelFolder("$androidId.sv")
|
||||||
* The folder name is our user ID plus .sv extension (for SeedVault).
|
private val delegate = WebDavBackend(webDavConfig, root)
|
||||||
* 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)
|
@Throws(IOException::class)
|
||||||
override suspend fun init() {
|
override suspend fun init() {
|
||||||
val location = "$url/".toHttpUrl()
|
// no-op
|
||||||
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)
|
@Throws(IOException::class)
|
||||||
override suspend fun getAvailableChunkIds(): List<String> {
|
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>()
|
val chunkIds = ArrayList<String>()
|
||||||
try {
|
delegate.list(topLevelFolder, FileBackupFileType.Blob::class) { fileInfo ->
|
||||||
val duration = measureDuration {
|
chunkIds.add(fileInfo.fileHandle.name)
|
||||||
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
|
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
|
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
|
||||||
val chunkFolderName = chunkId.substring(0, 2)
|
val fileHandle = FileBackupFileType.Blob(androidId, chunkId)
|
||||||
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl()
|
return delegate.save(fileHandle).outputStream()
|
||||||
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)
|
@Throws(IOException::class)
|
||||||
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||||
val location = "$url/$folder/$timestamp$SNAPSHOT_EXT".toHttpUrl()
|
val fileHandle = FileBackupFileType.Snapshot(androidId, timestamp)
|
||||||
debugLog { "getBackupSnapshotOutputStream($location)" }
|
return delegate.save(fileHandle).outputStream()
|
||||||
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 *******************************/
|
/************************* Restore *******************************/
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
|
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
|
||||||
val location = "$url/".toHttpUrl()
|
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
|
||||||
debugLog { "getBackupSnapshotsForRestore($location)" }
|
|
||||||
|
|
||||||
val snapshots = ArrayList<StoredSnapshot>()
|
val snapshots = ArrayList<StoredSnapshot>()
|
||||||
try {
|
delegate.list(null, FileBackupFileType.Snapshot::class) { fileInfo ->
|
||||||
davCollection.propfindDepthTwo { response, relation ->
|
val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
|
||||||
debugLog { "getBackupSnapshotsForRestore() = $response" }
|
val folderName = handle.topLevelFolder.name
|
||||||
// This callback will be called for every file in the folder
|
val timestamp = handle.time
|
||||||
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) {
|
val storedSnapshot = StoredSnapshot(folderName, timestamp)
|
||||||
val name = response.hrefName()
|
snapshots.add(storedSnapshot)
|
||||||
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
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
|
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
|
||||||
val timestamp = storedSnapshot.timestamp
|
val androidId = storedSnapshot.androidId
|
||||||
val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl()
|
val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
|
||||||
debugLog { "getBackupSnapshotInputStream($location)" }
|
return delegate.load(handle).inputStream()
|
||||||
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)
|
@Throws(IOException::class)
|
||||||
|
@ -193,92 +81,38 @@ internal class WebDavStoragePlugin(
|
||||||
snapshot: StoredSnapshot,
|
snapshot: StoredSnapshot,
|
||||||
chunkId: String,
|
chunkId: String,
|
||||||
): InputStream {
|
): InputStream {
|
||||||
val chunkFolderName = chunkId.substring(0, 2)
|
val handle = FileBackupFileType.Blob(snapshot.androidId, chunkId)
|
||||||
val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl()
|
return delegate.load(handle).inputStream()
|
||||||
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 *******************************/
|
/************************* Pruning *******************************/
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
|
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
|
||||||
val location = "$url/$folder/".toHttpUrl()
|
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
|
||||||
debugLog { "getCurrentBackupSnapshots($location)" }
|
|
||||||
|
|
||||||
val snapshots = ArrayList<StoredSnapshot>()
|
val snapshots = ArrayList<StoredSnapshot>()
|
||||||
try {
|
delegate.list(topLevelFolder, FileBackupFileType.Snapshot::class) { fileInfo ->
|
||||||
val duration = measureDuration {
|
val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
|
||||||
davCollection.propfind(
|
val folderName = handle.topLevelFolder.name
|
||||||
depth = 1,
|
val timestamp = handle.time
|
||||||
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
|
val storedSnapshot = StoredSnapshot(folderName, timestamp)
|
||||||
) { response, relation ->
|
snapshots.add(storedSnapshot)
|
||||||
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
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
|
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
|
||||||
val timestamp = storedSnapshot.timestamp
|
val androidId = storedSnapshot.androidId
|
||||||
Log.d(TAG, "Deleting snapshot $timestamp")
|
val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
|
||||||
|
delegate.remove(handle)
|
||||||
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)
|
@Throws(IOException::class)
|
||||||
override suspend fun deleteChunks(chunkIds: List<String>) {
|
override suspend fun deleteChunks(chunkIds: List<String>) {
|
||||||
chunkIds.forEach { chunkId ->
|
chunkIds.forEach { chunkId ->
|
||||||
val chunkFolderName = chunkId.substring(0, 2)
|
val androidId = topLevelFolder.name.substringBefore(".sv")
|
||||||
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl()
|
val handle = FileBackupFileType.Blob(androidId, chunkId)
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
delegate.remove(handle)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.SafHandler
|
import com.stevesoltys.seedvault.plugins.saf.SafHandler
|
||||||
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
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.WebDavHandler
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
|
||||||
|
@ -26,6 +25,7 @@ import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
|
|
||||||
internal abstract class StorageViewModel(
|
internal abstract class StorageViewModel(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
|
|
|
@ -8,23 +8,20 @@ package com.stevesoltys.seedvault.plugins.webdav
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.stevesoltys.seedvault.TestApp
|
import com.stevesoltys.seedvault.TestApp
|
||||||
import com.stevesoltys.seedvault.getRandomByteArray
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
|
||||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
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.assertTrue
|
||||||
import org.junit.jupiter.api.Assertions.fail
|
import org.junit.jupiter.api.Assertions.fail
|
||||||
import org.junit.jupiter.api.assertThrows
|
import org.junit.jupiter.api.assertThrows
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Config(
|
@Config(
|
||||||
|
@ -33,13 +30,13 @@ import kotlin.random.Random
|
||||||
)
|
)
|
||||||
internal class WebDavStoragePluginTest : TransportTest() {
|
internal class WebDavStoragePluginTest : TransportTest() {
|
||||||
|
|
||||||
private val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig())
|
private val plugin = WebDavStoragePlugin(WebDavTestConfig.getConfig())
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test self-test`() = runBlocking {
|
fun `test self-test`() = runBlocking {
|
||||||
assertTrue(plugin.test())
|
assertTrue(plugin.test())
|
||||||
|
|
||||||
val plugin2 = WebDavStoragePlugin(context, WebDavConfig("https://github.com/", "", ""))
|
val plugin2 = WebDavStoragePlugin(WebDavConfig("https://github.com/", "", ""))
|
||||||
val e = assertThrows<Exception> {
|
val e = assertThrows<Exception> {
|
||||||
assertFalse(plugin2.test())
|
assertFalse(plugin2.test())
|
||||||
}
|
}
|
||||||
|
@ -86,37 +83,4 @@ internal class WebDavStoragePluginTest : TransportTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.plugins.webdav
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
import org.junit.Assume.assumeFalse
|
import org.junit.Assume.assumeFalse
|
||||||
import org.junit.jupiter.api.Assertions.fail
|
import org.junit.jupiter.api.Assertions.fail
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
package com.stevesoltys.seedvault.storage
|
package com.stevesoltys.seedvault.storage
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.getRandomByteArray
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
@ -14,14 +13,13 @@ import org.calyxos.backup.storage.api.StoredSnapshot
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.jupiter.api.assertThrows
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
internal class WebDavStoragePluginTest : BackupTest() {
|
internal class WebDavStoragePluginTest : BackupTest() {
|
||||||
|
|
||||||
private val plugin = WebDavStoragePlugin("foo", WebDavTestConfig.getConfig())
|
private val androidId = "abcdef0123456789"
|
||||||
|
private val plugin = WebDavStoragePlugin(androidId, WebDavTestConfig.getConfig())
|
||||||
|
|
||||||
private val snapshot = StoredSnapshot("foo.sv", System.currentTimeMillis())
|
private val snapshot = StoredSnapshot("$androidId.sv", System.currentTimeMillis())
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test chunks`() = runBlocking {
|
fun `test chunks`() = runBlocking {
|
||||||
|
@ -82,8 +80,9 @@ internal class WebDavStoragePluginTest : BackupTest() {
|
||||||
)
|
)
|
||||||
|
|
||||||
// other device writes another snapshot
|
// other device writes another snapshot
|
||||||
val otherPlugin = WebDavStoragePlugin("bar", WebDavTestConfig.getConfig())
|
val androidId2 = "0123456789abcdef"
|
||||||
val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis())
|
val otherPlugin = WebDavStoragePlugin(androidId2, WebDavTestConfig.getConfig())
|
||||||
|
val otherSnapshot = StoredSnapshot("$androidId2.sv", System.currentTimeMillis())
|
||||||
val otherSnapshotBytes = getRandomByteArray()
|
val otherSnapshotBytes = getRandomByteArray()
|
||||||
assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds())
|
assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds())
|
||||||
otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use {
|
otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use {
|
||||||
|
@ -104,44 +103,6 @@ internal class WebDavStoragePluginTest : BackupTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test missing root dir`() = runBlocking {
|
|
||||||
val plugin = WebDavStoragePlugin(
|
|
||||||
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) }
|
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
|
||||||
|
|
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=trace
|
|
@ -5,12 +5,26 @@
|
||||||
|
|
||||||
package org.calyxos.seedvault.core.backends
|
package org.calyxos.seedvault.core.backends
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import okio.BufferedSink
|
import okio.BufferedSink
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
public interface Backend {
|
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): BufferedSink
|
public suspend fun save(handle: FileHandle): BufferedSink
|
||||||
|
|
||||||
public suspend fun load(handle: FileHandle): BufferedSource
|
public suspend fun load(handle: FileHandle): BufferedSource
|
||||||
|
@ -25,7 +39,16 @@ public interface Backend {
|
||||||
|
|
||||||
public suspend fun rename(from: TopLevelFolder, to: TopLevelFolder)
|
public suspend fun rename(from: TopLevelFolder, to: TopLevelFolder)
|
||||||
|
|
||||||
// TODO really all?
|
@VisibleForTesting
|
||||||
public suspend fun removeAll()
|
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?
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,12 @@ public sealed class LegacyAppBackupFile : FileHandle() {
|
||||||
|
|
||||||
public sealed class FileBackupFileType : FileHandle() {
|
public sealed class FileBackupFileType : FileHandle() {
|
||||||
public abstract val androidId: String
|
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 val topLevelFolder: TopLevelFolder get() = TopLevelFolder("$androidId.sv")
|
||||||
|
|
||||||
public data class Blob(
|
public data class Blob(
|
||||||
|
|
|
@ -45,6 +45,14 @@ public class SafBackend(
|
||||||
private val context: Context get() = appContext.getBackendContext { safConfig.isUsb }
|
private val context: Context get() = appContext.getBackendContext { safConfig.isUsb }
|
||||||
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
|
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
|
||||||
|
|
||||||
|
override suspend fun test(): Boolean {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFreeSpace(): Long? {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun save(handle: FileHandle): BufferedSink {
|
override suspend fun save(handle: FileHandle): BufferedSink {
|
||||||
val file = cache.getFile(handle)
|
val file = cache.getFile(handle)
|
||||||
return file.getOutputStream(context.contentResolver).sink().buffer()
|
return file.getOutputStream(context.contentResolver).sink().buffer()
|
||||||
|
@ -148,4 +156,6 @@ public class SafBackend(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val providerPackageName: String? get() = TODO("Not yet implemented")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import at.bitfire.dav4jvm.PropertyRegistry
|
||||||
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
|
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
|
||||||
import at.bitfire.dav4jvm.exception.HttpException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.exception.NotFoundException
|
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
@ -51,7 +52,7 @@ import kotlin.reflect.KClass
|
||||||
private const val DEBUG_LOG = true
|
private const val DEBUG_LOG = true
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
internal class WebDavBackend(
|
public class WebDavBackend(
|
||||||
webDavConfig: WebDavConfig,
|
webDavConfig: WebDavConfig,
|
||||||
root: String = DIRECTORY_ROOT,
|
root: String = DIRECTORY_ROOT,
|
||||||
) : Backend {
|
) : Backend {
|
||||||
|
@ -75,13 +76,53 @@ internal class WebDavBackend(
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val url = "${webDavConfig.url}/$root"
|
private val baseUrl = webDavConfig.url.trimEnd('/')
|
||||||
|
private val url = "$baseUrl/$root"
|
||||||
private val folders = mutableSetOf<HttpUrl>() // cache for existing/created folders
|
private val folders = mutableSetOf<HttpUrl>() // cache for existing/created folders
|
||||||
|
|
||||||
init {
|
init {
|
||||||
PropertyRegistry.register(GetLastModified.Factory)
|
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): BufferedSink {
|
override suspend fun save(handle: FileHandle): BufferedSink {
|
||||||
val location = handle.toHttpUrl()
|
val location = handle.toHttpUrl()
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
@ -118,10 +159,15 @@ internal class WebDavBackend(
|
||||||
val location = handle.toHttpUrl()
|
val location = handle.toHttpUrl()
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
val response = davCollection.get(accept = "", headers = null)
|
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" }
|
log.debugLog { "load($location) = $response" }
|
||||||
if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}")
|
if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}")
|
||||||
return response.body?.source() ?: throw IOException()
|
return response.body?.source() ?: throw IOException("Body was null for $location")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun list(
|
override suspend fun list(
|
||||||
|
@ -145,58 +191,67 @@ internal class WebDavBackend(
|
||||||
}
|
}
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
val tokenFolders = mutableSetOf<HttpUrl>()
|
val tokenFolders = mutableSetOf<HttpUrl>()
|
||||||
davCollection.propfindDepthInfinity(depth) { response, relation ->
|
try {
|
||||||
log.debugLog { "list() = $response" }
|
davCollection.propfindDepthInfinity(depth) { response, relation ->
|
||||||
|
log.debugLog { "list() = $response" }
|
||||||
|
|
||||||
// work around nginx's inability to find files starting with .
|
// work around nginx's inability to find files starting with .
|
||||||
if (relation != SELF && LegacyAppBackupFile.Metadata::class in fileTypes &&
|
if (relation != SELF && LegacyAppBackupFile.Metadata::class in fileTypes &&
|
||||||
response.isFolder() && response.hrefName().matches(tokenRegex)
|
response.isFolder() && response.hrefName().matches(tokenRegex)
|
||||||
) {
|
) {
|
||||||
tokenFolders.add(response.href)
|
tokenFolders.add(response.href)
|
||||||
}
|
}
|
||||||
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) {
|
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) {
|
||||||
val name = response.hrefName()
|
val name = response.hrefName()
|
||||||
val parentName = response.href.pathSegments[response.href.pathSegments.size - 2]
|
val parentName = response.href.pathSegments[response.href.pathSegments.size - 2]
|
||||||
|
|
||||||
if (LegacyAppBackupFile.Metadata::class in fileTypes) {
|
if (LegacyAppBackupFile.Metadata::class in fileTypes) {
|
||||||
if (name == FILE_BACKUP_METADATA && parentName.matches(tokenRegex)) {
|
if (name == FILE_BACKUP_METADATA && parentName.matches(tokenRegex)) {
|
||||||
val metadata = LegacyAppBackupFile.Metadata(parentName.toLong())
|
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()
|
val size = response.properties.contentLength()
|
||||||
callback(FileInfo(blob, size))
|
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
|
// direct query for .backup.metadata as nginx doesn't support listing hidden files
|
||||||
tokenFolders.forEach { url ->
|
tokenFolders.forEach { url ->
|
||||||
|
@ -251,8 +306,13 @@ internal class WebDavBackend(
|
||||||
val location = "$url/${from.name}/".toHttpUrl()
|
val location = "$url/${from.name}/".toHttpUrl()
|
||||||
val toUrl = "$url/${to.name}/".toHttpUrl()
|
val toUrl = "$url/${to.name}/".toHttpUrl()
|
||||||
val davCollection = DavCollection(okHttpClient, location)
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
davCollection.move(toUrl, false) { response ->
|
try {
|
||||||
log.debugLog { "rename(${from.name}, ${to.name}) = $response" }
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,9 +325,14 @@ internal class WebDavBackend(
|
||||||
}
|
}
|
||||||
} catch (e: NotFoundException) {
|
} catch (e: NotFoundException) {
|
||||||
log.info { "Not found: $location" }
|
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) {
|
private fun FileHandle.toHttpUrl(): HttpUrl = when (this) {
|
||||||
// careful with trailing slashes, use only for folders/collections
|
// careful with trailing slashes, use only for folders/collections
|
||||||
is TopLevelFolder -> "$url/$name/".toHttpUrl()
|
is TopLevelFolder -> "$url/$name/".toHttpUrl()
|
||||||
|
|
|
@ -29,7 +29,9 @@ public data class StoredSnapshot(
|
||||||
* The timestamp identifying a snapshot of the [userId].
|
* The timestamp identifying a snapshot of the [userId].
|
||||||
*/
|
*/
|
||||||
public val timestamp: Long,
|
public val timestamp: Long,
|
||||||
)
|
) {
|
||||||
|
public val androidId: String = userId.substringBefore(".sv")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines which backup snapshots should be retained when pruning backups.
|
* Defines which backup snapshots should be retained when pruning backups.
|
||||||
|
|
Loading…
Reference in a new issue