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 9d6d99fc6..4308542ef 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 @@ -25,8 +25,11 @@ public interface Backend { */ public suspend fun getFreeSpace(): Long? + @Deprecated(message = "use save(FileHandle, BackendSaver) instead") public suspend fun save(handle: FileHandle): OutputStream + public suspend fun save(handle: FileHandle, saver: BackendSaver): Long + public suspend fun load(handle: FileHandle): InputStream public suspend fun list( diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt index af7cc8ecf..3ff4dcd6a 100644 --- a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendFactory.kt @@ -12,6 +12,8 @@ import org.calyxos.seedvault.core.backends.webdav.WebDavBackend import org.calyxos.seedvault.core.backends.webdav.WebDavConfig public class BackendFactory { + // TODO: implement a backend wrapper that handles retries for transient errors + public fun createSafBackend(context: Context, config: SafProperties): Backend = SafBackend(context, config) diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendSaver.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendSaver.kt new file mode 100644 index 000000000..4458926e7 --- /dev/null +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendSaver.kt @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.calyxos.seedvault.core.backends + +import java.io.OutputStream + +/** + * Used to save data with [Backend]s. + */ +public interface BackendSaver { + /** + * The number of bytes that will be saved or `null` if unknown. + */ + public val size: Long + + /** + * The SHA256 hash (in lower-case hex string representation) the bytes to be saved have, + * or `null` if it isn't known. + */ + public val sha256: String? + + /** + * Called by the backend when it wants to save the data to the provided [outputStream]. + * Can be called more than once, in case the backend encountered an error saving. + * + * @return the number of bytes saved. Should be equal to [size]. + */ + public fun save(outputStream: OutputStream): Long +} diff --git a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt index c98fe923a..b1b660fc0 100644 --- a/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt +++ b/core/src/main/java/org/calyxos/seedvault/core/backends/BackendTest.kt @@ -7,6 +7,7 @@ package org.calyxos.seedvault.core.backends import androidx.annotation.VisibleForTesting import org.calyxos.seedvault.core.toHexString +import java.io.OutputStream import kotlin.random.Random import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -15,6 +16,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue @VisibleForTesting +@Suppress("BlockingMethodInNonBlockingContext") public abstract class BackendTest { public abstract val backend: Backend @@ -26,13 +28,8 @@ public abstract class BackendTest { val now = System.currentTimeMillis() val bytes1 = Random.nextBytes(1337) val bytes2 = Random.nextBytes(1337 * 8) - backend.save(LegacyAppBackupFile.Metadata(now)).use { - it.write(bytes1) - } - - backend.save(FileBackupFileType.Snapshot(androidId, now)).use { - it.write(bytes2) - } + backend.save(LegacyAppBackupFile.Metadata(now), getSaver(bytes1)) + backend.save(FileBackupFileType.Snapshot(androidId, now), getSaver(bytes2)) var metadata: LegacyAppBackupFile.Metadata? = null var fileSnapshot: FileBackupFileType.Snapshot? = null @@ -58,9 +55,7 @@ public abstract class BackendTest { val blobName = Random.nextBytes(32).toHexString() var blob: FileBackupFileType.Blob? = null val bytes3 = Random.nextBytes(1337 * 16) - backend.save(FileBackupFileType.Blob(androidId, blobName)).use { - it.write(bytes3) - } + backend.save(FileBackupFileType.Blob(androidId, blobName), getSaver(bytes3)) backend.list( null, FileBackupFileType.Snapshot::class, @@ -91,9 +86,7 @@ public abstract class BackendTest { val bytes4 = Random.nextBytes(1337) val bytes5 = Random.nextBytes(1337 * 8) - backend.save(AppBackupFileType.Snapshot(repoId, snapshotId)).use { - it.write(bytes4) - } + backend.save(AppBackupFileType.Snapshot(repoId, snapshotId), getSaver(bytes4)) var appSnapshot: AppBackupFileType.Snapshot? = null backend.list( @@ -108,9 +101,7 @@ public abstract class BackendTest { assertNotNull(appSnapshot) assertContentEquals(bytes4, backend.load(appSnapshot as FileHandle).readAllBytes()) - backend.save(AppBackupFileType.Blob(repoId, blobId)).use { - it.write(bytes5) - } + backend.save(AppBackupFileType.Blob(repoId, blobId), getSaver(bytes5)) var blobHandle: AppBackupFileType.Blob? = null backend.list( @@ -151,9 +142,7 @@ public abstract class BackendTest { backend.remove(blob) try { - backend.save(blob).use { - it.write(bytes) - } + backend.save(blob, getSaver(bytes)) assertContentEquals(bytes, backend.load(blob as FileHandle).readAllBytes()) } finally { backend.remove(blob) @@ -170,13 +159,21 @@ public abstract class BackendTest { val blob = AppBackupFileType.Blob(repoId, Random.nextBytes(32).toHexString()) val bytes = Random.nextBytes(2342) try { - backend.save(blob).use { - it.write(bytes) - } + backend.save(blob, getSaver(bytes)) assertContentEquals(bytes, backend.load(blob as FileHandle).readAllBytes()) } finally { backend.remove(blob) } } + private fun getSaver(bytes: ByteArray) = object : BackendSaver { + override val size: Long = bytes.size.toLong() + override val sha256: String? = null + + override fun save(outputStream: OutputStream): Long { + outputStream.write(bytes) + return size + } + } + } 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 d23121f41..695aaeaed 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 @@ -18,6 +18,7 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.calyxos.seedvault.core.backends.AppBackupFileType import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendSaver import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT import org.calyxos.seedvault.core.backends.Constants.FILE_BACKUP_METADATA import org.calyxos.seedvault.core.backends.Constants.appSnapshotRegex @@ -86,12 +87,19 @@ public class SafBackend( } else bytesAvailable } + @Deprecated("use save(FileHandle, BackendSaver) instead") override suspend fun save(handle: FileHandle): OutputStream { log.debugLog { "save($handle)" } val file = cache.getOrCreateFile(handle) return file.getOutputStream(context.contentResolver) } + override suspend fun save(handle: FileHandle, saver: BackendSaver): Long { + log.debugLog { "save($handle)" } + val file = cache.getOrCreateFile(handle) + return file.getOutputStream(context.contentResolver).use { saver.save(it) } + } + override suspend fun load(handle: FileHandle): InputStream { log.debugLog { "load($handle)" } val file = cache.getOrCreateFile(handle) 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 d42fb9b7f..93d283538 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 @@ -27,6 +27,7 @@ import okhttp3.RequestBody import okio.BufferedSink import org.calyxos.seedvault.core.backends.AppBackupFileType import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendSaver import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT import org.calyxos.seedvault.core.backends.Constants.FILE_BACKUP_METADATA import org.calyxos.seedvault.core.backends.Constants.appSnapshotRegex @@ -121,6 +122,7 @@ public class WebDavBackend( return availableBytes } + @Deprecated("use save(FileHandle, BackendSaver) instead") override suspend fun save(handle: FileHandle): OutputStream { val location = handle.toHttpUrl() val davCollection = DavCollection(okHttpClient, location) @@ -153,6 +155,27 @@ public class WebDavBackend( return pipedOutputStream } + override suspend fun save(handle: FileHandle, saver: BackendSaver): Long { + val location = handle.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + davCollection.ensureFoldersExist(log, folders) + + val body = object : RequestBody() { + override fun isOneShot(): Boolean = true + override fun contentType() = "application/octet-stream".toMediaType() + override fun contentLength(): Long = saver.size + override fun writeTo(sink: BufferedSink) { + saver.save(sink.outputStream()) + } + } + return suspendCoroutine { cont -> + davCollection.put(body) { response -> + log.debugLog { "save($location) = $response" } + cont.resume(saver.size) + } + } + } + override suspend fun load(handle: FileHandle): InputStream { val location = handle.toHttpUrl() val davCollection = DavCollection(okHttpClient, location) diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt index 514c58db8..a29f5bcad 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafBackend.kt @@ -8,6 +8,7 @@ package de.grobox.storagebackuptester.plugin import android.content.Context import android.net.Uri import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.BackendSaver import org.calyxos.seedvault.core.backends.FileHandle import org.calyxos.seedvault.core.backends.FileInfo import org.calyxos.seedvault.core.backends.TopLevelFolder @@ -47,6 +48,11 @@ class TestSafBackend( return delegate.save(handle) } + override suspend fun save(handle: FileHandle, saver: BackendSaver): Long { + if (getLocationUri() == null) return 0 + return delegate.save(handle, saver) + } + override suspend fun load(handle: FileHandle): InputStream { return delegate.load(handle) }