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?)
}