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 300dbe41e7..7713a22bbd 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
@@ -23,6 +23,7 @@ package eu.opencloud.android.presentation.thumbnails
import android.accounts.Account
import android.accounts.AccountManager
import android.net.Uri
+import androidx.annotation.VisibleForTesting
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
@@ -99,7 +100,7 @@ object ThumbnailsRequester : KoinComponent {
}
fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String =
- getPreviewUri(file.remotePath, etag ?: file.remoteEtag, account, width, height)
+ getPreviewUri(file.remotePath, getThumbnailCacheToken(file, etag), account, width, height)
fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String =
getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height)
@@ -107,6 +108,13 @@ object ThumbnailsRequester : KoinComponent {
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(remotePath: 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 e588f16b38..a8f3355996 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,16 @@ class DownloadFileWorker(
val finalFile = File(finalLocationForFile)
val currentTime = System.currentTimeMillis()
ocFile.apply {
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = downloadRemoteFileOperation.etag,
+ existingEtag = etag,
+ existingRemoteEtag = remoteEtag,
+ localContentHashTokenProvider = { FileEtagCacheTokenResolver.sha256Token(finalFile) },
+ )
needsToUpdateThumbnail = true
modificationTimestamp = downloadRemoteFileOperation.modificationTimestamp
- etag = downloadRemoteFileOperation.etag
+ etag = resolvedEtags.etag
+ remoteEtag = resolvedEtags.remoteEtag
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/FileEtagCacheTokenResolver.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/FileEtagCacheTokenResolver.kt
new file mode 100644
index 0000000000..2c5216619d
--- /dev/null
+++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/FileEtagCacheTokenResolver.kt
@@ -0,0 +1,122 @@
+/**
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 ownCloud 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 java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.security.MessageDigest
+
+internal object FileEtagCacheTokenResolver {
+ private const val DEFAULT_BUFFER_SIZE = 8 * 1024
+ private val HEX_CHARS = "0123456789abcdef".toCharArray()
+
+ data class ResolvedEtags(
+ val etag: String?,
+ val remoteEtag: String?,
+ )
+
+ fun resolve(
+ serverEtag: String?,
+ existingEtag: String?,
+ existingRemoteEtag: String?,
+ localContentHashToken: String? = null,
+ ): ResolvedEtags {
+ val normalizedServerEtag = normalizeToken(serverEtag)
+ val normalizedHashToken = normalizeToken(localContentHashToken)
+ return resolveWithNormalizedValues(
+ normalizedServerEtag = normalizedServerEtag,
+ existingEtag = existingEtag,
+ existingRemoteEtag = existingRemoteEtag,
+ normalizedHashToken = normalizedHashToken,
+ )
+ }
+
+ fun resolve(
+ serverEtag: String?,
+ existingEtag: String?,
+ existingRemoteEtag: String?,
+ localContentHashTokenProvider: () -> String?,
+ ): ResolvedEtags {
+ val normalizedServerEtag = normalizeToken(serverEtag)
+ val normalizedHashToken = if (normalizedServerEtag == null) {
+ normalizeToken(localContentHashTokenProvider())
+ } else {
+ null
+ }
+ return resolveWithNormalizedValues(
+ normalizedServerEtag = normalizedServerEtag,
+ existingEtag = existingEtag,
+ existingRemoteEtag = existingRemoteEtag,
+ normalizedHashToken = normalizedHashToken,
+ )
+ }
+
+ fun sha256Token(file: File): String? =
+ try {
+ if (!file.isFile || !file.canRead()) {
+ null
+ } else {
+ val digest = MessageDigest.getInstance("SHA-256")
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ FileInputStream(file).use { input ->
+ while (true) {
+ val bytesRead = input.read(buffer)
+ if (bytesRead == -1) break
+ digest.update(buffer, 0, bytesRead)
+ }
+ }
+ "sha256:${digest.digest().toHexString()}"
+ }
+ } catch (ignored: IOException) {
+ null
+ } catch (ignored: SecurityException) {
+ null
+ }
+
+ private fun resolveWithNormalizedValues(
+ normalizedServerEtag: String?,
+ existingEtag: String?,
+ existingRemoteEtag: String?,
+ normalizedHashToken: String?,
+ ): ResolvedEtags =
+ ResolvedEtags(
+ etag = normalizedServerEtag ?: existingEtag,
+ remoteEtag = normalizedServerEtag
+ ?: normalizedHashToken
+ ?: normalizeToken(existingRemoteEtag)
+ ?: normalizeToken(existingEtag),
+ )
+
+ private fun normalizeToken(value: String?): String? =
+ value
+ ?.trim()
+ ?.removeSurrounding("\"")
+ ?.takeIf { it.isNotBlank() }
+
+ private fun ByteArray.toHexString(): String {
+ val output = StringBuilder(size * 2)
+ for (byte in this) {
+ val value = byte.toInt() and 0xff
+ output.append(HEX_CHARS[value ushr 4])
+ output.append(HEX_CHARS[value and 0x0f])
+ }
+ return output.toString()
+ }
+}
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 bee2f350dd..9efad97cb1 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
@@ -60,6 +63,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
@@ -108,6 +112,12 @@ 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 = ""
+ private var finalContentHashToken: String? = null
override suspend fun doWork(): Result = try {
prepareFile()
@@ -116,7 +126,9 @@ class UploadFileFromContentUriWorker(
checkParentFolderExistence(clientForThisUpload)
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
uploadDocument(clientForThisUpload)
+ resolveFinalEtagIfNeeded(clientForThisUpload)
updateUploadsDatabaseWithResult(null)
+ updateFilesDatabaseWithLatestDetails()
Result.success()
}catch (throwable: Throwable) {
Timber.e(throwable)
@@ -302,9 +314,7 @@ class UploadFileFromContentUriWorker(
val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank()
val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE)
- var attemptedTus = false
if (shouldTryTus) {
- attemptedTus = true
Timber.d(
"Attempting TUS upload (size=%d, threshold=%d, resume=%s)",
fileSize,
@@ -312,7 +322,7 @@ class UploadFileFromContentUriWorker(
hasPendingTusSession
)
val tusSucceeded = try {
- tusUploadHelper.upload(
+ val returnedEtag = tusUploadHelper.upload(
client = client,
transfer = ocTransfer,
uploadId = uploadIdInStorageManager,
@@ -326,6 +336,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")
@@ -336,6 +349,7 @@ class UploadFileFromContentUriWorker(
}
if (tusSucceeded) {
+ captureFinalContentHashTokenIfNeeded()
removeCacheFile()
Timber.d("TUS upload completed for %s", uploadPath)
return
@@ -351,6 +365,7 @@ class UploadFileFromContentUriWorker(
Timber.d("Falling back to single PUT upload for %s", uploadPath)
uploadPlainFile(client)
+ captureFinalContentHashTokenIfNeeded()
clearTusState()
removeCacheFile()
}
@@ -369,10 +384,33 @@ class UploadFileFromContentUriWorker(
val result = executeRemoteOperation { uploadFileOperation.execute(client) }
if (result == Unit) {
+ finalEtag = uploadFileOperation.etag
clearTusState()
}
}
+ private fun resolveFinalEtagIfNeeded(client: OpenCloudClient) {
+ if (finalEtag.isNotBlank()) 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 captureFinalContentHashTokenIfNeeded() {
+ if (finalEtag.isBlank() && finalContentHashToken.isNullOrBlank()) {
+ finalContentHashToken = FileEtagCacheTokenResolver.sha256Token(File(cachePath))
+ }
+ }
+
private fun updateProgressFromTus(offset: Long, totalSize: Long) {
if (this.isStopped) {
Timber.w("Cancelling TUS upload. The worker is stopped by user or system")
@@ -440,6 +478,40 @@ class UploadFileFromContentUriWorker(
TransferStatus.TRANSFER_FAILED
}
+ private fun updateFilesDatabaseWithLatestDetails() {
+ val currentTime = System.currentTimeMillis()
+ val file = getFileByRemotePathUseCase(
+ GetFileByRemotePathUseCase.Params(
+ account.name,
+ uploadPath,
+ ocTransfer.spaceId,
+ )
+ )
+ file.getDataOrNull()?.let { ocFile ->
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = finalEtag,
+ existingEtag = ocFile.etag,
+ existingRemoteEtag = ocFile.remoteEtag,
+ localContentHashToken = finalContentHashToken,
+ )
+ val fileWithNewDetails = ocFile.copy(
+ storagePath = null,
+ needsToUpdateThumbnail = true,
+ etag = resolvedEtags.etag,
+ remoteEtag = resolvedEtags.remoteEtag,
+ 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 91296f8427..a55ec1c611 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
@@ -102,6 +103,7 @@ class UploadFileFromFileSystemWorker(
private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) }
private var finalEtag: String = ""
+ private var finalContentHashToken: String? = null
private val foregroundJob = SupervisorJob()
private val foregroundScope = CoroutineScope(Dispatchers.Default + foregroundJob)
@@ -126,6 +128,7 @@ class UploadFileFromFileSystemWorker(
checkParentFolderExistence(clientForThisUpload)
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
uploadDocument(clientForThisUpload)
+ resolveFinalEtagIfNeeded(clientForThisUpload)
updateUploadsDatabaseWithResult(null)
updateFilesDatabaseWithLatestDetails()
Result.success()
@@ -299,6 +302,7 @@ class UploadFileFromFileSystemWorker(
}
if (tusSucceeded) {
+ captureFinalContentHashTokenIfNeeded()
if (removeLocal) {
removeLocalFile()
}
@@ -315,6 +319,7 @@ class UploadFileFromFileSystemWorker(
Timber.d("Falling back to single PUT upload for %s", uploadPath)
uploadPlainFile(client)
+ captureFinalContentHashTokenIfNeeded()
}
private fun uploadPlainFile(client: OpenCloudClient) {
@@ -340,6 +345,28 @@ class UploadFileFromFileSystemWorker(
}
}
+ private fun resolveFinalEtagIfNeeded(client: OpenCloudClient) {
+ if (finalEtag.isNotBlank()) 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 captureFinalContentHashTokenIfNeeded() {
+ if (finalEtag.isBlank() && finalContentHashToken.isNullOrBlank()) {
+ finalContentHashToken = FileEtagCacheTokenResolver.sha256Token(File(fileSystemPath))
+ }
+ }
+
private fun updateProgressFromTus(offset: Long, totalSize: Long) {
if (this.isStopped) {
Timber.w("Cancelling TUS upload. The worker is stopped by user or system")
@@ -409,13 +436,20 @@ 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 file = getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(account.name, uploadPath, ocTransfer.spaceId))
file.getDataOrNull()?.let { ocFile ->
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = finalEtag,
+ existingEtag = ocFile.etag,
+ existingRemoteEtag = ocFile.remoteEtag,
+ localContentHashToken = finalContentHashToken,
+ )
val fileWithNewDetails =
if (ocTransfer.forceOverwrite) {
ocFile.copy(
needsToUpdateThumbnail = true,
- etag = finalEtag,
+ etag = resolvedEtags.etag,
+ remoteEtag = resolvedEtags.remoteEtag,
length = fileSize,
lastSyncDateForData = currentTime,
modifiedAtLastSyncForData = currentTime,
@@ -425,7 +459,8 @@ class UploadFileFromFileSystemWorker(
ocFile.copy(
storagePath = null,
needsToUpdateThumbnail = true,
- etag = finalEtag.ifBlank { ocFile.etag },
+ etag = resolvedEtags.etag,
+ remoteEtag = resolvedEtags.remoteEtag,
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
new file mode 100644
index 0000000000..747cbcb43f
--- /dev/null
+++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt
@@ -0,0 +1,82 @@
+/**
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 ownCloud 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.presentation.thumbnails
+
+import eu.opencloud.android.domain.files.model.OCFile
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ThumbnailsRequesterTest {
+
+ @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 uses hash remote etag before etag`() {
+ val file = file(remoteEtag = "sha256:local-content-hash", etag = "local-etag")
+
+ val token = ThumbnailsRequester.getThumbnailCacheToken(file)
+
+ assertEquals("sha256:local-content-hash", 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)
+ }
+
+ 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/FileEtagCacheTokenResolverTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/workers/FileEtagCacheTokenResolverTest.kt
new file mode 100644
index 0000000000..061a6e88d6
--- /dev/null
+++ b/opencloudApp/src/test/java/eu/opencloud/android/workers/FileEtagCacheTokenResolverTest.kt
@@ -0,0 +1,112 @@
+/**
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 ownCloud 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
+import java.io.File
+
+class FileEtagCacheTokenResolverTest {
+
+ @Test
+ fun `server etag wins for sync and thumbnail tokens`() {
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = " \"server-etag\" ",
+ existingEtag = "existing-etag",
+ existingRemoteEtag = "existing-remote-etag",
+ localContentHashToken = "sha256:local-hash",
+ )
+
+ assertEquals("server-etag", resolvedEtags.etag)
+ assertEquals("server-etag", resolvedEtags.remoteEtag)
+ }
+
+ @Test
+ fun `blank server etag preserves existing sync etag`() {
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = " ",
+ existingEtag = "existing-etag",
+ existingRemoteEtag = "existing-remote-etag",
+ localContentHashToken = "sha256:local-hash",
+ )
+
+ assertEquals("existing-etag", resolvedEtags.etag)
+ }
+
+ @Test
+ fun `blank server etag uses local content hash for thumbnail token`() {
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = "",
+ existingEtag = "existing-etag",
+ existingRemoteEtag = "existing-remote-etag",
+ localContentHashToken = " sha256:local-hash ",
+ )
+
+ assertEquals("existing-etag", resolvedEtags.etag)
+ assertEquals("sha256:local-hash", resolvedEtags.remoteEtag)
+ }
+
+ @Test
+ fun `blank server etag without hash preserves existing thumbnail tokens`() {
+ val resolvedRemoteEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = null,
+ existingEtag = "existing-etag",
+ existingRemoteEtag = "existing-remote-etag",
+ )
+
+ assertEquals("existing-etag", resolvedRemoteEtags.etag)
+ assertEquals("existing-remote-etag", resolvedRemoteEtags.remoteEtag)
+
+ val resolvedEtagFallback = FileEtagCacheTokenResolver.resolve(
+ serverEtag = null,
+ existingEtag = "existing-etag",
+ existingRemoteEtag = " ",
+ )
+
+ assertEquals("existing-etag", resolvedEtagFallback.etag)
+ assertEquals("existing-etag", resolvedEtagFallback.remoteEtag)
+ }
+
+ @Test
+ fun `blank server etag without any fallback leaves thumbnail token missing`() {
+ val resolvedEtags = FileEtagCacheTokenResolver.resolve(
+ serverEtag = null,
+ existingEtag = "",
+ existingRemoteEtag = null,
+ )
+
+ assertEquals("", resolvedEtags.etag)
+ assertNull(resolvedEtags.remoteEtag)
+ }
+
+ @Test
+ fun `sha256 token hashes readable file content`() {
+ val file = File.createTempFile("etag-token", ".txt")
+ try {
+ file.writeText("opencloud")
+
+ val token = FileEtagCacheTokenResolver.sha256Token(file)
+
+ assertEquals("sha256:dac2d7ad9a952aae0302e5cf934d512167aa2142946973b52c868ef6b69400b8", token)
+ } finally {
+ file.delete()
+ }
+ }
+}
diff --git a/opencloudData/src/androidTest/java/eu/opencloud/android/data/files/db/FileDaoTest.kt b/opencloudData/src/androidTest/java/eu/opencloud/android/data/files/db/FileDaoTest.kt
new file mode 100644
index 0000000000..48e2b95b78
--- /dev/null
+++ b/opencloudData/src/androidTest/java/eu/opencloud/android/data/files/db/FileDaoTest.kt
@@ -0,0 +1,97 @@
+/**
+ * openCloud Android client application
+ *
+ * Copyright (C) 2026 ownCloud 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.data.files.db
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import eu.opencloud.android.data.OpencloudDatabase
+import eu.opencloud.android.testutil.OC_FILE_ENTITY
+import eu.opencloud.android.testutil.OC_FOLDER_ENTITY
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@MediumTest
+class FileDaoTest {
+ @Rule
+ @JvmField
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ private lateinit var fileDao: FileDao
+
+ @Before
+ fun setUp() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ OpencloudDatabase.switchToInMemory(context)
+ val db: OpencloudDatabase = OpencloudDatabase.getDatabase(context)
+ fileDao = db.fileDao()
+ }
+
+ @Test
+ fun copyKeepsSourceRemoteEtagForThumbnailCacheToken() {
+ val finalRemotePath = "/Photos/copied-image.jpg"
+ val sourceFile = OC_FILE_ENTITY.copy(
+ remoteEtag = "source-remote-etag",
+ etag = "source-etag",
+ ).apply { id = OC_FILE_ENTITY.id }
+
+ fileDao.copy(
+ sourceFile = sourceFile,
+ targetFolder = OC_FOLDER_ENTITY,
+ finalRemotePath = finalRemotePath,
+ remoteId = "copiedRemoteId",
+ replace = false,
+ )
+
+ val copiedFile = fileDao.getFileByOwnerAndRemotePath(
+ owner = OC_FOLDER_ENTITY.owner,
+ remotePath = finalRemotePath,
+ spaceId = OC_FOLDER_ENTITY.spaceId,
+ )
+
+ assertEquals("source-remote-etag", copiedFile?.remoteEtag)
+ }
+
+ @Test
+ fun copyFallsBackToSourceEtagWhenSourceRemoteEtagIsBlank() {
+ val finalRemotePath = "/Photos/copied-image-with-fallback.jpg"
+ val sourceFile = OC_FILE_ENTITY.copy(
+ remoteEtag = "",
+ etag = "source-etag",
+ ).apply { id = OC_FILE_ENTITY.id }
+
+ fileDao.copy(
+ sourceFile = sourceFile,
+ targetFolder = OC_FOLDER_ENTITY,
+ finalRemotePath = finalRemotePath,
+ remoteId = "copiedRemoteIdWithFallback",
+ replace = false,
+ )
+
+ val copiedFile = fileDao.getFileByOwnerAndRemotePath(
+ owner = OC_FOLDER_ENTITY.owner,
+ remotePath = finalRemotePath,
+ spaceId = OC_FOLDER_ENTITY.spaceId,
+ )
+
+ assertEquals("source-etag", copiedFile?.remoteEtag)
+ }
+}
diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt
index 9b09f2f9fc..8491459a98 100644
--- a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt
+++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt
@@ -291,6 +291,7 @@ interface FileDao {
name = null,
needsToUpdateThumbnail = true,
etag = "",
+ remoteEtag = sourceFile.remoteEtag?.takeIf { it.isNotBlank() } ?: sourceFile.etag,
creationTimestamp = null,
permissions = null,
treeEtag = "",