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 @@ -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
Expand Down Expand Up @@ -99,14 +100,21 @@ 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)

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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()
}
}
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 @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -302,17 +314,15 @@ 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,
TusUploadHelper.DEFAULT_CHUNK_SIZE,
hasPendingTusSession
)
val tusSucceeded = try {
tusUploadHelper.upload(
val returnedEtag = tusUploadHelper.upload(
client = client,
transfer = ocTransfer,
uploadId = uploadIdInStorageManager,
Expand All @@ -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")
Expand All @@ -336,6 +349,7 @@ class UploadFileFromContentUriWorker(
}

if (tusSucceeded) {
captureFinalContentHashTokenIfNeeded()
removeCacheFile()
Timber.d("TUS upload completed for %s", uploadPath)
return
Expand All @@ -351,6 +365,7 @@ class UploadFileFromContentUriWorker(

Timber.d("Falling back to single PUT upload for %s", uploadPath)
uploadPlainFile(client)
captureFinalContentHashTokenIfNeeded()
clearTusState()
removeCacheFile()
}
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading