diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/ContentUriUploadCacheValidator.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/ContentUriUploadCacheValidator.kt new file mode 100644 index 0000000000..3ffbf1cadc --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/ContentUriUploadCacheValidator.kt @@ -0,0 +1,27 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.workers + +internal object ContentUriUploadCacheValidator { + fun isValidCacheSize( + actualSize: Long, + expectedSize: Long, + ): Boolean = + actualSize > 0 && (expectedSize <= 0 || actualSize == expectedSize) +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt index 79a0327f1e..a0e77f5145 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -5,12 +5,14 @@ import eu.opencloud.android.domain.capabilities.model.OCCapability import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.domain.transfers.model.OCTransfer import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation import eu.opencloud.android.lib.resources.files.tus.GetTusUploadOffsetRemoteOperation import eu.opencloud.android.lib.resources.files.tus.PatchTusUploadChunkRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.TusChecksumHelper import eu.opencloud.android.domain.exceptions.FileNotFoundException import timber.log.Timber import java.io.File @@ -53,6 +55,8 @@ class TusUploadHelper( ) : String? { // Reset cancelled state for new upload cancelled = false + val checksum = TusChecksumHelper.parseStoredChecksum(transfer.tusUploadChecksum) + ?.takeIf { it.uploadAlgorithm == TusChecksumHelper.SHA1_WIRE_ALGORITHM } Timber.d("TUS: starting upload for %s size=%d", remotePath, fileSize) val (resolvedTusUrl, createdOffset) = prepareUpload( @@ -64,7 +68,8 @@ class TusUploadHelper( fileSize = fileSize, mimeType = mimeType, lastModified = lastModified, - spaceWebDavUrl = spaceWebDavUrl + spaceWebDavUrl = spaceWebDavUrl, + checksum = checksum, ) val offset = fetchCurrentOffset(client, resolvedTusUrl, createdOffset) @@ -81,6 +86,7 @@ class TusUploadHelper( progressCallback = progressCallback, initialOffset = offset, uploadId = uploadId, + checksum = checksum, ) verifyUploadCompletion(finalOffset, fileSize, uploadId) @@ -97,10 +103,10 @@ class TusUploadHelper( fileSize: Long, mimeType: String, lastModified: String?, - spaceWebDavUrl: String? + spaceWebDavUrl: String?, + checksum: TusChecksumHelper.StoredChecksum?, ): Pair { var tusUrl = transfer.tusUploadUrl - val checksumHex = transfer.tusUploadChecksum?.substringAfter("sha256:") var createdOffset: Long? = null if (tusUrl.isNullOrBlank()) { @@ -110,7 +116,7 @@ class TusUploadHelper( "mimetype" to mimeType, ) lastModified?.takeIf { it.isNotBlank() }?.let { metadata["mtime"] = it } - checksumHex?.let { metadata["checksum"] = "sha256 $it" } + checksum?.let { metadata["checksum"] = it.metadataValue } Timber.d( "TUS: creating upload resource filename=%s size=%d metadata=%s", @@ -124,15 +130,20 @@ class TusUploadHelper( spaceWebDavUrl = spaceWebDavUrl ) - // Use creation-with-upload like the browser does for OpenCloud compatibility - val firstChunkSize = minOf(CreateTusUploadRemoteOperation.DEFAULT_FIRST_CHUNK, fileSize) + // Checked uploads must send every byte via PATCH so each chunk can carry Upload-Checksum. + val useCreationWithUpload = checksum == null + val firstChunkSize = if (useCreationWithUpload) { + minOf(CreateTusUploadRemoteOperation.DEFAULT_FIRST_CHUNK, fileSize) + } else { + null + } val creationResult = executeRemoteOperation { CreateTusUploadRemoteOperation( file = File(localPath), remotePath = remotePath, mimetype = mimeType, metadata = metadata, - useCreationWithUpload = true, + useCreationWithUpload = useCreationWithUpload, firstChunkSize = firstChunkSize, tusUrl = "", collectionUrlOverride = collectionUrl, @@ -152,7 +163,7 @@ class TusUploadHelper( tusUploadUrl = tusUrl, tusUploadLength = fileSize, tusUploadMetadata = metadataString, - tusUploadChecksum = checksumHex?.let { "sha256:$it" }, + tusUploadChecksum = checksum?.storageValue, tusResumableVersion = "1.0.0", tusUploadExpires = null, tusUploadConcat = null, @@ -188,16 +199,7 @@ class TusUploadHelper( Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize) throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize") } - transferRepository.updateTusState( - id = uploadId, - tusUploadUrl = null, - tusUploadLength = null, - tusUploadMetadata = null, - tusUploadChecksum = null, - tusResumableVersion = null, - tusUploadExpires = null, - tusUploadConcat = null, - ) + clearTusState(uploadId) } private fun finalizeEtag( @@ -241,6 +243,7 @@ class TusUploadHelper( progressCallback: ((Long, Long) -> Unit)?, initialOffset: Long, uploadId: Long, + checksum: TusChecksumHelper.StoredChecksum?, ): Pair { var offset = initialOffset var lastEtag: String? = null @@ -264,6 +267,7 @@ class TusUploadHelper( offset = offset, chunkSize = chunkSize, httpMethodOverride = httpOverride, + checksum = checksum, ).apply { progressListener?.let { addDataTransferProgressListener(it) } } @@ -272,6 +276,12 @@ class TusUploadHelper( val patchResult = patchOperation.execute(client) lastEtag = patchOperation.etag.takeIf { it.isNotBlank() } activePatchOperation = null + if (checksum != null && isChecksumFailure(patchResult.httpCode)) { + clearTusState(uploadId) + throw java.io.IOException( + "TUS: checksum upload rejected with HTTP ${patchResult.httpCode} at offset $offset" + ) + } if (!patchResult.isSuccess || patchResult.data == null || patchResult.data!! < offset) { consecutiveFailures++ Timber.w( @@ -352,6 +362,9 @@ class TusUploadHelper( return Pair(offset, lastEtag) } + private fun isChecksumFailure(httpCode: Int): Boolean = + httpCode == HttpConstants.HTTP_BAD_REQUEST || httpCode == HttpConstants.HTTP_CHECKSUM_MISMATCH + private fun resolveTusCollectionUrl( client: OpenCloudClient, spaceWebDavUrl: String?, @@ -402,22 +415,26 @@ class TusUploadHelper( throw e } catch (e: FileNotFoundException) { Timber.w(e, "TUS: upload not found on server (404), clearing state to restart") - transferRepository.updateTusState( - id = uploadId, - tusUploadUrl = null, - tusUploadLength = null, - tusUploadMetadata = null, - tusUploadChecksum = null, - tusResumableVersion = null, - tusUploadExpires = null, - tusUploadConcat = null, - ) + clearTusState(uploadId) throw java.io.IOException("TUS: upload session lost (404), forcing restart", e) } catch (recoverError: Throwable) { Timber.w(recoverError, "TUS: recover offset failed") null } + private fun clearTusState(uploadId: Long) { + transferRepository.updateTusState( + id = uploadId, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + companion object { const val DEFAULT_CHUNK_SIZE = ChunkedUploadFromFileSystemOperation.CHUNK_SIZE diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt index 08eedd26e2..f5a636b9af 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -60,6 +60,7 @@ import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCo import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation +import eu.opencloud.android.lib.resources.files.tus.TusChecksumHelper import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID @@ -147,18 +148,37 @@ class UploadFileFromContentUriWorker( cachePath = localStorageProvider.getTemporalPath(account.name, ocTransfer.spaceId) + File.separator + flatCacheName - // Re-copy if the cache file is missing or empty. A previous run may have copied it - // and then had it removed (e.g. by removeCacheFile() at the end of a successful run - // that the OS killed before bookkeeping). Only the contentUri from worker params is - // authoritative. val cacheFile = File(cachePath) - if (!cacheFile.exists() || cacheFile.length() == 0L) { + if (!isCacheFileReadyForUpload(cacheFile)) { checkDocumentFileExists() checkPermissionsToReadDocumentAreGranted() copyFileToLocalStorage() } } + private fun isCacheFileReadyForUpload(cacheFile: File): Boolean { + if (!cacheFile.exists()) return false + + val cacheSize = cacheFile.length() + val isValidCacheSize = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = cacheSize, + expectedSize = ocTransfer.fileSize, + ) + if (isValidCacheSize) return true + + Timber.w( + "Cached upload file for %s has invalid size. expected=%d actual=%d. Deleting and recopying.", + contentUri, + ocTransfer.fileSize, + cacheSize, + ) + if (!cacheFile.delete()) { + Timber.w("Could not delete invalid cached upload file: %s", cacheFile.absolutePath) + } + clearTusState() + return false + } + private fun areParametersValid(): Boolean { val paramAccountName = workerParameters.inputData.getString(KEY_PARAM_ACCOUNT_NAME) val paramUploadPath = workerParameters.inputData.getString(KEY_PARAM_UPLOAD_PATH) @@ -208,11 +228,14 @@ class UploadFileFromContentUriWorker( private fun copyFileToLocalStorage() { val documentFile = DocumentFile.fromSingleUri(appContext, contentUri) val cacheFile = File(cachePath) + val partFile = File("$cachePath.part") val cacheDir = cacheFile.parentFile if (cacheDir != null && !cacheDir.exists()) { cacheDir.mkdirs() } - cacheFile.createNewFile() + if (partFile.exists() && !partFile.delete()) { + Timber.w("Could not delete stale partial cache file: %s", partFile.absolutePath) + } // openInputStream can return null if the content provider is unavailable or permissions were revoked. // Failing here avoids silently uploading a 0-byte file. @@ -221,20 +244,60 @@ class UploadFileFromContentUriWorker( Timber.e("Failed to open input stream for %s — content provider unavailable or permissions revoked", contentUri) throw LocalFileNotFoundException() } - val outputStream = FileOutputStream(cachePath) - inputStream.use { input -> - outputStream.use { output -> - input.copyTo(output) + val checksumResult = try { + inputStream.use { input -> + FileOutputStream(partFile).use { output -> + TusChecksumHelper.copyAndSha1Hex(input, output) + } } + } catch (throwable: Throwable) { + partFile.delete() + throw throwable } - // Guard against a truncated or empty copy (e.g. file deleted mid-read). - if (cacheFile.length() == 0L) { - Timber.e("Cache file is 0 bytes after copy from %s — source may have been deleted mid-read", contentUri) + val copiedSize = checksumResult.bytesCopied + if (!ContentUriUploadCacheValidator.isValidCacheSize(copiedSize, ocTransfer.fileSize)) { + Timber.e( + "Partial cache copy from %s. expected=%d actual=%d", + contentUri, + ocTransfer.fileSize, + copiedSize, + ) + partFile.delete() + clearTusState() + throw IOException( + "Cache copy size mismatch for $contentUri: " + + "expected ${ocTransfer.fileSize} bytes, copied $copiedSize bytes" + ) + } + + if (cacheFile.exists() && !cacheFile.delete()) { + partFile.delete() + throw IOException("Could not replace cached upload file: ${cacheFile.absolutePath}") + } + if (!partFile.renameTo(cacheFile)) { + partFile.delete() + throw IOException("Could not finalize cached upload file: ${cacheFile.absolutePath}") + } + + val finalSize = cacheFile.length() + if (!ContentUriUploadCacheValidator.isValidCacheSize(finalSize, ocTransfer.fileSize)) { + Timber.e( + "Invalid finalized cache copy from %s. expected=%d actual=%d", + contentUri, + ocTransfer.fileSize, + finalSize, + ) cacheFile.delete() - throw LocalFileNotFoundException() + clearTusState() + throw IOException( + "Final cache copy size mismatch for $contentUri: " + + "expected ${ocTransfer.fileSize} bytes, copied $finalSize bytes" + ) } + persistTusChecksum(checksumResult.sha1Hex) + transferRepository.updateTransferSourcePath(uploadIdInStorageManager, contentUri.toString()) transferRepository.updateTransferLocalPath(uploadIdInStorageManager, cachePath) @@ -315,11 +378,16 @@ class UploadFileFromContentUriWorker( ) if (shouldTryTus) { + if (hasPendingTusSession && !hasStoredSha1Checksum()) { + Timber.w("TUS session for %s has no original checksum. Clearing state and recreating.", uploadPath) + clearTusState() + } + ensureOriginalTusChecksum() Timber.d( "Attempting TUS upload (size=%d, threshold=%d, resume=%s)", fileSize, TusUploadHelper.DEFAULT_CHUNK_SIZE, - hasPendingTusSession + !ocTransfer.tusUploadUrl.isNullOrBlank() ) val tusSucceeded = try { tusUploadHelper.upload( @@ -406,6 +474,34 @@ class UploadFileFromContentUriWorker( cacheFile.delete() } + private fun ensureOriginalTusChecksum() { + if (hasStoredSha1Checksum()) return + + val inputStream = appContext.contentResolver.openInputStream(contentUri) + if (inputStream == null) { + Timber.e("Failed to open input stream for checksum source %s", contentUri) + throw LocalFileNotFoundException() + } + + val sha1Hex = inputStream.use { input -> + TusChecksumHelper.sha1Hex(input) + } + persistTusChecksum(sha1Hex) + } + + private fun persistTusChecksum(sha1Hex: String) { + val checksum = TusChecksumHelper.storedSha1(sha1Hex).storageValue + transferRepository.updateTusChecksum( + id = uploadIdInStorageManager, + tusUploadChecksum = checksum, + ) + ocTransfer = ocTransfer.copy(tusUploadChecksum = checksum) + } + + private fun hasStoredSha1Checksum(): Boolean = + TusChecksumHelper.parseStoredChecksum(ocTransfer.tusUploadChecksum)?.uploadAlgorithm == + TusChecksumHelper.SHA1_WIRE_ALGORITHM + private fun clearTusState() { transferRepository.updateTusState( id = uploadIdInStorageManager, @@ -417,6 +513,15 @@ class UploadFileFromContentUriWorker( tusUploadExpires = null, tusUploadConcat = null, ) + ocTransfer = ocTransfer.copy( + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) } private fun shouldRetry(throwable: Throwable?): Boolean { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt index 7c32bccef1..7a97b003ff 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -53,6 +53,7 @@ import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCo import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation +import eu.opencloud.android.lib.resources.files.tus.TusChecksumHelper import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath @@ -267,11 +268,16 @@ class UploadFileFromFileSystemWorker( ) if (shouldTryTus) { + if (hasPendingTusSession && !hasStoredSha1Checksum()) { + Timber.w("TUS session for %s has no original checksum. Clearing state and recreating.", uploadPath) + clearTusState() + } + ensureOriginalTusChecksum() Timber.d( "Attempting TUS upload (size=%d, threshold=%d, resume=%s)", fileSize, TusUploadHelper.DEFAULT_CHUNK_SIZE, - hasPendingTusSession + !ocTransfer.tusUploadUrl.isNullOrBlank() ) val tusSucceeded = try { val returnedEtag = tusUploadHelper.upload( @@ -376,8 +382,34 @@ class UploadFileFromFileSystemWorker( tusUploadExpires = null, tusUploadConcat = null, ) + ocTransfer = ocTransfer.copy( + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) } + private fun ensureOriginalTusChecksum() { + if (hasStoredSha1Checksum()) return + + val checksum = TusChecksumHelper.storedSha1( + TusChecksumHelper.sha1Hex(File(fileSystemPath)) + ).storageValue + transferRepository.updateTusChecksum( + id = uploadIdInStorageManager, + tusUploadChecksum = checksum, + ) + ocTransfer = ocTransfer.copy(tusUploadChecksum = checksum) + } + + private fun hasStoredSha1Checksum(): Boolean = + TusChecksumHelper.parseStoredChecksum(ocTransfer.tusUploadChecksum)?.uploadAlgorithm == + TusChecksumHelper.SHA1_WIRE_ALGORITHM + private fun shouldRetry(throwable: Throwable?): Boolean { if (throwable == null) return false if (throwable is LocalFileNotFoundException) return false diff --git a/opencloudApp/src/test/java/eu/opencloud/android/workers/ContentUriUploadCacheValidatorTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/workers/ContentUriUploadCacheValidatorTest.kt new file mode 100644 index 0000000000..0b2d88a9a1 --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/workers/ContentUriUploadCacheValidatorTest.kt @@ -0,0 +1,88 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.workers + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContentUriUploadCacheValidatorTest { + + @Test + fun `exact large cache size is valid`() { + val fileSize = 5_832_800_958L + + val isValid = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = fileSize, + expectedSize = fileSize, + ) + + assertTrue(isValid) + } + + @Test + fun `partial non-zero cache size is invalid`() { + val isValid = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = 1_180_123_136L, + expectedSize = 5_832_800_958L, + ) + + assertFalse(isValid) + } + + @Test + fun `larger than expected cache size is invalid`() { + val isValid = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = 5_832_800_959L, + expectedSize = 5_832_800_958L, + ) + + assertFalse(isValid) + } + + @Test + fun `zero byte cache size is invalid`() { + val isValid = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = 0L, + expectedSize = 5_832_800_958L, + ) + + assertFalse(isValid) + } + + @Test + fun `unknown expected size keeps existing non-zero behavior`() { + val isValid = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = 42L, + expectedSize = -1L, + ) + + assertTrue(isValid) + } + + @Test + fun `non-positive expected size still rejects zero byte cache`() { + val isValid = ContentUriUploadCacheValidator.isValidCacheSize( + actualSize = 0L, + expectedSize = -1L, + ) + + assertFalse(isValid) + } +} diff --git a/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt index 4c9693c6aa..fe21c79e23 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt @@ -23,6 +23,7 @@ import androidx.test.core.app.ApplicationProvider import eu.opencloud.android.domain.capabilities.model.OCCapability import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.resources.files.tus.TusChecksumHelper import eu.opencloud.android.testutil.OC_TRANSFER import io.mockk.every import io.mockk.mockk @@ -33,6 +34,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -40,6 +42,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.io.File +import java.io.IOException @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -62,21 +65,24 @@ class TusUploadHelperTest { } @Test - fun upload_createsSessionWithFirstChunkAndClearsTusState() { + fun upload_createsCheckedSessionWithoutFirstChunkAndClearsTusState() { val localFile = tempFileWithBytes(byteArrayOf(1, 2, 3, 4, 5)) + val sha1Hex = TusChecksumHelper.sha1Hex(localFile) + val storedChecksum = TusChecksumHelper.storedSha1(sha1Hex).storageValue val uploadUrl = "/uploads/new-session" server.enqueue( MockResponse() .setResponseCode(201) .addHeader("Location", uploadUrl) - .addHeader("Upload-Offset", "5") + .addHeader("Upload-Offset", "0") ) + server.enqueue(MockResponse().setResponseCode(204).addHeader("Upload-Offset", "5")) server.enqueue(MockResponse().setResponseCode(404)) val progress = mutableListOf() val resultEtag = TusUploadHelper(transferRepository).upload( client = newClient(), - transfer = OC_TRANSFER.copy(tusUploadUrl = null, tusUploadChecksum = "sha256:abc"), + transfer = OC_TRANSFER.copy(tusUploadUrl = null, tusUploadChecksum = storedChecksum), uploadId = UPLOAD_ID, localPath = localFile.absolutePath, remotePath = "/Photos/image.jpg", @@ -90,22 +96,35 @@ class TusUploadHelperTest { ) assertNull(resultEtag) - assertEquals(listOf(5L), progress) + assertEquals(listOf(0L, 5L), progress) val createRequest = server.takeRequest() assertEquals("POST", createRequest.method) assertEquals("/dav/spaces/personal/Photos", createRequest.path) - assertEquals("0", createRequest.getHeader("Upload-Offset")) + assertNull(createRequest.getHeader("Upload-Offset")) assertEquals("5", createRequest.getHeader("Upload-Length")) assertTrue(createRequest.getHeader("Upload-Metadata")!!.contains("checksum")) + val patchRequest = server.takeRequest() + assertEquals("PATCH", patchRequest.method) + assertEquals("0", patchRequest.getHeader("Upload-Offset")) + assertEquals( + TusChecksumHelper.uploadChecksumHeader( + file = localFile, + offset = 0, + length = 5, + algorithm = TusChecksumHelper.SHA1_WIRE_ALGORITHM, + ), + patchRequest.getHeader("Upload-Checksum") + ) + verify { transferRepository.updateTusState( id = UPLOAD_ID, tusUploadUrl = server.url(uploadUrl).toString(), tusUploadLength = 5, - tusUploadMetadata = "filename=image.jpg;mimetype=image/jpeg;mtime=1700000000;checksum=sha256 abc", - tusUploadChecksum = "sha256:abc", + tusUploadMetadata = "filename=image.jpg;mimetype=image/jpeg;mtime=1700000000;checksum=SHA1 $sha1Hex", + tusUploadChecksum = storedChecksum, tusResumableVersion = "1.0.0", tusUploadExpires = null, tusUploadConcat = null, @@ -161,6 +180,7 @@ class TusUploadHelperTest { assertEquals("PATCH", patchRequest.method) assertEquals("/uploads/existing-session", patchRequest.path) assertEquals("2", patchRequest.getHeader("Upload-Offset")) + assertNull(patchRequest.getHeader("Upload-Checksum")) verify(exactly = 1) { transferRepository.updateTusState( @@ -176,6 +196,64 @@ class TusUploadHelperTest { } } + @Test + fun upload_checksumMismatchClearsTusStateAndThrows() { + val localFile = tempFileWithBytes(byteArrayOf(1, 2, 3, 4, 5)) + val sha1Hex = TusChecksumHelper.sha1Hex(localFile) + val storedChecksum = TusChecksumHelper.storedSha1(sha1Hex).storageValue + val uploadUrl = "/uploads/checksum-mismatch" + server.enqueue( + MockResponse() + .setResponseCode(201) + .addHeader("Location", uploadUrl) + .addHeader("Upload-Offset", "0") + ) + server.enqueue(MockResponse().setResponseCode(460)) + + val thrown = assertThrows(IOException::class.java) { + TusUploadHelper(transferRepository).upload( + client = newClient(), + transfer = OC_TRANSFER.copy(tusUploadUrl = null, tusUploadChecksum = storedChecksum), + uploadId = UPLOAD_ID, + localPath = localFile.absolutePath, + remotePath = "/Photos/image.jpg", + fileSize = localFile.length(), + mimeType = "image/jpeg", + lastModified = "1700000000", + tusSupport = tusSupport(), + progressListener = null, + progressCallback = null, + spaceWebDavUrl = server.url("/dav/spaces/personal").toString(), + ) + } + + assertTrue(thrown.message!!.contains("checksum")) + server.takeRequest() + val patchRequest = server.takeRequest() + assertEquals("PATCH", patchRequest.method) + assertEquals( + TusChecksumHelper.uploadChecksumHeader( + file = localFile, + offset = 0, + length = 5, + algorithm = TusChecksumHelper.SHA1_WIRE_ALGORITHM, + ), + patchRequest.getHeader("Upload-Checksum") + ) + verify { + transferRepository.updateTusState( + id = UPLOAD_ID, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + } + @Test fun shouldAttemptTusUpload_usesFallbackForSmallFilesWithoutPendingSession() { val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload( diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java index 6bf34c99bc..8197c3be6f 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java @@ -208,6 +208,8 @@ public class HttpConstants { // 424 Failed Dependency (WebDAV - RFC 2518) public static final int HTTP_FAILED_DEPENDENCY = 424; public static final int HTTP_TOO_EARLY = 425; + // 460 Checksum Mismatch (TUS checksum extension) + public static final int HTTP_CHECKSUM_MISMATCH = 460; /** * 5xx Client Error diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt index 5a173a0c4d..1964c60cd1 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt @@ -32,6 +32,7 @@ class PatchTusUploadChunkRemoteOperation( private val offset: Long, private val chunkSize: Long, private val httpMethodOverride: String? = null, + private val checksum: TusChecksumHelper.StoredChecksum? = null, ) : RemoteOperation() { private val cancellationRequested = AtomicBoolean(false) @@ -74,6 +75,17 @@ class PatchTusUploadChunkRemoteOperation( setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) setRequestHeader(HttpConstants.UPLOAD_OFFSET, offset.toString()) setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) + checksum?.let { + setRequestHeader( + HttpConstants.UPLOAD_CHECKSUM, + TusChecksumHelper.uploadChecksumHeader( + file = file, + offset = offset, + length = chunkSize, + algorithm = it.uploadAlgorithm, + ) + ) + } } activeMethod = method diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/TusChecksumHelper.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/TusChecksumHelper.kt new file mode 100644 index 0000000000..669b6a5eba --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/TusChecksumHelper.kt @@ -0,0 +1,150 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.lib.resources.files.tus + +import android.util.Base64 +import java.io.EOFException +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.io.OutputStream +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.util.Locale +import kotlin.math.min + +object TusChecksumHelper { + private const val BUFFER_SIZE = 64 * 1024 + private const val SHA1_DIGEST_ALGORITHM = "SHA-1" + const val SHA1_WIRE_ALGORITHM = "sha1" + private const val SHA1_METADATA_ALGORITHM = "SHA1" + + data class StoredChecksum( + val algorithm: String, + val hex: String, + ) { + val storageValue: String + get() = "${algorithm.lowercase(Locale.ROOT)}:${hex.lowercase(Locale.ROOT)}" + + val metadataValue: String + get() = "${metadataAlgorithm()} ${hex.lowercase(Locale.ROOT)}" + + val uploadAlgorithm: String + get() = algorithm.lowercase(Locale.ROOT) + + private fun metadataAlgorithm(): String = + when (algorithm.lowercase(Locale.ROOT)) { + SHA1_WIRE_ALGORITHM -> SHA1_METADATA_ALGORITHM + else -> algorithm.uppercase(Locale.ROOT) + } + } + + data class CopyChecksumResult( + val bytesCopied: Long, + val sha1Hex: String, + ) + + fun storedSha1(hex: String): StoredChecksum = + StoredChecksum( + algorithm = SHA1_WIRE_ALGORITHM, + hex = hex.lowercase(Locale.ROOT), + ) + + fun parseStoredChecksum(value: String?): StoredChecksum? { + if (value.isNullOrBlank()) return null + + val separatorIndex = value.indexOf(':') + if (separatorIndex <= 0 || separatorIndex == value.lastIndex) return null + + val algorithm = value.substring(0, separatorIndex).trim().lowercase(Locale.ROOT) + val hex = value.substring(separatorIndex + 1).trim().lowercase(Locale.ROOT) + if (algorithm.isBlank() || hex.isBlank()) return null + + return StoredChecksum(algorithm = algorithm, hex = hex) + } + + fun sha1Hex(file: File): String = + FileInputStream(file).use { input -> + sha1Hex(input) + } + + fun sha1Hex(inputStream: InputStream): String { + val digest = MessageDigest.getInstance(SHA1_DIGEST_ALGORITHM) + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + val read = inputStream.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + return digest.digest().toHex() + } + + fun copyAndSha1Hex(inputStream: InputStream, outputStream: OutputStream): CopyChecksumResult { + val digest = MessageDigest.getInstance(SHA1_DIGEST_ALGORITHM) + val buffer = ByteArray(BUFFER_SIZE) + var copiedBytes = 0L + + while (true) { + val read = inputStream.read(buffer) + if (read == -1) break + outputStream.write(buffer, 0, read) + digest.update(buffer, 0, read) + copiedBytes += read.toLong() + } + + return CopyChecksumResult( + bytesCopied = copiedBytes, + sha1Hex = digest.digest().toHex(), + ) + } + + fun uploadChecksumHeader(file: File, offset: Long, length: Long, algorithm: String): String { + if (algorithm.lowercase(Locale.ROOT) != SHA1_WIRE_ALGORITHM) { + throw IllegalArgumentException("Unsupported TUS checksum algorithm: $algorithm") + } + val base64Digest = sha1Base64ForFileRange(file, offset, length) + return "$SHA1_WIRE_ALGORITHM $base64Digest" + } + + fun sha1Base64ForFileRange(file: File, offset: Long, length: Long): String { + require(offset >= 0) { "Offset must be non-negative" } + require(length >= 0) { "Length must be non-negative" } + + val digest = MessageDigest.getInstance(SHA1_DIGEST_ALGORITHM) + val buffer = ByteArray(BUFFER_SIZE) + var remaining = length + + RandomAccessFile(file, "r").use { raf -> + raf.seek(offset) + while (remaining > 0) { + val read = raf.read(buffer, 0, min(buffer.size.toLong(), remaining).toInt()) + if (read == -1) { + throw EOFException("Unable to read $length bytes from ${file.absolutePath} at offset $offset") + } + digest.update(buffer, 0, read) + remaining -= read.toLong() + } + } + + return Base64.encodeToString(digest.digest(), Base64.NO_WRAP) + } + + private fun ByteArray.toHex(): String = + joinToString(separator = "") { "%02x".format(it.toInt() and 0xff) } +} diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusChecksumHelperTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusChecksumHelperTest.kt new file mode 100644 index 0000000000..4ba47dd06a --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusChecksumHelperTest.kt @@ -0,0 +1,93 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.lib.resources.files.tus + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.security.MessageDigest +import java.util.Base64 + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TusChecksumHelperTest { + + @Test + fun sha1Hex_returnsKnownDigest() { + val digest = TusChecksumHelper.sha1Hex(ByteArrayInputStream("hello".toByteArray())) + + assertEquals("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", digest) + } + + @Test + fun copyAndSha1Hex_writesBytesAndReturnsDigest() { + val bytes = "original-upload-source".toByteArray() + val output = ByteArrayOutputStream() + + val result = TusChecksumHelper.copyAndSha1Hex(ByteArrayInputStream(bytes), output) + + assertEquals(bytes.size.toLong(), result.bytesCopied) + assertEquals(expectedSha1Hex(bytes), result.sha1Hex) + assertArrayEquals(bytes, output.toByteArray()) + } + + @Test + fun sha1Base64ForFileRange_returnsDigestForRequestedRange() { + val bytes = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9) + val file = tempFile(bytes) + val range = bytes.copyOfRange(3, 8) + + val digest = TusChecksumHelper.sha1Base64ForFileRange(file, offset = 3, length = 5) + + assertEquals(expectedSha1Base64(range), digest) + } + + @Test + fun copyAndSha1Hex_countsLargeCopiesPastBufferSize() { + val bytes = ByteArray(140_000) { index -> (index % 251).toByte() } + val output = ByteArrayOutputStream() + + val result = TusChecksumHelper.copyAndSha1Hex(ByteArrayInputStream(bytes), output) + + assertEquals(bytes.size.toLong(), result.bytesCopied) + assertEquals(expectedSha1Hex(bytes), result.sha1Hex) + assertArrayEquals(bytes, output.toByteArray()) + } + + private fun tempFile(bytes: ByteArray): File = + File.createTempFile("tus-checksum", ".bin").apply { + writeBytes(bytes) + } + + private fun expectedSha1Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-1") + .digest(bytes) + .joinToString(separator = "") { "%02x".format(it.toInt() and 0xff) } + + private fun expectedSha1Base64(bytes: ByteArray): String = + Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-1").digest(bytes) + ) +} diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt index 944c7155ae..5b51efd8b1 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt @@ -61,6 +61,14 @@ class TusIntegrationTest { return client } + private fun decodeTusMetadata(header: String): Map = + header.split(",") + .filter { it.isNotBlank() } + .associate { entry -> + val parts = entry.split(" ", limit = 2) + parts[0] to String(Base64.getDecoder().decode(parts[1])) + } + @Test fun create_patch_head_delete_success() { val client = newClient() @@ -181,6 +189,90 @@ class TusIntegrationTest { assertEquals("1.0.0", delReq.getHeader("Tus-Resumable")) } + @Test + fun create_encodesChecksumMetadataWithoutCreationWithUpload() { + val client = newClient() + val collectionPath = "/remote.php/dav/uploads/$userId" + val locationPath = "$collectionPath/UPLD-checksum" + val localFile = File.createTempFile("tus", ".bin").apply { + writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + } + val sha1Hex = TusChecksumHelper.sha1Hex(localFile) + server.enqueue( + MockResponse() + .setResponseCode(201) + .addHeader("Tus-Resumable", "1.0.0") + .addHeader("Location", locationPath) + .addHeader("Upload-Offset", "0") + ) + + val create = CreateTusUploadRemoteOperation( + file = localFile, + remotePath = "/test.bin", + mimetype = "application/octet-stream", + metadata = mapOf( + "filename" to "test.bin", + "checksum" to "SHA1 $sha1Hex", + ), + useCreationWithUpload = false, + firstChunkSize = null, + tusUrl = null, + collectionUrlOverride = server.url(collectionPath).toString(), + base64Encoder = object : Base64Encoder { + override fun encode(bytes: ByteArray): String = + Base64.getEncoder().encodeToString(bytes) + } + ) + + val createResult = create.execute(client) + assertTrue("Create operation failed", createResult.isSuccess) + + val postReq = server.takeRequest() + assertEquals("POST", postReq.method) + assertEquals("5", postReq.getHeader("Upload-Length")) + assertNull(postReq.getHeader("Upload-Offset")) + val metadata = decodeTusMetadata(postReq.getHeader("Upload-Metadata")!!) + assertEquals("test.bin", metadata["filename"]) + assertEquals("SHA1 $sha1Hex", metadata["checksum"]) + } + + @Test + fun patch_sendsUploadChecksumHeader() { + val client = newClient() + val locationPath = "/remote.php/dav/uploads/$userId/UPLD-checksum-patch" + val localFile = File.createTempFile("tus", ".bin").apply { + writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + } + val checksum = TusChecksumHelper.storedSha1(TusChecksumHelper.sha1Hex(localFile)) + server.enqueue( + MockResponse() + .setResponseCode(204) + .addHeader("Upload-Offset", "5") + ) + + val patch = PatchTusUploadChunkRemoteOperation( + localPath = localFile.absolutePath, + uploadUrl = server.url(locationPath).toString(), + offset = 0, + chunkSize = 5, + checksum = checksum, + ) + val patchResult = patch.execute(client) + + assertTrue(patchResult.isSuccess) + val patchReq = server.takeRequest() + assertEquals("PATCH", patchReq.method) + assertEquals( + TusChecksumHelper.uploadChecksumHeader( + file = localFile, + offset = 0, + length = 5, + algorithm = TusChecksumHelper.SHA1_WIRE_ALGORITHM, + ), + patchReq.getHeader("Upload-Checksum") + ) + } + @Test fun creation_with_upload_returns_offset() { val client = newClient() diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt index 764cc39deb..6a67b2c0b3 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt @@ -72,4 +72,6 @@ interface LocalTransferDataSource { ) fun updateTusUrl(id: Long, tusUploadUrl: String?) + + fun updateTusChecksum(id: Long, tusUploadChecksum: String?) } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt index 7b3101589d..a07f769ac9 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt @@ -171,6 +171,10 @@ class OCLocalTransferDataSource( transferDao.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) } + override fun updateTusChecksum(id: Long, tusUploadChecksum: String?) { + transferDao.updateTusChecksum(id = id, tusUploadChecksum = tusUploadChecksum) + } + companion object { diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt index d23aedee0d..a86fda33d5 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt @@ -79,6 +79,9 @@ interface TransferDao { @Query(UPDATE_TUS_URL) fun updateTusUrl(id: Long, tusUploadUrl: String?) + @Query(UPDATE_TUS_CHECKSUM) + fun updateTusChecksum(id: Long, tusUploadChecksum: String?) + @Query(DELETE_TRANSFER_WITH_ID) fun deleteTransferWithId(id: Long) @@ -163,6 +166,12 @@ interface TransferDao { WHERE id = :id """ + private const val UPDATE_TUS_CHECKSUM = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadChecksum = :tusUploadChecksum + WHERE id = :id + """ + private const val DELETE_TRANSFER_WITH_ID = """ DELETE FROM $TRANSFERS_TABLE_NAME diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt index e27b95f459..447b4c4eb5 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt @@ -139,4 +139,7 @@ class OCTransferRepository( override fun updateTusUrl(id: Long, tusUploadUrl: String?) = localTransferDataSource.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) + + override fun updateTusChecksum(id: Long, tusUploadChecksum: String?) = + localTransferDataSource.updateTusChecksum(id = id, tusUploadChecksum = tusUploadChecksum) } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/transfers/repository/OCTransferRepositoryTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/transfers/repository/OCTransferRepositoryTest.kt index 8a87179d6c..197f115383 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/transfers/repository/OCTransferRepositoryTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/transfers/repository/OCTransferRepositoryTest.kt @@ -123,6 +123,17 @@ class OCTransferRepositoryTest { } } + @Test + fun `updateTusChecksum updates TUS checksum correctly`() { + val checksum = "sha1:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" + + ocTransferRepository.updateTusChecksum(OC_TRANSFER.id!!, checksum) + + verify(exactly = 1) { + localTransferDataSource.updateTusChecksum(OC_TRANSFER.id!!, checksum) + } + } + @Test fun `deleteTransferById removes a transfer correctly`() { ocTransferRepository.deleteTransferById(OC_TRANSFER.id!!) diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt index 3fd9b8d72d..d5e4cac8e4 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt @@ -72,4 +72,6 @@ interface TransferRepository { ) fun updateTusUrl(id: Long, tusUploadUrl: String?) + + fun updateTusChecksum(id: Long, tusUploadChecksum: String?) }