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("\"\"")) + } +}