Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

package eu.opencloud.android.workers

internal object FileEtagNormalizer {
fun normalize(value: String?): String? =
value
?.trim()
?.removeSurrounding("\"")
?.takeIf { it.isNotBlank() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -322,7 +333,7 @@ class UploadFileFromContentUriWorker(
hasPendingTusSession
)
val tusSucceeded = try {
tusUploadHelper.upload(
val returnedEtag = tusUploadHelper.upload(
client = client,
transfer = ocTransfer,
uploadId = uploadIdInStorageManager,
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,6 +127,7 @@ class UploadFileFromFileSystemWorker(
checkParentFolderExistence(clientForThisUpload)
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
uploadDocument(clientForThisUpload)
resolveFinalEtagIfNeeded(clientForThisUpload)
updateUploadsDatabaseWithResult(null)
updateFilesDatabaseWithLatestDetails()
Result.success()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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