diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt
index e2f8d625d..883ba67ae 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt
@@ -100,14 +100,28 @@ object ThumbnailsRequester : KoinComponent {
}
fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String =
- getPreviewUri(file, null, etag ?: file.remoteEtag, account, width, height)
+ getPreviewUri(file, null, getThumbnailCacheToken(file, etag), account, width, height)
fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String =
- getPreviewUri(fileWithSyncInfo.file, fileWithSyncInfo.space?.root?.webDavUrl, fileWithSyncInfo.file.remoteEtag, account, width, height)
+ getPreviewUri(
+ fileWithSyncInfo.file,
+ fileWithSyncInfo.space?.root?.webDavUrl,
+ getThumbnailCacheToken(fileWithSyncInfo.file),
+ account,
+ width,
+ height
+ )
fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String =
String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag)
+ @VisibleForTesting
+ internal fun getThumbnailCacheToken(file: OCFile, explicitEtag: String? = null): String =
+ firstNotBlank(explicitEtag, file.remoteEtag, file.etag).orEmpty()
+
+ private fun firstNotBlank(vararg values: String?): String? =
+ values.firstOrNull { !it.isNullOrBlank() }
+
private fun getPreviewUri(file: OCFile, spaceWebDavUrl: String?, etag: String?, account: Account, width: Int, height: Int): String {
val baseUrl = accountBaseUrls.getOrPut(account.name) {
val accountManager = AccountManager.get(appContext)
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt
index e588f16b3..d2e6bf038 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt
@@ -208,9 +208,11 @@ class DownloadFileWorker(
val finalFile = File(finalLocationForFile)
val currentTime = System.currentTimeMillis()
ocFile.apply {
+ val serverEtag = FileEtagNormalizer.normalize(downloadRemoteFileOperation.etag).orEmpty()
needsToUpdateThumbnail = true
modificationTimestamp = downloadRemoteFileOperation.modificationTimestamp
- etag = downloadRemoteFileOperation.etag
+ etag = serverEtag
+ remoteEtag = serverEtag
storagePath = finalLocationForFile
length = finalFile.length()
// Use the file's actual mtime, not the current time. SynchronizeFileUseCase
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/FileEtagNormalizer.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/FileEtagNormalizer.kt
new file mode 100644
index 000000000..51e930e7e
--- /dev/null
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/FileEtagNormalizer.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 FileEtagNormalizer {
+ fun normalize(value: String?): String? =
+ value
+ ?.trim()
+ ?.removeSurrounding("\"")
+ ?.takeIf { it.isNotBlank() }
+}
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 08eedd26e..32e36e618 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt
@@ -45,7 +45,10 @@ import eu.opencloud.android.domain.exceptions.ServerConnectionTimeoutException
import eu.opencloud.android.domain.exceptions.ServerNotReachableException
import eu.opencloud.android.domain.exceptions.ServerResponseTimeoutException
import eu.opencloud.android.domain.exceptions.UnauthorizedException
+import eu.opencloud.android.domain.files.usecases.CleanConflictUseCase
+import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase
import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase
+import eu.opencloud.android.domain.files.usecases.SaveFileOrFolderUseCase
import eu.opencloud.android.domain.transfers.TransferRepository
import eu.opencloud.android.domain.transfers.model.OCTransfer
import eu.opencloud.android.domain.transfers.model.TransferResult
@@ -59,6 +62,7 @@ import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener
import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation
import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation
+import eu.opencloud.android.lib.resources.files.ReadRemoteFileOperation
import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation
import eu.opencloud.android.presentation.authentication.AccountUtils
import eu.opencloud.android.utils.NotificationUtils
@@ -107,6 +111,11 @@ class UploadFileFromContentUriWorker(
private val transferRepository: TransferRepository by inject()
private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject()
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
+ private val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
+ private val saveFileOrFolderUseCase: SaveFileOrFolderUseCase by inject()
+ private val cleanConflictUseCase: CleanConflictUseCase by inject()
+
+ private var finalEtag: String = ""
override suspend fun doWork(): Result = try {
prepareFile()
@@ -115,7 +124,9 @@ class UploadFileFromContentUriWorker(
checkParentFolderExistence(clientForThisUpload)
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
uploadDocument(clientForThisUpload)
+ resolveFinalEtagIfNeeded(clientForThisUpload)
updateUploadsDatabaseWithResult(null)
+ updateFilesDatabaseWithLatestDetails()
Result.success()
}catch (throwable: Throwable) {
Timber.e(throwable)
@@ -322,7 +333,7 @@ class UploadFileFromContentUriWorker(
hasPendingTusSession
)
val tusSucceeded = try {
- tusUploadHelper.upload(
+ val returnedEtag = tusUploadHelper.upload(
client = client,
transfer = ocTransfer,
uploadId = uploadIdInStorageManager,
@@ -336,6 +347,9 @@ class UploadFileFromContentUriWorker(
progressCallback = ::updateProgressFromTus,
spaceWebDavUrl = spaceWebDavUrl,
)
+ if (!returnedEtag.isNullOrBlank()) {
+ finalEtag = returnedEtag
+ }
true
}catch (throwable: Throwable) {
Timber.w(throwable, "TUS upload failed, falling back to single PUT")
@@ -379,10 +393,27 @@ class UploadFileFromContentUriWorker(
val result = executeRemoteOperation { uploadFileOperation.execute(client) }
if (result == Unit) {
+ finalEtag = uploadFileOperation.etag
clearTusState()
}
}
+ private fun resolveFinalEtagIfNeeded(client: OpenCloudClient) {
+ if (FileEtagNormalizer.normalize(finalEtag) != null) return
+
+ finalEtag = try {
+ executeRemoteOperation {
+ ReadRemoteFileOperation(
+ remotePath = uploadPath,
+ spaceWebDavUrl = spaceWebDavUrl,
+ ).execute(client)
+ }.etag.orEmpty()
+ } catch (e: Throwable) {
+ Timber.w(e, "Could not resolve final ETag for %s after upload", uploadPath)
+ ""
+ }
+ }
+
private fun updateProgressFromTus(offset: Long, totalSize: Long) {
if (this.isStopped) {
Timber.w("Cancelling TUS upload. The worker is stopped by user or system")
@@ -450,6 +481,35 @@ class UploadFileFromContentUriWorker(
TransferStatus.TRANSFER_FAILED
}
+ private fun updateFilesDatabaseWithLatestDetails() {
+ val currentTime = System.currentTimeMillis()
+ val serverEtag = FileEtagNormalizer.normalize(finalEtag).orEmpty()
+ val file = getFileByRemotePathUseCase(
+ GetFileByRemotePathUseCase.Params(
+ account.name,
+ uploadPath,
+ ocTransfer.spaceId,
+ )
+ )
+ file.getDataOrNull()?.let { ocFile ->
+ val fileWithNewDetails = ocFile.copy(
+ storagePath = null,
+ needsToUpdateThumbnail = true,
+ etag = serverEtag,
+ remoteEtag = serverEtag,
+ length = fileSize,
+ modificationTimestamp = lastModified.toLongOrNull()?.times(1000L) ?: currentTime,
+ lastSyncDateForData = currentTime,
+ modifiedAtLastSyncForData = currentTime,
+ etagInConflict = null,
+ )
+ saveFileOrFolderUseCase(SaveFileOrFolderUseCase.Params(fileWithNewDetails))
+ ocFile.id?.let { fileId ->
+ cleanConflictUseCase(CleanConflictUseCase.Params(fileId = fileId))
+ }
+ }
+ }
+
private fun showNotification(throwable: Throwable) {
// check credentials error
val needsToUpdateCredentials = throwable is UnauthorizedException
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 7c32bccef..4899f23c6 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt
@@ -52,6 +52,7 @@ import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener
import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation
import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation
+import eu.opencloud.android.lib.resources.files.ReadRemoteFileOperation
import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation
import eu.opencloud.android.presentation.authentication.AccountUtils
import eu.opencloud.android.utils.NotificationUtils
@@ -126,6 +127,7 @@ class UploadFileFromFileSystemWorker(
checkParentFolderExistence(clientForThisUpload)
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
uploadDocument(clientForThisUpload)
+ resolveFinalEtagIfNeeded(clientForThisUpload)
updateUploadsDatabaseWithResult(null)
updateFilesDatabaseWithLatestDetails()
Result.success()
@@ -342,6 +344,22 @@ class UploadFileFromFileSystemWorker(
}
}
+ private fun resolveFinalEtagIfNeeded(client: OpenCloudClient) {
+ if (FileEtagNormalizer.normalize(finalEtag) != null) return
+
+ finalEtag = try {
+ executeRemoteOperation {
+ ReadRemoteFileOperation(
+ remotePath = uploadPath,
+ spaceWebDavUrl = spaceWebDavUrl,
+ ).execute(client)
+ }.etag.orEmpty()
+ } catch (e: Throwable) {
+ Timber.w(e, "Could not resolve final ETag for %s after upload", uploadPath)
+ ""
+ }
+ }
+
private fun updateProgressFromTus(offset: Long, totalSize: Long) {
if (this.isStopped) {
Timber.w("Cancelling TUS upload. The worker is stopped by user or system")
@@ -411,13 +429,15 @@ class UploadFileFromFileSystemWorker(
private fun updateFilesDatabaseWithLatestDetails() {
val currentTime = System.currentTimeMillis()
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
- val file = getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(account.name, ocTransfer.remotePath, ocTransfer.spaceId))
+ val serverEtag = FileEtagNormalizer.normalize(finalEtag).orEmpty()
+ val file = getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(account.name, uploadPath, ocTransfer.spaceId))
file.getDataOrNull()?.let { ocFile ->
val fileWithNewDetails =
if (ocTransfer.forceOverwrite) {
ocFile.copy(
needsToUpdateThumbnail = true,
- etag = finalEtag,
+ etag = serverEtag,
+ remoteEtag = serverEtag,
length = fileSize,
lastSyncDateForData = currentTime,
modifiedAtLastSyncForData = currentTime,
@@ -427,7 +447,8 @@ class UploadFileFromFileSystemWorker(
ocFile.copy(
storagePath = null,
needsToUpdateThumbnail = true,
- etag = finalEtag.ifBlank { ocFile.etag },
+ etag = serverEtag,
+ remoteEtag = serverEtag,
length = fileSize,
lastSyncDateForData = currentTime,
modifiedAtLastSyncForData = currentTime,
diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt
index ac869b96b..ddeea5856 100644
--- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt
+++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt
@@ -1,7 +1,7 @@
/**
* openCloud Android client application
*
- * Copyright (C) 2026 opencloud.
+ * 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,
@@ -19,6 +19,7 @@
package eu.opencloud.android.presentation.thumbnails
import android.net.Uri
+import eu.opencloud.android.domain.files.model.OCFile
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
@@ -42,6 +43,42 @@ class ThumbnailsRequesterTest {
unmockkStatic(Uri::class)
}
+ @Test
+ fun `thumbnail cache token prefers explicit etag`() {
+ val file = file(remoteEtag = "remote-etag", etag = "local-etag")
+
+ val token = ThumbnailsRequester.getThumbnailCacheToken(file, explicitEtag = "explicit-etag")
+
+ assertEquals("explicit-etag", token)
+ }
+
+ @Test
+ fun `thumbnail cache token uses remote etag before etag`() {
+ val file = file(remoteEtag = "remote-etag", etag = "local-etag")
+
+ val token = ThumbnailsRequester.getThumbnailCacheToken(file)
+
+ assertEquals("remote-etag", token)
+ }
+
+ @Test
+ fun `thumbnail cache token falls back to etag when remote etag is missing`() {
+ val fileWithNullRemoteEtag = file(remoteEtag = null, etag = "local-etag")
+ val fileWithBlankRemoteEtag = file(remoteEtag = " ", etag = "local-etag")
+
+ assertEquals("local-etag", ThumbnailsRequester.getThumbnailCacheToken(fileWithNullRemoteEtag))
+ assertEquals("local-etag", ThumbnailsRequester.getThumbnailCacheToken(fileWithBlankRemoteEtag))
+ }
+
+ @Test
+ fun `thumbnail cache token is blank without any etag`() {
+ val file = file(remoteEtag = null, etag = "")
+
+ val token = ThumbnailsRequester.getThumbnailCacheToken(file, explicitEtag = " ")
+
+ assertEquals("", token)
+ }
+
@Test
fun `preview uri for personal file uses legacy webdav path`() {
val uri = ThumbnailsRequester.buildPreviewUri(
@@ -116,4 +153,15 @@ class ThumbnailsRequesterTest {
private fun encodeSpaces(value: String): String =
value.replace(" ", "%20")
+
+ private fun file(remoteEtag: String?, etag: String?) =
+ OCFile(
+ owner = "owner",
+ length = 1,
+ modificationTimestamp = 1,
+ remotePath = "/Photos/image.jpg",
+ mimeType = "image/jpeg",
+ remoteEtag = remoteEtag,
+ etag = etag,
+ )
}
diff --git a/opencloudApp/src/test/java/eu/opencloud/android/workers/FileEtagNormalizerTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/workers/FileEtagNormalizerTest.kt
new file mode 100644
index 000000000..d98211ba0
--- /dev/null
+++ b/opencloudApp/src/test/java/eu/opencloud/android/workers/FileEtagNormalizerTest.kt
@@ -0,0 +1,44 @@
+/**
+ * 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.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class FileEtagNormalizerTest {
+
+ @Test
+ fun `normalize trims surrounding whitespace and quotes`() {
+ assertEquals("server-etag", FileEtagNormalizer.normalize(" \"server-etag\" "))
+ }
+
+ @Test
+ fun `normalize keeps unquoted etag`() {
+ assertEquals("server-etag", FileEtagNormalizer.normalize("server-etag"))
+ }
+
+ @Test
+ fun `normalize returns null for blank values`() {
+ assertNull(FileEtagNormalizer.normalize(null))
+ assertNull(FileEtagNormalizer.normalize(""))
+ assertNull(FileEtagNormalizer.normalize(" "))
+ assertNull(FileEtagNormalizer.normalize("\"\""))
+ }
+}