From dd872cd5fe610d2431fcc7b3583b0b2788f0cf53 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Wed, 29 Oct 2025 23:28:38 +0100 Subject: [PATCH 1/8] feat(upload): implement TUS resumable upload protocol - Add TUS 1.0.0 protocol support with PATCH, HEAD, OPTIONS methods - Create TUS operations: check support, create/patch/delete uploads, get offset - Add database schema v48 with tusUploadUrl field in transfers - Wire TUS into ContentUri and FileSystem upload workers - Add TUS integration tests and update HttpConstants - Add Timber debug logging in MainApp Why: Enable robust resumable uploads for large files, improving reliability over slow/unstable connections and reducing bandwidth waste on failures. --- .../main/java/eu/opencloud/android/MainApp.kt | 9 + .../workers/UploadFileFromContentUriWorker.kt | 145 +- .../workers/UploadFileFromFileSystemWorker.kt | 195 ++- opencloudComLibrary/build.gradle | 6 +- .../android/lib/common/OpenCloudClient.java | 4 +- .../lib/common/http/HttpConstants.java | 17 + .../http/methods/nonwebdav/HeadMethod.kt | 18 + .../http/methods/nonwebdav/OptionsMethod.kt | 18 + .../http/methods/nonwebdav/PatchMethod.kt | 22 + .../network/ChunkFromFileRequestBody.kt | 15 +- .../operations/RemoteOperationResult.java | 4 + .../tus/CheckTusSupportRemoteOperation.kt | 93 ++ .../tus/CreateTusUploadRemoteOperation.kt | 199 +++ .../tus/DeleteTusUploadRemoteOperation.kt | 40 + .../tus/GetTusUploadOffsetRemoteOperation.kt | 44 + .../tus/PatchTusUploadChunkRemoteOperation.kt | 95 ++ .../resources/files/tus/TusIntegrationTest.kt | 203 +++ .../48.json | 1194 +++++++++++++++++ .../android/data/OpencloudDatabase.kt | 1 + .../opencloud/android/data/ProviderMeta.java | 2 +- .../datasources/LocalTransferDataSource.kt | 16 + .../OCLocalTransferDataSource.kt | 48 + .../data/transfers/db/OCTransferEntity.kt | 9 + .../android/data/transfers/db/TransferDao.kt | 45 + .../repository/OCTransferRepository.kt | 30 + .../domain/transfers/TransferRepository.kt | 16 + .../domain/transfers/model/OCTransfer.kt | 9 + 27 files changed, 2474 insertions(+), 23 deletions(-) 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 diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 4ac0b4d0f..b7c14c475 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -99,6 +99,15 @@ class MainApp : Application() { appContext = applicationContext + // Ensure Logcat shows Timber logs in debug builds + if (BuildConfig.DEBUG) { + try { + Timber.plant(Timber.DebugTree()) + } catch (_: Throwable) { + // ignore if already planted + } + } + startLogsIfEnabled() DebugInjector.injectDebugTools(appContext) 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..8a8dd2437 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -34,7 +34,6 @@ 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 @@ -45,6 +44,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 @@ -57,6 +57,9 @@ import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperatio 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.lib.resources.files.tus.CreateTusUploadRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.GetTusUploadOffsetRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.PatchTusUploadChunkRemoteOperation import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath @@ -70,6 +73,8 @@ import org.koin.core.component.inject import timber.log.Timber import java.io.File import java.io.FileOutputStream +import java.io.FileInputStream +import java.security.MessageDigest class UploadFileFromContentUriWorker( private val appContext: Context, @@ -97,6 +102,8 @@ class UploadFileFromContentUriWorker( private val transferRepository: TransferRepository by inject() private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() + private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() + override suspend fun doWork(): Result { @@ -116,6 +123,7 @@ class UploadFileFromContentUriWorker( checkPermissionsToReadDocumentAreGranted() copyFileToLocalStorage() } + val clientForThisUpload = getClientForThisUpload() checkParentFolderExistence(clientForThisUpload) checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload) @@ -243,7 +251,6 @@ class UploadFileFromContentUriWorker( mimeType = cacheFile.extension fileSize = cacheFile.length() - val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() val capabilitiesForAccount = getStoredCapabilitiesUseCase( GetStoredCapabilitiesUseCase.Params( accountName = account.name @@ -252,14 +259,129 @@ class UploadFileFromContentUriWorker( 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) - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { - uploadChunkedFile(client) + // Prefer TUS for large files: optimistically try TUS and fall back on failure + val usedTus = if (fileSize > CHUNK_SIZE) { + Timber.d("Attempting TUS for large upload (size=%d, threshold=%d)", fileSize, CHUNK_SIZE) + val ok = try { + uploadTusFile(client) + true + } catch (e: Exception) { + Timber.w(e, "TUS flow failed, will fallback to existing upload methods") + false + } + Timber.d("TUS attempt result: %s", if (ok) "success" else "failed") + ok } else { - uploadPlainFile(client) + Timber.d("Skipping TUS: file too small (size=%d <= threshold=%d)", fileSize, CHUNK_SIZE) + false + } + + if (!usedTus) { + if (isChunkingAllowed && fileSize > CHUNK_SIZE) { + uploadChunkedFile(client) + } else { + uploadPlainFile(client) + } } removeCacheFile() } + private fun uploadTusFile(client: OpenCloudClient) { + Timber.i("Starting TUS upload for %s (size=%d)", uploadPath, fileSize) + + // 1) Create or resume session + var tusUrl = ocTransfer.tusUploadUrl + if (tusUrl.isNullOrBlank()) { + val fileName = File(uploadPath).name + val sha256 = try { computeSha256Hex(cachePath) } catch (e: Exception) { Timber.w(e, "SHA-256 computation failed"); "" } + val metadata = linkedMapOf( + "filename" to fileName, + "mimetype" to mimeType + ) + if (sha256.isNotEmpty()) { + metadata["checksum"] = "sha256 $sha256" + } + // Without explicit capability info, avoid creation-with-upload to maximize compatibility + val firstChunk = 0L + val useCreationWithUpload = false + + val create = CreateTusUploadRemoteOperation( + file = File(cachePath), + remotePath = uploadPath, + mimetype = mimeType, + metadata = metadata, + useCreationWithUpload = useCreationWithUpload, + firstChunkSize = null, + tusUrl = "" + ) + val createResult = create.execute(client) + if (!createResult.isSuccess || createResult.data.isNullOrBlank()) { + throw IllegalStateException("Failed to create TUS upload resource") + } + tusUrl = createResult.data + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = tusUrl, + tusUploadOffset = 0L, + tusUploadLength = fileSize, + tusUploadMetadata = "filename=$fileName${if (sha256.isNotEmpty()) ";checksum=sha256 $sha256" else ""}", + tusUploadChecksum = if (sha256.isNotEmpty()) "sha256:$sha256" else null, + tusResumableVersion = "1.0.0", + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + // 2) Query current offset + var offset = 0L + GetTusUploadOffsetRemoteOperation(tusUrl!!).execute(client).also { res -> + if (res.isSuccess && (res.data ?: -1L) >= 0) offset = res.data!! + } + Timber.d("TUS resume offset: %d / %d", offset, fileSize) + + // Use fixed chunk size if server max is unknown + val serverMaxChunk: Long? = null + Timber.d("TUS using fixed chunk size: %d", CHUNK_SIZE) + + // 3) PATCH loop + while (offset < fileSize) { + val remaining = fileSize - offset + val limitByServer = serverMaxChunk ?: Long.MAX_VALUE + val toSend = minOf(CHUNK_SIZE, remaining, limitByServer) + Timber.d("TUS using chunk=%d remaining=%d", toSend, remaining) + val patch = PatchTusUploadChunkRemoteOperation( + localPath = cachePath, + uploadUrl = tusUrl, + offset = offset, + chunkSize = toSend + ).apply { + addDataTransferProgressListener(this@UploadFileFromContentUriWorker) + } + val result = patch.execute(client) + if (result.isSuccess && (result.data ?: -1L) >= 0) { + offset = result.data!! + transferRepository.updateTusOffset(uploadIdInStorageManager, offset) + // Also push overall progress explicitly + val percent: Int = (100.0 * offset.toDouble() / fileSize.toDouble()).toInt() + CoroutineScope(Dispatchers.IO).launch { + val progress = workDataOf(DownloadFileWorker.WORKER_KEY_PROGRESS to percent) + setProgress(progress) + } + } else { + // Try recover on typical TUS conflicts by re-querying offset; else fail to fallback + val head = GetTusUploadOffsetRemoteOperation(tusUrl).execute(client) + val recovered = head.isSuccess && (head.data ?: -1L) >= 0 + if (recovered && head.data!! > offset) { + offset = head.data!! + transferRepository.updateTusOffset(uploadIdInStorageManager, offset) + continue + } + throw IllegalStateException("TUS PATCH failed and could not recover") + } + } + Timber.i("TUS upload finished for %s", uploadPath) + } + private fun uploadPlainFile(client: OpenCloudClient) { uploadFileOperation = UploadFileFromFileSystemOperation( localPath = cachePath, @@ -314,6 +436,19 @@ class UploadFileFromContentUriWorker( cacheFile.delete() } + private fun computeSha256Hex(path: String): String { + val md = MessageDigest.getInstance("SHA-256") + FileInputStream(path).use { fis -> + val buf = ByteArray(1024 * 1024) + while (true) { + val read = fis.read(buf) + if (read <= 0) break + md.update(buf, 0, read) + } + } + return md.digest().joinToString("") { b -> "%02x".format(b) } + } + 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..0b2dc0fa5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -55,6 +55,10 @@ import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperatio 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.lib.resources.files.tus.CheckTusSupportRemoteOperation +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 eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath @@ -67,6 +71,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest class UploadFileFromFileSystemWorker( private val appContext: Context, @@ -126,6 +132,161 @@ class UploadFileFromFileSystemWorker( } } + private fun computeSha256Hex(path: String): String { + val md = MessageDigest.getInstance("SHA-256") + FileInputStream(path).use { fis -> + val buf = ByteArray(1024 * 1024) + while (true) { + val read = fis.read(buf) + if (read <= 0) break + md.update(buf, 0, read) + } + } + return md.digest().joinToString("") { b -> "%02x".format(b) } + } + + private fun uploadViaTus(client: OpenCloudClient): Boolean { + try { + Timber.d("TUS: entering uploadViaTus for %s size=%d", uploadPath, fileSize) + // 1) Create or reuse TUS upload URL + var tusUrl = ocTransfer.tusUploadUrl + if (tusUrl.isNullOrBlank()) { + val filename = File(uploadPath).name + val sha256 = try { computeSha256Hex(fileSystemPath) } catch (e: Exception) { Timber.w(e, "SHA-256 computation failed"); "" } + val metadata = linkedMapOf( + "filename" to filename, + "mimetype" to mimetype, + "mtime" to lastModified + ) + if (sha256.isNotEmpty()) { + metadata["checksum"] = "sha256 $sha256" + } + // Without explicit capability info, avoid creation-with-upload to maximize compatibility + val useCreationWithUpload = false + + Timber.d( + "TUS: creating upload resource (filename=%s) length=%d creation-with-upload=%s", + filename, fileSize, useCreationWithUpload + ) + + val createOperation = CreateTusUploadRemoteOperation( + file = File(fileSystemPath), + remotePath = uploadPath, + mimetype = mimetype, + metadata = metadata, + useCreationWithUpload = useCreationWithUpload, + firstChunkSize = null, + tusUrl = "" + ).execute(client) + + if (!createOperation.isSuccess || createOperation.data.isNullOrBlank()) { + Timber.w("TUS create failed, falling back to WebDAV") + return false + } + tusUrl = createOperation.data + Timber.d("TUS: created upload at %s", tusUrl) + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = tusUrl, + tusUploadOffset = 0L, + tusUploadLength = fileSize, + tusUploadMetadata = "filename=$filename;mimetype=$mimetype${if (sha256.isNotEmpty()) ";checksum=sha256 $sha256" else ""}", + tusUploadChecksum = if (sha256.isNotEmpty()) "sha256:$sha256" else null, + tusResumableVersion = "1.0.0", + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + // 2) Get current offset (resume capable) + val offsetRes = GetTusUploadOffsetRemoteOperation(tusUrl!!).execute(client) + var offset = if (offsetRes.isSuccess && offsetRes.data != null && offsetRes.data!! >= 0) offsetRes.data!! else 0L + Timber.d("TUS resume offset: %d / %d", offset, fileSize) + + // 3) PATCH loop with basic retry/resume on transient failures + var consecutiveFailures = 0 + val maxRetries = 5 + val serverMaxChunk: Long? = null + while (offset < fileSize) { + val remaining = fileSize - offset + val limitByServer = serverMaxChunk ?: Long.MAX_VALUE + val chunk = minOf(CHUNK_SIZE, remaining, limitByServer) + Timber.d("TUS using chunk=%d remaining=%d", chunk, remaining) + + val patchOp = PatchTusUploadChunkRemoteOperation( + localPath = fileSystemPath, + uploadUrl = tusUrl, + offset = offset, + chunkSize = chunk, + ).apply { + addDataTransferProgressListener(this@UploadFileFromFileSystemWorker) + } + + val patchRes = patchOp.execute(client) + if (!patchRes.isSuccess || patchRes.data == null || patchRes.data!! < offset) { + Timber.w("TUS PATCH failed at offset %d (retry %d/%d)", offset, consecutiveFailures + 1, maxRetries) + + // Try to recover by re-checking current offset from server and continue + val recover = try { + val off = GetTusUploadOffsetRemoteOperation(tusUrl!!).execute(client) + if (off.isSuccess && off.data != null && off.data!! >= 0) { + val newOffset = off.data!! + if (newOffset > offset) { + offset = newOffset + transferRepository.updateTusOffset(uploadIdInStorageManager, offset) + consecutiveFailures = 0 + true + } else { + false + } + } else { + false + } + } catch (re: Throwable) { + Timber.w(re, "TUS recover offset check failed") + false + } + + if (!recover) { + // Backoff before next retry + val delayMs = kotlin.math.min(2000L, 250L shl consecutiveFailures) + try { Thread.sleep(delayMs) } catch (_: InterruptedException) {} + consecutiveFailures++ + if (consecutiveFailures >= maxRetries) { + Timber.w("TUS giving up after %d retries at offset %d", maxRetries, offset) + return false + } + } + + // continue loop (either after recovered offset or after counting a retry) + continue + } + + offset = patchRes.data!! + transferRepository.updateTusOffset(uploadIdInStorageManager, offset) + consecutiveFailures = 0 + } + + // 4) Completed: clear TUS state + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = null, + tusUploadOffset = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + Timber.i("TUS upload completed for %s", uploadPath) + return true + } catch (e: Throwable) { + Timber.e(e, "TUS upload failed, will fallback to WebDAV") + return false + } + } + private fun areParametersValid(): Boolean { val paramAccountName = workerParameters.inputData.getString(KEY_PARAM_ACCOUNT_NAME) val paramUploadPath = workerParameters.inputData.getString(KEY_PARAM_UPLOAD_PATH) @@ -232,10 +393,24 @@ class UploadFileFromFileSystemWorker( 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) - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { - uploadChunkedFile(client) + // Prefer TUS for large files: optimistically try TUS create and let it fail fast if unsupported + val usedTus = if (fileSize > CHUNK_SIZE) { + Timber.d("Attempting TUS for large upload (size=%d, threshold=%d)", fileSize, CHUNK_SIZE) + val ok = uploadViaTus(client) + Timber.d("TUS attempt result: %s", if (ok) "success" else "failed") + ok } else { - uploadPlainFile(client) + Timber.d("Skipping TUS: file too small (size=%d <= threshold=%d)", fileSize, CHUNK_SIZE) + false + } + + if (!usedTus) { + Timber.d("Proceeding without TUS: %s", if (isChunkingAllowed && fileSize > CHUNK_SIZE) "chunked WebDAV" else "plain WebDAV") + if (isChunkingAllowed && fileSize > CHUNK_SIZE) { + uploadChunkedFile(client) + } else { + uploadPlainFile(client) + } } } @@ -397,12 +572,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..47b5f68e3 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 @@ -52,6 +52,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 +83,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 +96,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 +152,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..42b490e90 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,14 +62,19 @@ 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) } synchronized(dataTransferListeners) { 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..5b0760d8c 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; 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..09bea6fd1 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt @@ -0,0 +1,93 @@ +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 = + try { + val initial = (collectionUrlOverride ?: client.uploadsWebDavUri.toString()).trim() + val withSlash = if (initial.endsWith("/")) initial else "$initial/" + val root = run { + val idx = initial.indexOf("/uploads/") + if (idx > 0) initial.substring(0, idx + "/uploads/".length) else withSlash + } + val altInitial = initial.replace("/remote.php/dav/", "/dav/") + val altWithSlash = if (altInitial.endsWith("/")) altInitial else "$altInitial/" + val altRoot = run { + val idxDav = altInitial.indexOf("/uploads/") + if (idxDav > 0) altInitial.substring(0, idxDav + "/uploads/".length) else altWithSlash + } + + val candidates = linkedSetOf(initial, withSlash, root, altInitial, altWithSlash, altRoot) + var supported = false + var lastResult: RemoteOperationResult? = null + + loop@ 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) ?: "" + supported = version.contains(HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) && + extensions.split(',').map { it.trim() }.any { it.equals("creation", ignoreCase = true) || it.equals("creation-with-upload", ignoreCase = true) } + Timber.d("TUS supported (headers) at %s: %s, version: %s, extensions: %s", endpoint, supported, version, extensions) + if (supported) { + lastResult = RemoteOperationResult(ResultCode.OK).apply { data = true } + break@loop + } + + // Fallback probe on this endpoint: try POST with minimal test + Timber.d("TUS headers inconclusive at %s, probing with POST test", endpoint) + val tempFile = java.io.File.createTempFile("tus_probe", ".tmp") + tempFile.writeText("test") + val probe = CreateTusUploadRemoteOperation( + file = tempFile, + remotePath = "/tus_probe_test.tmp", + mimetype = "text/plain", + metadata = emptyMap(), + useCreationWithUpload = false, + firstChunkSize = null, + tusUrl = "", + collectionUrlOverride = endpoint, + ).execute(client) + tempFile.delete() + if (probe.isSuccess && !probe.data.isNullOrBlank()) { + supported = true + // Cleanup + try { DeleteTusUploadRemoteOperation(probe.data!!).execute(client) } catch (ignored: Exception) { Timber.w(ignored) } + lastResult = RemoteOperationResult(ResultCode.OK).apply { data = true } + break@loop + } + } else { + lastResult = RemoteOperationResult(options).apply { data = false } + } + } + + lastResult ?: RemoteOperationResult(ResultCode.OK).apply { data = supported } + } 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 new file mode 100644 index 000000000..05d7139bc --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt @@ -0,0 +1,199 @@ +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.HeadMethod +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.FileInputStream +import java.io.RandomAccessFile +import java.net.URL +import java.nio.channels.FileChannel +import java.security.MessageDigest + +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() { + + override fun run(client: OpenCloudClient): RemoteOperationResult { + return try { + // Determine TUS endpoint URL based on provided parameters + val targetFileUrl = when { + // 1. Use collectionUrlOverride if provided (for TUS support checks) + !collectionUrlOverride.isNullOrBlank() -> collectionUrlOverride + + // 2. Use tusUrl if provided and not empty (for existing upload sessions) + !tusUrl.isNullOrBlank() -> tusUrl + + // 3. Construct TUS endpoint URL to parent directory (server expects directory, not file) + else -> { + val parentDir = remotePath.substringBeforeLast('/', "/") + buildString { + append(client.baseUri.toString().trimEnd('/')) + append("/remote.php/webdav") + append(WebdavUtils.encodePath(parentDir)) + if (!endsWith("/")) append("/") + } + } + } + + Timber.d("TUS Creation URL: %s", targetFileUrl) + + // TUS Upload-Metadata (decoded for debugging) + val filename = remotePath.substringAfterLast('/') + val mtime = (file.lastModified() / 1000).toString() + val checksum = "sha1 " + computeSha1(file) + Timber.d("TUS Metadata: filename='%s', mtime='%s', checksum='%s'", filename, mtime, checksum) + + // Check if target file already exists (which might cause HTTP 412) + Timber.d("TUS: Checking if target file exists at %s", targetFileUrl) + try { + val headMethod = HeadMethod(URL(targetFileUrl)) + val headStatus = client.executeHttpMethod(headMethod) + if (headStatus == 200) { + Timber.w("TUS: Target file already exists (HEAD returned 200) - this might cause HTTP 412") + } else { + Timber.d("TUS: Target file does not exist (HEAD returned %d)", headStatus) + } + } catch (e: Exception) { + Timber.d("TUS: HEAD request failed: %s", e.message) + } + + // Prepare request body first + val postBody: RequestBody = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + // creation-with-upload: include first chunk + RandomAccessFile(file, "r").use { raf -> + 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 = mutableMapOf() + allMetadata["filename"] = remotePath.substringAfterLast('/') + allMetadata["mtime"] = (file.lastModified() / 1000).toString() + allMetadata["checksum"] = "sha1 " + computeSha1(file) + + 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 "") + + // 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 computeSha1(file: File): String { + val digest = MessageDigest.getInstance("SHA-1") + FileInputStream(file).use { fis -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} 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..8464f8b69 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt @@ -0,0 +1,95 @@ +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.PatchMethod +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.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, +) : RemoteOperation() { + + private val cancellationRequested = AtomicBoolean(false) + private val dataTransferListeners: MutableSet = HashSet() + private var patchMethod: PatchMethod? = 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()) + } + + patchMethod = 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) + } + + val status = client.executeHttpMethod(patchMethod) + Timber.d("Patch TUS upload chunk - $status${if (!isSuccess(status)) "(FAIL)" else ""}") + + if (isSuccess(status)) { + val newOffset = patchMethod!!.getResponseHeader(HttpConstants.UPLOAD_OFFSET)?.toLongOrNull() + if (newOffset != null) RemoteOperationResult(ResultCode.OK).apply { data = newOffset } + else RemoteOperationResult(patchMethod).apply { data = -1L } + } else RemoteOperationResult(patchMethod) + } + } catch (e: Exception) { + val result = if (patchMethod?.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) + patchMethod?.abort() + } + } + + private fun isSuccess(status: Int): Boolean = + status.isOneOf(HttpConstants.HTTP_OK, HttpConstants.HTTP_NO_CONTENT) +} 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..21ad0faca --- /dev/null +++ b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json @@ -0,0 +1,1194 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "f408f3867bf91ee326848528f5897778", + "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, `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": "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, `tusUploadOffset` INTEGER, `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": "tusUploadOffset", + "columnName": "tusUploadOffset", + "affinity": "INTEGER", + "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, 'f408f3867bf91ee326848528f5897778')" + ] + } +} \ No newline at end of file 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..4d1953daa 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt @@ -82,6 +82,7 @@ import eu.opencloud.android.data.user.db.UserQuotaEntity AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46), AutoMigration(from = 46, to = 47), + AutoMigration(from = 47, to = 48), ], version = ProviderMeta.DB_VERSION, exportSchema = true 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..c62aaa3da 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() { } 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..76e2e0794 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,20 @@ interface LocalTransferDataSource { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + + // TUS state management + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadOffset: Long?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + fun updateTusOffset(id: Long, tusUploadOffset: Long?) + 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..eeaaf3c82 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,38 @@ class OCLocalTransferDataSource( transferDao.deleteTransfersWithStatus(TransferStatus.TRANSFER_SUCCEEDED.value) } + // TUS state management + override fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadOffset: Long?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) { + transferDao.updateTusState( + id = id, + tusUploadUrl = tusUploadUrl, + tusUploadOffset = tusUploadOffset, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, + ) + } + + override fun updateTusOffset(id: Long, tusUploadOffset: Long?) { + transferDao.updateTusOffset(id = id, tusUploadOffset = tusUploadOffset) + } + + override fun updateTusUrl(id: Long, tusUploadUrl: String?) { + transferDao.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) + } companion object { @@ -161,6 +193,14 @@ class OCLocalTransferDataSource( transferId = transferId, spaceId = spaceId, sourcePath = sourcePath, + tusUploadUrl = tusUploadUrl, + tusUploadOffset = tusUploadOffset, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, ) @VisibleForTesting fun OCTransfer.toEntity() = OCTransferEntity( @@ -177,6 +217,14 @@ class OCLocalTransferDataSource( transferId = transferId, spaceId = spaceId, sourcePath = sourcePath, + tusUploadUrl = tusUploadUrl, + tusUploadOffset = tusUploadOffset, + 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..7e302c2ba 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,15 @@ data class OCTransferEntity( val transferId: String? = null, val spaceId: String? = null, val sourcePath: String? = null, + // TUS protocol state persistence + val tusUploadUrl: String? = null, + val tusUploadOffset: Long? = 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..f0378cfbc 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,26 @@ 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?, + tusUploadOffset: Long?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + @Query(UPDATE_TUS_OFFSET) + fun updateTusOffset(id: Long, tusUploadOffset: Long?) + + @Query(UPDATE_TUS_URL) + fun updateTusUrl(id: Long, tusUploadUrl: String?) + @Query(DELETE_TRANSFER_WITH_ID) fun deleteTransferWithId(id: Long) @@ -126,6 +146,31 @@ interface TransferDao { WHERE id = :id """ + private const val UPDATE_TUS_STATE = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadUrl = :tusUploadUrl, + tusUploadOffset = :tusUploadOffset, + tusUploadLength = :tusUploadLength, + tusUploadMetadata = :tusUploadMetadata, + tusUploadChecksum = :tusUploadChecksum, + tusResumableVersion = :tusResumableVersion, + tusUploadExpires = :tusUploadExpires, + tusUploadConcat = :tusUploadConcat + WHERE id = :id + """ + + private const val UPDATE_TUS_OFFSET = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadOffset = :tusUploadOffset + 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..807435530 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,34 @@ class OCTransferRepository( override fun clearSuccessfulTransfers() = localTransferDataSource.clearSuccessfulTransfers() + + // TUS state management + override fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadOffset: Long?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) = + localTransferDataSource.updateTusState( + id = id, + tusUploadUrl = tusUploadUrl, + tusUploadOffset = tusUploadOffset, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, + ) + + override fun updateTusOffset(id: Long, tusUploadOffset: Long?) = + localTransferDataSource.updateTusOffset(id = id, tusUploadOffset = tusUploadOffset) + + override fun updateTusUrl(id: Long, tusUploadUrl: String?) = + localTransferDataSource.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) } 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..5cd9c3a6e 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,20 @@ interface TransferRepository { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + + // TUS state management + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadOffset: Long?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + fun updateTusOffset(id: Long, tusUploadOffset: Long?) + 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..bd80b2cac 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,15 @@ data class OCTransfer( val transferId: String? = null, val spaceId: String? = null, val sourcePath: String? = null, + // TUS protocol state + val tusUploadUrl: String? = null, + val tusUploadOffset: Long? = 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") From db9d7382ca228507c3ef56f97ffc5072d2952838 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Thu, 23 Oct 2025 15:41:43 +0100 Subject: [PATCH 2/8] chore(deps): refine Dependabot config with weekly runs and groups - Gradle: switch to weekly (Mon 05:00 UTC), add PR limit (10), auto rebase add labels (Dependencies, Gradle), use deps scope commit messages, and group bumps (Kotlin, AndroidX, Google, Testing, Linting) - Add gradle-wrapper ecosystem: monthly (Mon 05:00 UTC), PR limit (5), auto rebase, labels (Dependencies, Gradle Wrapper), deps scope messages - GitHub Actions: schedule weekly (Mon 05:00 UTC), PR limit (5), auto rebase, labels (Dependencies, CI), deps scope messages - Remove boilerplate comments Why: reduce PR noise, batch related updates, standardize labeling/messages, ensure predictable update windows, and improve mergeability with auto-rebase --- .github/dependabot.yml | 70 +++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 36ccc72fe..81ea3a9a2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,21 +1,69 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - version: 2 updates: - - package-ecosystem: "gradle" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "gradle" + directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "UTC" + open-pull-requests-limit: 10 + rebase-strategy: "auto" labels: - "Dependencies" + - "Gradle" commit-message: - prefix: "feat" + prefix: "deps" + include: "scope" + groups: + kotlin-stack: + patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + - "com.google.devtools.ksp*" + androidx-core: + patterns: + - "androidx*" + google-mobile: + patterns: + - "com.google.*" + testing-libraries: + patterns: + - "junit*" + - "androidx.test*" + - "androidx.benchmark*" + - "io.mockk*" + linting: + patterns: + - "com.pinterest.ktlint*" + - "io.gitlab.arturbosch.detekt*" + - package-ecosystem: "gradle-wrapper" + directory: "/" + schedule: + interval: "monthly" + day: "monday" + time: "05:00" + timezone: "UTC" + open-pull-requests-limit: 5 + rebase-strategy: "auto" + labels: + - "Dependencies" + - "Gradle Wrapper" + commit-message: + prefix: "deps" + include: "scope" - package-ecosystem: "github-actions" - directory: "/" # Location of package manifests + directory: "/" schedule: interval: "weekly" + day: "monday" + time: "05:00" + timezone: "UTC" + open-pull-requests-limit: 5 + rebase-strategy: "auto" + labels: + - "Dependencies" + - "CI" commit-message: - prefix: "feat" + prefix: "deps" + include: "scope" From 816620468490367c123cc6e6d887f754dac316ab Mon Sep 17 00:00:00 2001 From: zerox80 Date: Thu, 23 Oct 2025 18:04:35 +0100 Subject: [PATCH 3/8] fix(cache): add LruCache and null-safe disk cache init for thumbnails - Introduce in-memory LruCache for thumbnails to reduce disk I/O and speed up image loading - Add null/recycled bitmap checks before caching to prevent crashes and wasted work - Make disk cache initialization resilient: fall back to internal cache dir when external is unavailable; log and skip if none - Add memory cache lock and wire add/remove to keep memory and disk caches in sync - Minor cleanup: remove unused imports/fields and improve logging --- .../datamodel/ThumbnailsCacheManager.java | 260 ++++++++++++++---- .../files/details/FileDetailsFragment.kt | 5 +- .../files/filelist/FileListAdapter.kt | 119 ++++++-- .../files/filelist/MainFileListFragment.kt | 39 ++- .../removefile/RemoveFilesDialogFragment.kt | 2 +- .../presentation/sharing/ShareFileFragment.kt | 3 +- .../adapter/ReceiveExternalFilesAdapter.java | 2 +- 7 files changed, 325 insertions(+), 105 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java b/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java index d4147ce3e..e16e90c45 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java @@ -32,7 +32,9 @@ import android.media.ThumbnailUtils; import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; import android.widget.ImageView; +import android.util.LruCache; import androidx.core.content.ContextCompat; import eu.opencloud.android.MainApp; @@ -50,7 +52,6 @@ import eu.opencloud.android.ui.adapter.DiskLruImageCache; import eu.opencloud.android.utils.BitmapUtils; import kotlin.Lazy; -import org.jetbrains.annotations.NotNull; import timber.log.Timber; import java.io.File; @@ -69,13 +70,14 @@ public class ThumbnailsCacheManager { private static final String CACHE_FOLDER = "thumbnailCache"; private static final Object mThumbnailsDiskCacheLock = new Object(); + private static final Object mMemoryCacheLock = new Object(); private static DiskLruImageCache mThumbnailCache = null; private static boolean mThumbnailCacheStarting = true; + private static final LruCache mMemoryCache = createMemoryCache(); private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB private static final CompressFormat mCompressFormat = CompressFormat.JPEG; private static final int mCompressQuality = 70; - private static OpenCloudClient mClient = null; private static final String PREVIEW_URI = "%s%s?x=%d&y=%d&c=%s&preview=1"; private static final String SPACE_SPECIAL_URI = "%s?scalingup=0&a=1&x=%d&y=%d&c=%s&preview=1"; @@ -97,17 +99,23 @@ protected Void doInBackground(File... params) { try { // Check if media is mounted or storage is built-in, if so, // try and use external cache dir; otherwise use internal cache dir - final String cachePath = - MainApp.Companion.getAppContext().getExternalCacheDir().getPath() + - File.separator + CACHE_FOLDER; - Timber.d("create dir: %s", cachePath); - final File diskCacheDir = new File(cachePath); - mThumbnailCache = new DiskLruImageCache( - diskCacheDir, - DISK_CACHE_SIZE, - mCompressFormat, - mCompressQuality - ); + File cacheBaseDir = MainApp.Companion.getAppContext().getExternalCacheDir(); + if (cacheBaseDir == null) { + cacheBaseDir = MainApp.Companion.getAppContext().getCacheDir(); + } + if (cacheBaseDir != null) { + final File diskCacheDir = new File(cacheBaseDir, CACHE_FOLDER); + Timber.d("create dir: %s", diskCacheDir.getAbsolutePath()); + mThumbnailCache = new DiskLruImageCache( + diskCacheDir, + DISK_CACHE_SIZE, + mCompressFormat, + mCompressQuality + ); + } else { + Timber.w("Thumbnail cache could not be opened: no cache directory available"); + mThumbnailCache = null; + } } catch (Exception e) { Timber.e(e, "Thumbnail cache could not be opened "); mThumbnailCache = null; @@ -121,6 +129,10 @@ protected Void doInBackground(File... params) { } public static void addBitmapToCache(String key, Bitmap bitmap) { + if (key == null || bitmap == null || bitmap.isRecycled()) { + return; + } + addBitmapToMemoryCache(key, bitmap); synchronized (mThumbnailsDiskCacheLock) { if (mThumbnailCache != null) { mThumbnailCache.put(key, bitmap); @@ -129,6 +141,7 @@ public static void addBitmapToCache(String key, Bitmap bitmap) { } public static void removeBitmapFromCache(String key) { + removeBitmapFromMemoryCache(key); synchronized (mThumbnailsDiskCacheLock) { if (mThumbnailCache != null) { mThumbnailCache.removeKey(key); @@ -137,6 +150,14 @@ public static void removeBitmapFromCache(String key) { } public static Bitmap getBitmapFromDiskCache(String key) { + Bitmap memoryBitmap = getBitmapFromMemoryCache(key); + if (memoryBitmap != null) { + if (!memoryBitmap.isRecycled()) { + return memoryBitmap; + } else { + removeBitmapFromMemoryCache(key); + } + } synchronized (mThumbnailsDiskCacheLock) { // Wait while disk cache is started from background thread while (mThumbnailCacheStarting) { @@ -147,27 +168,125 @@ public static Bitmap getBitmapFromDiskCache(String key) { } } if (mThumbnailCache != null) { - return mThumbnailCache.getBitmap(key); + Bitmap diskBitmap = mThumbnailCache.getBitmap(key); + if (diskBitmap != null && !diskBitmap.isRecycled()) { + addBitmapToMemoryCache(key, diskBitmap); + } + return diskBitmap; } } return null; } + public static Bitmap getBitmapFromDiskCache(OCFile file) { + String cacheKey = getCacheKeyForFile(file); + if (cacheKey == null) { + return null; + } + return getBitmapFromDiskCache(cacheKey); + } + + private static LruCache createMemoryCache() { + final int maxMemoryInKb = (int) (Runtime.getRuntime().maxMemory() / 1024); + final int cacheSizeInKb = Math.max(maxMemoryInKb / 8, 1024); + return new LruCache(cacheSizeInKb) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + if (bitmap == null) { + return 0; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return bitmap.getAllocationByteCount() / 1024; + } else { + return bitmap.getByteCount() / 1024; + } + } + }; + } + + private static Bitmap getBitmapFromMemoryCache(String key) { + if (key == null) { + return null; + } + synchronized (mMemoryCacheLock) { + return mMemoryCache.get(key); + } + } + + private static void addBitmapToMemoryCache(String key, Bitmap bitmap) { + if (key == null || bitmap == null || bitmap.isRecycled()) { + return; + } + synchronized (mMemoryCacheLock) { + mMemoryCache.put(key, bitmap); + } + } + + private static void removeBitmapFromMemoryCache(String key) { + if (key == null) { + return; + } + synchronized (mMemoryCacheLock) { + mMemoryCache.remove(key); + } + } + + private static void disableThumbnailsForFile(OCFile file) { + if (file == null) { + return; + } + Long fileId = file.getId(); + if (fileId == null) { + Timber.w("Unable to disable thumbnails for file without id: %s", file.getRemotePath()); + return; + } + Lazy disableThumbnailsForFileUseCaseLazy = + inject(DisableThumbnailsForFileUseCase.class); + disableThumbnailsForFileUseCaseLazy.getValue() + .invoke(new DisableThumbnailsForFileUseCase.Params(fileId)); + } + + private static String getCacheKeyForFile(OCFile file) { + if (file == null) { + return null; + } + String remoteId = file.getRemoteId(); + if (remoteId != null && !remoteId.isEmpty()) { + return remoteId; + } + String remotePath = file.getRemotePath(); + if (remotePath == null || remotePath.isEmpty()) { + return null; + } + StringBuilder cacheKeyBuilder = new StringBuilder(); + if (file.getOwner() != null) { + cacheKeyBuilder.append(file.getOwner()); + } + cacheKeyBuilder.append('|'); + if (file.getSpaceId() != null) { + cacheKeyBuilder.append(file.getSpaceId()); + } + cacheKeyBuilder.append('|'); + cacheKeyBuilder.append(remotePath); + return cacheKeyBuilder.toString(); + } + public static class ThumbnailGenerationTask extends AsyncTask { private final WeakReference mImageViewReference; - private static Account mAccount; + private final Account mAccount; private Object mFile; private FileDataStorageManager mStorageManager; + private OpenCloudClient mClient; public ThumbnailGenerationTask(ImageView imageView, Account account) { // Use a WeakReference to ensure the ImageView can be garbage collected mImageViewReference = new WeakReference<>(imageView); mAccount = account; + mClient = null; } public ThumbnailGenerationTask(ImageView imageView) { - // Use a WeakReference to ensure the ImageView can be garbage collected - mImageViewReference = new WeakReference<>(imageView); + this(imageView, null); } @Override @@ -182,6 +301,8 @@ protected Bitmap doInBackground(Object... params) { ); mClient = SingleSessionManager.getDefaultSingleton(). getClientFor(ocAccount, MainApp.Companion.getAppContext()); + } else { + mClient = null; } mFile = params[0]; @@ -259,8 +380,11 @@ private int getThumbnailDimension() { return Math.round(r.getDimension(R.dimen.file_icon_size_grid)); } - private String getPreviewUrl(OCFile ocFile, Account account) { - String baseUrl = mClient.getBaseUri() + "/remote.php/dav/files/" + AccountUtils.getUserId(account, MainApp.Companion.getAppContext()); + private String getPreviewUrl(OCFile ocFile) { + if (mClient == null || mAccount == null) { + return null; + } + String baseUrl = mClient.getBaseUri() + "/remote.php/dav/files/" + AccountUtils.getUserId(mAccount, MainApp.Companion.getAppContext()); if (ocFile.getSpaceId() != null) { Lazy getWebDavUrlForSpaceUseCaseLazy = inject(GetWebDavUrlForSpaceUseCase.class); @@ -269,6 +393,9 @@ private String getPreviewUrl(OCFile ocFile, Account account) { ); } + if (baseUrl == null) { + return null; + } return String.format(Locale.ROOT, PREVIEW_URI, baseUrl, @@ -281,7 +408,10 @@ private String getPreviewUrl(OCFile ocFile, Account account) { private Bitmap doOCFileInBackground() { OCFile file = (OCFile) mFile; - final String imageKey = String.valueOf(file.getRemoteId()); + final String imageKey = getCacheKeyForFile(file); + if (imageKey == null) { + return null; + } // Check disk cache in background thread Bitmap thumbnail = getBitmapFromDiskCache(imageKey); @@ -295,30 +425,43 @@ private Bitmap doOCFileInBackground() { if (mClient != null) { GetMethod get; try { - String uri = getPreviewUrl(file, mAccount); + String uri = getPreviewUrl(file); + if (uri == null) { + return thumbnail; + } Timber.d("URI: %s", uri); get = new GetMethod(new URL(uri)); int status = mClient.executeHttpMethod(get); if (status == HttpConstants.HTTP_OK) { InputStream inputStream = get.getResponseBodyAsStream(); - Bitmap bitmap = BitmapFactory.decodeStream(inputStream); - thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); - - // Handle PNG - if (file.getMimeType().equalsIgnoreCase("image/png")) { - thumbnail = handlePNG(thumbnail, px); - } - - // Add thumbnail to cache - if (thumbnail != null) { - addBitmapToCache(imageKey, thumbnail); + try { + if (inputStream != null) { + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + if (bitmap != null) { + thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); + } + if (thumbnail != null && file.getMimeType().equalsIgnoreCase("image/png")) { + thumbnail = handlePNG(thumbnail, px); + } + if (thumbnail != null) { + addBitmapToCache(imageKey, thumbnail); + file.setNeedsToUpdateThumbnail(false); + disableThumbnailsForFile(file); + } + } + } finally { + mClient.exhaustResponse(inputStream); } } else { - mClient.exhaustResponse(get.getResponseBodyAsStream()); - } - if (status == HttpConstants.HTTP_OK || status == HttpConstants.HTTP_NOT_FOUND) { - @NotNull Lazy disableThumbnailsForFileUseCaseLazy = inject(DisableThumbnailsForFileUseCase.class); - disableThumbnailsForFileUseCaseLazy.getValue().invoke(new DisableThumbnailsForFileUseCase.Params(file.getId())); + InputStream responseStream = get.getResponseBodyAsStream(); + try { + if (status == HttpConstants.HTTP_NOT_FOUND) { + file.setNeedsToUpdateThumbnail(false); + disableThumbnailsForFile(file); + } + } finally { + mClient.exhaustResponse(responseStream); + } } } catch (Exception e) { Timber.e(e); @@ -399,17 +542,27 @@ private Bitmap doSpaceImageInBackground() { int status = mClient.executeHttpMethod(get); if (status == HttpConstants.HTTP_OK) { InputStream inputStream = get.getResponseBodyAsStream(); - Bitmap bitmap = BitmapFactory.decodeStream(inputStream); - thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); - - // Handle PNG - if (spaceSpecial.getFile().getMimeType().equalsIgnoreCase("image/png")) { - thumbnail = handlePNG(thumbnail, px); - } - - // Add thumbnail to cache - if (thumbnail != null) { - addBitmapToCache(imageKey, thumbnail); + try { + if (inputStream != null) { + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + if (bitmap != null) { + thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); + } + + // Handle PNG + if (thumbnail != null + && spaceSpecial.getFile() != null + && spaceSpecial.getFile().getMimeType().equalsIgnoreCase("image/png")) { + thumbnail = handlePNG(thumbnail, px); + } + + // Add thumbnail to cache + if (thumbnail != null) { + addBitmapToCache(imageKey, thumbnail); + } + } + } finally { + mClient.exhaustResponse(inputStream); } } else { mClient.exhaustResponse(get.getResponseBodyAsStream()); @@ -444,6 +597,17 @@ public static boolean cancelPotentialThumbnailWork(Object file, ImageView imageV return true; } + public static void executeThumbnailTask(ThumbnailGenerationTask task, Object file) { + if (task == null || file == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, file); + } else { + task.execute(file); + } + } + private static ThumbnailGenerationTask getBitmapWorkerTask(ImageView imageView) { if (imageView != null) { final Drawable drawable = imageView.getDrawable(); 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..f450d070d 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 @@ -428,8 +428,7 @@ class FileDetailsFragment : FileFragment() { } } if (ocFile.isImage) { - val tagId = ocFile.remoteId.toString() - var thumbnail: Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(tagId) + var thumbnail: Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(ocFile) if (thumbnail != null && !ocFile.needsToUpdateThumbnail) { imageView.setImageBitmap(thumbnail) } else { @@ -441,7 +440,7 @@ class FileDetailsFragment : FileFragment() { } val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(MainApp.appContext.resources, thumbnail, task) imageView.setImageDrawable(asyncDrawable) - task.execute(ocFile) + ThumbnailsCacheManager.executeThumbnailTask(task, ocFile) } } } else { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt index 911bae6f6..3c57db5e3 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt @@ -60,13 +60,19 @@ class FileListAdapter( var files = mutableListOf() private var account: Account? = AccountUtils.getCurrentOpenCloudAccount(context) private var fileListOption: FileListOption = FileListOption.ALL_FILES + private val disallowTouchesWithOtherWindows = + PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + + init { + setHasStableIds(true) + } fun updateFileList(filesToAdd: List, fileListOption: FileListOption) { val listWithFooter = mutableListOf() listWithFooter.addAll(filesToAdd) - if (listWithFooter.isNotEmpty()) { + if (listWithFooter.isNotEmpty() && !isPickerMode) { listWithFooter.add(OCFooterFile(manageListOfFilesAndGenerateText(filesToAdd))) } @@ -85,13 +91,22 @@ class FileListAdapter( diffResult.dispatchUpdatesTo(this) } + override fun getItemId(position: Int): Long { + val item = files.getOrNull(position) + return when (item) { + is OCFileWithSyncInfo -> item.file.id ?: item.file.remotePath.hashCode().toLong() + is OCFooterFile -> Long.MIN_VALUE + position + else -> position.toLong() + } + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = when (viewType) { ViewType.LIST_ITEM.ordinal -> { val binding = ItemFileListBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.LIST_ITEM - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } ListViewHolder(binding) } @@ -100,7 +115,7 @@ class FileListAdapter( val binding = GridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.GRID_IMAGE - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } GridImageViewHolder(binding) } @@ -109,7 +124,7 @@ class FileListAdapter( val binding = GridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.GRID_ITEM - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } GridViewHolder(binding) } @@ -118,7 +133,7 @@ class FileListAdapter( val binding = ListFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.FOOTER - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } FooterViewHolder(binding) } @@ -126,9 +141,11 @@ class FileListAdapter( override fun getItemCount(): Int = files.size - override fun getItemId(position: Int): Long = position.toLong() + private fun hasFooter(): Boolean = files.lastOrNull() is OCFooterFile - private fun isFooter(position: Int) = position == files.size.minus(1) + private fun isFooter(position: Int) = files.getOrNull(position) is OCFooterFile + + private fun selectableItemCount(): Int = files.size - if (hasFooter()) 1 else 0 override fun getItemViewType(position: Int): Int = @@ -166,33 +183,44 @@ class FileListAdapter( fun selectAll() { // Last item on list is the footer, so that element must be excluded from selection - selectAll(totalItems = files.size - 1) + selectAll(totalItems = selectableItemCount()) } fun selectInverse() { // Last item on list is the footer, so that element must be excluded from selection - toggleSelectionInBulk(totalItems = files.size - 1) + toggleSelectionInBulk(totalItems = selectableItemCount()) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val viewType = getItemViewType(position) + AccountUtils.getCurrentOpenCloudAccount(context)?.let { currentAccount -> + if (currentAccount != account) { + account = currentAccount + } + } ?: run { + if (account != null) { + account = null + } + } + if (viewType != ViewType.FOOTER.ordinal) { // Is Item + val hasActiveSelection = selectedItemCount > 0 val fileWithSyncInfo = files[position] as OCFileWithSyncInfo val file = fileWithSyncInfo.file val name = file.fileName val fileIcon = holder.itemView.findViewById(R.id.thumbnail).apply { tag = file.id } - val thumbnail: Bitmap? = file.remoteId?.let { ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) } + val thumbnail: Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(file) holder.itemView.findViewById(R.id.ListItemLayout)?.apply { contentDescription = "LinearLayout-$name" // Allow or disallow touches with other visible windows - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } holder.itemView.findViewById(R.id.share_icons_layout).isVisible = @@ -201,26 +229,35 @@ class FileListAdapter( holder.itemView.findViewById(R.id.shared_via_users_icon).isVisible = file.sharedWithSharee == true || file.isSharedWithMe - setSpecificViewHolder(viewType, holder, fileWithSyncInfo, thumbnail) + setSpecificViewHolder(viewType, holder, fileWithSyncInfo, thumbnail, hasActiveSelection) setIconPinAccordingToFilesLocalState(holder.itemView.findViewById(R.id.localFileIndicator), fileWithSyncInfo) holder.itemView.setOnClickListener { + val adapterPosition = holder.bindingAdapterPosition + if (adapterPosition == RecyclerView.NO_POSITION) { + return@setOnClickListener + } + val currentItem = files.getOrNull(adapterPosition) as? OCFileWithSyncInfo ?: return@setOnClickListener listener.onItemClick( - ocFileWithSyncInfo = fileWithSyncInfo, - position = position + ocFileWithSyncInfo = currentItem, + position = adapterPosition ) } holder.itemView.setOnLongClickListener { + val adapterPosition = holder.bindingAdapterPosition + if (adapterPosition == RecyclerView.NO_POSITION) { + return@setOnLongClickListener false + } listener.onLongItemClick( - position = position + position = adapterPosition ) } holder.itemView.setBackgroundColor(Color.WHITE) val checkBoxV = holder.itemView.findViewById(R.id.custom_checkbox).apply { - isVisible = getCheckedItems().isNotEmpty() + isVisible = hasActiveSelection } if (isSelected(position)) { @@ -234,6 +271,7 @@ class FileListAdapter( if (file.isFolder) { // Folder fileIcon.setImageResource(R.drawable.ic_menu_archive) + fileIcon.setBackgroundColor(Color.TRANSPARENT) } else { // Set file icon depending on its mimetype. Ask for thumbnail later. fileIcon.setImageResource(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) @@ -241,20 +279,28 @@ class FileListAdapter( if (thumbnail != null) { fileIcon.setImageBitmap(thumbnail) } - if (file.needsToUpdateThumbnail && ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, fileIcon)) { - // generate new Thumbnail - val task = ThumbnailsCacheManager.ThumbnailGenerationTask(fileIcon, account) - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(context.resources, thumbnail, task) - - // If drawable is not visible, do not update it. - if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { - fileIcon.setImageDrawable(asyncDrawable) + + if (file.needsToUpdateThumbnail) { + val canStartTask = ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, fileIcon) + val activeAccount = account + if (activeAccount != null && canStartTask) { + // generate new Thumbnail + val task = ThumbnailsCacheManager.ThumbnailGenerationTask(fileIcon, activeAccount) + val placeholder = thumbnail ?: ThumbnailsCacheManager.mDefaultImg + val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(context.resources, placeholder, task) + + // If drawable is not visible, do not update it. + if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { + fileIcon.setImageDrawable(asyncDrawable) + } + ThumbnailsCacheManager.executeThumbnailTask(task, file) } - task.execute(file) } - if (file.mimeType == "image/png") { + if (file.mimeType.equals("image/png", ignoreCase = true)) { fileIcon.setBackgroundColor(ContextCompat.getColor(context, R.color.background_color)) + } else { + fileIcon.setBackgroundColor(Color.TRANSPARENT) } } @@ -270,18 +316,24 @@ class FileListAdapter( } } - private fun setSpecificViewHolder(viewType: Int, holder: RecyclerView.ViewHolder, fileWithSyncInfo: OCFileWithSyncInfo, thumbnail: Bitmap?) { + private fun setSpecificViewHolder( + viewType: Int, + holder: RecyclerView.ViewHolder, + fileWithSyncInfo: OCFileWithSyncInfo, + thumbnail: Bitmap?, + hasActiveSelection: Boolean, + ) { val file = fileWithSyncInfo.file when (viewType) { ViewType.LIST_ITEM.ordinal -> { val view = holder as ListViewHolder view.binding.let { - it.fileListConstraintLayout.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + it.fileListConstraintLayout.filterTouchesWhenObscured = disallowTouchesWithOtherWindows it.Filename.text = file.fileName it.fileListSize.text = DisplayUtils.bytesToHumanReadable(file.length, context, true) it.fileListLastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp) - it.threeDotMenu.isVisible = getCheckedItems().isEmpty() + it.threeDotMenu.isVisible = !hasActiveSelection it.threeDotMenu.contentDescription = context.getString(R.string.content_description_file_operations, file.fileName) if (fileListOption.isAvailableOffline() || (fileListOption.isSharedByLink() && fileWithSyncInfo.space == null)) { it.spacePathLine.path.apply { @@ -321,7 +373,10 @@ class FileListAdapter( val layoutParams = fileIcon.layoutParams as ViewGroup.MarginLayoutParams if (thumbnail == null) { - view.binding.Filename.text = file.fileName + view.binding.Filename.apply { + text = file.fileName + isVisible = true + } // Reset layout params values default manageGridLayoutParams( layoutParams = layoutParams, @@ -330,6 +385,10 @@ class FileListAdapter( width = context.resources.getDimensionPixelSize(R.dimen.item_file_grid_width), ) } else { + view.binding.Filename.apply { + text = "" + isVisible = false + } manageGridLayoutParams( layoutParams = layoutParams, marginVertical = context.resources.getDimensionPixelSize(R.dimen.item_file_image_grid_margin), 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..14631aca0 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 @@ -603,29 +603,28 @@ class MainFileListFragment : Fragment(), } else { // Set file icon depending on its mimetype. Ask for thumbnail later. 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)) { - // generate new Thumbnail - val task = ThumbnailsCacheManager.ThumbnailGenerationTask( - thumbnailBottomSheet, - AccountUtils.getCurrentOpenCloudAccount(requireContext()) - ) - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(resources, thumbnail, task) - // If drawable is not visible, do not update it. - if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { - thumbnailBottomSheet.setImageDrawable(asyncDrawable) - } - task.execute(file) - } + val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file) + if (thumbnail != null) { + thumbnailBottomSheet.setImageBitmap(thumbnail) + } + 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) - if (file.mimeType == "image/png") { - thumbnailBottomSheet.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.background_color)) + // If drawable is not visible, do not update it. + if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { + thumbnailBottomSheet.setImageDrawable(asyncDrawable) } + ThumbnailsCacheManager.executeThumbnailTask(task, file) + } + + if (file.mimeType == "image/png") { + thumbnailBottomSheet.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.background_color)) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt index af3baf9dd..5ee6055f6 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt @@ -121,7 +121,7 @@ class RemoveFilesDialogFragment : DialogFragment() { if (files.size == 1) { val file = files[0] // Show the thumbnail when the file has one - val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) + val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file) if (thumbnail != null) { thumbnailImageView.setImageBitmap(thumbnail) } else { 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..d13f9c07d 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 @@ -237,8 +237,7 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe ) ) if (file!!.isImage) { - val remoteId = file?.remoteId.toString() - val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(remoteId) + val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file!!) if (thumbnail != null) { binding.shareFileIcon.setImageBitmap(thumbnail) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java index ec7f97096..f8c6853ec 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java @@ -167,7 +167,7 @@ public View getView(int position, View convertView, ViewGroup parent) { task ); fileIcon.setImageDrawable(asyncDrawable); - task.execute(file); + ThumbnailsCacheManager.executeThumbnailTask(task, file); } } } else { From 10f19fe21385ecd6646e21181c88373ca12dfc06 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 24 Oct 2025 07:29:22 +0100 Subject: [PATCH 4/8] feat(upload): honor TUS capabilities and refine fallback logic - Read filesTusSupport from capabilities and pass into TUS flow - Respect server maxChunkSize and HTTP method override during PATCH - Prefer TUS for large files; on failure, force chunked fallback for large files when TUS is advertised, even if legacy chunking is off - Expanded logging for chosen chunk sizes and overrides - Applied to ContentUri and FileSystem upload workers Why: improve interoperability with varying servers/proxies, respect server limits, and increase reliability of large uploads. --- .../workers/UploadFileFromContentUriWorker.kt | 23 +- .../workers/UploadFileFromFileSystemWorker.kt | 26 +- .../lib/common/http/HttpConstants.java | 1 + .../tus/PatchTusUploadChunkRemoteOperation.kt | 41 +- .../lib/resources/status/RemoteCapability.kt | 9 + .../status/responses/CapabilityResponse.kt | 24 +- .../49.json | 1224 +++++++++++++++++ .../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_49.kt | 30 + .../domain/capabilities/model/OCCapability.kt | 9 + .../android/testutil/OCCapability.kt | 1 + 15 files changed, 1389 insertions(+), 26 deletions(-) create mode 100644 opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/49.json create mode 100644 opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_49.kt 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 8a8dd2437..aa40d354a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -44,6 +44,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.model.OCCapability import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.lib.common.OpenCloudAccount import eu.opencloud.android.lib.common.OpenCloudClient @@ -257,13 +258,14 @@ class UploadFileFromContentUriWorker( ) ) val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed() + val tusSupport = capabilitiesForAccount?.filesTusSupport Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE) // Prefer TUS for large files: optimistically try TUS and fall back on failure val usedTus = if (fileSize > CHUNK_SIZE) { Timber.d("Attempting TUS for large upload (size=%d, threshold=%d)", fileSize, CHUNK_SIZE) val ok = try { - uploadTusFile(client) + uploadTusFile(client, tusSupport) true } catch (e: Exception) { Timber.w(e, "TUS flow failed, will fallback to existing upload methods") @@ -277,7 +279,9 @@ class UploadFileFromContentUriWorker( } if (!usedTus) { - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { + val shouldForceChunkedFallback = !isChunkingAllowed && fileSize > CHUNK_SIZE && tusSupport != null + val useChunkedFallback = (isChunkingAllowed && fileSize > CHUNK_SIZE) || shouldForceChunkedFallback + if (useChunkedFallback) { uploadChunkedFile(client) } else { uploadPlainFile(client) @@ -286,7 +290,7 @@ class UploadFileFromContentUriWorker( removeCacheFile() } - private fun uploadTusFile(client: OpenCloudClient) { + private fun uploadTusFile(client: OpenCloudClient, tusSupport: OCCapability.TusSupport?) { Timber.i("Starting TUS upload for %s (size=%d)", uploadPath, fileSize) // 1) Create or resume session @@ -339,9 +343,13 @@ class UploadFileFromContentUriWorker( } Timber.d("TUS resume offset: %d / %d", offset, fileSize) - // Use fixed chunk size if server max is unknown - val serverMaxChunk: Long? = null - Timber.d("TUS using fixed chunk size: %d", CHUNK_SIZE) + val serverMaxChunk: Long? = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong() + Timber.d( + "TUS chunk preferences: clientChunk=%d serverMax=%s httpOverride=%s", + CHUNK_SIZE, + serverMaxChunk, + tusSupport?.httpMethodOverride + ) // 3) PATCH loop while (offset < fileSize) { @@ -353,7 +361,8 @@ class UploadFileFromContentUriWorker( localPath = cachePath, uploadUrl = tusUrl, offset = offset, - chunkSize = toSend + chunkSize = toSend, + httpMethodOverride = tusSupport?.httpMethodOverride, ).apply { addDataTransferProgressListener(this@UploadFileFromContentUriWorker) } 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 0b2dc0fa5..6f24a718c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -30,6 +30,7 @@ import androidx.work.workDataOf import eu.opencloud.android.R import eu.opencloud.android.data.executeRemoteOperation import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior +import eu.opencloud.android.domain.capabilities.model.OCCapability import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.UnauthorizedException @@ -145,7 +146,7 @@ class UploadFileFromFileSystemWorker( return md.digest().joinToString("") { b -> "%02x".format(b) } } - private fun uploadViaTus(client: OpenCloudClient): Boolean { + private fun uploadViaTus(client: OpenCloudClient, tusSupport: OCCapability.TusSupport?): Boolean { try { Timber.d("TUS: entering uploadViaTus for %s size=%d", uploadPath, fileSize) // 1) Create or reuse TUS upload URL @@ -206,7 +207,13 @@ class UploadFileFromFileSystemWorker( // 3) PATCH loop with basic retry/resume on transient failures var consecutiveFailures = 0 val maxRetries = 5 - val serverMaxChunk: Long? = null + val serverMaxChunk: Long? = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong() + Timber.d( + "TUS chunk preferences: clientChunk=%d serverMax=%s httpOverride=%s", + CHUNK_SIZE, + serverMaxChunk, + tusSupport?.httpMethodOverride + ) while (offset < fileSize) { val remaining = fileSize - offset val limitByServer = serverMaxChunk ?: Long.MAX_VALUE @@ -218,6 +225,7 @@ class UploadFileFromFileSystemWorker( uploadUrl = tusUrl, offset = offset, chunkSize = chunk, + httpMethodOverride = tusSupport?.httpMethodOverride, ).apply { addDataTransferProgressListener(this@UploadFileFromFileSystemWorker) } @@ -391,12 +399,13 @@ class UploadFileFromFileSystemWorker( ) ) val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed() + val tusSupport = capabilitiesForAccount?.filesTusSupport Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE) // Prefer TUS for large files: optimistically try TUS create and let it fail fast if unsupported val usedTus = if (fileSize > CHUNK_SIZE) { Timber.d("Attempting TUS for large upload (size=%d, threshold=%d)", fileSize, CHUNK_SIZE) - val ok = uploadViaTus(client) + val ok = uploadViaTus(client, tusSupport) Timber.d("TUS attempt result: %s", if (ok) "success" else "failed") ok } else { @@ -405,8 +414,15 @@ class UploadFileFromFileSystemWorker( } if (!usedTus) { - Timber.d("Proceeding without TUS: %s", if (isChunkingAllowed && fileSize > CHUNK_SIZE) "chunked WebDAV" else "plain WebDAV") - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { + val shouldForceChunkedFallback = !isChunkingAllowed && fileSize > CHUNK_SIZE && tusSupport != null + val useChunkedFallback = (isChunkingAllowed && fileSize > CHUNK_SIZE) || shouldForceChunkedFallback + Timber.d( + "Proceeding without TUS: %s (forcedChunkFallback=%s, httpOverride=%s)", + if (useChunkedFallback) "chunked WebDAV" else "plain WebDAV", + shouldForceChunkedFallback, + tusSupport?.httpMethodOverride + ) + if (useChunkedFallback) { uploadChunkedFile(client) } else { uploadPlainFile(client) 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 47b5f68e3..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"; 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 8464f8b69..f04cd769d 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 @@ -2,7 +2,9 @@ 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 @@ -16,6 +18,7 @@ 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 /** @@ -28,11 +31,12 @@ class PatchTusUploadChunkRemoteOperation( 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 patchMethod: PatchMethod? = null + private var activeMethod: HttpBaseMethod? = null override fun run(client: OpenCloudClient): RemoteOperationResult = try { @@ -52,23 +56,40 @@ class PatchTusUploadChunkRemoteOperation( return RemoteOperationResult(OperationCancelledException()) } - patchMethod = PatchMethod(URL(uploadUrl), body).apply { + 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) } - val status = client.executeHttpMethod(patchMethod) - Timber.d("Patch TUS upload chunk - $status${if (!isSuccess(status)) "(FAIL)" else ""}") + 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 = patchMethod!!.getResponseHeader(HttpConstants.UPLOAD_OFFSET)?.toLongOrNull() - if (newOffset != null) RemoteOperationResult(ResultCode.OK).apply { data = newOffset } - else RemoteOperationResult(patchMethod).apply { data = -1L } - } else RemoteOperationResult(patchMethod) + 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 (patchMethod?.isAborted == true) { + val result = if (activeMethod?.isAborted == true) { RemoteOperationResult(OperationCancelledException()) } else RemoteOperationResult(e) Timber.e(result.exception, "Patch TUS upload chunk failed: ${result.logMessage}") @@ -86,7 +107,7 @@ class PatchTusUploadChunkRemoteOperation( fun cancel() { synchronized(cancellationRequested) { cancellationRequested.set(true) - patchMethod?.abort() + activeMethod?.abort() } } 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/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/49.json b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/49.json new file mode 100644 index 000000000..5cac1713e --- /dev/null +++ b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/49.json @@ -0,0 +1,1224 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "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, `tusUploadOffset` INTEGER, `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": "tusUploadOffset", + "columnName": "tusUploadOffset", + "affinity": "INTEGER", + "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')" + ] + } +} \ No newline at end of file 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 4d1953daa..761fb6d02 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_48_49 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 @@ -124,7 +125,8 @@ abstract class OpencloudDatabase : RoomDatabase() { MIGRATION_35_36, MIGRATION_37_38, MIGRATION_41_42, - MIGRATION_42_43) + MIGRATION_42_43, + MIGRATION_48_49) .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 c62aaa3da..af940d80d 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 = 48; + public static final int DB_VERSION = 49; 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_49.kt b/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_49.kt new file mode 100644 index 000000000..7f0bf79ea --- /dev/null +++ b/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_49.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_48_49 = object : Migration(48, 49) { + 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/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/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 e99a96e7221e294530259d7ebb58ec3699a5d4ee Mon Sep 17 00:00:00 2001 From: zerox80 Date: Thu, 30 Oct 2025 00:00:01 +0100 Subject: [PATCH 5/8] fix(upload): use source file mtime for retries; avoid null string - On retry, derive lastModifiedInSeconds from File.lastModified() - Pass a nullable value to workers rather than the literal null string - Switch to WorkManager Data API to handle nulls - Ensures accurate Last-Modified metadata and more reliable conflict checks --- .../RetryUploadFromContentUriUseCase.kt | 10 +++++- .../uploads/RetryUploadFromSystemUseCase.kt | 10 +++++- .../UploadFileFromContentUriUseCase.kt | 30 +++++++++------- .../uploads/UploadFileFromSystemUseCase.kt | 34 +++++++++++-------- .../workers/UploadFileFromContentUriWorker.kt | 20 +++++++++-- .../workers/UploadFileFromFileSystemWorker.kt | 16 +++++++-- 6 files changed, 87 insertions(+), 33 deletions(-) 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..98591f00b 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,10 @@ package eu.opencloud.android.usecases.transfers.uploads import android.net.Uri import androidx.work.Constraints +import androidx.work.Data 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 +39,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() @@ -82,7 +86,7 @@ class UploadFileFromContentUriUseCase( 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..7157e96c5 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,10 @@ package eu.opencloud.android.usecases.transfers.uploads import androidx.work.Constraints +import androidx.work.Data 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 +38,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) @@ -62,7 +68,7 @@ class UploadFileFromSystemUseCase( .build() val behavior = UploadBehavior.fromString(params.behavior) - if (behavior == UploadBehavior.MOVE) { + if (behavior == UploadBehavior.MOVE && params.sourcePath != null) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() .setInputData(inputDataRemoveSourceFileWorker) .build() @@ -79,7 +85,7 @@ class UploadFileFromSystemUseCase( 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/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt index aa40d354a..5560ae5d4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -87,7 +87,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 @@ -151,7 +151,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 @@ -186,6 +186,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()) { @@ -203,6 +204,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 = @@ -251,6 +266,7 @@ class UploadFileFromContentUriWorker( val cacheFile = File(cachePath) mimeType = cacheFile.extension fileSize = cacheFile.length() + ensureValidLastModified(null, cacheFile) val capabilitiesForAccount = getStoredCapabilitiesUseCase( GetStoredCapabilitiesUseCase.Params( 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 6f24a718c..7c7c62c7f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -85,7 +85,7 @@ 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 @@ -308,7 +308,7 @@ class UploadFileFromFileSystemWorker( 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 @@ -335,6 +335,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 = From b7c74407a7d80b42172ea7062778f465542f7684 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 31 Oct 2025 09:32:32 +0100 Subject: [PATCH 6/8] chore: update Gradle build configuration settings - Added android.overridePathCheck=true to gradle.properties to handle path validation - Enabled buildConfig feature explicitly in app's build.gradle - Preserved existing buildfeatures.buildconfig setting in gradle.properties for compatibility --- gradle.properties | 3 ++- opencloudApp/build.gradle | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 022f0d32f..6d5db7721 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ -android.defaults.buildfeatures.buildconfig=true +android.defaults.buildfeatures.buildconfig=true android.enableJetifier=true android.nonFinalResIds=false android.nonTransitiveRClass=false android.useAndroidX=true org.gradle.jvmargs=-Xmx1536M +android.overridePathCheck=true 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 { From 12582a8b89f2f8c5bafb11677a05c4720d84651c Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 1 Nov 2025 12:25:10 +0100 Subject: [PATCH 7/8] chore(deps): remove gradle-wrapper updates configuration Removed the gradle-wrapper update configuration from .github/dependabot.yml to stop automated updates for the Gradle wrapper. This change aligns with the project's dependency management strategy. --- .github/dependabot.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 81ea3a9a2..8ded9fd3e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,21 +37,6 @@ updates: patterns: - "com.pinterest.ktlint*" - "io.gitlab.arturbosch.detekt*" - - package-ecosystem: "gradle-wrapper" - directory: "/" - schedule: - interval: "monthly" - day: "monday" - time: "05:00" - timezone: "UTC" - open-pull-requests-limit: 5 - rebase-strategy: "auto" - labels: - - "Dependencies" - - "Gradle Wrapper" - commit-message: - prefix: "deps" - include: "scope" - package-ecosystem: "github-actions" directory: "/" schedule: From 44c3b4a4e081553856d1c0a281e172daa06f00f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 11:26:32 +0000 Subject: [PATCH 8/8] deps(deps): bump actions/setup-java from 4 to 5 Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/android-unit-tests.yml | 2 +- .github/workflows/detekt.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-unit-tests.yml b/.github/workflows/android-unit-tests.yml index 1c10ad549..b714f9146 100644 --- a/.github/workflows/android-unit-tests.yml +++ b/.github/workflows/android-unit-tests.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' diff --git a/.github/workflows/detekt.yml b/.github/workflows/detekt.yml index a70f9aa82..cf83466f4 100644 --- a/.github/workflows/detekt.yml +++ b/.github/workflows/detekt.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin'