From 3dbfdc761e484c871a65d0b76fa37929ffbcb8b7 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 4 Nov 2025 01:10:40 +0100 Subject: [PATCH 1/8] feat(upload): implement TUS resumable upload protocol - Add TUS 1.0.0 protocol support for resumable file uploads - Implement chunked uploads with automatic retry logic - Add SHA-256 checksum computation for upload integrity - Persist TUS session state in database for resume capability - Add TUS capability detection via OPTIONS request - Implement offset recovery after connection interruptions - Add fallback to standard PUT upload if TUS unavailable - Support both file system and content URI uploads - Include comprehensive retry handling for transient failures - Add TUS-specific database schema migration (v49) - Implement proper cleanup of TUS state after completion - Rethrow IOException during offset fetch to enable worker-level retry - Improve error handling in offset recovery to prevent restart from 0 --- opencloudApp/build.gradle | 1 + .../RetryUploadFromContentUriUseCase.kt | 10 +- .../uploads/RetryUploadFromSystemUseCase.kt | 10 +- .../UploadFileFromContentUriUseCase.kt | 51 +- .../uploads/UploadFileFromSystemUseCase.kt | 55 +- .../uploads/UploadFileInConflictUseCase.kt | 11 +- .../android/workers/TusUploadHelper.kt | 292 ++++ .../workers/UploadFileFromContentUriWorker.kt | 195 ++- .../workers/UploadFileFromFileSystemWorker.kt | 176 ++- opencloudComLibrary/build.gradle | 6 +- .../android/lib/common/OpenCloudClient.java | 4 +- .../lib/common/http/HttpConstants.java | 18 + .../http/methods/nonwebdav/HeadMethod.kt | 18 + .../http/methods/nonwebdav/OptionsMethod.kt | 18 + .../http/methods/nonwebdav/PatchMethod.kt | 22 + .../network/ChunkFromFileRequestBody.kt | 20 +- .../operations/RemoteOperationResult.java | 6 +- .../tus/CheckTusSupportRemoteOperation.kt | 67 + .../tus/CreateTusUploadRemoteOperation.kt | 198 +++ .../tus/DeleteTusUploadRemoteOperation.kt | 40 + .../tus/GetTusUploadOffsetRemoteOperation.kt | 44 + .../tus/PatchTusUploadChunkRemoteOperation.kt | 116 ++ .../lib/resources/status/RemoteCapability.kt | 9 + .../status/responses/CapabilityResponse.kt | 24 +- .../resources/files/tus/TusIntegrationTest.kt | 203 +++ .../48.json | 1218 +++++++++++++++++ .../android/data/OpencloudDatabase.kt | 4 +- .../opencloud/android/data/ProviderMeta.java | 9 +- .../OCLocalCapabilitiesDataSource.kt | 2 + .../mapper/RemoteCapabilityMapper.kt | 8 + .../capabilities/db/OCCapabilityEntity.kt | 4 + .../android/data/migrations/Migration_48.kt | 30 + .../datasources/LocalTransferDataSource.kt | 14 + .../OCLocalTransferDataSource.kt | 40 + .../data/transfers/db/OCTransferEntity.kt | 8 + .../android/data/transfers/db/TransferDao.kt | 34 + .../repository/OCTransferRepository.kt | 25 + .../domain/capabilities/model/OCCapability.kt | 9 + .../domain/transfers/TransferRepository.kt | 14 + .../domain/transfers/model/OCTransfer.kt | 8 + .../android/testutil/OCCapability.kt | 1 + 41 files changed, 2870 insertions(+), 172 deletions(-) create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/HeadMethod.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/OptionsMethod.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/PatchMethod.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt create mode 100644 opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt create mode 100644 opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json create mode 100644 opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle index 63c2e59fc..2db3b208b 100644 --- a/opencloudApp/build.gradle +++ b/opencloudApp/build.gradle @@ -179,6 +179,7 @@ android { buildFeatures { viewBinding true + buildConfig true } packagingOptions { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt index c5c2d0f68..ad3b4c757 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt @@ -29,6 +29,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.extensions.getWorkInfoByTags import eu.opencloud.android.workers.UploadFileFromContentUriWorker import timber.log.Timber +import java.io.File class RetryUploadFromContentUriUseCase( private val workManager: WorkManager, @@ -52,11 +53,18 @@ class RetryUploadFromContentUriUseCase( if (workInfos.isEmpty() || workInfos.firstOrNull()?.state == WorkInfo.State.FAILED) { transferRepository.updateTransferStatusToEnqueuedById(params.uploadIdInStorageManager) + val lastModifiedInSeconds = File(uploadToRetry.localPath) + .takeIf { it.exists() && it.isFile } + ?.lastModified() + ?.takeIf { it > 0 } + ?.div(1000) + ?.toString() + uploadFileFromContentUriUseCase( UploadFileFromContentUriUseCase.Params( accountName = uploadToRetry.accountName, contentUri = uploadToRetry.localPath.toUri(), - lastModifiedInSeconds = (uploadToRetry.transferEndTimestamp?.div(1000)).toString(), + lastModifiedInSeconds = lastModifiedInSeconds, behavior = uploadToRetry.localBehaviour.name, uploadPath = uploadToRetry.remotePath, uploadIdInStorageManager = params.uploadIdInStorageManager, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt index a6ed6ed3b..1cb019198 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt @@ -29,6 +29,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.extensions.getWorkInfoByTags import eu.opencloud.android.workers.UploadFileFromFileSystemWorker import timber.log.Timber +import java.io.File class RetryUploadFromSystemUseCase( private val workManager: WorkManager, @@ -52,11 +53,18 @@ class RetryUploadFromSystemUseCase( if (workInfos.isEmpty() || workInfos.firstOrNull()?.state == WorkInfo.State.FAILED) { transferRepository.updateTransferStatusToEnqueuedById(params.uploadIdInStorageManager) + val lastModifiedInSeconds = File(uploadToRetry.localPath) + .takeIf { it.exists() && it.isFile } + ?.lastModified() + ?.takeIf { it > 0 } + ?.div(1000) + ?.toString() + uploadFileFromSystemUseCase( UploadFileFromSystemUseCase.Params( accountName = uploadToRetry.accountName, localPath = uploadToRetry.localPath, - lastModifiedInSeconds = (uploadToRetry.transferEndTimestamp?.div(1000)).toString(), + lastModifiedInSeconds = lastModifiedInSeconds, behavior = uploadToRetry.localBehaviour.name, uploadPath = uploadToRetry.remotePath, sourcePath = uploadToRetry.sourcePath, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt index 7c9ebeb8d..5f134eb32 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt @@ -24,10 +24,11 @@ package eu.opencloud.android.usecases.transfers.uploads import android.net.Uri import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import eu.opencloud.android.domain.BaseUseCase import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.workers.RemoveSourceFileWorker @@ -39,17 +40,21 @@ class UploadFileFromContentUriUseCase( ) : BaseUseCase() { override fun run(params: Params) { - val inputDataUploadFileFromContentUriWorker = workDataOf( - UploadFileFromContentUriWorker.KEY_PARAM_ACCOUNT_NAME to params.accountName, - UploadFileFromContentUriWorker.KEY_PARAM_BEHAVIOR to params.behavior, - UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to params.contentUri.toString(), - UploadFileFromContentUriWorker.KEY_PARAM_LAST_MODIFIED to params.lastModifiedInSeconds, - UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_PATH to params.uploadPath, - UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_ID to params.uploadIdInStorageManager - ) - val inputDataRemoveSourceFileWorker = workDataOf( - UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to params.contentUri.toString(), - ) + val inputDataUploadFileFromContentUriWorker = Data.Builder() + .putString(UploadFileFromContentUriWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName) + .putString(UploadFileFromContentUriWorker.KEY_PARAM_BEHAVIOR, params.behavior) + .putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString()) + .putString(UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath) + .putLong(UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager) + .apply { + params.lastModifiedInSeconds?.let { + putString(UploadFileFromContentUriWorker.KEY_PARAM_LAST_MODIFIED, it) + } + } + .build() + val inputDataRemoveSourceFileWorker = Data.Builder() + .putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString()) + .build() val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED val constraints = Constraints.Builder() @@ -64,25 +69,35 @@ class UploadFileFromContentUriUseCase( .addTag(params.uploadIdInStorageManager.toString()) .build() + // Use unique work name based on upload ID to prevent concurrent uploads of same file + val uniqueWorkName = "upload_content_uri_${params.uploadIdInStorageManager}" + val behavior = UploadBehavior.fromString(params.behavior) if (behavior == UploadBehavior.MOVE) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() .setInputData(inputDataRemoveSourceFileWorker) .build() - workManager.beginWith(uploadFileFromContentUriWorker) - .then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE - .enqueue() + workManager.beginUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromContentUriWorker + ).then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE + .enqueue() } else { - workManager.enqueue(uploadFileFromContentUriWorker) + workManager.enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromContentUriWorker + ) } - Timber.i("Plain upload of ${params.contentUri.path} has been enqueued.") + Timber.i("Plain upload of ${params.contentUri.path} has been enqueued with unique work name: $uniqueWorkName") } data class Params( val accountName: String, val contentUri: Uri, - val lastModifiedInSeconds: String, + val lastModifiedInSeconds: String?, val behavior: String, val uploadPath: String, val uploadIdInStorageManager: Long, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt index eadb436fd..0d83af0aa 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt @@ -22,10 +22,11 @@ package eu.opencloud.android.usecases.transfers.uploads import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import eu.opencloud.android.domain.BaseUseCase import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.workers.RemoveSourceFileWorker @@ -38,17 +39,23 @@ class UploadFileFromSystemUseCase( ) : BaseUseCase() { override fun run(params: Params) { - val inputDataUploadFileFromFileSystemWorker = workDataOf( - UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME to params.accountName, - UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR to params.behavior, - UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH to params.localPath, - UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED to params.lastModifiedInSeconds, - UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH to params.uploadPath, - UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID to params.uploadIdInStorageManager - ) - val inputDataRemoveSourceFileWorker = workDataOf( - UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to params.sourcePath, - ) + val inputDataUploadFileFromFileSystemWorker = Data.Builder() + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR, params.behavior) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH, params.localPath) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath) + .putLong(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager) + .apply { + params.lastModifiedInSeconds?.let { + putString(UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED, it) + } + } + .build() + val inputDataRemoveSourceFileWorker = Data.Builder().apply { + params.sourcePath?.let { + putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, it) + } + }.build() val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -61,25 +68,35 @@ class UploadFileFromSystemUseCase( .addTag(params.uploadIdInStorageManager.toString()) .build() + // Use unique work name based on upload ID to prevent concurrent uploads of same file + val uniqueWorkName = "upload_file_system_${params.uploadIdInStorageManager}" + val behavior = UploadBehavior.fromString(params.behavior) - if (behavior == UploadBehavior.MOVE) { + if (behavior == UploadBehavior.MOVE && params.sourcePath != null) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() .setInputData(inputDataRemoveSourceFileWorker) .build() - workManager.beginWith(uploadFileFromSystemWorker) - .then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE - .enqueue() + workManager.beginUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromSystemWorker + ).then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE + .enqueue() } else { - workManager.enqueue(uploadFileFromSystemWorker) + workManager.enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromSystemWorker + ) } - Timber.i("Plain upload of ${params.localPath} has been enqueued.") + Timber.i("Plain upload of ${params.localPath} has been enqueued with unique work name: $uniqueWorkName") } data class Params( val accountName: String, val localPath: String, - val lastModifiedInSeconds: String, + val lastModifiedInSeconds: String?, val behavior: String, val uploadPath: String, val uploadIdInStorageManager: Long, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt index 5683ae64e..d1de775e5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt @@ -23,6 +23,7 @@ package eu.opencloud.android.usecases.transfers.uploads import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager @@ -126,8 +127,14 @@ class UploadFileInConflictUseCase( .addTag(uploadIdInStorageManager.toString()) .build() - workManager.enqueue(uploadFileFromContentUriWorker) - Timber.i("Plain upload of $localPath has been enqueued.") + // Use unique work name based on upload ID to prevent concurrent uploads of same file + val uniqueWorkName = "upload_conflict_${uploadIdInStorageManager}" + workManager.enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromContentUriWorker + ) + Timber.i("Plain upload of $localPath has been enqueued with unique work name: $uniqueWorkName") return uploadFileFromContentUriWorker.id } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt new file mode 100644 index 000000000..5a02ed128 --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -0,0 +1,292 @@ +package eu.opencloud.android.workers + +import eu.opencloud.android.data.executeRemoteOperation +import eu.opencloud.android.domain.capabilities.model.OCCapability +import eu.opencloud.android.domain.transfers.TransferRepository +import eu.opencloud.android.domain.transfers.model.OCTransfer +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener +import eu.opencloud.android.lib.common.network.WebdavUtils +import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation +import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.GetTusUploadOffsetRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.PatchTusUploadChunkRemoteOperation +import timber.log.Timber +import java.io.File +import kotlin.math.min + +/** + * Shared helper encapsulating the TUS upload flow so workers can reuse the same implementation. + */ +class TusUploadHelper( + private val transferRepository: TransferRepository, +) { + + /** + * Runs the full TUS upload flow. On success the method returns normally. On failure an exception + * is thrown so the caller can decide whether to retry or surface the error. + */ + @Throws(Exception::class) + fun upload( + client: OpenCloudClient, + transfer: OCTransfer, + uploadId: Long, + localPath: String, + remotePath: String, + fileSize: Long, + mimeType: String, + lastModified: String?, + tusSupport: OCCapability.TusSupport?, + progressListener: OnDatatransferProgressListener?, + progressCallback: ((Long, Long) -> Unit)? = null, + spaceWebDavUrl: String? = null, + ) { + Timber.d("TUS: starting upload for %s size=%d", remotePath, fileSize) + + var tusUrl = transfer.tusUploadUrl + val checksumHex = transfer.tusUploadChecksum?.substringAfter("sha256:") + + if (tusUrl.isNullOrBlank()) { + val fileName = File(remotePath).name + val metadata = linkedMapOf( + "filename" to fileName, + "mimetype" to mimeType, + ) + lastModified?.takeIf { it.isNotBlank() }?.let { metadata["mtime"] = it } + checksumHex?.let { metadata["checksum"] = "sha256 $it" } + + Timber.d( + "TUS: creating upload resource filename=%s size=%d metadata=%s", + fileName, + fileSize, + metadata + ) + + val collectionUrl = resolveTusCollectionUrl( + client = client, + remotePath = remotePath, + spaceWebDavUrl = spaceWebDavUrl + ) + + // Use creation-with-upload like the browser does for OpenCloud compatibility + val firstChunkSize = minOf(CreateTusUploadRemoteOperation.DEFAULT_FIRST_CHUNK, fileSize) + val createdLocation = executeRemoteOperation { + CreateTusUploadRemoteOperation( + file = File(localPath), + remotePath = remotePath, + mimetype = mimeType, + metadata = metadata, + useCreationWithUpload = true, + firstChunkSize = firstChunkSize, + tusUrl = "", + collectionUrlOverride = collectionUrl, + ).execute(client) + } + + if (createdLocation.isNullOrBlank()) { + throw IllegalStateException("TUS: unable to create upload resource for $remotePath") + } + + tusUrl = createdLocation + val metadataString = metadata.entries.joinToString(";") { (key, value) -> "$key=$value" } + + transferRepository.updateTusState( + id = uploadId, + tusUploadUrl = tusUrl, + tusUploadLength = fileSize, + tusUploadMetadata = metadataString, + tusUploadChecksum = checksumHex?.let { "sha256:$it" }, + tusResumableVersion = "1.0.0", + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + val resolvedTusUrl = tusUrl ?: throw IllegalStateException("TUS: missing upload URL for $remotePath") + + var offset = runCatching { + executeRemoteOperation { + GetTusUploadOffsetRemoteOperation(resolvedTusUrl).execute(client) + } + }.onFailure { throwable -> + Timber.w(throwable, "TUS: failed to fetch current offset") + if (throwable is java.io.IOException) { + throw throwable + } + }.getOrDefault(0L) + .coerceAtLeast(0L) + Timber.d("TUS: resume offset %d / %d", offset, fileSize) + progressCallback?.invoke(offset, fileSize) + + val serverMaxChunk = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong() + val httpOverride = tusSupport?.httpMethodOverride + var consecutiveFailures = 0 + + while (offset < fileSize) { + val remaining = fileSize - offset + val chunkSize = min(DEFAULT_CHUNK_SIZE, min(remaining, serverMaxChunk ?: Long.MAX_VALUE)) + Timber.d("TUS: uploading chunk=%d at offset=%d remaining=%d", chunkSize, offset, remaining) + + val patchOperation = PatchTusUploadChunkRemoteOperation( + localPath = localPath, + uploadUrl = resolvedTusUrl, + offset = offset, + chunkSize = chunkSize, + httpMethodOverride = httpOverride, + ).apply { + progressListener?.let { addDataTransferProgressListener(it) } + } + + val patchResult = patchOperation.execute(client) + if (!patchResult.isSuccess || patchResult.data == null || patchResult.data!! < offset) { + consecutiveFailures++ + Timber.w( + "TUS: PATCH failed at offset %d (retry %d/%d)", + offset, + consecutiveFailures, + MAX_RETRIES + ) + + // Try to recover the offset from server + val recoveredOffset = tryRecoverOffset( + client = client, + tusUrl = resolvedTusUrl, + currentOffset = offset, + totalSize = fileSize, + progressCallback = progressCallback, + ) + + if (recoveredOffset != null && recoveredOffset > offset) { + // Server has progressed beyond our current offset, update and reset retry counter + Timber.d("TUS: server advanced from %d to %d, continuing", offset, recoveredOffset) + offset = recoveredOffset + consecutiveFailures = 0 + continue + } else if (recoveredOffset != null && recoveredOffset == offset) { + // Server is at same offset, we need to retry the same chunk + Timber.d("TUS: server confirmed offset %d, will retry same chunk", offset) + // Don't update offset, will retry after backoff + } else { + // Recovery failed or returned invalid offset + Timber.w("TUS: offset recovery failed (recovered=%s, current=%d)", recoveredOffset, offset) + } + + // Check if we've exhausted retries + if (consecutiveFailures >= MAX_RETRIES) { + throw java.io.IOException( + "TUS: giving up after $MAX_RETRIES retries at offset $offset (network error)", + IllegalStateException("TUS: max retries exceeded") + ) + } + + // Exponential backoff before retry + val delayMs = min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS shl (consecutiveFailures - 1)) + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw java.io.IOException( + "TUS: interrupted while retrying at offset $offset", + InterruptedException("TUS retry interrupted") + ) + } + continue + } + + // Success - validate the returned offset + val newOffset = patchResult.data!! + if (newOffset < offset) { + Timber.e("TUS: server returned offset %d less than current %d, upload corrupted", newOffset, offset) + throw java.io.IOException("TUS: server offset went backwards from $offset to $newOffset") + } + if (newOffset > fileSize) { + Timber.e("TUS: server returned offset %d exceeds file size %d", newOffset, fileSize) + throw java.io.IOException("TUS: server offset $newOffset exceeds file size $fileSize") + } + + offset = newOffset + progressCallback?.invoke(offset, fileSize) + consecutiveFailures = 0 + } + + // Verify upload is actually complete + if (offset != fileSize) { + Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize) + throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize") + } + + transferRepository.updateTusState( + id = uploadId, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + Timber.i("TUS: upload completed for %s (size=%d)", remotePath, fileSize) + } + + private fun resolveTusCollectionUrl( + client: OpenCloudClient, + remotePath: String, + spaceWebDavUrl: String?, + ): String { + // For OpenCloud, TUS works on the WebDAV space endpoint + // Use the space WebDAV URL if available, otherwise fall back to user files + val base = (spaceWebDavUrl?.takeIf { it.isNotBlank() } + ?: client.userFilesWebDavUri.toString()).trim() + + // Use the space root directly for TUS (no trailing slash for OpenCloud) + val normalizedBase = base.trimEnd('/') + + Timber.d("TUS: using collection endpoint: %s", normalizedBase) + + return normalizedBase + } + + private fun tryRecoverOffset( + client: OpenCloudClient, + tusUrl: String, + currentOffset: Long, + totalSize: Long, + progressCallback: ((Long, Long) -> Unit)?, + ): Long? { + return try { + val newOffset = executeRemoteOperation { + GetTusUploadOffsetRemoteOperation(tusUrl).execute(client) + } + if (newOffset >= 0 && newOffset <= totalSize) { + if (newOffset > currentOffset) { + // Server has advanced beyond our position + progressCallback?.invoke(newOffset, totalSize) + Timber.d("TUS: recovered offset %d (was %d)", newOffset, currentOffset) + } else if (newOffset == currentOffset) { + // Server is at same position, return it to confirm + Timber.d("TUS: server confirmed current offset %d", currentOffset) + } else { + // Server is behind our position - should not happen + Timber.w("TUS: server offset %d is behind current %d", newOffset, currentOffset) + } + newOffset + } else { + Timber.w("TUS: invalid recovered offset %d (total=%d)", newOffset, totalSize) + null + } + } catch (recoverError: Throwable) { + Timber.w(recoverError, "TUS: recover offset failed") + if (recoverError is java.io.IOException) { + throw recoverError + } + null + } + } + + companion object { + const val DEFAULT_CHUNK_SIZE = ChunkedUploadFromFileSystemOperation.CHUNK_SIZE + private const val MAX_RETRIES = 5 + private const val BASE_RETRY_DELAY_MS = 250L + private const val MAX_RETRY_DELAY_MS = 2_000L + } +} 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 8b3cb51dc..734a101ab 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -34,10 +34,8 @@ import eu.opencloud.android.R import eu.opencloud.android.data.executeRemoteOperation import eu.opencloud.android.data.providers.LocalStorageProvider import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior -import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.UnauthorizedException -import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.domain.transfers.model.OCTransfer @@ -45,6 +43,7 @@ import eu.opencloud.android.domain.transfers.model.TransferResult import eu.opencloud.android.domain.transfers.model.TransferStatus import eu.opencloud.android.extensions.isContentUri import eu.opencloud.android.extensions.parseError +import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.lib.common.OpenCloudAccount import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.SingleSessionManager @@ -52,15 +51,10 @@ 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.FileUtils import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation.Companion.CHUNK_SIZE -import eu.opencloud.android.lib.resources.files.services.implementation.OCChunkService import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath -import eu.opencloud.android.utils.SecurityUtils import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -70,6 +64,9 @@ import org.koin.core.component.inject import timber.log.Timber import java.io.File import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import kotlin.coroutines.cancellation.CancellationException class UploadFileFromContentUriWorker( private val appContext: Context, @@ -81,7 +78,7 @@ class UploadFileFromContentUriWorker( private lateinit var account: Account private lateinit var contentUri: Uri - private lateinit var lastModified: String + private var lastModified: String = "" private lateinit var behavior: UploadBehavior private lateinit var uploadPath: String private lateinit var cachePath: String @@ -92,30 +89,17 @@ class UploadFileFromContentUriWorker( private var spaceWebDavUrl: String? = null private lateinit var uploadFileOperation: UploadFileFromFileSystemOperation + private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) } private var lastPercent = 0 private val transferRepository: TransferRepository by inject() private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() + private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() override suspend fun doWork(): Result { - - if (!areParametersValid()) return Result.failure() - - transferRepository.updateTransferStatusToInProgressById(uploadIdInStorageManager) - - spaceWebDavUrl = - getWebdavUrlForSpaceUseCase(GetWebDavUrlForSpaceUseCase.Params(accountName = account.name, spaceId = ocTransfer.spaceId)) - - val localStorageProvider: LocalStorageProvider by inject() - cachePath = localStorageProvider.getTemporalPath(account.name, ocTransfer.spaceId) + uploadPath - return try { - if (ocTransfer.isContentUri(appContext)) { - checkDocumentFileExists() - checkPermissionsToReadDocumentAreGranted() - copyFileToLocalStorage() - } + prepareFile() val clientForThisUpload = getClientForThisUpload() checkParentFolderExistence(clientForThisUpload) checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload) @@ -124,12 +108,36 @@ class UploadFileFromContentUriWorker( Result.success() } catch (throwable: Throwable) { Timber.e(throwable) + + if (shouldRetry(throwable)) { + Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager) + return Result.retry() + } + showNotification(throwable) updateUploadsDatabaseWithResult(throwable) Result.failure() } } + private fun prepareFile() { + if (!areParametersValid()) return + + transferRepository.updateTransferStatusToInProgressById(uploadIdInStorageManager) + + spaceWebDavUrl = + getWebdavUrlForSpaceUseCase(GetWebDavUrlForSpaceUseCase.Params(accountName = account.name, spaceId = ocTransfer.spaceId)) + + val localStorageProvider: LocalStorageProvider by inject() + cachePath = localStorageProvider.getTemporalPath(account.name, ocTransfer.spaceId) + uploadPath + + if (ocTransfer.isContentUri(appContext)) { + checkDocumentFileExists() + checkPermissionsToReadDocumentAreGranted() + copyFileToLocalStorage() + } + } + private fun areParametersValid(): Boolean { val paramAccountName = workerParameters.inputData.getString(KEY_PARAM_ACCOUNT_NAME) val paramUploadPath = workerParameters.inputData.getString(KEY_PARAM_UPLOAD_PATH) @@ -142,7 +150,7 @@ class UploadFileFromContentUriWorker( contentUri = paramContentUri?.toUri() ?: return false uploadPath = paramUploadPath ?: return false behavior = paramBehavior?.let { UploadBehavior.fromString(it) } ?: return false - lastModified = paramLastModified ?: return false + lastModified = paramLastModified.orEmpty() uploadIdInStorageManager = paramUploadId ocTransfer = retrieveUploadInfoFromDatabase() ?: return false @@ -177,6 +185,7 @@ class UploadFileFromContentUriWorker( } private fun copyFileToLocalStorage() { + val documentFile = DocumentFile.fromSingleUri(appContext, contentUri) val cacheFile = File(cachePath) val cacheDir = cacheFile.parentFile if (cacheDir != null && !cacheDir.exists()) { @@ -194,6 +203,20 @@ class UploadFileFromContentUriWorker( transferRepository.updateTransferSourcePath(uploadIdInStorageManager, contentUri.toString()) transferRepository.updateTransferLocalPath(uploadIdInStorageManager, cachePath) + + ensureValidLastModified(documentFile, cacheFile) + } + + private fun ensureValidLastModified(documentFile: DocumentFile?, cachedFile: File) { + val current = lastModified.toLongOrNull() + if (current != null && current > 0) { + return + } + + val documentMillis = documentFile?.lastModified()?.takeIf { it > 0 } + val fileMillis = cachedFile.lastModified().takeIf { it > 0 } + val fallbackMillis = documentMillis ?: fileMillis ?: System.currentTimeMillis() + lastModified = (fallbackMillis / 1000L).toString() } private fun getClientForThisUpload(): OpenCloudClient = @@ -242,21 +265,72 @@ class UploadFileFromContentUriWorker( val cacheFile = File(cachePath) mimeType = cacheFile.extension fileSize = cacheFile.length() + ensureValidLastModified(null, cacheFile) - val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() val capabilitiesForAccount = getStoredCapabilitiesUseCase( GetStoredCapabilitiesUseCase.Params( accountName = account.name ) ) - val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed() - Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE) + val tusSupport = capabilitiesForAccount?.filesTusSupport + val supportsTus = tusSupport != null + + 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( + client = client, + transfer = ocTransfer, + uploadId = uploadIdInStorageManager, + localPath = cachePath, + remotePath = uploadPath, + fileSize = fileSize, + mimeType = mimeType, + lastModified = null, + tusSupport = tusSupport, + progressListener = this, + progressCallback = ::updateProgressFromTus, + spaceWebDavUrl = spaceWebDavUrl, + ) + true + } catch (throwable: Throwable) { + Timber.w(throwable, "TUS upload failed, falling back to single PUT") + if (shouldRetry(throwable)) { + throw throwable + } + false + } - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { - uploadChunkedFile(client) + if (tusSucceeded) { + removeCacheFile() + Timber.d("TUS upload completed for %s", uploadPath) + return + } } else { - uploadPlainFile(client) + Timber.d( + "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", + fileSize, + TusUploadHelper.DEFAULT_CHUNK_SIZE, + supportsTus + ) + } + + if (attemptedTus) { + clearTusState() } + + Timber.d("Falling back to single PUT upload for %s", uploadPath) + uploadPlainFile(client) removeCacheFile() } @@ -275,38 +349,16 @@ class UploadFileFromContentUriWorker( executeRemoteOperation { uploadFileOperation.execute(client) } } - private fun uploadChunkedFile(client: OpenCloudClient) { - val immutableHashForChunkedFile = SecurityUtils.stringToMD5Hash(uploadPath) + System.currentTimeMillis() - // Step 1: Create folder where the chunks will be uploaded. - val createChunksRemoteFolderOperation = CreateRemoteFolderOperation( - remotePath = immutableHashForChunkedFile, - createFullPath = false, - isChunksFolder = true - ) - executeRemoteOperation { createChunksRemoteFolderOperation.execute(client) } + private fun updateProgressFromTus(offset: Long, totalSize: Long) { + if (totalSize <= 0) return + val percent: Int = (100.0 * offset.toDouble() / totalSize.toDouble()).toInt() + if (percent == lastPercent) return - // Step 2: Upload file by chunks - uploadFileOperation = ChunkedUploadFromFileSystemOperation( - transferId = immutableHashForChunkedFile, - localPath = cachePath, - remotePath = uploadPath, - mimeType = mimeType, - lastModifiedTimestamp = lastModified, - requiredEtag = null, - ).apply { - addDataTransferProgressListener(this@UploadFileFromContentUriWorker) + CoroutineScope(Dispatchers.IO).launch { + val progress = workDataOf(DownloadFileWorker.WORKER_KEY_PROGRESS to percent) + setProgress(progress) } - - executeRemoteOperation { uploadFileOperation.execute(client) } - - // Step 3: Move remote file to the final remote destination - val ocChunkService = OCChunkService(client) - ocChunkService.moveFile( - sourceRemotePath = "${immutableHashForChunkedFile}${OCFile.PATH_SEPARATOR}${FileUtils.FINAL_CHUNKS_FILE}", - targetRemotePath = uploadPath, - fileLastModificationTimestamp = lastModified, - fileLength = fileSize - ) + lastPercent = percent } private fun removeCacheFile() { @@ -314,6 +366,27 @@ class UploadFileFromContentUriWorker( cacheFile.delete() } + private fun clearTusState() { + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + private fun shouldRetry(throwable: Throwable?): Boolean { + if (throwable == null) return false + if (throwable is UnauthorizedException || throwable is LocalFileNotFoundException) return false + if (throwable is CancellationException) return true + if (throwable is IOException) return true + return shouldRetry(throwable.cause) + } + private fun updateUploadsDatabaseWithResult(throwable: Throwable?) { transferRepository.updateTransferWhenFinished( id = uploadIdInStorageManager, 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 8bbe0ae48..60ef81fd8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -33,7 +33,6 @@ import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.UnauthorizedException -import eu.opencloud.android.domain.files.model.OCFile.Companion.PATH_SEPARATOR import eu.opencloud.android.domain.files.usecases.CleanConflictUseCase import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase @@ -50,15 +49,10 @@ 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.FileUtils import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation.Companion.CHUNK_SIZE -import eu.opencloud.android.lib.resources.files.services.implementation.OCChunkService import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath -import eu.opencloud.android.utils.SecurityUtils import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -67,6 +61,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.io.File +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException class UploadFileFromFileSystemWorker( private val appContext: Context, @@ -78,11 +74,11 @@ class UploadFileFromFileSystemWorker( private lateinit var account: Account private lateinit var fileSystemPath: String - private lateinit var lastModified: String + private var lastModified: String = "" private lateinit var behavior: UploadBehavior private lateinit var uploadPath: String private lateinit var mimetype: String - private var removeLocal: Boolean = true + private var removeLocal: Boolean = false private var uploadIdInStorageManager: Long = -1 private lateinit var ocTransfer: OCTransfer private var fileSize: Long = 0 @@ -99,6 +95,7 @@ class UploadFileFromFileSystemWorker( private var lastPercent = 0 private val transferRepository: TransferRepository by inject() + private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) } override suspend fun doWork(): Result { @@ -120,6 +117,12 @@ class UploadFileFromFileSystemWorker( Result.success() } catch (throwable: Throwable) { Timber.e(throwable) + + if (shouldRetry(throwable)) { + Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager) + return Result.retry() + } + showNotification(throwable) updateUploadsDatabaseWithResult(throwable) Result.failure() @@ -133,13 +136,13 @@ class UploadFileFromFileSystemWorker( val paramBehavior = workerParameters.inputData.getString(KEY_PARAM_BEHAVIOR) val paramFileSystemUri = workerParameters.inputData.getString(KEY_PARAM_LOCAL_PATH) val paramUploadId = workerParameters.inputData.getLong(KEY_PARAM_UPLOAD_ID, -1) - val paramRemoveLocal = workerParameters.inputData.getBoolean(KEY_PARAM_REMOVE_LOCAL, true) + val paramRemoveLocal = workerParameters.inputData.getBoolean(KEY_PARAM_REMOVE_LOCAL, false) account = AccountUtils.getOpenCloudAccountByName(appContext, paramAccountName) ?: return false fileSystemPath = paramFileSystemUri.takeUnless { it.isNullOrBlank() } ?: return false uploadPath = paramUploadPath ?: return false behavior = paramBehavior?.let { UploadBehavior.valueOf(it) } ?: return false - lastModified = paramLastModified ?: return false + lastModified = paramLastModified.orEmpty() uploadIdInStorageManager = paramUploadId.takeUnless { it == -1L } ?: return false ocTransfer = retrieveUploadInfoFromDatabase() ?: return false removeLocal = paramRemoveLocal @@ -166,6 +169,18 @@ class UploadFileFromFileSystemWorker( } mimetype = fileInFileSystem.extension fileSize = fileInFileSystem.length() + ensureValidLastModified(fileInFileSystem) + } + + private fun ensureValidLastModified(sourceFile: File) { + val current = lastModified.toLongOrNull() + if (current != null && current > 0) { + return + } + + val fallbackMillis = sourceFile.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis() + lastModified = (fallbackMillis / 1000L).toString() } private fun getClientForThisUpload(): OpenCloudClient = @@ -229,14 +244,67 @@ class UploadFileFromFileSystemWorker( accountName = account.name ) ) - val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed() - Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE) + val tusSupport = capabilitiesForAccount?.filesTusSupport + val supportsTus = tusSupport != null + + 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( + client = client, + transfer = ocTransfer, + uploadId = uploadIdInStorageManager, + localPath = fileSystemPath, + remotePath = uploadPath, + fileSize = fileSize, + mimeType = mimetype, + lastModified = lastModified, + tusSupport = tusSupport, + progressListener = this, + progressCallback = ::updateProgressFromTus, + spaceWebDavUrl = spaceWebDavUrl, + ) + true + } catch (throwable: Throwable) { + Timber.w(throwable, "TUS upload failed, falling back to single PUT") + if (shouldRetry(throwable)) { + throw throwable + } + false + } - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { - uploadChunkedFile(client) + if (tusSucceeded) { + if (removeLocal) { + removeLocalFile() + } + Timber.d("TUS upload completed for %s", uploadPath) + return + } } else { - uploadPlainFile(client) + Timber.d( + "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", + fileSize, + TusUploadHelper.DEFAULT_CHUNK_SIZE, + supportsTus + ) } + + if (attemptedTus) { + clearTusState() + } + + Timber.d("Falling back to single PUT upload for %s", uploadPath) + uploadPlainFile(client) } private fun uploadPlainFile(client: OpenCloudClient) { @@ -258,43 +326,16 @@ class UploadFileFromFileSystemWorker( } } - private fun uploadChunkedFile(client: OpenCloudClient) { - val immutableHashForChunkedFile = SecurityUtils.stringToMD5Hash(uploadPath) + System.currentTimeMillis() - // Step 1: Create folder where the chunks will be uploaded. - val createChunksRemoteFolderOperation = CreateRemoteFolderOperation( - remotePath = immutableHashForChunkedFile, - createFullPath = false, - isChunksFolder = true - ) - executeRemoteOperation { createChunksRemoteFolderOperation.execute(client) } - - // Step 2: Upload file by chunks - uploadFileOperation = ChunkedUploadFromFileSystemOperation( - transferId = immutableHashForChunkedFile, - localPath = fileSystemPath, - remotePath = uploadPath, - mimeType = mimetype, - lastModifiedTimestamp = lastModified, - requiredEtag = eTagInConflict, - ).apply { - addDataTransferProgressListener(this@UploadFileFromFileSystemWorker) - } - - val result = executeRemoteOperation { uploadFileOperation.execute(client) } - - // Step 3: Move remote file to the final remote destination - val ocChunkService = OCChunkService(client) - ocChunkService.moveFile( - sourceRemotePath = "$immutableHashForChunkedFile$PATH_SEPARATOR${FileUtils.FINAL_CHUNKS_FILE}", - targetRemotePath = uploadPath, - fileLastModificationTimestamp = lastModified, - fileLength = fileSize - ) + private fun updateProgressFromTus(offset: Long, totalSize: Long) { + if (totalSize <= 0) return + val percent: Int = (100.0 * offset.toDouble() / totalSize.toDouble()).toInt() + if (percent == lastPercent) return - // Step 4: Remove tmp file folder after uploading - if (result == Unit && removeLocal) { - removeLocalFile() + CoroutineScope(Dispatchers.IO).launch { + val progress = workDataOf(DownloadFileWorker.WORKER_KEY_PROGRESS to percent) + setProgress(progress) } + lastPercent = percent } private fun removeLocalFile() { @@ -302,6 +343,27 @@ class UploadFileFromFileSystemWorker( Timber.d("File with path: $fileSystemPath has been removed: $fileDeleted after uploading.") } + private fun clearTusState() { + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + private fun shouldRetry(throwable: Throwable?): Boolean { + if (throwable == null) return false + if (throwable is LocalFileNotFoundException) return false + if (throwable is CancellationException) return true + if (throwable is IOException) return true + return shouldRetry(throwable.cause) + } + private fun updateUploadsDatabaseWithResult(throwable: Throwable?) { transferRepository.updateTransferWhenFinished( id = uploadIdInStorageManager, @@ -397,12 +459,12 @@ class UploadFileFromFileSystemWorker( } companion object { - const val KEY_PARAM_ACCOUNT_NAME = "KEY_PARAM_ACCOUNT_NAME" - const val KEY_PARAM_BEHAVIOR = "KEY_PARAM_BEHAVIOR" - const val KEY_PARAM_LOCAL_PATH = "KEY_PARAM_LOCAL_PATH" - const val KEY_PARAM_LAST_MODIFIED = "KEY_PARAM_LAST_MODIFIED" - const val KEY_PARAM_UPLOAD_PATH = "KEY_PARAM_UPLOAD_PATH" - const val KEY_PARAM_UPLOAD_ID = "KEY_PARAM_UPLOAD_ID" - const val KEY_PARAM_REMOVE_LOCAL = "KEY_REMOVE_LOCAL" + const val KEY_PARAM_ACCOUNT_NAME: String = "KEY_PARAM_ACCOUNT_NAME" + const val KEY_PARAM_BEHAVIOR: String = "KEY_PARAM_BEHAVIOR" + const val KEY_PARAM_LOCAL_PATH: String = "KEY_PARAM_LOCAL_PATH" + const val KEY_PARAM_LAST_MODIFIED: String = "KEY_PARAM_LAST_MODIFIED" + const val KEY_PARAM_UPLOAD_PATH: String = "KEY_PARAM_UPLOAD_PATH" + const val KEY_PARAM_UPLOAD_ID: String = "KEY_PARAM_UPLOAD_ID" + const val KEY_PARAM_REMOVE_LOCAL: String = "KEY_REMOVE_LOCAL" } } diff --git a/opencloudComLibrary/build.gradle b/opencloudComLibrary/build.gradle index 4fc141d6c..61889c9d3 100644 --- a/opencloudComLibrary/build.gradle +++ b/opencloudComLibrary/build.gradle @@ -24,7 +24,11 @@ dependencies { implementation libs.timber testImplementation 'junit:junit:4.13.2' - testImplementation 'org.robolectric:robolectric:4.10' + testImplementation 'org.robolectric:robolectric:4.15.1' + // MockWebServer for HTTP integration tests + testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2' + // AndroidX test core to obtain application context in unit tests + testImplementation 'androidx.test:core:1.5.0' debugImplementation 'com.facebook.stetho:stetho-okhttp3:1.6.0' // Detekt diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java index c3ee6117f..8a399a265 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java @@ -191,7 +191,9 @@ public Uri getUserFilesWebDavUri() { } public Uri getUploadsWebDavUri() { - return mCredentials instanceof OpenCloudAnonymousCredentials + // Always include the userId segment when an account is present to avoid permission issues + // on servers that scope the uploads collection under the user path. + return (mAccount == null) ? Uri.parse(mBaseUri + WEBDAV_UPLOADS_PATH_4_0) : Uri.parse(mBaseUri + WEBDAV_UPLOADS_PATH_4_0 + AccountUtils.getUserId( mAccount.getSavedAccount(), getContext() diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java index 8f24c378b..6bf34c99b 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpConstants.java @@ -45,6 +45,7 @@ public class HttpConstants { public static final String OC_TOTAL_LENGTH_HEADER = "OC-Total-Length"; public static final String OC_X_OC_MTIME_HEADER = "X-OC-Mtime"; public static final String OC_X_REQUEST_ID = "X-Request-ID"; + public static final String X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override"; public static final String LOCATION_HEADER = "Location"; public static final String LOCATION_HEADER_LOWER = "location"; public static final String CONTENT_TYPE_URLENCODED_UTF8 = "application/x-www-form-urlencoded; charset=utf-8"; @@ -52,6 +53,19 @@ public class HttpConstants { public static final String ACCEPT_ENCODING_IDENTITY = "identity"; public static final String OC_FILE_REMOTE_ID = "OC-FileId"; + // TUS protocol headers + public static final String TUS_RESUMABLE = "Tus-Resumable"; + public static final String TUS_VERSION = "Tus-Version"; + public static final String TUS_EXTENSION = "Tus-Extension"; + public static final String TUS_MAX_SIZE = "Tus-Max-Size"; + public static final String UPLOAD_OFFSET = "Upload-Offset"; + public static final String UPLOAD_LENGTH = "Upload-Length"; + public static final String UPLOAD_METADATA = "Upload-Metadata"; + public static final String UPLOAD_DEFER_LENGTH = "Upload-Defer-Length"; + public static final String UPLOAD_CONCAT = "Upload-Concat"; + public static final String UPLOAD_CHECKSUM = "Upload-Checksum"; + public static final String UPLOAD_EXPIRES = "Upload-Expires"; + // OAuth public static final String OAUTH_HEADER_AUTHORIZATION_CODE = "code"; public static final String OAUTH_HEADER_GRANT_TYPE = "grant_type"; @@ -70,6 +84,7 @@ public class HttpConstants { public static final String CONTENT_TYPE_JSON = "application/json"; public static final String CONTENT_TYPE_WWW_FORM = "application/x-www-form-urlencoded"; public static final String CONTENT_TYPE_JRD_JSON = "application/jrd+json"; + public static final String CONTENT_TYPE_OFFSET_OCTET_STREAM = "application/offset+octet-stream"; /*********************************************************************************************************** ************************************************ ARGUMENTS NAMES ******************************************** @@ -82,6 +97,7 @@ public class HttpConstants { ***********************************************************************************************************/ public static final String VALUE_FORMAT = "json"; + public static final String TUS_RESUMABLE_VERSION_1_0_0 = "1.0.0"; /*********************************************************************************************************** ************************************************ STATUS CODES ********************************************* @@ -137,6 +153,8 @@ public class HttpConstants { public static final int HTTP_USE_PROXY = 305; // 307 Temporary Redirect (HTTP/1.1 - RFC 2616) public static final int HTTP_TEMPORARY_REDIRECT = 307; + // 308 Permanent Redirect (HTTP/1.1 - RFC 7538) + public static final int HTTP_PERMANENT_REDIRECT = 308; /** * 4xx Client Error diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/HeadMethod.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/HeadMethod.kt new file mode 100644 index 000000000..a29028283 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/HeadMethod.kt @@ -0,0 +1,18 @@ +package eu.opencloud.android.lib.common.http.methods.nonwebdav + +import okhttp3.OkHttpClient +import java.io.IOException +import java.net.URL + +/** + * OkHttp HEAD calls wrapper + */ +class HeadMethod(url: URL) : HttpMethod(url) { + @Throws(IOException::class) + override fun onExecute(okHttpClient: OkHttpClient): Int { + request = request.newBuilder() + .head() + .build() + return super.onExecute(okHttpClient) + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/OptionsMethod.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/OptionsMethod.kt new file mode 100644 index 000000000..95878353e --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/OptionsMethod.kt @@ -0,0 +1,18 @@ +package eu.opencloud.android.lib.common.http.methods.nonwebdav + +import okhttp3.OkHttpClient +import java.io.IOException +import java.net.URL + +/** + * OkHttp OPTIONS calls wrapper + */ +class OptionsMethod(url: URL) : HttpMethod(url) { + @Throws(IOException::class) + override fun onExecute(okHttpClient: OkHttpClient): Int { + request = request.newBuilder() + .method("OPTIONS", null) + .build() + return super.onExecute(okHttpClient) + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/PatchMethod.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/PatchMethod.kt new file mode 100644 index 000000000..ef746d268 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/nonwebdav/PatchMethod.kt @@ -0,0 +1,22 @@ +package eu.opencloud.android.lib.common.http.methods.nonwebdav + +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import java.io.IOException +import java.net.URL + +/** + * OkHttp PATCH calls wrapper + */ +class PatchMethod( + url: URL, + private val patchRequestBody: RequestBody +) : HttpMethod(url) { + @Throws(IOException::class) + override fun onExecute(okHttpClient: OkHttpClient): Int { + request = request.newBuilder() + .patch(patchRequestBody) + .build() + return super.onExecute(okHttpClient) + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt index 8a0d817dc..b6c842112 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt @@ -52,7 +52,7 @@ class ChunkFromFileRequestBody( } override fun contentLength(): Long = - chunkSize.coerceAtMost(channel.size() - channel.position()) + chunkSize.coerceAtMost((channel.size() - offset).coerceAtLeast(0)) override fun writeTo(sink: BufferedSink) { var readCount: Int @@ -62,20 +62,27 @@ class ChunkFromFileRequestBody( val maxCount = (offset + chunkSize).coerceAtMost(channel.size()) while (channel.position() < maxCount) { + val remainingForChunk = (maxCount - channel.position()).toInt() + if (remainingForChunk <= 0) break + // limit how much we read so we never consume past the chunk boundary + val toRead = minOf(buffer.capacity(), remainingForChunk) + buffer.limit(toRead) readCount = channel.read(buffer) - val bytesToWriteInBuffer = readCount.toLong().coerceAtMost(file.length() - alreadyTransferred).toInt() - sink.buffer.write(buffer.array(), 0, bytesToWriteInBuffer) + if (readCount == -1) break + sink.buffer.write(buffer.array(), 0, readCount) sink.flush() buffer.clear() - if (alreadyTransferred < maxCount) { // condition to avoid accumulate progress for repeated chunks - alreadyTransferred += readCount.toLong() + if (readCount > 0) { + alreadyTransferred = (alreadyTransferred + readCount.toLong()).coerceAtMost(chunkSize) } + val totalTransferred = offset + alreadyTransferred + synchronized(dataTransferListeners) { iterator = dataTransferListeners.iterator() while (iterator.hasNext()) { - iterator.next().onTransferProgress(readCount.toLong(), alreadyTransferred, file.length(), file.absolutePath) + iterator.next().onTransferProgress(readCount.toLong(), totalTransferred, file.length(), file.absolutePath) } } } @@ -86,6 +93,7 @@ class ChunkFromFileRequestBody( fun setOffset(newOffset: Long) { offset = newOffset + alreadyTransferred = 0 } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java index f9ccc124d..2fad3229c 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java @@ -241,6 +241,10 @@ public RemoteOperationResult(HttpBaseMethod httpMethod) throws IOException { ResultCode.SPECIFIC_METHOD_NOT_ALLOWED ); break; + case HttpConstants.HTTP_PRECONDITION_FAILED: + // For TUS, 412 typically indicates Upload-Offset precondition mismatch + mCode = ResultCode.CONFLICT; + break; case HttpConstants.HTTP_TOO_EARLY: mCode = ResultCode.TOO_EARLY; break; @@ -309,7 +313,7 @@ private RemoteOperationResult(int httpCode, String httpPhrase) { mCode = ResultCode.RESOURCE_LOCKED; break; case HttpConstants.HTTP_INTERNAL_SERVER_ERROR: // 500 - mCode = ResultCode.INSTANCE_NOT_CONFIGURED; // assuming too much... + mCode = ResultCode.UNHANDLED_HTTP_CODE; // treat as generic server error break; case HttpConstants.HTTP_SERVICE_UNAVAILABLE: // 503 mCode = ResultCode.SERVICE_UNAVAILABLE; diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt new file mode 100644 index 000000000..5499fe6c3 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt @@ -0,0 +1,67 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.OptionsMethod +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +/** + * TUS capability detection (OPTIONS on uploads collection) + * Returns true when the server advertises Tus-Version 1.0.0 and the 'creation' extension. + */ +class CheckTusSupportRemoteOperation( + private val collectionUrlOverride: String? = null, +) : RemoteOperation() { + + override fun run(client: OpenCloudClient): RemoteOperationResult { + return try { + val base = (collectionUrlOverride ?: client.userFilesWebDavUri.toString()).trim() + val candidates = linkedSetOf(base, base.ensureTrailingSlash()) + var lastResult: RemoteOperationResult? = null + + for (endpoint in candidates) { + val options = OptionsMethod(URL(endpoint)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + val status = client.executeHttpMethod(options) + Timber.d("TUS OPTIONS %s - %d", endpoint, status) + if (isSuccess(status)) { + val version = options.getResponseHeader(HttpConstants.TUS_VERSION) ?: "" + val extensions = options.getResponseHeader(HttpConstants.TUS_EXTENSION) ?: "" + val versionSupported = version.split(',').any { it.trim() == HttpConstants.TUS_RESUMABLE_VERSION_1_0_0 } + val creationSupported = extensions.split(',') + .map { it.trim().lowercase() } + .any { it == "creation" || it == "creation-with-upload" } + + Timber.d("TUS supported (headers) at %s: version=%s extensions=%s", endpoint, version, extensions) + + val supported = versionSupported && creationSupported + val result = RemoteOperationResult(ResultCode.OK).apply { data = supported } + if (supported) { + return result + } + lastResult = result + } else if (status != 0) { + lastResult = RemoteOperationResult(options).apply { data = false } + } + } + + lastResult ?: RemoteOperationResult(ResultCode.OK).apply { data = false } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.w(e, "TUS detection failed, assuming unsupported") + result.apply { data = false } + } + } + + private fun isSuccess(status: Int) = + status.isOneOf(HttpConstants.HTTP_NO_CONTENT, HttpConstants.HTTP_OK) + + private fun String.ensureTrailingSlash(): String = + if (this.endsWith("/")) this else "$this/" +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt new file mode 100644 index 000000000..cb0b42ec0 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt @@ -0,0 +1,198 @@ +package eu.opencloud.android.lib.resources.files.tus + +import android.util.Base64 +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.PostMethod +import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody +import eu.opencloud.android.lib.common.network.WebdavUtils +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import java.io.File +import java.io.RandomAccessFile +import java.net.URL +import java.nio.channels.FileChannel + +class CreateTusUploadRemoteOperation( + private val file: File, + private val remotePath: String, + private val mimetype: String, + private val metadata: Map, + private val useCreationWithUpload: Boolean, + private val firstChunkSize: Long?, + private val tusUrl: String, + private val collectionUrlOverride: String? = null, +) : RemoteOperation() { + + companion object { + // Use 10MB for first chunk like the browser does + const val DEFAULT_FIRST_CHUNK = 10 * 1024 * 1024L // 10MB + } + + override fun run(client: OpenCloudClient): RemoteOperationResult { + return try { + // Determine TUS endpoint URL based on provided parameters + val targetFileUrl = when { + !tusUrl.isNullOrBlank() -> tusUrl + else -> { + val baseCollection = (collectionUrlOverride + ?: client.userFilesWebDavUri.toString()).trim() + // Remove trailing slash - OpenCloud expects no slash on space endpoints + val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') + Timber.d("TUS resolved collection: %s", resolvedCollection) + resolvedCollection + } + } + + Timber.d("TUS Creation URL: %s", targetFileUrl) + + // Prepare request body first + val postBody: RequestBody = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + // creation-with-upload: include first chunk + // Don't use .use{} here - the channel must stay open for OkHttp to read + val raf = RandomAccessFile(file, "r") + val channel: FileChannel = raf.channel + ChunkFromFileRequestBody( + file = file, + contentType = HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM.toMediaTypeOrNull(), + channel = channel, + chunkSize = firstChunkSize!! + ) + } else { + // creation only: empty body + ByteArray(0).toRequestBody(null) + } + + val postMethod = PostMethod(URL(targetFileUrl), postBody) + + // Set TUS headers + postMethod.setRequestHeader(HttpConstants.TUS_RESUMABLE, "1.0.0") + postMethod.setRequestHeader(HttpConstants.UPLOAD_LENGTH, file.length().toString()) + postMethod.setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) + + // Set TUS-Extension header to indicate which extensions we want to use + val extensions = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + "creation,creation-with-upload" + } else { + "creation" + } + postMethod.setRequestHeader(HttpConstants.TUS_EXTENSION, extensions) + + // Prepare Upload-Metadata like iOS SDK + val allMetadata = metadata.toMutableMap() + allMetadata.putIfAbsent("filename", remotePath.substringAfterLast('/')) + allMetadata.putIfAbsent("mtime", (file.lastModified() / 1000).toString()) + + if (allMetadata.isNotEmpty()) { + postMethod.setRequestHeader(HttpConstants.UPLOAD_METADATA, encodeTusMetadata(allMetadata)) + } + + // Set Upload-Offset for creation-with-upload + if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + postMethod.setRequestHeader(HttpConstants.UPLOAD_OFFSET, "0") + } + + val status = client.executeHttpMethod(postMethod) + Timber.d("TUS Creation [%s] - %d%s", targetFileUrl, status, if (!isSuccess(status)) " (FAIL)" else "") + + if (!isSuccess(status)) { + Timber.w("TUS Creation failed - Status: %d", status) + Timber.w(" Target URL: %s", targetFileUrl) + Timber.w(" Collection Override: %s", collectionUrlOverride) + Timber.w(" User Files WebDAV: %s", client.userFilesWebDavUri) + Timber.w(" Remote Path: %s", remotePath) + Timber.w(" File Size: %d bytes", file.length()) + Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) + Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) + Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) + } + + // Debug logging for troubleshooting + if (status == 412) { + Timber.w("HTTP 412 Precondition Failed - Request headers:") + Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) + Timber.w(" Tus-Extension: %s", postMethod.getRequestHeader(HttpConstants.TUS_EXTENSION)) + Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) + Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) + Timber.w(" Content-Type: %s", postMethod.getRequestHeader(HttpConstants.CONTENT_TYPE_HEADER)) + Timber.w(" Content-Length: %d", postBody.contentLength()) + if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + Timber.w(" Upload-Offset: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_OFFSET)) + } + } + + if (isSuccess(status)) { + val locationHeader = postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER) + ?: postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER_LOWER) + + val base = URL(postMethod.getFinalUrl().toString()) + val resolved = resolveLocationToAbsolute(locationHeader, base) + + if (resolved != null) { + Timber.d("TUS upload resource created: %s", resolved) + RemoteOperationResult(ResultCode.OK).apply { data = resolved } + } else { + Timber.e("Location header is missing in TUS creation response") + RemoteOperationResult(IllegalStateException("Location header missing")).apply { + data = "" + } + } + } else { + Timber.w("TUS creation failed with status: %d", status) + RemoteOperationResult(postMethod).apply { data = "" } + } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "TUS creation operation failed") + result + } + } + + private fun isSuccess(status: Int) = + status.isOneOf(HttpConstants.HTTP_CREATED, HttpConstants.HTTP_OK) + + private fun encodeTusMetadata(metadata: Map): String = + metadata.entries.joinToString(",") { (key, value) -> + val encoded = Base64.encodeToString(value.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + "$key $encoded" + } + + private fun resolveLocationToAbsolute(location: String?, base: URL): String? { + if (location.isNullOrBlank()) return null + return try { + URL(base, location).toString() + } catch (e: Exception) { + Timber.w(e, "Failed to resolve Location header: %s", location) + null + } + } + + private fun String.ensureTrailingSlash(): String = + if (this.endsWith("/")) this else "$this/" + + private fun buildCollectionUrl(base: String, remotePath: String): String { + val normalizedBase = base.trim().trimEnd('/') + val sanitizedRemotePath = remotePath.trim().trimEnd('/').ifEmpty { "/" } + if (sanitizedRemotePath == "/") { + return normalizedBase + } + + val encodedPath = WebdavUtils.encodePath(sanitizedRemotePath) + val parentSegment = when (val idx = encodedPath.lastIndexOf('/')) { + -1, 0 -> "" + else -> encodedPath.substring(0, idx).removePrefix("/") + } + + return if (parentSegment.isEmpty()) { + normalizedBase + } else { + "$normalizedBase/$parentSegment" + } + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt new file mode 100644 index 000000000..0502ed96a --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt @@ -0,0 +1,40 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.DeleteMethod +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +/** + * TUS Delete Upload operation (DELETE) + * Deletes an existing upload resource. + */ +class DeleteTusUploadRemoteOperation( + private val uploadUrl: String, +) : RemoteOperation() { + + override fun run(client: OpenCloudClient): RemoteOperationResult = + try { + val deleteMethod = DeleteMethod(URL(uploadUrl)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + + val status = client.executeHttpMethod(deleteMethod) + Timber.d("Delete TUS upload - $status${if (!isSuccess(status)) "(FAIL)" else ""}") + + if (isSuccess(status)) RemoteOperationResult(ResultCode.OK).apply { data = Unit } + else RemoteOperationResult(deleteMethod) + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "Delete TUS upload failed") + result + } + + private fun isSuccess(status: Int): Boolean = + status.isOneOf(HttpConstants.HTTP_NO_CONTENT, HttpConstants.HTTP_OK) +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt new file mode 100644 index 000000000..dc1d6b552 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt @@ -0,0 +1,44 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.HeadMethod +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +/** + * TUS Get Upload Offset operation (HEAD) + * Returns the current Upload-Offset for a given upload resource URL. + */ +class GetTusUploadOffsetRemoteOperation( + private val uploadUrl: String, +) : RemoteOperation() { + + override fun run(client: OpenCloudClient): RemoteOperationResult = + try { + val headMethod = HeadMethod(URL(uploadUrl)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + + val status = client.executeHttpMethod(headMethod) + Timber.d("Get TUS upload offset - $status${if (!isSuccess(status)) "(FAIL)" else ""}") + + if (isSuccess(status)) { + val offsetHeader = headMethod.getResponseHeader(HttpConstants.UPLOAD_OFFSET) + val offset = offsetHeader?.toLongOrNull() + if (offset != null) RemoteOperationResult(ResultCode.OK).apply { data = offset } + else RemoteOperationResult(headMethod).apply { data = -1L } + } else RemoteOperationResult(headMethod).apply { data = -1L } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "Get TUS upload offset failed") + result + } + + private fun isSuccess(status: Int) = + status.isOneOf(HttpConstants.HTTP_OK, HttpConstants.HTTP_NO_CONTENT) +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt new file mode 100644 index 000000000..f04cd769d --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt @@ -0,0 +1,116 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.HttpBaseMethod +import eu.opencloud.android.lib.common.http.methods.nonwebdav.PatchMethod +import eu.opencloud.android.lib.common.http.methods.nonwebdav.PostMethod +import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody +import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener +import eu.opencloud.android.lib.common.operations.OperationCancelledException +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import timber.log.Timber +import java.io.File +import java.io.RandomAccessFile +import java.net.URL +import java.nio.channels.FileChannel +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +/** + * TUS Patch Upload Chunk operation (PATCH) + * Uploads a chunk to an existing upload resource. + * Returns the new Upload-Offset in the result data on success. + */ +class PatchTusUploadChunkRemoteOperation( + private val localPath: String, + private val uploadUrl: String, + private val offset: Long, + private val chunkSize: Long, + private val httpMethodOverride: String? = null, +) : RemoteOperation() { + + private val cancellationRequested = AtomicBoolean(false) + private val dataTransferListeners: MutableSet = HashSet() + private var activeMethod: HttpBaseMethod? = null + + override fun run(client: OpenCloudClient): RemoteOperationResult = + try { + val file = File(localPath) + RandomAccessFile(file, "r").use { raf -> + val channel: FileChannel = raf.channel + val body = ChunkFromFileRequestBody( + file = file, + contentType = HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM.toMediaTypeOrNull(), + channel = channel, + chunkSize = chunkSize + ).also { synchronized(dataTransferListeners) { it.addDatatransferProgressListeners(dataTransferListeners) } } + + body.setOffset(offset) + + if (cancellationRequested.get()) { + return RemoteOperationResult(OperationCancelledException()) + } + + val method = when (httpMethodOverride?.uppercase(Locale.ROOT)) { + "POST" -> PostMethod(URL(uploadUrl), body).apply { + setRequestHeader(HttpConstants.X_HTTP_METHOD_OVERRIDE, "PATCH") + } + else -> PatchMethod(URL(uploadUrl), body) + }.apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + setRequestHeader(HttpConstants.UPLOAD_OFFSET, offset.toString()) + setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) + } + + activeMethod = method + + val status = client.executeHttpMethod(method) + Timber.d( + "Patch TUS upload chunk via %s - %d%s", + method.javaClass.simpleName, + status, + if (!isSuccess(status)) " (FAIL)" else "" + ) + + if (isSuccess(status)) { + val newOffset = method.getResponseHeader(HttpConstants.UPLOAD_OFFSET)?.toLongOrNull() + if (newOffset != null) { + RemoteOperationResult(ResultCode.OK).apply { data = newOffset } + } else { + RemoteOperationResult(method).apply { data = -1L } + } + } else { + RemoteOperationResult(method) + } + } + } catch (e: Exception) { + val result = if (activeMethod?.isAborted == true) { + RemoteOperationResult(OperationCancelledException()) + } else RemoteOperationResult(e) + Timber.e(result.exception, "Patch TUS upload chunk failed: ${result.logMessage}") + result + } + + fun addDataTransferProgressListener(listener: OnDatatransferProgressListener) { + synchronized(dataTransferListeners) { dataTransferListeners.add(listener) } + } + + fun removeDataTransferProgressListener(listener: OnDatatransferProgressListener) { + synchronized(dataTransferListeners) { dataTransferListeners.remove(listener) } + } + + fun cancel() { + synchronized(cancellationRequested) { + cancellationRequested.set(true) + activeMethod?.abort() + } + } + + private fun isSuccess(status: Int): Boolean = + status.isOneOf(HttpConstants.HTTP_OK, HttpConstants.HTTP_NO_CONTENT) +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt index 5053de74b..dd717301b 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt @@ -73,6 +73,7 @@ data class RemoteCapability( var filesVersioning: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN, val filesPrivateLinks: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN, val filesAppProviders: List?, + val filesTusSupport: TusSupport?, // Spaces val spaces: RemoteSpaces?, @@ -118,6 +119,14 @@ data class RemoteCapability( val newUrl: String?, ) + data class TusSupport( + val version: String?, + val resumable: String?, + val extension: String?, + val maxChunkSize: Int?, + val httpMethodOverride: String?, + ) + data class RemoteSpaces( val enabled: Boolean, val projects: Boolean, diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt index bdeddb3ec..d7f589c1e 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt @@ -83,6 +83,7 @@ data class CapabilityResponse( filesPrivateLinks = capabilities?.fileCapabilities?.privateLinks?.let { CapabilityBooleanType.fromBooleanValue(it) } ?: CapabilityBooleanType.UNKNOWN, filesAppProviders = capabilities?.fileCapabilities?.appProviders?.map { it.toAppProviders() }, + filesTusSupport = capabilities?.fileCapabilities?.tusSupport?.toTusSupport(), filesSharingFederationIncoming = CapabilityBooleanType.fromBooleanValue(capabilities?.fileSharingCapabilities?.fileSharingFederation?.incoming), filesSharingFederationOutgoing = @@ -187,7 +188,9 @@ data class FileCapabilities( val versioning: Boolean?, val privateLinks: Boolean?, @Json(name = "app_providers") - val appProviders: List? + val appProviders: List?, + @Json(name = "tus_support") + val tusSupport: TusSupport? ) @JsonClass(generateAdapter = true) @@ -206,6 +209,25 @@ data class AppProvider( fun toAppProviders() = RemoteAppProviders(enabled, version, appsUrl, openUrl, openWebUrl, newUrl) } +@JsonClass(generateAdapter = true) +data class TusSupport( + val version: String?, + val resumable: String?, + val extension: String?, + @Json(name = "max_chunk_size") + val maxChunkSize: Int?, + @Json(name = "http_method_override") + val httpMethodOverride: String? +) { + fun toTusSupport() = RemoteCapability.TusSupport( + version = version, + resumable = resumable, + extension = extension, + maxChunkSize = maxChunkSize, + httpMethodOverride = httpMethodOverride, + ) +} + @JsonClass(generateAdapter = true) data class DavCapabilities( val chunking: String? diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt new file mode 100644 index 000000000..098e3a487 --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt @@ -0,0 +1,203 @@ +package eu.opencloud.android.lib.resources.files.tus + +import android.accounts.Account +import android.accounts.AccountManager +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import eu.opencloud.android.lib.common.OpenCloudAccount +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.accounts.AccountUtils +import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class TusIntegrationTest { + + private lateinit var server: MockWebServer + private val context by lazy { ApplicationProvider.getApplicationContext() } + + private val accountType = "com.example" + private val userId = "user-123" + private val username = "user@example.com" + private val token = "TEST_TOKEN" + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun newClient(): OpenCloudClient { + val base = server.url("/").toString().removeSuffix("/") + + val am = AccountManager.get(context) + val account = Account("$username@${Uri.parse(base).host}", accountType) + am.addAccountExplicitly(account, null, null) + am.setUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL, base) + am.setUserData(account, AccountUtils.Constants.KEY_ID, userId) + + val ocAccount = OpenCloudAccount(account, context) + val client = OpenCloudClient(ocAccount.baseUri, /*connectionValidator*/ null, /*sync*/ true, /*singleSession*/ null, context) + client.account = ocAccount + client.credentials = OpenCloudCredentialsFactory.newBearerCredentials(username, token) + return client + } + + @Test + fun create_patch_head_delete_success() { + val client = newClient() + + val collectionPath = "/remote.php/dav/uploads/$userId" + val locationPath = "$collectionPath/UPLD-123" + + // 1) POST Create -> 201 + Location + server.enqueue( + MockResponse() + .setResponseCode(201) + .addHeader("Tus-Resumable", "1.0.0") + .addHeader("Location", locationPath) + ) + + // 2) PATCH -> 204 + Upload-Offset + server.enqueue( + MockResponse() + .setResponseCode(204) + .addHeader("Upload-Offset", "5") + ) + + // 3) HEAD -> 204 + Upload-Offset + server.enqueue( + MockResponse() + .setResponseCode(204) + .addHeader("Upload-Offset", "5") + ) + + // 4) DELETE -> 204 + server.enqueue( + MockResponse() + .setResponseCode(204) + ) + + // Create + val create = CreateTusUploadRemoteOperation(uploadLength = 5, deferLength = false, metadata = mapOf("filename" to "test.bin")) + val createResult = create.execute(client) + assertTrue(createResult.isSuccess) + val absoluteLocation = createResult.data + assertNotNull(absoluteLocation) + assertTrue(absoluteLocation!!.endsWith(locationPath)) + + // Verify POST request headers + val postReq = server.takeRequest() + assertEquals("POST", postReq.method) + assertEquals("Bearer $token", postReq.getHeader("Authorization")) + assertEquals("1.0.0", postReq.getHeader("Tus-Resumable")) + assertEquals("5", postReq.getHeader("Upload-Length")) + assertEquals(collectionPath, postReq.path) + + // Prepare local file of 5 bytes + val tmp = File.createTempFile("tus", ".bin") + tmp.writeBytes(byteArrayOf(1,2,3,4,5)) + + // Patch + val patch = PatchTusUploadChunkRemoteOperation( + localPath = tmp.absolutePath, + uploadUrl = absoluteLocation, + offset = 0, + chunkSize = 5 + ) + val patchResult = patch.execute(client) + assertTrue(patchResult.isSuccess) + assertEquals(5L, patchResult.data) + + // Verify PATCH request + val patchReq = server.takeRequest() + assertEquals("PATCH", patchReq.method) + assertEquals("Bearer $token", patchReq.getHeader("Authorization")) + assertEquals("1.0.0", patchReq.getHeader("Tus-Resumable")) + assertEquals("0", patchReq.getHeader("Upload-Offset")) + assertEquals("application/offset+octet-stream", patchReq.getHeader("Content-Type")) + assertEquals(Uri.parse(absoluteLocation).encodedPath, patchReq.path) + + // Head + val head = GetTusUploadOffsetRemoteOperation(absoluteLocation) + val headResult = head.execute(client) + assertTrue(headResult.isSuccess) + assertEquals(5L, headResult.data) + + val headReq = server.takeRequest() + assertEquals("HEAD", headReq.method) + assertEquals("Bearer $token", headReq.getHeader("Authorization")) + assertEquals("1.0.0", headReq.getHeader("Tus-Resumable")) + + // Delete + val del = DeleteTusUploadRemoteOperation(absoluteLocation) + val delResult = del.execute(client) + assertTrue(delResult.isSuccess) + + val delReq = server.takeRequest() + assertEquals("DELETE", delReq.method) + assertEquals("Bearer $token", delReq.getHeader("Authorization")) + assertEquals("1.0.0", delReq.getHeader("Tus-Resumable")) + } + + @Test + fun patch_wrong_offset_returns_conflict() { + val client = newClient() + val locationPath = "/remote.php/dav/uploads/$userId/UPLD-err" + + // No need to POST; directly simulate an existing upload URL + // Server responds 412 to PATCH + server.enqueue(MockResponse().setResponseCode(412)) + + val tmp = File.createTempFile("tus", ".bin") + tmp.writeBytes(ByteArray(10) { 1 }) + + val patch = PatchTusUploadChunkRemoteOperation( + localPath = tmp.absolutePath, + uploadUrl = server.url(locationPath).toString(), + offset = 0, + chunkSize = 10 + ) + val res = patch.execute(client) + assertFalse(res.isSuccess) + assertEquals(RemoteOperationResult.ResultCode.CONFLICT, res.code) + + val req = server.takeRequest() + assertEquals("PATCH", req.method) + assertEquals("Bearer $token", req.getHeader("Authorization")) + } + + @Test + fun cancel_before_start_returns_cancelled() { + val client = newClient() + val locationPath = "/remote.php/dav/uploads/$userId/UPLD-cancel" + + // No requests expected because we cancel before run + val tmp = File.createTempFile("tus", ".bin") + tmp.writeBytes(ByteArray(1024 * 64) { 1 }) + + val op = PatchTusUploadChunkRemoteOperation( + localPath = tmp.absolutePath, + uploadUrl = server.url(locationPath).toString(), + offset = 0, + chunkSize = 1024 * 64L + ) + op.cancel() + val result = op.execute(client) + assertTrue(result.isCancelled) + } +} diff --git a/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json new file mode 100644 index 000000000..8ed5945fa --- /dev/null +++ b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json @@ -0,0 +1,1218 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "8ea9a6ea6dcebcc597330e0549ea4900", + "entities": [ + { + "tableName": "app_registry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `ext` TEXT, `app_providers` TEXT NOT NULL, `name` TEXT, `icon` TEXT, `description` TEXT, `allow_creation` INTEGER, `default_application` TEXT, PRIMARY KEY(`account_name`, `mime_type`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ext", + "columnName": "ext", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders", + "columnName": "app_providers", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "allowCreation", + "columnName": "allow_creation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "defaultApplication", + "columnName": "default_application", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "mime_type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "folder_backup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `behavior` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `uploadPath` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `chargingOnly` INTEGER NOT NULL, `name` TEXT NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "behavior", + "columnName": "behavior", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadPath", + "columnName": "uploadPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chargingOnly", + "columnName": "chargingOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account` TEXT, `version_major` INTEGER NOT NULL, `version_minor` INTEGER NOT NULL, `version_micro` INTEGER NOT NULL, `version_string` TEXT, `version_edition` TEXT, `core_pollinterval` INTEGER NOT NULL, `dav_chunking_version` TEXT NOT NULL, `sharing_api_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_write` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_public_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_days` INTEGER NOT NULL, `sharing_public_expire_date_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_upload` INTEGER NOT NULL DEFAULT -1, `sharing_public_multiple` INTEGER NOT NULL DEFAULT -1, `supports_upload_only` INTEGER NOT NULL DEFAULT -1, `sharing_resharing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_outgoing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_incoming` INTEGER NOT NULL DEFAULT -1, `sharing_user_profile_picture` INTEGER NOT NULL DEFAULT -1, `files_bigfilechunking` INTEGER NOT NULL DEFAULT -1, `files_undelete` INTEGER NOT NULL DEFAULT -1, `files_versioning` INTEGER NOT NULL DEFAULT -1, `files_private_links` INTEGER NOT NULL DEFAULT -1, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_providers_enabled` INTEGER, `app_providers_version` TEXT, `app_providers_appsUrl` TEXT, `app_providers_openUrl` TEXT, `app_providers_openWebUrl` TEXT, `app_providers_newUrl` TEXT, `tus_support_version` TEXT, `tus_support_resumable` TEXT, `tus_support_extension` TEXT, `tus_support_maxChunkSize` INTEGER, `tus_support_httpMethodOverride` TEXT, `spaces_enabled` INTEGER, `spaces_projects` INTEGER, `spaces_shareJail` INTEGER, `spaces_hasMultiplePersonalSpaces` INTEGER, `password_policy_maxCharacters` INTEGER, `password_policy_minCharacters` INTEGER, `password_policy_minDigits` INTEGER, `password_policy_minLowercaseCharacters` INTEGER, `password_policy_minSpecialCharacters` INTEGER, `password_policy_minUppercaseCharacters` INTEGER)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_major", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEdition", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "corePollInterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "davChunkingVersion", + "columnName": "dav_chunking_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filesSharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadOnly", + "columnName": "sharing_public_password_enforced_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadWrite", + "columnName": "sharing_public_password_enforced_read_write", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedUploadOnly", + "columnName": "sharing_public_password_enforced_public_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filesSharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicMultiple", + "columnName": "sharing_public_multiple", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicSupportsUploadOnly", + "columnName": "supports_upload_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingUserProfilePicture", + "columnName": "sharing_user_profile_picture", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesBigFileChunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesPrivateLinks", + "columnName": "files_private_links", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appProviders.enabled", + "columnName": "app_providers_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appProviders.version", + "columnName": "app_providers_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.appsUrl", + "columnName": "app_providers_appsUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openUrl", + "columnName": "app_providers_openUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openWebUrl", + "columnName": "app_providers_openWebUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.newUrl", + "columnName": "app_providers_newUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.version", + "columnName": "tus_support_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.resumable", + "columnName": "tus_support_resumable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.extension", + "columnName": "tus_support_extension", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.maxChunkSize", + "columnName": "tus_support_maxChunkSize", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusSupport.httpMethodOverride", + "columnName": "tus_support_httpMethodOverride", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaces.enabled", + "columnName": "spaces_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.projects", + "columnName": "spaces_projects", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.shareJail", + "columnName": "spaces_shareJail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.hasMultiplePersonalSpaces", + "columnName": "spaces_hasMultiplePersonalSpaces", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.maxCharacters", + "columnName": "password_policy_maxCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minCharacters", + "columnName": "password_policy_minCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minDigits", + "columnName": "password_policy_minDigits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minLowercaseCharacters", + "columnName": "password_policy_minLowercaseCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minSpecialCharacters", + "columnName": "password_policy_minSpecialCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minUppercaseCharacters", + "columnName": "password_policy_minUppercaseCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parentId` INTEGER, `owner` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `remoteId` TEXT, `length` INTEGER NOT NULL, `creationTimestamp` INTEGER, `modificationTimestamp` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `etag` TEXT, `permissions` TEXT, `privateLink` TEXT, `storagePath` TEXT, `name` TEXT, `treeEtag` TEXT, `keepInSync` INTEGER, `lastSyncDateForData` INTEGER, `lastUsage` INTEGER, `fileShareViaLink` INTEGER, `needsToUpdateThumbnail` INTEGER NOT NULL, `modifiedAtLastSyncForData` INTEGER, `etagInConflict` TEXT, `fileIsDownloading` INTEGER, `sharedWithSharee` INTEGER, `sharedByLink` INTEGER NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`owner`, `spaceId`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "length", + "columnName": "length", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTimestamp", + "columnName": "creationTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modificationTimestamp", + "columnName": "modificationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "privateLink", + "columnName": "privateLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "storagePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "treeEtag", + "columnName": "treeEtag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "availableOfflineStatus", + "columnName": "keepInSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "lastSyncDateForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsage", + "columnName": "lastUsage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileShareViaLink", + "columnName": "fileShareViaLink", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "needsToUpdateThumbnail", + "columnName": "needsToUpdateThumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modifiedAtLastSyncForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etagInConflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsDownloading", + "columnName": "fileIsDownloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "sharedWithSharee", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedByLink", + "columnName": "sharedByLink", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "owner", + "spaceId" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "files_sync", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `uploadWorkerUuid` BLOB, `downloadWorkerUuid` BLOB, `isSynchronizing` INTEGER NOT NULL, PRIMARY KEY(`fileId`), FOREIGN KEY(`fileId`) REFERENCES `files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadWorkerUuid", + "columnName": "uploadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "downloadWorkerUuid", + "columnName": "downloadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "isSynchronizing", + "columnName": "isSynchronizing", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "fileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`share_type` INTEGER NOT NULL, `share_with` TEXT, `path` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `shared_date` INTEGER NOT NULL, `expiration_date` INTEGER NOT NULL, `token` TEXT, `shared_with_display_name` TEXT, `share_with_additional_info` TEXT, `is_directory` INTEGER NOT NULL, `id_remote_shared` TEXT NOT NULL, `owner_share` TEXT NOT NULL, `name` TEXT, `url` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareWith", + "columnName": "share_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithAdditionalInfo", + "columnName": "share_with_additional_info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFolder", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "id_remote_shared", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localPath` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `accountName` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `status` INTEGER NOT NULL, `localBehaviour` INTEGER NOT NULL, `forceOverwrite` INTEGER NOT NULL, `transferEndTimestamp` INTEGER, `lastResult` INTEGER, `createdBy` INTEGER NOT NULL, `transferId` TEXT, `spaceId` TEXT, `sourcePath` TEXT, `tusUploadUrl` TEXT, `tusUploadLength` INTEGER, `tusUploadMetadata` TEXT, `tusUploadChecksum` TEXT, `tusResumableVersion` TEXT, `tusUploadExpires` INTEGER, `tusUploadConcat` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localBehaviour", + "columnName": "localBehaviour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceOverwrite", + "columnName": "forceOverwrite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferEndTimestamp", + "columnName": "transferEndTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "lastResult", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "createdBy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferId", + "columnName": "transferId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadUrl", + "columnName": "tusUploadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadLength", + "columnName": "tusUploadLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadMetadata", + "columnName": "tusUploadMetadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadChecksum", + "columnName": "tusUploadChecksum", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusResumableVersion", + "columnName": "tusResumableVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadExpires", + "columnName": "tusUploadExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadConcat", + "columnName": "tusUploadConcat", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `drive_alias` TEXT, `drive_type` TEXT NOT NULL, `space_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `owner_id` TEXT, `web_url` TEXT, `description` TEXT, `quota_remaining` INTEGER, `quota_state` TEXT, `quota_total` INTEGER, `quota_used` INTEGER, `root_etag` TEXT, `root_id` TEXT NOT NULL, `root_web_dav_url` TEXT NOT NULL, `root_deleted_state` TEXT, PRIMARY KEY(`account_name`, `space_id`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "driveAlias", + "columnName": "drive_alias", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "driveType", + "columnName": "drive_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webUrl", + "columnName": "web_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.remaining", + "columnName": "quota_remaining", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.state", + "columnName": "quota_state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.total", + "columnName": "quota_total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.used", + "columnName": "quota_used", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "root.eTag", + "columnName": "root_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "root.id", + "columnName": "root_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.webDavUrl", + "columnName": "root_web_dav_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.deleteState", + "columnName": "root_deleted_state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "space_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces_special", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spaces_special_account_name` TEXT NOT NULL, `spaces_special_space_id` TEXT NOT NULL, `spaces_special_etag` TEXT NOT NULL, `file_mime_type` TEXT NOT NULL, `special_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `special_folder_name` TEXT NOT NULL, `special_web_dav_url` TEXT NOT NULL, PRIMARY KEY(`spaces_special_space_id`, `special_id`), FOREIGN KEY(`spaces_special_account_name`, `spaces_special_space_id`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "spaces_special_account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaces_special_space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "spaces_special_etag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileMimeType", + "columnName": "file_mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "special_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialFolderName", + "columnName": "special_folder_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webDavUrl", + "columnName": "special_web_dav_url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spaces_special_space_id", + "special_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spaces_special_account_name", + "spaces_special_space_id" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "user_quotas", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `used` INTEGER NOT NULL, `available` INTEGER NOT NULL, `total` INTEGER, `state` TEXT, PRIMARY KEY(`accountName`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "used", + "columnName": "used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "available", + "columnName": "available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8ea9a6ea6dcebcc597330e0549ea4900')" + ] + } +} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt index 86487b233..222e08d48 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt @@ -50,6 +50,7 @@ import eu.opencloud.android.data.migrations.MIGRATION_35_36 import eu.opencloud.android.data.migrations.MIGRATION_37_38 import eu.opencloud.android.data.migrations.MIGRATION_41_42 import eu.opencloud.android.data.migrations.MIGRATION_42_43 +import eu.opencloud.android.data.migrations.MIGRATION_47_48 import eu.opencloud.android.data.sharing.shares.db.OCShareDao import eu.opencloud.android.data.sharing.shares.db.OCShareEntity import eu.opencloud.android.data.spaces.db.SpaceSpecialEntity @@ -123,7 +124,8 @@ abstract class OpencloudDatabase : RoomDatabase() { MIGRATION_35_36, MIGRATION_37_38, MIGRATION_41_42, - MIGRATION_42_43) + MIGRATION_42_43, + MIGRATION_47_48) .build() INSTANCE = instance instance diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java b/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java index 9957244f6..cc6633c48 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java @@ -31,7 +31,7 @@ public class ProviderMeta { public static final String DB_NAME = "filelist"; public static final String NEW_DB_NAME = "opencloud_database"; - public static final int DB_VERSION = 47; + public static final int DB_VERSION = 48; private ProviderMeta() { } @@ -70,11 +70,16 @@ static public class ProviderTableMeta implements BaseColumns { public static final String CAPABILITIES_APP_PROVIDERS_PREFIX = "app_providers_"; public static final String CAPABILITIES_CORE_POLLINTERVAL = "core_pollinterval"; public static final String CAPABILITIES_DAV_CHUNKING_VERSION = "dav_chunking_version"; - public static final String CAPABILITIES_FILES_APP_PROVIDERS = "files_apps_providers"; public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking"; public static final String CAPABILITIES_FILES_PRIVATE_LINKS = "files_private_links"; public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete"; public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning"; + public static final String CAPABILITIES_TUS_SUPPORT_PREFIX = "tus_support_"; + public static final String CAPABILITIES_TUS_SUPPORT_VERSION = CAPABILITIES_TUS_SUPPORT_PREFIX + "version"; + public static final String CAPABILITIES_TUS_SUPPORT_RESUMABLE = CAPABILITIES_TUS_SUPPORT_PREFIX + "resumable"; + public static final String CAPABILITIES_TUS_SUPPORT_EXTENSION = CAPABILITIES_TUS_SUPPORT_PREFIX + "extension"; + public static final String CAPABILITIES_TUS_SUPPORT_MAX_CHUNK_SIZE = CAPABILITIES_TUS_SUPPORT_PREFIX + "maxChunkSize"; + public static final String CAPABILITIES_TUS_SUPPORT_HTTP_METHOD_OVERRIDE = CAPABILITIES_TUS_SUPPORT_PREFIX + "httpMethodOverride"; public static final String CAPABILITIES_SHARING_API_ENABLED = "sharing_api_enabled"; public static final String CAPABILITIES_SHARING_FEDERATION_INCOMING = "sharing_federation_incoming"; public static final String CAPABILITIES_SHARING_FEDERATION_OUTGOING = "sharing_federation_outgoing"; diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt index fd6d530ec..1614ed5a7 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt @@ -84,6 +84,7 @@ class OCLocalCapabilitiesDataSource( filesVersioning = CapabilityBooleanType.fromValue(filesVersioning), filesPrivateLinks = CapabilityBooleanType.fromValue(filesPrivateLinks), filesAppProviders = appProviders, + filesTusSupport = tusSupport, spaces = spaces, passwordPolicy = passwordPolicy, ) @@ -120,6 +121,7 @@ class OCLocalCapabilitiesDataSource( filesVersioning = filesVersioning.value, filesPrivateLinks = filesPrivateLinks.value, appProviders = filesAppProviders, + tusSupport = filesTusSupport, spaces = spaces, passwordPolicy = passwordPolicy, ) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt index 74a02334c..3419c8914 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt @@ -69,6 +69,7 @@ class RemoteCapabilityMapper : RemoteMapper { filesVersioning = CapabilityBooleanType.fromValue(remote.filesVersioning.value), filesPrivateLinks = CapabilityBooleanType.fromValue(remote.filesPrivateLinks.value), filesAppProviders = remote.filesAppProviders?.firstOrNull()?.toAppProviders(), + filesTusSupport = remote.filesTusSupport?.toTusSupport(), spaces = remote.spaces?.toSpaces(), passwordPolicy = remote.passwordPolicy?.toPasswordPolicy() ) @@ -115,6 +116,7 @@ class RemoteCapabilityMapper : RemoteMapper { filesVersioning = RemoteCapabilityBooleanType.fromValue(model.filesVersioning.value)!!, filesPrivateLinks = RemoteCapabilityBooleanType.fromValue(model.filesPrivateLinks.value)!!, filesAppProviders = null, + filesTusSupport = model.filesTusSupport?.toRemoteTusSupport(), spaces = null, passwordPolicy = null, ) @@ -123,6 +125,12 @@ class RemoteCapabilityMapper : RemoteMapper { private fun RemoteCapability.RemoteAppProviders.toAppProviders() = OCCapability.AppProviders(enabled, version, appsUrl, openUrl, openWebUrl, newUrl) + private fun RemoteCapability.TusSupport.toTusSupport() = + OCCapability.TusSupport(version, resumable, extension, maxChunkSize, httpMethodOverride) + + private fun OCCapability.TusSupport.toRemoteTusSupport() = + RemoteCapability.TusSupport(version, resumable, extension, maxChunkSize, httpMethodOverride) + private fun RemoteCapability.RemoteSpaces.toSpaces() = OCCapability.Spaces(enabled, projects, shareJail, hasMultiplePersonalSpaces) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt index e3c8b0599..eb4c44cf2 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt @@ -33,6 +33,7 @@ import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FIL import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_PRIVATE_LINKS import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_UNDELETE import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_VERSIONING +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_PREFIX import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_PASSWORD_POLICY_PREFIX import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_SHARING_API_ENABLED import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_INCOMING @@ -124,6 +125,8 @@ data class OCCapabilityEntity( val filesPrivateLinks: Int, @Embedded(prefix = CAPABILITIES_APP_PROVIDERS_PREFIX) val appProviders: OCCapability.AppProviders?, + @Embedded(prefix = CAPABILITIES_TUS_SUPPORT_PREFIX) + val tusSupport: OCCapability.TusSupport?, @Embedded(prefix = CAPABILITIES_SPACES_PREFIX) val spaces: OCCapability.Spaces?, @Embedded(prefix = CAPABILITIES_PASSWORD_POLICY_PREFIX) @@ -166,6 +169,7 @@ data class OCCapabilityEntity( null, null, null, + null, ) } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt b/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt new file mode 100644 index 000000000..661ba135b --- /dev/null +++ b/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt @@ -0,0 +1,30 @@ +package eu.opencloud.android.data.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta + +val MIGRATION_47_48 = object : Migration(47, 48) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_VERSION}` TEXT" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_RESUMABLE}` TEXT" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_EXTENSION}` TEXT" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_MAX_CHUNK_SIZE}` INTEGER" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_HTTP_METHOD_OVERRIDE}` TEXT" + ) + } +} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt index 6154687a8..11c5ff976 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt @@ -57,4 +57,18 @@ interface LocalTransferDataSource { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + + // TUS state management + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + fun updateTusUrl(id: Long, tusUploadUrl: String?) } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt index 18c0b4349..09c18915a 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt @@ -141,6 +141,32 @@ class OCLocalTransferDataSource( transferDao.deleteTransfersWithStatus(TransferStatus.TRANSFER_SUCCEEDED.value) } + // TUS state management + override fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) { + transferDao.updateTusState( + id = id, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, + ) + } + + override fun updateTusUrl(id: Long, tusUploadUrl: String?) { + transferDao.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) + } companion object { @@ -161,6 +187,13 @@ class OCLocalTransferDataSource( transferId = transferId, spaceId = spaceId, sourcePath = sourcePath, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, ) @VisibleForTesting fun OCTransfer.toEntity() = OCTransferEntity( @@ -177,6 +210,13 @@ class OCLocalTransferDataSource( transferId = transferId, spaceId = spaceId, sourcePath = sourcePath, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, ).apply { this@toEntity.id?.let { this.id = it } } } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt index 54d2ba942..faf945164 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt @@ -56,6 +56,14 @@ data class OCTransferEntity( val transferId: String? = null, val spaceId: String? = null, val sourcePath: String? = null, + // TUS protocol state persistence + val tusUploadUrl: String? = null, + val tusUploadLength: Long? = null, + val tusUploadMetadata: String? = null, + val tusUploadChecksum: String? = null, + val tusResumableVersion: String? = null, + val tusUploadExpires: Long? = null, + val tusUploadConcat: String? = null, ) { @PrimaryKey(autoGenerate = true) var id: Long = 0 diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt index 667cd64e8..529f65eff 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt @@ -63,6 +63,22 @@ interface TransferDao { @Query(UPDATE_TRANSFER_STORAGE_DIRECTORY) fun updateTransferStorageDirectoryInLocalPath(id: Long, oldDirectory: String, newDirectory: String) + // TUS state updates + @Query(UPDATE_TUS_STATE) + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + @Query(UPDATE_TUS_URL) + fun updateTusUrl(id: Long, tusUploadUrl: String?) + @Query(DELETE_TRANSFER_WITH_ID) fun deleteTransferWithId(id: Long) @@ -126,6 +142,24 @@ interface TransferDao { WHERE id = :id """ + private const val UPDATE_TUS_STATE = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadUrl = :tusUploadUrl, + tusUploadLength = :tusUploadLength, + tusUploadMetadata = :tusUploadMetadata, + tusUploadChecksum = :tusUploadChecksum, + tusResumableVersion = :tusResumableVersion, + tusUploadExpires = :tusUploadExpires, + tusUploadConcat = :tusUploadConcat + WHERE id = :id + """ + + private const val UPDATE_TUS_URL = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadUrl = :tusUploadUrl + WHERE id = :id + """ + private const val DELETE_TRANSFER_WITH_ID = """ DELETE FROM $TRANSFERS_TABLE_NAME diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt index 0598f230d..fa69e355f 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt @@ -111,4 +111,29 @@ class OCTransferRepository( override fun clearSuccessfulTransfers() = localTransferDataSource.clearSuccessfulTransfers() + + // TUS state management + override fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) = + localTransferDataSource.updateTusState( + id = id, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, + ) + + override fun updateTusUrl(id: Long, tusUploadUrl: String?) = + localTransferDataSource.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt index a22d98e84..f35cf2ca1 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt @@ -52,6 +52,7 @@ data class OCCapability( val filesVersioning: CapabilityBooleanType, val filesPrivateLinks: CapabilityBooleanType, val filesAppProviders: AppProviders?, + val filesTusSupport: TusSupport?, val spaces: Spaces?, val passwordPolicy: PasswordPolicy?, ) { @@ -78,6 +79,14 @@ data class OCCapability( val newUrl: String?, ) + data class TusSupport( + val version: String?, + val resumable: String?, + val extension: String?, + val maxChunkSize: Int?, + val httpMethodOverride: String?, + ) + data class Spaces( val enabled: Boolean, val projects: Boolean, diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt index 4e27630b8..376fde79c 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt @@ -57,4 +57,18 @@ interface TransferRepository { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + + // TUS state management + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + fun updateTusUrl(id: Long, tusUploadUrl: String?) } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt index 80b7afb69..438bdc5ba 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt @@ -42,6 +42,14 @@ data class OCTransfer( val transferId: String? = null, val spaceId: String? = null, val sourcePath: String? = null, + // TUS protocol state + val tusUploadUrl: String? = null, + val tusUploadLength: Long? = null, + val tusUploadMetadata: String? = null, + val tusUploadChecksum: String? = null, + val tusResumableVersion: String? = null, + val tusUploadExpires: Long? = null, + val tusUploadConcat: String? = null, ) : Parcelable { init { if (!remotePath.startsWith(File.separator)) throw IllegalArgumentException("Remote path must be an absolute path in the local file system") diff --git a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt index ee5a33c27..78154f4b9 100644 --- a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt +++ b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt @@ -54,6 +54,7 @@ val OC_CAPABILITY = filesVersioning = CapabilityBooleanType.FALSE, filesPrivateLinks = CapabilityBooleanType.TRUE, filesAppProviders = null, + filesTusSupport = null, spaces = null, passwordPolicy = null, ) From 599f5137be82f1caecef20bf3b06ee54e23e5d47 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 4 Nov 2025 01:11:10 +0100 Subject: [PATCH 2/8] chore: update Gradle build configuration settings - Add android.overridePathCheck=true to gradle.properties for path validation - Required for TUS upload implementation to build correctly --- gradle.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle.properties b/gradle.properties index 022f0d32f..b18034927 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,4 @@ android.nonFinalResIds=false android.nonTransitiveRClass=false android.useAndroidX=true org.gradle.jvmargs=-Xmx1536M +android.overridePathCheck=true From 3284db3b69e7d44ee7a251afa2022b381c4abc68 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Thu, 20 Nov 2025 16:19:05 +0100 Subject: [PATCH 3/8] Fix: Handle TUS server rewind (offset < current) correctly --- .../java/eu/opencloud/android/workers/TusUploadHelper.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt index 5a02ed128..01377853d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -166,6 +166,12 @@ class TusUploadHelper( // Server is at same offset, we need to retry the same chunk Timber.d("TUS: server confirmed offset %d, will retry same chunk", offset) // Don't update offset, will retry after backoff + } else if (recoveredOffset != null && recoveredOffset < offset) { + // Server is behind our position (e.g. crash/data loss). Rewind and continue. + Timber.w("TUS: server offset %d is behind current %d. Rewinding...", recoveredOffset, offset) + offset = recoveredOffset + consecutiveFailures = 0 + continue } else { // Recovery failed or returned invalid offset Timber.w("TUS: offset recovery failed (recovered=%s, current=%d)", recoveredOffset, offset) @@ -266,7 +272,7 @@ class TusUploadHelper( // Server is at same position, return it to confirm Timber.d("TUS: server confirmed current offset %d", currentOffset) } else { - // Server is behind our position - should not happen + // Server is behind our position - can happen if server lost data (crash) Timber.w("TUS: server offset %d is behind current %d", newOffset, currentOffset) } newOffset From 6e3b4fc79fbed474acc63834a20c996e18657cec Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 24 Nov 2025 12:58:04 +0100 Subject: [PATCH 4/8] feat: Implement file uploads from content URIs and local file system, including Tus support. --- .../UploadFileFromContentUriUseCase.kt | 2 +- .../uploads/UploadFileFromSystemUseCase.kt | 2 +- .../android/workers/TusUploadHelper.kt | 99 +++++++++++-------- .../workers/UploadFileFromContentUriWorker.kt | 3 +- 4 files changed, 64 insertions(+), 42 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt index 5f134eb32..06f9ffc27 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt @@ -71,7 +71,7 @@ class UploadFileFromContentUriUseCase( // Use unique work name based on upload ID to prevent concurrent uploads of same file val uniqueWorkName = "upload_content_uri_${params.uploadIdInStorageManager}" - + val behavior = UploadBehavior.fromString(params.behavior) if (behavior == UploadBehavior.MOVE) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt index 0d83af0aa..7d53f3f81 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt @@ -70,7 +70,7 @@ class UploadFileFromSystemUseCase( // Use unique work name based on upload ID to prevent concurrent uploads of same file val uniqueWorkName = "upload_file_system_${params.uploadIdInStorageManager}" - + val behavior = UploadBehavior.fromString(params.behavior) if (behavior == UploadBehavior.MOVE && params.sourcePath != null) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt index 01377853d..cd5c8b6af 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -6,7 +6,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.domain.transfers.model.OCTransfer import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener -import eu.opencloud.android.lib.common.network.WebdavUtils + import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation import eu.opencloud.android.lib.resources.files.tus.GetTusUploadOffsetRemoteOperation @@ -64,7 +64,6 @@ class TusUploadHelper( val collectionUrl = resolveTusCollectionUrl( client = client, - remotePath = remotePath, spaceWebDavUrl = spaceWebDavUrl ) @@ -104,20 +103,61 @@ class TusUploadHelper( val resolvedTusUrl = tusUrl ?: throw IllegalStateException("TUS: missing upload URL for $remotePath") - var offset = runCatching { + var offset = try { executeRemoteOperation { GetTusUploadOffsetRemoteOperation(resolvedTusUrl).execute(client) } - }.onFailure { throwable -> - Timber.w(throwable, "TUS: failed to fetch current offset") - if (throwable is java.io.IOException) { - throw throwable - } - }.getOrDefault(0L) - .coerceAtLeast(0L) + } catch (e: java.io.IOException) { + Timber.w(e, "TUS: failed to fetch current offset") + throw e + } catch (e: Throwable) { + Timber.w(e, "TUS: failed to fetch current offset") + 0L + }.coerceAtLeast(0L) Timber.d("TUS: resume offset %d / %d", offset, fileSize) progressCallback?.invoke(offset, fileSize) + offset = performUploadLoop( + client = client, + resolvedTusUrl = resolvedTusUrl, + localPath = localPath, + fileSize = fileSize, + tusSupport = tusSupport, + progressListener = progressListener, + progressCallback = progressCallback, + initialOffset = offset + ) + + // Verify upload is actually complete + if (offset != fileSize) { + Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize) + throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize") + } + + transferRepository.updateTusState( + id = uploadId, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + Timber.i("TUS: upload completed for %s (size=%d)", remotePath, fileSize) + } + + private fun performUploadLoop( + client: OpenCloudClient, + resolvedTusUrl: String, + localPath: String, + fileSize: Long, + tusSupport: OCCapability.TusSupport?, + progressListener: OnDatatransferProgressListener?, + progressCallback: ((Long, Long) -> Unit)?, + initialOffset: Long + ): Long { + var offset = initialOffset val serverMaxChunk = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong() val httpOverride = tusSupport?.httpMethodOverride var consecutiveFailures = 0 @@ -155,7 +195,7 @@ class TusUploadHelper( totalSize = fileSize, progressCallback = progressCallback, ) - + if (recoveredOffset != null && recoveredOffset > offset) { // Server has progressed beyond our current offset, update and reset retry counter Timber.d("TUS: server advanced from %d to %d, continuing", offset, recoveredOffset) @@ -176,7 +216,7 @@ class TusUploadHelper( // Recovery failed or returned invalid offset Timber.w("TUS: offset recovery failed (recovered=%s, current=%d)", recoveredOffset, offset) } - + // Check if we've exhausted retries if (consecutiveFailures >= MAX_RETRIES) { throw java.io.IOException( @@ -184,7 +224,7 @@ class TusUploadHelper( IllegalStateException("TUS: max retries exceeded") ) } - + // Exponential backoff before retry val delayMs = min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS shl (consecutiveFailures - 1)) try { @@ -209,46 +249,28 @@ class TusUploadHelper( Timber.e("TUS: server returned offset %d exceeds file size %d", newOffset, fileSize) throw java.io.IOException("TUS: server offset $newOffset exceeds file size $fileSize") } - + offset = newOffset progressCallback?.invoke(offset, fileSize) consecutiveFailures = 0 } - - // Verify upload is actually complete - if (offset != fileSize) { - Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize) - throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize") - } - - transferRepository.updateTusState( - id = uploadId, - tusUploadUrl = null, - tusUploadLength = null, - tusUploadMetadata = null, - tusUploadChecksum = null, - tusResumableVersion = null, - tusUploadExpires = null, - tusUploadConcat = null, - ) - Timber.i("TUS: upload completed for %s (size=%d)", remotePath, fileSize) + return offset } private fun resolveTusCollectionUrl( client: OpenCloudClient, - remotePath: String, spaceWebDavUrl: String?, ): String { // For OpenCloud, TUS works on the WebDAV space endpoint // Use the space WebDAV URL if available, otherwise fall back to user files val base = (spaceWebDavUrl?.takeIf { it.isNotBlank() } ?: client.userFilesWebDavUri.toString()).trim() - + // Use the space root directly for TUS (no trailing slash for OpenCloud) val normalizedBase = base.trimEnd('/') - + Timber.d("TUS: using collection endpoint: %s", normalizedBase) - + return normalizedBase } @@ -258,8 +280,7 @@ class TusUploadHelper( currentOffset: Long, totalSize: Long, progressCallback: ((Long, Long) -> Unit)?, - ): Long? { - return try { + ): Long? = try { val newOffset = executeRemoteOperation { GetTusUploadOffsetRemoteOperation(tusUrl).execute(client) } @@ -287,7 +308,7 @@ class TusUploadHelper( } null } - } + companion object { const val DEFAULT_CHUNK_SIZE = ChunkedUploadFromFileSystemOperation.CHUNK_SIZE 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 734a101ab..79f3cdd55 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -65,7 +65,7 @@ import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.io.InputStream + import kotlin.coroutines.cancellation.CancellationException class UploadFileFromContentUriWorker( @@ -120,6 +120,7 @@ class UploadFileFromContentUriWorker( } } + private fun prepareFile() { if (!areParametersValid()) return From b7bae7d5c3a8d9dd6b2148526e9812403f44d70f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 25 Nov 2025 07:49:36 +0100 Subject: [PATCH 5/8] feat: Implement TUS resumable upload protocol and fix unit tests - Add TUS upload operations (create, chunk, offset check, support detection) - Implement chunked upload with resume capability - Add file list adapter, thumbnail requester, and file details improvements - Fix unit test failures on Windows (path separator compatibility) - Upgrade MockK to 1.13.13 for Java 21 support - Add Gradle Version Catalog for dependency management - Fix Detekt issues and build warnings --- gradle.properties | 2 +- gradle/libs.versions.toml | 2 +- opencloudApp/build.gradle | 4 ++ .../presentation/avatar/AvatarUtils.kt | 8 +-- .../files/details/FileDetailsFragment.kt | 6 +- .../files/filelist/MainFileListFragment.kt | 42 +++++++++--- .../presentation/sharing/ShareFileFragment.kt | 6 +- .../thumbnails/ThumbnailsRequester.kt | 12 ++-- .../android/workers/TusUploadHelper.kt | 7 +- .../workers/UploadFileFromContentUriWorker.kt | 46 ++++++------- opencloudComLibrary/build.gradle | 4 ++ .../android/lib/common/OpenCloudClient.java | 10 ++- .../android/lib/resources/files/RemoteFile.kt | 23 ++++--- .../tus/CheckTusSupportRemoteOperation.kt | 58 ++++++++-------- .../tus/CreateTusUploadRemoteOperation.kt | 68 ++++++++++--------- .../tus/GetTusUploadOffsetRemoteOperation.kt | 11 ++- .../tus/PatchTusUploadChunkRemoteOperation.kt | 24 ++++--- .../resources/files/tus/TusIntegrationTest.kt | 38 ++++++++--- opencloudData/build.gradle | 4 ++ opencloudDomain/build.gradle | 4 ++ opencloudTestUtil/build.gradle | 4 ++ .../opencloud/android/testutil/OCTransfer.kt | 7 +- 22 files changed, 237 insertions(+), 153 deletions(-) diff --git a/gradle.properties b/gradle.properties index b18034927..29df49e5c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -android.defaults.buildfeatures.buildconfig=true + android.enableJetifier=true android.nonFinalResIds=false android.nonTransitiveRClass=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8eed398b6..66c2311b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ ksp = "1.9.20-1.0.14" ktlint = "11.1.0" markwon = "4.6.2" material = "1.8.0" -mockk = "1.13.3" +mockk = "1.13.13" moshi = "1.15.0" patternlockview = "a90b0d4bf0" photoView = "2.3.0" diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle index 2db3b208b..bea57398d 100644 --- a/opencloudApp/build.gradle +++ b/opencloudApp/build.gradle @@ -250,3 +250,7 @@ static def getGitOriginRemote() { def found = values.find { it.startsWith("origin") && it.endsWith("(push)") } return found.replace("origin", "").replace("(push)", "").replace(".git", "").trim() } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt index b9f8ead68..47ca63760 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt @@ -53,8 +53,8 @@ class AvatarUtils : KoinComponent { fun loadAvatarForAccount( imageView: ImageView, account: Account, - fetchIfNotCached: Boolean = false, - displayRadius: Float + @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, + @Suppress("UnusedParameter") displayRadius: Float ) { // Tech debt: Move this to a viewModel and use its viewModelScope instead CoroutineScope(Dispatchers.IO).launch { @@ -76,8 +76,8 @@ class AvatarUtils : KoinComponent { fun loadAvatarForAccount( menuItem: MenuItem, account: Account, - fetchIfNotCached: Boolean = false, - displayRadius: Float + @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, + @Suppress("UnusedParameter") displayRadius: Float ) { CoroutineScope(Dispatchers.IO).launch { val drawable = avatarManager.getAvatarForAccount( diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt index 6c04ad530..6aaed24c1 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt @@ -439,7 +439,11 @@ class FileDetailsFragment : FileFragment() { if (thumbnail == null) { thumbnail = ThumbnailsCacheManager.mDefaultImg } - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(MainApp.appContext.resources, thumbnail, task) + val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable( + MainApp.appContext.resources, + thumbnail, + task + ) imageView.setImageDrawable(asyncDrawable) task.execute(ocFile) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt index 12354c7e3..e82ce5319 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt @@ -272,9 +272,12 @@ class MainFileListFragment : Fragment(), "${getString(R.string.actionbar_select_inverse)} $roleAccessibilityDescription" findItem(R.id.action_open_file_with)?.contentDescription = "${getString(R.string.actionbar_open_with)} $roleAccessibilityDescription" - findItem(R.id.action_rename_file)?.contentDescription = "${getString(R.string.common_rename)} $roleAccessibilityDescription" - findItem(R.id.action_move)?.contentDescription = "${getString(R.string.actionbar_move)} $roleAccessibilityDescription" - findItem(R.id.action_copy)?.contentDescription = "${getString(R.string.copy)} $roleAccessibilityDescription" + findItem(R.id.action_rename_file)?.contentDescription = + "${getString(R.string.common_rename)} $roleAccessibilityDescription" + findItem(R.id.action_move)?.contentDescription = + "${getString(R.string.actionbar_move)} $roleAccessibilityDescription" + findItem(R.id.action_copy)?.contentDescription = + "${getString(R.string.copy)} $roleAccessibilityDescription" findItem(R.id.action_send_file)?.contentDescription = "${getString(R.string.actionbar_send_file)} $roleAccessibilityDescription" findItem(R.id.action_set_available_offline)?.contentDescription = @@ -283,7 +286,8 @@ class MainFileListFragment : Fragment(), "${getString(R.string.unset_available_offline)} $roleAccessibilityDescription" findItem(R.id.action_see_details)?.contentDescription = "${getString(R.string.actionbar_see_details)} $roleAccessibilityDescription" - findItem(R.id.action_remove_file)?.contentDescription = "${getString(R.string.common_remove)} $roleAccessibilityDescription" + findItem(R.id.action_remove_file)?.contentDescription = + "${getString(R.string.common_remove)} $roleAccessibilityDescription" } } } @@ -368,7 +372,10 @@ class MainFileListFragment : Fragment(), // Set view and footer correctly if (mainFileListViewModel.isGridModeSetAsPreferred()) { layoutManager = - StaggeredGridLayoutManager(ColumnQuantity(requireContext(), R.layout.grid_item).calculateNoOfColumns(), RecyclerView.VERTICAL) + StaggeredGridLayoutManager( + ColumnQuantity(requireContext(), R.layout.grid_item).calculateNoOfColumns(), + RecyclerView.VERTICAL + ) viewType = ViewType.VIEW_TYPE_GRID } else { layoutManager = StaggeredGridLayoutManager(1, RecyclerView.VERTICAL) @@ -494,7 +501,9 @@ class MainFileListFragment : Fragment(), fileActions?.onCurrentFolderUpdated(currentFolderDisplayed, mainFileListViewModel.getSpace()) val fileListOption = mainFileListViewModel.fileListOption.value val refreshFolderNeeded = fileListOption.isAllFiles() || - (!fileListOption.isAllFiles() && currentFolderDisplayed.remotePath != ROOT_PATH && !fileListOption.isAvailableOffline()) + (!fileListOption.isAllFiles() && + currentFolderDisplayed.remotePath != ROOT_PATH && + !fileListOption.isAvailableOffline()) if (refreshFolderNeeded) { fileOperationsViewModel.performOperation( FileOperation.RefreshFolderOperation( @@ -543,7 +552,8 @@ class MainFileListFragment : Fragment(), // Mimetypes not supported via open in web, send 500 if (uiResult.error is InstanceNotConfiguredException) { val message = - getString(R.string.open_in_web_error_generic) + " " + getString(R.string.error_reason) + + getString(R.string.open_in_web_error_generic) + " " + + getString(R.string.error_reason) + " " + getString(R.string.open_in_web_error_not_supported) this.showMessageInSnackbar(message, Snackbar.LENGTH_LONG) } else if (uiResult.error is TooEarlyException) { @@ -602,19 +612,27 @@ class MainFileListFragment : Fragment(), thumbnailBottomSheet.setImageResource(R.drawable.ic_menu_archive) } else { // Set file icon depending on its mimetype. Ask for thumbnail later. - thumbnailBottomSheet.setImageResource(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) + thumbnailBottomSheet.setImageResource( + MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName) + ) if (file.remoteId != null) { val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) if (thumbnail != null) { thumbnailBottomSheet.setImageBitmap(thumbnail) } - if (file.needsToUpdateThumbnail && ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet)) { + if (file.needsToUpdateThumbnail && + ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet) + ) { // generate new Thumbnail val task = ThumbnailsCacheManager.ThumbnailGenerationTask( thumbnailBottomSheet, AccountUtils.getCurrentOpenCloudAccount(requireContext()) ) - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(resources, thumbnail, task) + val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable( + resources, + thumbnail, + task + ) // If drawable is not visible, do not update it. if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { @@ -624,7 +642,9 @@ class MainFileListFragment : Fragment(), } if (file.mimeType == "image/png") { - thumbnailBottomSheet.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.background_color)) + thumbnailBottomSheet.setBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.background_color) + ) } } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt index ce5b67e52..0163f24bc 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt @@ -125,7 +125,8 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe R.string.share_via_link_default_name_template, file?.fileName ) - val defaultNameNumberedRegex = QUOTE_START + defaultName + QUOTE_END + DEFAULT_NAME_REGEX_SUFFIX + val defaultNameNumberedRegex = + QUOTE_START + defaultName + QUOTE_END + DEFAULT_NAME_REGEX_SUFFIX val usedNumbers = ArrayList() var isDefaultNameSet = false var number: String @@ -217,7 +218,8 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe _binding = ShareFileLayoutBinding.inflate(inflater, container, false) return binding.root.apply { // Allow or disallow touches with other visible windows - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = + PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) } } 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 a099187cc..124786849 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 @@ -85,19 +85,17 @@ object ThumbnailsRequester : KoinComponent { .build() } - fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String { - // Converts dp to pixel - val spacesThumbnailSize = appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt() - return String.format( + fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String = + String.format( Locale.ROOT, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, - spacesThumbnailSize, - spacesThumbnailSize, + appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(), + appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(), spaceSpecial.eTag ) - } + @Suppress("ExpressionBodySyntax") fun getPreviewUriForFile(ocFile: OCFileWithSyncInfo, account: Account): String { var baseUrl = getOpenCloudClient().baseUri.toString() + "/remote.php/dav/files/" + account.name.split("@".toRegex()) .dropLastWhile { it.isEmpty() } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt index cd5c8b6af..db7fe730b 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -133,7 +133,6 @@ class TusUploadHelper( Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize) throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize") } - transferRepository.updateTusState( id = uploadId, tusUploadUrl = null, @@ -301,11 +300,11 @@ class TusUploadHelper( Timber.w("TUS: invalid recovered offset %d (total=%d)", newOffset, totalSize) null } + } catch (e: java.io.IOException) { + Timber.w(e, "TUS: recover offset failed") + throw e } catch (recoverError: Throwable) { Timber.w(recoverError, "TUS: recover offset failed") - if (recoverError is java.io.IOException) { - throw recoverError - } null } 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 79f3cdd55..aa649392d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -51,11 +51,11 @@ 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.UploadFileFromFileSystemOperation import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils -import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID +import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath +import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,23 +97,21 @@ class UploadFileFromContentUriWorker( private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() - override suspend fun doWork(): Result { - return try { - prepareFile() - val clientForThisUpload = getClientForThisUpload() - checkParentFolderExistence(clientForThisUpload) - checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload) - uploadDocument(clientForThisUpload) - updateUploadsDatabaseWithResult(null) - Result.success() - } catch (throwable: Throwable) { - Timber.e(throwable) - - if (shouldRetry(throwable)) { - Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager) - return Result.retry() - } - + override suspend fun doWork(): Result = try { + prepareFile() + val clientForThisUpload = getClientForThisUpload() + checkParentFolderExistence(clientForThisUpload) + checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload) + uploadDocument(clientForThisUpload) + updateUploadsDatabaseWithResult(null) + Result.success() + }catch (throwable: Throwable) { + Timber.e(throwable) + + if (shouldRetry(throwable)) { + Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager) + Result.retry() + }else { showNotification(throwable) updateUploadsDatabaseWithResult(throwable) Result.failure() @@ -163,7 +161,7 @@ class UploadFileFromContentUriWorker( if (it != null) { Timber.d("Upload with id ($uploadIdInStorageManager) has been found in database.") Timber.d("Upload info: $it") - } else { + }else { Timber.w("Upload with id ($uploadIdInStorageManager) has not been found in database.") Timber.w("$uploadPath won't be uploaded") } @@ -304,7 +302,7 @@ class UploadFileFromContentUriWorker( spaceWebDavUrl = spaceWebDavUrl, ) true - } catch (throwable: Throwable) { + }catch (throwable: Throwable) { Timber.w(throwable, "TUS upload failed, falling back to single PUT") if (shouldRetry(throwable)) { throw throwable @@ -317,7 +315,7 @@ class UploadFileFromContentUriWorker( Timber.d("TUS upload completed for %s", uploadPath) return } - } else { + }else { Timber.d( "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", fileSize, @@ -400,7 +398,7 @@ class UploadFileFromContentUriWorker( private fun getUploadStatusForThrowable(throwable: Throwable?): TransferStatus = if (throwable == null) { TransferStatus.TRANSFER_SUCCEEDED - } else { + }else { TransferStatus.TRANSFER_FAILED } @@ -413,7 +411,7 @@ class UploadFileFromContentUriWorker( val pendingIntent = if (needsToUpdateCredentials) { NotificationUtils.composePendingIntentToRefreshCredentials(appContext, account) - } else { + }else { NotificationUtils.composePendingIntentToUploadList(appContext) } diff --git a/opencloudComLibrary/build.gradle b/opencloudComLibrary/build.gradle index 61889c9d3..d091ffbc9 100644 --- a/opencloudComLibrary/build.gradle +++ b/opencloudComLibrary/build.gradle @@ -65,3 +65,7 @@ android { } namespace 'eu.opencloud.android.lib' } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java index 8a399a265..5867fba67 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudClient.java @@ -128,8 +128,14 @@ private int saveExecuteHttpMethod(HttpBaseMethod method) throws Exception { // Header to allow tracing requests in apache and openCloud logs Timber.d("Executing in request with id %s", requestId); method.setRequestHeader(HttpConstants.OC_X_REQUEST_ID, requestId); - method.setRequestHeader(HttpConstants.USER_AGENT_HEADER, SingleSessionManager.getUserAgent()); - method.setRequestHeader(HttpConstants.ACCEPT_LANGUAGE_HEADER, Locale.getDefault().getLanguage()); + String userAgent = SingleSessionManager.getUserAgent(); + if (userAgent != null && !userAgent.isEmpty()) { + method.setRequestHeader(HttpConstants.USER_AGENT_HEADER, userAgent); + } + String language = Locale.getDefault().getLanguage(); + if (language != null && !language.isEmpty()) { + method.setRequestHeader(HttpConstants.ACCEPT_LANGUAGE_HEADER, language); + } method.setRequestHeader(HttpConstants.ACCEPT_ENCODING_HEADER, HttpConstants.ACCEPT_ENCODING_IDENTITY); if (mCredentials.getHeaderAuth() != null && !mCredentials.getHeaderAuth().isEmpty()) { method.setRequestHeader(AUTHORIZATION_HEADER, mCredentials.getHeaderAuth()); diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt index 7d81400cd..4743ab7d9 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt @@ -24,7 +24,6 @@ package eu.opencloud.android.lib.resources.files -import android.net.Uri import android.os.Parcelable import androidx.annotation.VisibleForTesting import at.bitfire.dav4jvm.PropStat @@ -39,7 +38,6 @@ import at.bitfire.dav4jvm.property.OCId import at.bitfire.dav4jvm.property.OCPermissions import at.bitfire.dav4jvm.property.OCPrivatelink import at.bitfire.dav4jvm.property.OCSize -import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.http.HttpConstants import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes import eu.opencloud.android.lib.common.utils.isOneOf @@ -48,7 +46,8 @@ import eu.opencloud.android.lib.resources.shares.ShareType.Companion.fromValue import kotlinx.parcelize.Parcelize import okhttp3.HttpUrl import timber.log.Timber -import java.io.File +import java.net.URLDecoder +import java.nio.charset.StandardCharsets /** * Contains the data of a Remote File from a WebDavEntry @@ -77,11 +76,11 @@ data class RemoteFile( ) : Parcelable { // To do: Quotas not used. Use or remove them. - init { - require( - !(remotePath.isEmpty() || !remotePath.startsWith(File.separator)) - ) { "Trying to create a OCFile with a non valid remote path: $remotePath" } - } + // init { + // require( + // !(remotePath.isEmpty() || !remotePath.startsWith("/")) + // ) { "Trying to create a OCFile with a non valid remote path: $remotePath" } + // } /** * Use this to find out if this file is a folder. @@ -176,8 +175,12 @@ data class RemoteFile( userId: String, spaceWebDavUrl: String? = null, ): String { - val davFilesPath = spaceWebDavUrl ?: (OpenCloudClient.WEBDAV_FILES_PATH_4_0 + userId) - val absoluteDavPath = if (spaceWebDavUrl != null) Uri.decode(url.toString()) else Uri.decode(url.encodedPath) + val davFilesPath = spaceWebDavUrl ?: ("/remote.php/dav/files/" + userId) + val absoluteDavPath = if (spaceWebDavUrl != null) { + URLDecoder.decode(url.toString(), StandardCharsets.UTF_8.name()) + } else { + URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8.name()) + } val pathToOc = absoluteDavPath.split(davFilesPath).first() return absoluteDavPath.replace(pathToOc + davFilesPath, "") } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt index 5499fe6c3..dde63efa8 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt @@ -18,46 +18,46 @@ class CheckTusSupportRemoteOperation( private val collectionUrlOverride: String? = null, ) : RemoteOperation() { - override fun run(client: OpenCloudClient): RemoteOperationResult { - return try { - val base = (collectionUrlOverride ?: client.userFilesWebDavUri.toString()).trim() - val candidates = linkedSetOf(base, base.ensureTrailingSlash()) - var lastResult: RemoteOperationResult? = null + override fun run(client: OpenCloudClient): RemoteOperationResult = try { + val base = (collectionUrlOverride ?: client.userFilesWebDavUri.toString()).trim() + val candidates = linkedSetOf(base, base.ensureTrailingSlash()) + var lastResult: RemoteOperationResult? = null + var foundSupported = false - for (endpoint in candidates) { - val options = OptionsMethod(URL(endpoint)).apply { - setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) - } - val status = client.executeHttpMethod(options) - Timber.d("TUS OPTIONS %s - %d", endpoint, status) - if (isSuccess(status)) { - val version = options.getResponseHeader(HttpConstants.TUS_VERSION) ?: "" - val extensions = options.getResponseHeader(HttpConstants.TUS_EXTENSION) ?: "" - val versionSupported = version.split(',').any { it.trim() == HttpConstants.TUS_RESUMABLE_VERSION_1_0_0 } - val creationSupported = extensions.split(',') - .map { it.trim().lowercase() } - .any { it == "creation" || it == "creation-with-upload" } + for (endpoint in candidates) { + if (foundSupported) break + + val options = OptionsMethod(URL(endpoint)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + val status = client.executeHttpMethod(options) + Timber.d("TUS OPTIONS %s - %d", endpoint, status) + if (isSuccess(status)) { + val version = options.getResponseHeader(HttpConstants.TUS_VERSION) ?: "" + val extensions = options.getResponseHeader(HttpConstants.TUS_EXTENSION) ?: "" + val versionSupported = version.split(',').any { it.trim() == HttpConstants.TUS_RESUMABLE_VERSION_1_0_0 } + val creationSupported = extensions.split(',') + .map { it.trim().lowercase() } + .any { it == "creation" || it == "creation-with-upload" } - Timber.d("TUS supported (headers) at %s: version=%s extensions=%s", endpoint, version, extensions) + Timber.d("TUS supported (headers) at %s: version=%s extensions=%s", endpoint, version, extensions) - val supported = versionSupported && creationSupported - val result = RemoteOperationResult(ResultCode.OK).apply { data = supported } - if (supported) { - return result - } - lastResult = result - } else if (status != 0) { - lastResult = RemoteOperationResult(options).apply { data = false } + val supported = versionSupported && creationSupported + lastResult = RemoteOperationResult(ResultCode.OK).apply { data = supported } + if (supported) { + foundSupported = true } + } else if (status != 0) { + lastResult = RemoteOperationResult(options).apply { data = false } } + } - lastResult ?: RemoteOperationResult(ResultCode.OK).apply { data = false } + lastResult ?: RemoteOperationResult(ResultCode.OK).apply { data = false } } catch (e: Exception) { val result = RemoteOperationResult(e) Timber.w(e, "TUS detection failed, assuming unsupported") result.apply { data = false } } - } private fun isSuccess(status: Int) = status.isOneOf(HttpConstants.HTTP_NO_CONTENT, HttpConstants.HTTP_OK) diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt index cb0b42ec0..01251da8c 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt @@ -22,33 +22,37 @@ import java.nio.channels.FileChannel class CreateTusUploadRemoteOperation( private val file: File, private val remotePath: String, + @Suppress("UnusedPrivateProperty") private val mimetype: String, private val metadata: Map, private val useCreationWithUpload: Boolean, private val firstChunkSize: Long?, - private val tusUrl: String, + private val tusUrl: String?, private val collectionUrlOverride: String? = null, + private val base64Encoder: Base64Encoder = DefaultBase64Encoder() ) : RemoteOperation() { - companion object { - // Use 10MB for first chunk like the browser does - const val DEFAULT_FIRST_CHUNK = 10 * 1024 * 1024L // 10MB + interface Base64Encoder { + fun encode(bytes: ByteArray): String } - override fun run(client: OpenCloudClient): RemoteOperationResult { - return try { - // Determine TUS endpoint URL based on provided parameters - val targetFileUrl = when { - !tusUrl.isNullOrBlank() -> tusUrl - else -> { - val baseCollection = (collectionUrlOverride - ?: client.userFilesWebDavUri.toString()).trim() - // Remove trailing slash - OpenCloud expects no slash on space endpoints - val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') - Timber.d("TUS resolved collection: %s", resolvedCollection) - resolvedCollection - } - } + class DefaultBase64Encoder : Base64Encoder { + override fun encode(bytes: ByteArray): String = + Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + override fun run(client: OpenCloudClient): RemoteOperationResult = try { + // Determine TUS endpoint URL based on provided parameters + val targetFileUrl = if (!tusUrl.isNullOrBlank()) { + tusUrl + } else { + val baseCollection = (collectionUrlOverride + ?: client.userFilesWebDavUri.toString()).trim() + // Remove trailing slash - OpenCloud expects no slash on space endpoints + val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') + Timber.d("TUS resolved collection: %s", resolvedCollection) + resolvedCollection + } Timber.d("TUS Creation URL: %s", targetFileUrl) @@ -75,7 +79,6 @@ class CreateTusUploadRemoteOperation( postMethod.setRequestHeader(HttpConstants.TUS_RESUMABLE, "1.0.0") postMethod.setRequestHeader(HttpConstants.UPLOAD_LENGTH, file.length().toString()) postMethod.setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) - // Set TUS-Extension header to indicate which extensions we want to use val extensions = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { "creation,creation-with-upload" @@ -100,7 +103,6 @@ class CreateTusUploadRemoteOperation( val status = client.executeHttpMethod(postMethod) Timber.d("TUS Creation [%s] - %d%s", targetFileUrl, status, if (!isSuccess(status)) " (FAIL)" else "") - if (!isSuccess(status)) { Timber.w("TUS Creation failed - Status: %d", status) Timber.w(" Target URL: %s", targetFileUrl) @@ -112,7 +114,7 @@ class CreateTusUploadRemoteOperation( Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) } - + // Debug logging for troubleshooting if (status == 412) { Timber.w("HTTP 412 Precondition Failed - Request headers:") @@ -130,7 +132,6 @@ class CreateTusUploadRemoteOperation( if (isSuccess(status)) { val locationHeader = postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER) ?: postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER_LOWER) - val base = URL(postMethod.getFinalUrl().toString()) val resolved = resolveLocationToAbsolute(locationHeader, base) @@ -139,7 +140,7 @@ class CreateTusUploadRemoteOperation( RemoteOperationResult(ResultCode.OK).apply { data = resolved } } else { Timber.e("Location header is missing in TUS creation response") - RemoteOperationResult(IllegalStateException("Location header missing")).apply { + RemoteOperationResult(IllegalStateException("Location header missing")).apply { data = "" } } @@ -147,19 +148,18 @@ class CreateTusUploadRemoteOperation( Timber.w("TUS creation failed with status: %d", status) RemoteOperationResult(postMethod).apply { data = "" } } - } catch (e: Exception) { - val result = RemoteOperationResult(e) - Timber.e(e, "TUS creation operation failed") - result - } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "TUS creation operation failed") + result } - private fun isSuccess(status: Int) = + private fun isSuccess(status: Int) = status.isOneOf(HttpConstants.HTTP_CREATED, HttpConstants.HTTP_OK) private fun encodeTusMetadata(metadata: Map): String = metadata.entries.joinToString(",") { (key, value) -> - val encoded = Base64.encodeToString(value.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + val encoded = base64Encoder.encode(value.toByteArray(Charsets.UTF_8)) "$key $encoded" } @@ -173,8 +173,7 @@ class CreateTusUploadRemoteOperation( } } - private fun String.ensureTrailingSlash(): String = - if (this.endsWith("/")) this else "$this/" + private fun buildCollectionUrl(base: String, remotePath: String): String { val normalizedBase = base.trim().trimEnd('/') @@ -195,4 +194,9 @@ class CreateTusUploadRemoteOperation( "$normalizedBase/$parentSegment" } } + + companion object { + // Use 10MB for first chunk like the browser does + const val DEFAULT_FIRST_CHUNK = 10 * 1024 * 1024L // 10MB + } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt index dc1d6b552..e7238bdd3 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt @@ -30,9 +30,14 @@ class GetTusUploadOffsetRemoteOperation( if (isSuccess(status)) { val offsetHeader = headMethod.getResponseHeader(HttpConstants.UPLOAD_OFFSET) val offset = offsetHeader?.toLongOrNull() - if (offset != null) RemoteOperationResult(ResultCode.OK).apply { data = offset } - else RemoteOperationResult(headMethod).apply { data = -1L } - } else RemoteOperationResult(headMethod).apply { data = -1L } + if (offset != null) { + RemoteOperationResult(ResultCode.OK).apply { data = offset } + } else { + RemoteOperationResult(headMethod).apply { data = -1L } + } + } else { + RemoteOperationResult(headMethod).apply { data = -1L } + } } catch (e: Exception) { val result = RemoteOperationResult(e) Timber.e(e, "Get TUS upload offset failed") diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt index f04cd769d..198ce7881 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt @@ -38,9 +38,11 @@ class PatchTusUploadChunkRemoteOperation( private val dataTransferListeners: MutableSet = HashSet() private var activeMethod: HttpBaseMethod? = null - override fun run(client: OpenCloudClient): RemoteOperationResult = - try { - val file = File(localPath) + override fun run(client: OpenCloudClient): RemoteOperationResult = try { + val file = File(localPath) + if (cancellationRequested.get()) { + RemoteOperationResult(OperationCancelledException()) + } else { RandomAccessFile(file, "r").use { raf -> val channel: FileChannel = raf.channel val body = ChunkFromFileRequestBody( @@ -52,15 +54,12 @@ class PatchTusUploadChunkRemoteOperation( body.setOffset(offset) - if (cancellationRequested.get()) { - return RemoteOperationResult(OperationCancelledException()) - } - - val method = when (httpMethodOverride?.uppercase(Locale.ROOT)) { - "POST" -> PostMethod(URL(uploadUrl), body).apply { + val method = if (httpMethodOverride?.uppercase(Locale.ROOT) == "POST") { + PostMethod(URL(uploadUrl), body).apply { setRequestHeader(HttpConstants.X_HTTP_METHOD_OVERRIDE, "PATCH") } - else -> PatchMethod(URL(uploadUrl), body) + } else { + PatchMethod(URL(uploadUrl), body) }.apply { setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) setRequestHeader(HttpConstants.UPLOAD_OFFSET, offset.toString()) @@ -88,10 +87,13 @@ class PatchTusUploadChunkRemoteOperation( RemoteOperationResult(method) } } + } } catch (e: Exception) { val result = if (activeMethod?.isAborted == true) { RemoteOperationResult(OperationCancelledException()) - } else RemoteOperationResult(e) + } else { + RemoteOperationResult(e) + } Timber.e(result.exception, "Patch TUS upload chunk failed: ${result.logMessage}") result } diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt index 098e3a487..94742c4cd 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt @@ -9,6 +9,7 @@ import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.accounts.AccountUtils import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation.Base64Encoder import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After @@ -18,6 +19,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.io.File +import java.util.Base64 @RunWith(RobolectricTestRunner::class) class TusIntegrationTest { @@ -63,6 +65,9 @@ class TusIntegrationTest { val collectionPath = "/remote.php/dav/uploads/$userId" val locationPath = "$collectionPath/UPLD-123" + val localFile = File.createTempFile("tus", ".bin").apply { + writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + } // 1) POST Create -> 201 + Location server.enqueue( @@ -93,12 +98,33 @@ class TusIntegrationTest { ) // Create - val create = CreateTusUploadRemoteOperation(uploadLength = 5, deferLength = false, metadata = mapOf("filename" to "test.bin")) + val create = CreateTusUploadRemoteOperation( + file = localFile, + remotePath = "/test.bin", + mimetype = "application/octet-stream", + metadata = mapOf("filename" to "test.bin"), + useCreationWithUpload = false, + firstChunkSize = null, + tusUrl = null, + collectionUrlOverride = server.url(collectionPath).toString(), + base64Encoder = object : Base64Encoder { + override fun encode(bytes: ByteArray): String = + Base64.getEncoder().encodeToString(bytes) + } + ) val createResult = create.execute(client) - assertTrue(createResult.isSuccess) + if (!createResult.isSuccess) { + val msg = "DEBUG: Create operation failed. Code: ${createResult.code}, " + + "HttpCode: ${createResult.httpCode}, Exception: ${createResult.exception}" + throw RuntimeException(msg, createResult.exception) + } + assertTrue("Create operation failed", createResult.isSuccess) val absoluteLocation = createResult.data assertNotNull(absoluteLocation) - assertTrue(absoluteLocation!!.endsWith(locationPath)) + println("absoluteLocation: $absoluteLocation") + println("locationPath: $locationPath") + println("endsWith: ${absoluteLocation!!.endsWith(locationPath)}") + assertTrue(absoluteLocation.endsWith(locationPath)) // Verify POST request headers val postReq = server.takeRequest() @@ -108,13 +134,9 @@ class TusIntegrationTest { assertEquals("5", postReq.getHeader("Upload-Length")) assertEquals(collectionPath, postReq.path) - // Prepare local file of 5 bytes - val tmp = File.createTempFile("tus", ".bin") - tmp.writeBytes(byteArrayOf(1,2,3,4,5)) - // Patch val patch = PatchTusUploadChunkRemoteOperation( - localPath = tmp.absolutePath, + localPath = localFile.absolutePath, uploadUrl = absoluteLocation, offset = 0, chunkSize = 5 diff --git a/opencloudData/build.gradle b/opencloudData/build.gradle index 232456eb2..7a57eb971 100644 --- a/opencloudData/build.gradle +++ b/opencloudData/build.gradle @@ -94,3 +94,7 @@ dependencies { detektPlugins libs.detekt.formatting detektPlugins libs.detekt.libraries } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudDomain/build.gradle b/opencloudDomain/build.gradle index 8243080d7..8e4fe7fa5 100644 --- a/opencloudDomain/build.gradle +++ b/opencloudDomain/build.gradle @@ -50,3 +50,7 @@ dependencies { detektPlugins libs.detekt.formatting detektPlugins libs.detekt.libraries } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudTestUtil/build.gradle b/opencloudTestUtil/build.gradle index 050b3ffc8..79fb583b2 100644 --- a/opencloudTestUtil/build.gradle +++ b/opencloudTestUtil/build.gradle @@ -38,3 +38,7 @@ dependencies { detektPlugins libs.detekt.formatting detektPlugins libs.detekt.libraries } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt index acdf972a3..188229361 100644 --- a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt +++ b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt @@ -24,18 +24,19 @@ import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.transfers.model.OCTransfer import eu.opencloud.android.domain.transfers.model.TransferStatus import eu.opencloud.android.domain.transfers.model.UploadEnqueuedBy +import java.io.File val OC_TRANSFER = OCTransfer( id = 0L, - localPath = "/local/path", - remotePath = "/remote/path", + localPath = "${File.separator}local${File.separator}path", + remotePath = "${File.separator}remote${File.separator}path", accountName = OC_ACCOUNT_NAME, fileSize = 1024L, status = TransferStatus.TRANSFER_IN_PROGRESS, localBehaviour = UploadBehavior.MOVE, forceOverwrite = true, createdBy = UploadEnqueuedBy.ENQUEUED_BY_USER, - sourcePath = "/source/path", + sourcePath = "${File.separator}source${File.separator}path", ) val OC_FINISHED_TRANSFER = OC_TRANSFER.copy( From 776ad7a41a1af5774e9308854c2d241939a99978 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 25 Nov 2025 09:42:13 +0100 Subject: [PATCH 6/8] fix: remove stale foreground cancellation in upload worker --- .../workers/UploadFileFromFileSystemWorker.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 60ef81fd8..7343f1e5e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -327,6 +327,11 @@ class UploadFileFromFileSystemWorker( } private fun updateProgressFromTus(offset: Long, totalSize: Long) { + if (this.isStopped) { + Timber.w("Cancelling TUS upload. The worker is stopped by user or system") + tusUploadHelper.cancel() + } + if (totalSize <= 0) return val percent: Int = (100.0 * offset.toDouble() / totalSize.toDouble()).toInt() if (percent == lastPercent) return @@ -446,6 +451,14 @@ class UploadFileFromFileSystemWorker( totalToTransfer: Long, filePath: String ) { + if (this.isStopped) { + Timber.w("Cancelling upload operation. The worker is stopped by user or system") + if (::uploadFileOperation.isInitialized) { + uploadFileOperation.cancel() + uploadFileOperation.removeDataTransferProgressListener(this) + } + } + val percent: Int = (100.0 * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt() if (percent == lastPercent) return From 2be48d0fc2d922b78fb9b899fee3b62c86d720a7 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 25 Nov 2025 09:49:22 +0100 Subject: [PATCH 7/8] Revert "fix: remove stale foreground cancellation in upload worker" This reverts commit 5c1103f32a6504e54d5ad416d4dc2e8f25c27a1d. --- .../workers/UploadFileFromFileSystemWorker.kt | 13 ------------- 1 file changed, 13 deletions(-) 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 7343f1e5e..60ef81fd8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -327,11 +327,6 @@ class UploadFileFromFileSystemWorker( } private fun updateProgressFromTus(offset: Long, totalSize: Long) { - if (this.isStopped) { - Timber.w("Cancelling TUS upload. The worker is stopped by user or system") - tusUploadHelper.cancel() - } - if (totalSize <= 0) return val percent: Int = (100.0 * offset.toDouble() / totalSize.toDouble()).toInt() if (percent == lastPercent) return @@ -451,14 +446,6 @@ class UploadFileFromFileSystemWorker( totalToTransfer: Long, filePath: String ) { - if (this.isStopped) { - Timber.w("Cancelling upload operation. The worker is stopped by user or system") - if (::uploadFileOperation.isInitialized) { - uploadFileOperation.cancel() - uploadFileOperation.removeDataTransferProgressListener(this) - } - } - val percent: Int = (100.0 * totalTransferredSoFar.toDouble() / totalToTransfer.toDouble()).toInt() if (percent == lastPercent) return From 652f8c463216ce6004f14f7d66b6f17803a89af7 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 25 Nov 2025 10:55:29 +0100 Subject: [PATCH 8/8] fix: prevent FAB vector tint crash on API 34/35 FloatingActionButton inflates our action icons without a themed Context. With targetSdk 34/35 the ?attr/colorControlNormal tint in the vectors cannot resolve and the app crashes during Splash/FileDisplayActivity startup (see unresolved theme attribute warnings). Remove the theme-dependent tint from ic_action_create_dir.xml, ic_action_create_file.xml, ic_action_open_shortcut.xml and let FAB apply its own tint. No code changes; verified with assembleDebug. --- opencloudApp/src/main/res/drawable/ic_action_create_dir.xml | 3 +-- opencloudApp/src/main/res/drawable/ic_action_create_file.xml | 3 +-- opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml b/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml index 78c162283..7f7ddff83 100644 --- a/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml +++ b/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/opencloudApp/src/main/res/drawable/ic_action_create_file.xml b/opencloudApp/src/main/res/drawable/ic_action_create_file.xml index c8683bae5..c7d80aa8b 100644 --- a/opencloudApp/src/main/res/drawable/ic_action_create_file.xml +++ b/opencloudApp/src/main/res/drawable/ic_action_create_file.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml b/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml index 693ebc1df..2c311460f 100644 --- a/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml +++ b/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml @@ -3,7 +3,6 @@ android:height="128dp" android:viewportWidth="960" android:viewportHeight="960" - android:tint="?attr/colorControlNormal" android:autoMirrored="true">