From 8c05ccc39d01400e70f02cfe4c845536efff4fee Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 26 Aug 2024 11:00:20 -0300 Subject: [PATCH] Use new WebDavBackend in WebDavStoragePlugin --- .../seedvault/plugins/webdav/WebDavConfig.kt | 12 - .../seedvault/plugins/webdav/WebDavFactory.kt | 3 +- .../seedvault/plugins/webdav/WebDavHandler.kt | 1 + .../plugins/webdav/WebDavProperties.kt | 1 + .../seedvault/plugins/webdav/WebDavStorage.kt | 1 + .../plugins/webdav/WebDavStoragePlugin.kt | 218 +++------------- .../seedvault/settings/SettingsManager.kt | 2 +- .../seedvault/storage/WebDavStoragePlugin.kt | 242 +++--------------- .../seedvault/ui/storage/StorageViewModel.kt | 2 +- .../plugins/webdav/WebDavStoragePluginTest.kt | 42 +-- .../plugins/webdav/WebDavTestConfig.kt | 1 + .../storage/WebDavStoragePluginTest.kt | 51 +--- .../test/resources/simplelogger.properties | 1 + .../seedvault/core/backends/Backend.kt | 25 +- .../seedvault/core/backends/FileHandle.kt | 6 + .../seedvault/core/backends/saf/SafBackend.kt | 10 + .../core/backends/webdav/WebDavBackend.kt | 167 ++++++++---- .../calyxos/backup/storage/api/Snapshot.kt | 4 +- 18 files changed, 255 insertions(+), 534 deletions(-) delete mode 100644 app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt create mode 100644 app/src/test/resources/simplelogger.properties diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt deleted file mode 100644 index b95ff71b..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt +++ /dev/null @@ -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, -) diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt index 91a2d78c..bc8016b7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt @@ -9,13 +9,14 @@ import android.annotation.SuppressLint import android.content.Context import android.provider.Settings import com.stevesoltys.seedvault.plugins.StoragePlugin +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig class WebDavFactory( private val context: Context, ) { fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin { - return WebDavStoragePlugin(context, config) + return WebDavStoragePlugin(config) } fun createFilesStoragePlugin( diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt index 667b7568..d93f7f6c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt @@ -14,6 +14,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import okhttp3.HttpUrl.Companion.toHttpUrl +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig import java.io.IOException internal sealed interface WebDavConfigState { diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt index 29ea84a9..7f06cf7c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt @@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.plugins.webdav import android.content.Context import com.stevesoltys.seedvault.plugins.StorageProperties +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig data class WebDavProperties( override val config: WebDavConfig, diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt index faec3f84..b7175006 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt @@ -29,6 +29,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody import okio.BufferedSink +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig import org.xmlpull.v1.XmlPullParser import java.io.IOException import java.io.InputStream diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt index da6976c1..bc48607f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt @@ -5,233 +5,95 @@ package com.stevesoltys.seedvault.plugins.webdav -import android.content.Context import android.util.Log -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.Response.HrefRelation.SELF -import at.bitfire.dav4jvm.exception.HttpException -import at.bitfire.dav4jvm.exception.NotFoundException -import at.bitfire.dav4jvm.property.webdav.DisplayName -import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes -import at.bitfire.dav4jvm.property.webdav.ResourceType import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.chunkFolderRegex import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA -import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA -import com.stevesoltys.seedvault.plugins.tokenRegex -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT +import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.backends.webdav.WebDavBackend +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig import java.io.IOException import java.io.InputStream import java.io.OutputStream -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine internal class WebDavStoragePlugin( - context: Context, webDavConfig: WebDavConfig, root: String = DIRECTORY_ROOT, ) : WebDavStorage(webDavConfig, root), StoragePlugin { - override suspend fun test(): Boolean { - val location = (if (baseUrl.endsWith('/')) baseUrl else "$baseUrl/").toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) + private val delegate = WebDavBackend(webDavConfig, root) - val webDavSupported = suspendCoroutine { cont -> - davCollection.options { davCapabilities, response -> - debugLog { "test() = $davCapabilities $response" } - if (davCapabilities.contains("1")) cont.resume(true) - else if (davCapabilities.contains("2")) cont.resume(true) - else if (davCapabilities.contains("3")) cont.resume(true) - else cont.resume(false) - } - } - return webDavSupported + override suspend fun test(): Boolean { + return delegate.test() } override suspend fun getFreeSpace(): Long? { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - val availableBytes = suspendCoroutine { cont -> - davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ -> - debugLog { "getFreeSpace() = $response" } - val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes - val availableBytes = quota?.quotaAvailableBytes ?: -1 - if (availableBytes > 0) { - cont.resume(availableBytes) - } else { - cont.resume(null) - } - } - } - return availableBytes + return delegate.getFreeSpace() } @Throws(IOException::class) override suspend fun startNewRestoreSet(token: Long) { - val location = "$url/$token/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - val response = davCollection.createFolder() - debugLog { "startNewRestoreSet($token) = $response" } + // no-op } @Throws(IOException::class) override suspend fun initializeDevice() { - // TODO does it make sense to delete anything - // when [startNewRestoreSet] is always called first? Maybe unify both calls? - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - davCollection.head { response -> - debugLog { "Root exists: $response" } - } - } catch (e: NotFoundException) { - val response = davCollection.createFolder() - debugLog { "initializeDevice() = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } + // no-op } @Throws(IOException::class) override suspend fun getOutputStream(token: Long, name: String): OutputStream { - val location = "$url/$token/$name".toHttpUrl() - return try { - getOutputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting OutputStream for $token and $name: ", e) + val handle = when (name) { + FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token) + FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token) + else -> LegacyAppBackupFile.Blob(token, name) } + return delegate.save(handle).outputStream() } @Throws(IOException::class) override suspend fun getInputStream(token: Long, name: String): InputStream { - val location = "$url/$token/$name".toHttpUrl() - return try { - getInputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting InputStream for $token and $name: ", e) + val handle = when (name) { + FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token) + FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token) + else -> LegacyAppBackupFile.Blob(token, name) } + return delegate.load(handle).inputStream() } @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) + val handle = when (name) { + FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token) + FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token) + else -> LegacyAppBackupFile.Blob(token, name) } + delegate.remove(handle) } override suspend fun getAvailableBackups(): Sequence? { return try { - doGetAvailableBackups() + // get all restore set tokens in root folder that have a metadata file + val tokens = ArrayList() + 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 Log.e(TAG, "Error getting available backups: ", e) null } } - private suspend fun doGetAvailableBackups(): Sequence { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - // get all restore set tokens in root folder - val tokens = ArrayList() - 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) { - 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 + override val providerPackageName: String? = null // 100% built-in plugin } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index f62ce688..13cbc75a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -15,11 +15,11 @@ import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.transport.backup.BackupCoordinator +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig import java.util.concurrent.ConcurrentSkipListSet internal const val PREF_KEY_TOKEN = "token" diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt index e77a2a6d..7f458a30 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt @@ -5,187 +5,75 @@ 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.WebDavConfig -import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage -import okhttp3.HttpUrl.Companion.toHttpUrl import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoredSnapshot -import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT -import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex -import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex -import org.koin.core.time.measureDuration +import org.calyxos.seedvault.core.backends.FileBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder +import org.calyxos.seedvault.core.backends.webdav.WebDavBackend +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig import java.io.IOException import java.io.InputStream import java.io.OutputStream -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine internal class WebDavStoragePlugin( /** * The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) */ - androidId: String, + private val androidId: String, webDavConfig: WebDavConfig, root: String = DIRECTORY_ROOT, -) : WebDavStorage(webDavConfig, root), StoragePlugin { +) : StoragePlugin { - /** - * The folder name is our user ID plus .sv extension (for SeedVault). - * The user or `androidId` is unique to each combination of app-signing key, user, and device - * so we don't leak anything by not hashing this and can use it as is. - */ - private val folder: String = "$androidId.sv" + private val topLevelFolder = TopLevelFolder("$androidId.sv") + private val delegate = WebDavBackend(webDavConfig, root) @Throws(IOException::class) override suspend fun init() { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - davCollection.head { response -> - debugLog { "Root exists: $response" } - } - } catch (e: NotFoundException) { - val response = davCollection.createFolder() - debugLog { "init() = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } + // no-op } @Throws(IOException::class) override suspend fun getAvailableChunkIds(): List { - 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() - try { - val duration = measureDuration { - davCollection.propfindDepthTwo { response, relation -> - debugLog { "getAvailableChunkIds() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && response.isFolder()) { - val name = response.hrefName() - if (chunkFolderRegex.matches(name)) { - expectedChunkFolders.remove(name) - } - } else if (relation != SELF && response.href.pathSize >= 2) { - val folderName = - response.href.pathSegments[response.href.pathSegments.size - 2] - if (folderName != folder && chunkFolderRegex.matches(folderName)) { - val name = response.hrefName() - if (chunkRegex.matches(name)) chunkIds.add(name) - } - } - } - } - Log.i(TAG, "Retrieving chunks took $duration") - } catch (e: NotFoundException) { - debugLog { "Folder not found: $location" } - davCollection.createFolder() - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error populating chunk folders: ", e) + delegate.list(topLevelFolder, FileBackupFileType.Blob::class) { fileInfo -> + chunkIds.add(fileInfo.fileHandle.name) } - Log.i(TAG, "Got ${chunkIds.size} available chunks") - createMissingChunkFolders(expectedChunkFolders) return chunkIds } - @Throws(IOException::class) - private suspend fun createMissingChunkFolders( - missingChunkFolders: Set, - ) { - 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) override suspend fun getChunkOutputStream(chunkId: String): OutputStream { - val chunkFolderName = chunkId.substring(0, 2) - val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() - debugLog { "getChunkOutputStream($location) for $chunkId" } - return try { - getOutputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting OutputStream for $chunkId: ", e) - } + val fileHandle = FileBackupFileType.Blob(androidId, chunkId) + return delegate.save(fileHandle).outputStream() } @Throws(IOException::class) override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { - val location = "$url/$folder/$timestamp$SNAPSHOT_EXT".toHttpUrl() - debugLog { "getBackupSnapshotOutputStream($location)" } - return try { - getOutputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting OutputStream for $timestamp$SNAPSHOT_EXT: ", e) - } + val fileHandle = FileBackupFileType.Snapshot(androidId, timestamp) + return delegate.save(fileHandle).outputStream() } /************************* Restore *******************************/ @Throws(IOException::class) override suspend fun getBackupSnapshotsForRestore(): List { - val location = "$url/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - debugLog { "getBackupSnapshotsForRestore($location)" } - val snapshots = ArrayList() - try { - davCollection.propfindDepthTwo { response, relation -> - debugLog { "getBackupSnapshotsForRestore() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) { - val name = response.hrefName() - val match = snapshotRegex.matchEntire(name) - if (match != null) { - val timestamp = match.groupValues[1].toLong() - val folderName = - response.href.pathSegments[response.href.pathSegments.size - 2] - val storedSnapshot = StoredSnapshot(folderName, timestamp) - snapshots.add(storedSnapshot) - } - } - } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting snapshots for restore: ", e) + delegate.list(null, FileBackupFileType.Snapshot::class) { fileInfo -> + val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot + val folderName = handle.topLevelFolder.name + val timestamp = handle.time + val storedSnapshot = StoredSnapshot(folderName, timestamp) + snapshots.add(storedSnapshot) } return snapshots } @Throws(IOException::class) override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream { - val timestamp = storedSnapshot.timestamp - val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() - debugLog { "getBackupSnapshotInputStream($location)" } - return try { - getInputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting InputStream for $storedSnapshot: ", e) - } + val androidId = storedSnapshot.androidId + val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp) + return delegate.load(handle).inputStream() } @Throws(IOException::class) @@ -193,92 +81,38 @@ internal class WebDavStoragePlugin( snapshot: StoredSnapshot, chunkId: String, ): InputStream { - val chunkFolderName = chunkId.substring(0, 2) - val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl() - debugLog { "getChunkInputStream($location) for $chunkId" } - return try { - getInputStream(location) - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting InputStream for $chunkFolderName/$chunkId: ", e) - } + val handle = FileBackupFileType.Blob(snapshot.androidId, chunkId) + return delegate.load(handle).inputStream() } /************************* Pruning *******************************/ @Throws(IOException::class) override suspend fun getCurrentBackupSnapshots(): List { - val location = "$url/$folder/".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - debugLog { "getCurrentBackupSnapshots($location)" } - val snapshots = ArrayList() - try { - val duration = measureDuration { - davCollection.propfind( - depth = 1, - reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), - ) { response, relation -> - debugLog { "getCurrentBackupSnapshots() = $response" } - // This callback will be called for every file in the folder - if (relation != SELF && !response.isFolder()) { - val match = snapshotRegex.matchEntire(response.hrefName()) - if (match != null) { - val timestamp = match.groupValues[1].toLong() - val storedSnapshot = StoredSnapshot(folder, timestamp) - snapshots.add(storedSnapshot) - } - } - } - } - Log.i(TAG, "getCurrentBackupSnapshots took $duration") - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException("Error getting current snapshots: ", e) + delegate.list(topLevelFolder, FileBackupFileType.Snapshot::class) { fileInfo -> + val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot + val folderName = handle.topLevelFolder.name + val timestamp = handle.time + val storedSnapshot = StoredSnapshot(folderName, timestamp) + snapshots.add(storedSnapshot) } - Log.i(TAG, "Got ${snapshots.size} snapshots.") return snapshots } @Throws(IOException::class) override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) { - val timestamp = storedSnapshot.timestamp - Log.d(TAG, "Deleting snapshot $timestamp") - - val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - val response = suspendCoroutine { cont -> - davCollection.delete { response -> - cont.resume(response) - } - } - debugLog { "deleteBackupSnapshot() = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } + val androidId = storedSnapshot.androidId + val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp) + delegate.remove(handle) } @Throws(IOException::class) override suspend fun deleteChunks(chunkIds: List) { chunkIds.forEach { chunkId -> - val chunkFolderName = chunkId.substring(0, 2) - val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() - val davCollection = DavCollection(okHttpClient, location) - - try { - val response = suspendCoroutine { cont -> - davCollection.delete { response -> - cont.resume(response) - } - } - debugLog { "deleteChunks($chunkId) = $response" } - } catch (e: Exception) { - if (e is IOException) throw e - else throw IOException(e) - } + val androidId = topLevelFolder.name.substringBefore(".sv") + val handle = FileBackupFileType.Blob(androidId, chunkId) + delegate.remove(handle) } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt index 61d0f2e7..927646a1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt @@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.SafHandler import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin @@ -26,6 +25,7 @@ import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig internal abstract class StorageViewModel( private val app: Application, diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt index ed1c2844..7aa321e1 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt @@ -8,23 +8,20 @@ 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.calyxos.seedvault.core.backends.webdav.WebDavConfig import org.junit.Test import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.robolectric.annotation.Config -import java.io.IOException -import kotlin.random.Random @RunWith(AndroidJUnit4::class) @Config( @@ -33,13 +30,13 @@ import kotlin.random.Random ) internal class WebDavStoragePluginTest : TransportTest() { - private val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig()) + private val plugin = WebDavStoragePlugin(WebDavTestConfig.getConfig()) @Test fun `test self-test`() = runBlocking { assertTrue(plugin.test()) - val plugin2 = WebDavStoragePlugin(context, WebDavConfig("https://github.com/", "", "")) + val plugin2 = WebDavStoragePlugin(WebDavConfig("https://github.com/", "", "")) val e = assertThrows { 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 { - plugin.getOutputStream(token, file).use { it.write(getRandomByteArray()) } - } - - assertThrows { - 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 { - plugin.removeData(42L, "foo") - } - Unit - } - } diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt index e7f5c674..bb6099b8 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavTestConfig.kt @@ -5,6 +5,7 @@ package com.stevesoltys.seedvault.plugins.webdav +import org.calyxos.seedvault.core.backends.webdav.WebDavConfig import org.junit.Assume.assumeFalse import org.junit.jupiter.api.Assertions.fail diff --git a/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt index c393d15d..62bdb785 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/storage/WebDavStoragePluginTest.kt @@ -6,7 +6,6 @@ package com.stevesoltys.seedvault.storage import com.stevesoltys.seedvault.getRandomByteArray -import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig import com.stevesoltys.seedvault.transport.backup.BackupTest import kotlinx.coroutines.runBlocking @@ -14,14 +13,13 @@ import org.calyxos.backup.storage.api.StoredSnapshot import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.jupiter.api.assertThrows -import java.io.IOException internal class WebDavStoragePluginTest : BackupTest() { - private val 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 fun `test chunks`() = runBlocking { @@ -82,8 +80,9 @@ internal class WebDavStoragePluginTest : BackupTest() { ) // other device writes another snapshot - val otherPlugin = WebDavStoragePlugin("bar", WebDavTestConfig.getConfig()) - val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis()) + val androidId2 = "0123456789abcdef" + val otherPlugin = WebDavStoragePlugin(androidId2, WebDavTestConfig.getConfig()) + val otherSnapshot = StoredSnapshot("$androidId2.sv", System.currentTimeMillis()) val otherSnapshotBytes = getRandomByteArray() assertEquals(emptyList(), otherPlugin.getAvailableChunkIds()) 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 { - plugin.getCurrentBackupSnapshots() - } - assertThrows { - plugin.getBackupSnapshotsForRestore() - } - assertThrows { - plugin.getAvailableChunkIds() - } - assertThrows { - plugin.deleteChunks(listOf("foo")) - } - assertThrows { - plugin.deleteBackupSnapshot(snapshot) - } - assertThrows { - plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close() - } - assertThrows { - plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() } - } - assertThrows { - plugin.getChunkOutputStream("foo").close() - } - assertThrows { - plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() } - } - Unit - } - } private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } diff --git a/app/src/test/resources/simplelogger.properties b/app/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..e0f0d79d --- /dev/null +++ b/app/src/test/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel=trace diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt index cd275b69..4ae19495 100644 --- a/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/Backend.kt @@ -5,12 +5,26 @@ package org.calyxos.seedvault.core.backends +import androidx.annotation.VisibleForTesting import okio.BufferedSink import okio.BufferedSource import kotlin.reflect.KClass public interface Backend { + /** + * Returns true if the plugin is working, or false if it isn't. + * @throws Exception any kind of exception to provide more info on the error + */ + public suspend fun test(): Boolean + + /** + * Retrieves the available storage space in bytes. + * @return the number of bytes available or null if the number is unknown. + * Returning a negative number or zero to indicate unknown is discouraged. + */ + public suspend fun getFreeSpace(): Long? + public suspend fun save(handle: FileHandle): BufferedSink public suspend fun load(handle: FileHandle): BufferedSource @@ -25,7 +39,16 @@ public interface Backend { public suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) - // TODO really all? + @VisibleForTesting public suspend fun removeAll() + /** + * Returns the package name of the app that provides the storage backend + * which is used for the current backup location. + * + * Backends are advised to cache this as it will be requested frequently. + * + * @return null if no package name could be found + */ + public val providerPackageName: String? } diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt index 88e1321d..0b242b57 100644 --- a/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/FileHandle.kt @@ -43,6 +43,12 @@ public sealed class LegacyAppBackupFile : FileHandle() { public sealed class FileBackupFileType : FileHandle() { public abstract val androidId: String + + /** + * The folder name is our user ID plus .sv extension (for SeedVault). + * The user or `androidId` is unique to each combination of app-signing key, user, and device + * so we don't leak anything by not hashing this and can use it as is. + */ public val topLevelFolder: TopLevelFolder get() = TopLevelFolder("$androidId.sv") public data class Blob( diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt index e1a70632..2c262235 100644 --- a/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/saf/SafBackend.kt @@ -45,6 +45,14 @@ public class SafBackend( private val context: Context get() = appContext.getBackendContext { safConfig.isUsb } 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 { val file = cache.getFile(handle) return file.getOutputStream(context.contentResolver).sink().buffer() @@ -148,4 +156,6 @@ public class SafBackend( } } + override val providerPackageName: String? get() = TODO("Not yet implemented") + } diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt index b479a733..d611f581 100644 --- a/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/webdav/WebDavBackend.kt @@ -11,6 +11,7 @@ import at.bitfire.dav4jvm.PropertyRegistry import at.bitfire.dav4jvm.Response.HrefRelation.SELF import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.DelicateCoroutinesApi @@ -51,7 +52,7 @@ import kotlin.reflect.KClass private const val DEBUG_LOG = true @OptIn(DelicateCoroutinesApi::class) -internal class WebDavBackend( +public class WebDavBackend( webDavConfig: WebDavConfig, root: String = DIRECTORY_ROOT, ) : Backend { @@ -75,13 +76,53 @@ internal class WebDavBackend( .retryOnConnectionFailure(true) .build() - private val url = "${webDavConfig.url}/$root" + private val baseUrl = webDavConfig.url.trimEnd('/') + private val url = "$baseUrl/$root" private val folders = mutableSetOf() // cache for existing/created folders init { PropertyRegistry.register(GetLastModified.Factory) } + override suspend fun test(): Boolean { + val location = "$baseUrl/".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val hasCaps = suspendCoroutine { cont -> + davCollection.options { davCapabilities, response -> + log.debugLog { "test() = $davCapabilities $response" } + if (davCapabilities.contains("1")) cont.resume(true) + else if (davCapabilities.contains("2")) cont.resume(true) + else if (davCapabilities.contains("3")) cont.resume(true) + else cont.resume(false) + } + } + if (!hasCaps) return false + + val rootCollection = DavCollection(okHttpClient, "$url/foo".toHttpUrl()) + rootCollection.ensureFoldersExist(log, folders) // only considers parents, so foo isn't used + return true + } + + override suspend fun getFreeSpace(): Long? { + val location = "$url/".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val availableBytes = suspendCoroutine { cont -> + davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ -> + log.debugLog { "getFreeSpace() = $response" } + val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes + val availableBytes = quota?.quotaAvailableBytes ?: -1 + if (availableBytes > 0) { + cont.resume(availableBytes) + } else { + cont.resume(null) + } + } + } + return availableBytes + } + override suspend fun save(handle: FileHandle): BufferedSink { val location = handle.toHttpUrl() val davCollection = DavCollection(okHttpClient, location) @@ -118,10 +159,15 @@ internal class WebDavBackend( val location = handle.toHttpUrl() 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" } 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( @@ -145,58 +191,67 @@ internal class WebDavBackend( } val davCollection = DavCollection(okHttpClient, location) val tokenFolders = mutableSetOf() - davCollection.propfindDepthInfinity(depth) { response, relation -> - log.debugLog { "list() = $response" } + try { + davCollection.propfindDepthInfinity(depth) { response, relation -> + log.debugLog { "list() = $response" } - // work around nginx's inability to find files starting with . - if (relation != SELF && LegacyAppBackupFile.Metadata::class in fileTypes && - response.isFolder() && response.hrefName().matches(tokenRegex) - ) { - tokenFolders.add(response.href) - } - if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) { - val name = response.hrefName() - val parentName = response.href.pathSegments[response.href.pathSegments.size - 2] + // 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, - ) + 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(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 tokenFolders.forEach { url -> @@ -251,8 +306,13 @@ internal class WebDavBackend( 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" } + try { + davCollection.move(toUrl, false) { response -> + log.debugLog { "rename(${from.name}, ${to.name}) = $response" } + } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error renaming $location to ${to.name}", e) } } @@ -265,9 +325,14 @@ internal class WebDavBackend( } } catch (e: NotFoundException) { log.info { "Not found: $location" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error removing all at $location", e) } } + override val providerPackageName: String? = null // 100% built-in plugin + private fun FileHandle.toHttpUrl(): HttpUrl = when (this) { // careful with trailing slashes, use only for folders/collections is TopLevelFolder -> "$url/$name/".toHttpUrl() diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt index a0135283..e9998bd1 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/api/Snapshot.kt @@ -29,7 +29,9 @@ public data class StoredSnapshot( * The timestamp identifying a snapshot of the [userId]. */ public val timestamp: Long, -) +) { + public val androidId: String = userId.substringBefore(".sv") +} /** * Defines which backup snapshots should be retained when pruning backups.