From 6cb7aa1da4bd46cab3d752e96f641c179097600b Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 19:27:38 +0100 Subject: [PATCH 01/11] feat: use external storage with human-readable folder names - Switch to External Storage Directory in ScopedStorageProvider. - Request MANAGE_EXTERNAL_STORAGE permission on Android R+. - Use Space names instead of IDs for folder structure. - Rename data folder to OpenCloud. --- opencloudApp/src/main/AndroidManifest.xml | 1 + .../ui/activity/FileDisplayActivity.kt | 19 +++++++++++++++++++ .../android/workers/DownloadFileWorker.kt | 12 +++++++++++- opencloudApp/src/main/res/values/setup.xml | 2 +- .../data/providers/LocalStorageProvider.kt | 13 +++++++++++-- .../data/providers/ScopedStorageProvider.kt | 8 +++++++- 6 files changed, 50 insertions(+), 5 deletions(-) diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 93edc6257..6aea0dacd 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ API >= 23; the app needs to handle this --> + + Download all files + Download all files from your cloud for offline access (requires significant storage) + Download Everything + This will download ALL files from your cloud. This may use significant storage space and bandwidth. Continue? + + + Auto-sync local changes + Automatically upload changes to locally modified files + Auto-Sync + Local file changes will be automatically synced to the cloud. This requires a stable network connection. Continue? + diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index 91c72bb0d..b81b6a783 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -49,4 +49,18 @@ app:summary="@string/prefs_touches_with_other_visible_windows_summary" app:title="@string/prefs_touches_with_other_visible_windows" /> + + + + + + \ No newline at end of file From c33476336b9eb374cfefa3b5173e9652ce15fa9f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 20:38:11 +0100 Subject: [PATCH 03/11] fix(workers): Fix DownloadEverythingWorker to properly download all files - Root cause: refreshFolder() only returned changed files, not all - Solution: Use getFolderContent(folderId) after refresh to get ALL files from DB - Add recursive folder traversal with proper refresh before each folder scan - Improve LocalFileSyncWorker with better statistics and notifications - Remove setForegroundAsync to fix Android 14+ foreground service crash --- .../workers/DownloadEverythingWorker.kt | 260 +++++++++++++++--- .../android/workers/LocalFileSyncWorker.kt | 75 ++++- 2 files changed, 286 insertions(+), 49 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt index e93e4a7e1..20f79be9e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt @@ -21,26 +21,39 @@ package eu.opencloud.android.workers import android.accounts.AccountManager +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import eu.opencloud.android.MainApp +import eu.opencloud.android.R import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase +import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PATH import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase import eu.opencloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase import eu.opencloud.android.presentation.authentication.AccountUtils -import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase +import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.util.concurrent.TimeUnit /** - * Worker that downloads all files from all accounts for offline access. + * Worker that downloads ALL files from all accounts for offline access. * This is an opt-in feature that can be enabled in Security Settings. + * + * This worker: + * 1. Iterates through all connected accounts + * 2. Discovers all spaces (personal + project) for each account + * 3. Recursively scans all folders to find all files + * 4. Enqueues a download for each file that is not yet available locally + * 5. Shows a notification with progress information */ class DownloadEverythingWorker( private val appContext: Context, @@ -54,77 +67,234 @@ class DownloadEverythingWorker( private val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase by inject() private val getPersonalAndProjectSpacesForAccountUseCase: GetPersonalAndProjectSpacesForAccountUseCase by inject() private val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject() - private val synchronizeFolderUseCase: SynchronizeFolderUseCase by inject() + private val fileRepository: FileRepository by inject() + private val downloadFileUseCase: DownloadFileUseCase by inject() + + private var totalFilesFound = 0 + private var filesDownloaded = 0 + private var filesAlreadyLocal = 0 + private var filesSkipped = 0 + private var foldersProcessed = 0 override suspend fun doWork(): Result { Timber.i("DownloadEverythingWorker started") + + // Create notification channel and show initial notification + createNotificationChannel() + updateNotification("Starting download of all files...") return try { val accountManager = AccountManager.get(appContext) val accounts = accountManager.getAccountsByType(MainApp.accountType) - Timber.i("Found ${accounts.size} accounts to sync") + Timber.i("Found ${accounts.size} accounts to process") + updateNotification("Found ${accounts.size} accounts") - accounts.forEach { account -> + accounts.forEachIndexed { accountIndex, account -> val accountName = account.name - Timber.i("Syncing all files for account: $accountName") - - // Get capabilities for account - val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName)) - val spacesAvailableForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(appContext, account, capabilities) - - if (!spacesAvailableForAccount) { - // Account does not support spaces - sync legacy root - val rootLegacyFolder = getFileByRemotePathUseCase( - GetFileByRemotePathUseCase.Params(accountName, ROOT_PATH, null) - ).getDataOrNull() - rootLegacyFolder?.let { - syncFolderRecursively(it) - } - } else { - // Account supports spaces - sync all spaces - refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName)) - val spaces = getPersonalAndProjectSpacesForAccountUseCase( - GetPersonalAndProjectSpacesForAccountUseCase.Params(accountName) - ) - - Timber.i("Found ${spaces.size} spaces for account $accountName") - - spaces.forEach { space -> - val rootFolderForSpace = getFileByRemotePathUseCase( - GetFileByRemotePathUseCase.Params(accountName, ROOT_PATH, space.root.id) - ).getDataOrNull() - - rootFolderForSpace?.let { - Timber.i("Syncing space: ${space.name}") - syncFolderRecursively(it) + Timber.i("Processing account ${accountIndex + 1}/${accounts.size}: $accountName") + updateNotification("Account ${accountIndex + 1}/${accounts.size}: $accountName") + + try { + // Get capabilities for account + val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName)) + val spacesAvailableForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(appContext, account, capabilities) + + if (!spacesAvailableForAccount) { + // Account does not support spaces - process legacy root + Timber.i("Account $accountName uses legacy mode (no spaces)") + processSpaceRoot(accountName, ROOT_PATH, null) + } else { + // Account supports spaces - process all spaces + refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName)) + val spaces = getPersonalAndProjectSpacesForAccountUseCase( + GetPersonalAndProjectSpacesForAccountUseCase.Params(accountName) + ) + + Timber.i("Account $accountName has ${spaces.size} spaces") + + spaces.forEachIndexed { spaceIndex, space -> + Timber.i("Processing space ${spaceIndex + 1}/${spaces.size}: ${space.name}") + updateNotification("Space ${spaceIndex + 1}/${spaces.size}: ${space.name}") + + processSpaceRoot(accountName, ROOT_PATH, space.root.id) } } + } catch (e: Exception) { + Timber.e(e, "Error processing account $accountName") } } - Timber.i("DownloadEverythingWorker completed successfully") + val summary = "Done! Files: $totalFilesFound, Downloaded: $filesDownloaded, Already local: $filesAlreadyLocal, Skipped: $filesSkipped, Folders: $foldersProcessed" + Timber.i("DownloadEverythingWorker completed: $summary") + updateNotification(summary) + Result.success() } catch (exception: Exception) { Timber.e(exception, "DownloadEverythingWorker failed") + updateNotification("Failed: ${exception.message}") Result.failure() } } - private fun syncFolderRecursively(folder: OCFile) { - synchronizeFolderUseCase( - SynchronizeFolderUseCase.Params( - accountName = folder.owner, - remotePath = folder.remotePath, - spaceId = folder.spaceId, - syncMode = SynchronizeFolderUseCase.SyncFolderMode.SYNC_FOLDER_RECURSIVELY + /** + * Processes the root of a space by refreshing it and then recursively processing all content. + */ + private fun processSpaceRoot(accountName: String, remotePath: String, spaceId: String?) { + try { + Timber.i("Processing space root: remotePath=$remotePath, spaceId=$spaceId") + + // First refresh the root folder from server to ensure DB has latest data + fileRepository.refreshFolder( + remotePath = remotePath, + accountName = accountName, + spaceId = spaceId, + isActionSetFolderAvailableOfflineOrSynchronize = false ) - ) + + // Now get the root folder from local database + val rootFolder = getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params(accountName, remotePath, spaceId) + ).getDataOrNull() + + if (rootFolder == null) { + Timber.w("Root folder not found after refresh for spaceId=$spaceId") + return + } + + Timber.i("Got root folder with id=${rootFolder.id}, remotePath=${rootFolder.remotePath}") + + // Process the root folder recursively + processFolderRecursively(accountName, rootFolder, spaceId) + + } catch (e: Exception) { + Timber.e(e, "Error processing space root: spaceId=$spaceId") + } + } + + /** + * Recursively processes a folder: gets content from database, + * enqueues downloads for files, and recurses into subfolders. + */ + private fun processFolderRecursively(accountName: String, folder: OCFile, spaceId: String?) { + try { + val folderId = folder.id + if (folderId == null) { + Timber.w("Folder ${folder.remotePath} has no id, skipping") + return + } + + foldersProcessed++ + Timber.d("Processing folder: ${folder.remotePath} (id=$folderId)") + + // First refresh this folder from server + try { + fileRepository.refreshFolder( + remotePath = folder.remotePath, + accountName = accountName, + spaceId = spaceId, + isActionSetFolderAvailableOfflineOrSynchronize = false + ) + } catch (e: Exception) { + Timber.e(e, "Error refreshing folder ${folder.remotePath}") + } + + // Now get ALL content from local database (this returns everything, not just changes) + val folderContent = fileRepository.getFolderContent(folderId) + + Timber.d("Folder ${folder.remotePath} contains ${folderContent.size} items") + + folderContent.forEach { item -> + if (item.isFolder) { + // Recursively process subfolders + processFolderRecursively(accountName, item, spaceId) + } else { + // Process file + processFile(accountName, item) + } + } + + // Update notification periodically + if (foldersProcessed % 5 == 0) { + updateNotification("Scanning: $foldersProcessed folders, $totalFilesFound files found") + } + } catch (e: Exception) { + Timber.e(e, "Error processing folder ${folder.remotePath}") + } + } + + /** + * Processes a single file: checks if it's already local, + * and if not, enqueues a download. + */ + private fun processFile(accountName: String, file: OCFile) { + totalFilesFound++ + + try { + if (file.isAvailableLocally) { + // File is already downloaded + filesAlreadyLocal++ + Timber.d("File already local: ${file.fileName}") + } else { + // Enqueue download + val downloadId = downloadFileUseCase(DownloadFileUseCase.Params(accountName, file)) + if (downloadId != null) { + filesDownloaded++ + Timber.i("Enqueued download for: ${file.fileName}") + } else { + filesSkipped++ + Timber.d("Download already enqueued or skipped: ${file.fileName}") + } + } + + // Update notification periodically (every 20 files) + if (totalFilesFound % 20 == 0) { + updateNotification("Found: $totalFilesFound files, $filesDownloaded queued for download") + } + } catch (e: Exception) { + filesSkipped++ + Timber.e(e, "Error processing file ${file.fileName}") + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Download Everything", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows progress when downloading all files" + } + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun updateNotification(contentText: String) { + try { + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Download Everything") + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) + .setSmallIcon(R.drawable.notification_icon) + .setOngoing(true) + .setProgress(0, 0, true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Timber.e(e, "Error updating notification") + } } companion object { const val DOWNLOAD_EVERYTHING_WORKER = "DOWNLOAD_EVERYTHING_WORKER" const val repeatInterval: Long = 6L val repeatIntervalTimeUnit: TimeUnit = TimeUnit.HOURS + + private const val NOTIFICATION_CHANNEL_ID = "download_everything_channel" + private const val NOTIFICATION_ID = 9001 } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt index c4246bb30..bb43c047f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt @@ -21,10 +21,16 @@ package eu.opencloud.android.workers import android.accounts.AccountManager +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import eu.opencloud.android.MainApp +import eu.opencloud.android.R import eu.opencloud.android.domain.UseCaseResult import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase @@ -39,6 +45,8 @@ import java.util.concurrent.TimeUnit * * It monitors all downloaded files and checks if they have been modified locally. * If a file has been modified, it uploads the new version to the server. + * + * Shows a notification with sync progress and results. */ class LocalFileSyncWorker( private val appContext: Context, @@ -53,6 +61,8 @@ class LocalFileSyncWorker( override suspend fun doWork(): Result { Timber.i("LocalFileSyncWorker started") + + createNotificationChannel() return try { val accountManager = AccountManager.get(appContext) @@ -61,7 +71,12 @@ class LocalFileSyncWorker( Timber.i("Checking ${accounts.size} accounts for local file changes") var totalFilesChecked = 0 - var totalFilesUpdated = 0 + var filesUploaded = 0 + var filesDownloaded = 0 + var filesWithConflicts = 0 + var filesAlreadySynced = 0 + var filesNotFound = 0 + var errors = 0 accounts.forEach { account -> val accountName = account.name @@ -81,35 +96,54 @@ class LocalFileSyncWorker( when (val syncResult = useCaseResult.data) { is SynchronizeFileUseCase.SyncType.UploadEnqueued -> { Timber.i("File ${file.fileName} has local changes, upload enqueued") - totalFilesUpdated++ + filesUploaded++ } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { Timber.i("File ${file.fileName} has remote changes, download enqueued") - totalFilesUpdated++ + filesDownloaded++ } is SynchronizeFileUseCase.SyncType.ConflictDetected -> { Timber.w("File ${file.fileName} has a conflict with etag: ${syncResult.etagInConflict}") + filesWithConflicts++ } is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { Timber.d("File ${file.fileName} is already synchronized") + filesAlreadySynced++ } is SynchronizeFileUseCase.SyncType.FileNotFound -> { Timber.w("File ${file.fileName} was not found on server") + filesNotFound++ } } } is UseCaseResult.Error -> { Timber.e(useCaseResult.throwable, "Error syncing file ${file.fileName}") + errors++ } } } catch (e: Exception) { Timber.e(e, "Error syncing file ${file.fileName}") + errors++ } } } } - Timber.i("LocalFileSyncWorker completed: checked $totalFilesChecked files, updated $totalFilesUpdated") + val summary = buildString { + append("Checked: $totalFilesChecked") + if (filesUploaded > 0) append(" | Uploaded: $filesUploaded") + if (filesDownloaded > 0) append(" | Downloaded: $filesDownloaded") + if (filesWithConflicts > 0) append(" | Conflicts: $filesWithConflicts") + if (errors > 0) append(" | Errors: $errors") + } + + Timber.i("LocalFileSyncWorker completed: $summary") + + // Only show notification if something changed + if (filesUploaded > 0 || filesDownloaded > 0 || filesWithConflicts > 0) { + showCompletionNotification(summary) + } + Result.success() } catch (exception: Exception) { Timber.e(exception, "LocalFileSyncWorker failed") @@ -117,9 +151,42 @@ class LocalFileSyncWorker( } } + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Auto-Sync", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows when local file changes are synced" + } + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun showCompletionNotification(summary: String) { + try { + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Auto-Sync Complete") + .setContentText(summary) + .setSmallIcon(R.drawable.notification_icon) + .setAutoCancel(true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Timber.e(e, "Error showing notification") + } + } + companion object { const val LOCAL_FILE_SYNC_WORKER = "LOCAL_FILE_SYNC_WORKER" const val repeatInterval: Long = 5L val repeatIntervalTimeUnit: TimeUnit = TimeUnit.MINUTES + + private const val NOTIFICATION_CHANNEL_ID = "auto_sync_channel" + private const val NOTIFICATION_ID = 9002 } } From 0f69672a7be0c6337583dc5d110f2a1f699609b3 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 20:55:18 +0100 Subject: [PATCH 04/11] fix(i18n): Change German storage permission dialog to English --- .../eu/opencloud/android/ui/activity/FileDisplayActivity.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index 5a842a2e9..2543760e2 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -294,14 +294,14 @@ class FileDisplayActivity : FileActivity(), if (!android.os.Environment.isExternalStorageManager()) { val builder = AlertDialog.Builder(this) builder.setTitle(getString(R.string.app_name)) - builder.setMessage("Um Offline-Dateien öffentlich speichern zu können, benötigt die App Zugriff auf alle Dateien.") - builder.setPositiveButton("Einstellungen") { _, _ -> + builder.setMessage("To save offline files, the app needs access to all files.") + builder.setPositiveButton("Settings") { _, _ -> val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") intent.data = Uri.parse("package:$packageName") startActivity(intent) } - builder.setNegativeButton("Abbrechen", null) + builder.setNegativeButton("Cancel", null) builder.show() } } From aa7f0bd19602eb1fcd7946383d51e9549d1c8d1e Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 11 Jan 2026 21:03:15 +0100 Subject: [PATCH 05/11] fix: Use Graph API endpoint for user avatars Changed avatar endpoint from legacy /index.php/avatar/ to /graph/v1.0/me/photo/ for openCloud compatibility. --- .../lib/resources/users/GetRemoteUserAvatarOperation.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt index 9e355c03c..e6ba9bd6f 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt @@ -31,7 +31,6 @@ 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 timber.log.Timber -import java.io.File import java.io.IOException import java.io.InputStream import java.net.URL @@ -48,8 +47,7 @@ class GetRemoteUserAvatarOperation(private val avatarDimension: Int) : RemoteOpe var result: RemoteOperationResult try { - val endPoint = - client.baseUri.toString() + NON_OFFICIAL_AVATAR_PATH + client.credentials.username + File.separator + avatarDimension + val endPoint = client.baseUri.toString() + GRAPH_AVATAR_PATH Timber.d("avatar URI: %s", endPoint) val getMethod = GetMethod(URL(endPoint)) @@ -109,6 +107,6 @@ class GetRemoteUserAvatarOperation(private val avatarDimension: Int) : RemoteOpe private fun isSuccess(status: Int) = status == HttpConstants.HTTP_OK companion object { - private const val NON_OFFICIAL_AVATAR_PATH = "/index.php/avatar/" + private const val GRAPH_AVATAR_PATH = "/graph/v1.0/me/photo/\$value" } } From 821a2a09b09eb49be0019b5a7046880fb4cb6441 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 11 Jan 2026 21:03:35 +0100 Subject: [PATCH 06/11] fix: Auto-resolve sync conflicts by uploading local version When a file changes both locally and remotely, automatically upload the local version instead of requiring manual conflict resolution. This provides a seamless auto-sync experience (last write wins). --- .../synchronization/SynchronizeFileUseCase.kt | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index f75824b29..c37f8673f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -83,17 +83,10 @@ class SynchronizeFileUseCase( Timber.i("So it has changed remotely: $changedRemotely") if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. We got a conflict, save the conflict. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. We got a conflict with etag: ${serverFile.etag}") - if (fileToSynchronize.etagInConflict == null) { - saveConflictUseCase( - SaveConflictUseCase.Params( - fileId = fileToSynchronize.id!!, - eTagInConflict = serverFile.etag!! - ) - ) - } - SyncType.ConflictDetected(serverFile.etag!!) + // 5.1 File has changed locally and remotely. Auto-resolve by uploading local version. + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Auto-resolving by uploading local version.") + val uuid = requestForUpload(accountName, fileToSynchronize) + SyncType.UploadEnqueued(uuid) } else if (changedRemotely) { // 5.2 File has changed ONLY remotely -> download new version Timber.i("File ${fileToSynchronize.fileName} has changed remotely. Let's download the new version") From 61fa9cfbeb1e4e0a3e3e7027173bf1d7d054423d Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 16:46:00 +0100 Subject: [PATCH 07/11] feat: Auto-resolve sync conflicts with conflicted copies - When a file is modified both locally and remotely, create a conflicted copy of the local file and download the remote version. This ensures no data loss, matching desktop client behavior. --- .../DocumentsStorageProvider.kt | 10 ++-- .../files/details/FileDetailsFragment.kt | 6 +-- .../ui/activity/FileDisplayActivity.kt | 6 +-- .../synchronization/SynchronizeFileUseCase.kt | 47 +++++++++++++++++-- .../android/workers/LocalFileSyncWorker.kt | 4 +- opencloudApp/src/main/res/values/strings.xml | 1 + 6 files changed, 52 insertions(+), 22 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt index 45d8a58f3..37477b471 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -158,13 +158,9 @@ class DocumentsStorageProvider : DocumentsProvider() { ) ) Timber.d("Synced ${ocFile.remotePath} from ${ocFile.owner} with result: $result") - if (result.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) { - context?.let { - NotificationUtils.notifyConflict( - fileInConflict = ocFile, - context = it - ) - } + if (result.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy) { + val conflictResult = result.getDataOrNull() as SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy + Timber.i("File sync conflict auto-resolved. Conflicted copy at: ${conflictResult.conflictedCopyPath}") } }.start() } 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 6aaed24c1..fcc9e71be 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 @@ -190,10 +190,8 @@ class FileDetailsFragment : FileFragment() { SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { showMessageInSnackbar(getString(R.string.sync_file_nothing_to_do_msg)) } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - val showConflictActivityIntent = Intent(requireActivity(), ConflictsResolveActivity::class.java) - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file) - startActivity(showConflictActivityIntent) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + showMessageInSnackbar(getString(R.string.sync_conflict_resolved_with_copy)) } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index 2543760e2..b2dfd5706 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -1384,10 +1384,8 @@ class FileDisplayActivity : FileActivity(), } } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - val showConflictActivityIntent = Intent(this, ConflictsResolveActivity::class.java) - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file) - startActivity(showConflictActivityIntent) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + showSnackMessage(getString(R.string.sync_conflict_resolved_with_copy)) } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index c37f8673f..3e7c6184c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -31,6 +31,10 @@ import eu.opencloud.android.usecases.transfers.uploads.UploadFileInConflictUseCa import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import timber.log.Timber +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.UUID class SynchronizeFileUseCase( @@ -83,10 +87,20 @@ class SynchronizeFileUseCase( Timber.i("So it has changed remotely: $changedRemotely") if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. Auto-resolve by uploading local version. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Auto-resolving by uploading local version.") - val uuid = requestForUpload(accountName, fileToSynchronize) - SyncType.UploadEnqueued(uuid) + // 5.1 File has changed locally and remotely. Create conflicted copy of local, download remote. + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") + val conflictedCopyPath = createConflictedCopyPath(fileToSynchronize) + val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) + if (renamed) { + Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) + } else { + Timber.w("Failed to rename local file to conflicted copy") + // Fallback: download remote anyway, local changes may be overwritten + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.DownloadEnqueued(uuid) + } } else if (changedRemotely) { // 5.2 File has changed ONLY remotely -> download new version Timber.i("File ${fileToSynchronize.fileName} has changed remotely. Let's download the new version") @@ -124,13 +138,36 @@ class SynchronizeFileUseCase( ) ) + private fun createConflictedCopyPath(ocFile: OCFile): String { + val originalPath = ocFile.storagePath!! + val file = File(originalPath) + val nameWithoutExt = file.nameWithoutExtension + val extension = file.extension + val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(Date()) + val conflictedName = if (extension.isNotEmpty()) { + "${nameWithoutExt}_conflicted_copy_$timestamp.$extension" + } else { + "${nameWithoutExt}_conflicted_copy_$timestamp" + } + return File(file.parent, conflictedName).absolutePath + } + + private fun renameLocalFile(oldPath: String, newPath: String): Boolean { + return try { + File(oldPath).renameTo(File(newPath)) + } catch (e: Exception) { + Timber.e(e, "Failed to rename local file from $oldPath to $newPath") + false + } + } + data class Params( val fileToSynchronize: OCFile, ) sealed interface SyncType { object FileNotFound : SyncType - data class ConflictDetected(val etagInConflict: String) : SyncType + data class ConflictResolvedWithCopy(val workerId: UUID?, val conflictedCopyPath: String) : SyncType data class DownloadEnqueued(val workerId: UUID?) : SyncType data class UploadEnqueued(val workerId: UUID?) : SyncType object AlreadySynchronized : SyncType diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt index bb43c047f..284d920a6 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt @@ -102,8 +102,8 @@ class LocalFileSyncWorker( Timber.i("File ${file.fileName} has remote changes, download enqueued") filesDownloaded++ } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - Timber.w("File ${file.fileName} has a conflict with etag: ${syncResult.etagInConflict}") + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + Timber.i("File ${file.fileName} had a conflict. Conflicted copy created at: ${syncResult.conflictedCopyPath}") filesWithConflicts++ } is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 405f94c64..742d8d178 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -403,6 +403,7 @@ A new version was found in server. Downloading… Download enqueued Upload enqueued + Conflict resolved. Your local changes were saved as a separate copy. Folder could not be created File could not be created Forbidden characters: / \\ From 8fd7fa81494ad268c9157e7c5a6050896672aa63 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 16:50:08 +0100 Subject: [PATCH 08/11] feat: Refresh parent folder after creating conflicted copy - This makes the conflicted copy visible in the app's file list immediately after sync conflict resolution --- .../synchronization/SynchronizeFileUseCase.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index 3e7c6184c..411d5c81b 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -93,6 +93,17 @@ class SynchronizeFileUseCase( val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) if (renamed) { Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + // Refresh parent folder so the conflicted copy appears in the file list + try { + fileRepository.refreshFolder( + remotePath = fileToSynchronize.getParentRemotePath(), + accountName = accountName, + spaceId = fileToSynchronize.spaceId + ) + Timber.i("Parent folder refreshed after creating conflicted copy") + } catch (e: Exception) { + Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") + } val uuid = requestForDownload(accountName, fileToSynchronize) SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) } else { From b98c0dffb98d402af5821e0acdedcde46f946744 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:00:42 +0100 Subject: [PATCH 09/11] feat: Add 'Prefer local version on conflict' setting - New preference in Security settings to choose conflict resolution strategy - When enabled: upload local version (overwrites remote) - When disabled (default): create conflicted copy and download remote - Ensures no data loss while giving users control over conflict behavior --- .../security/SettingsSecurityFragment.kt | 9 +++ .../security/SettingsSecurityViewModel.kt | 7 +++ .../synchronization/SynchronizeFileUseCase.kt | 61 ++++++++++++------- opencloudApp/src/main/res/values/strings.xml | 4 ++ .../src/main/res/xml/settings_security.xml | 7 +++ 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt index ce5708beb..5ebe308eb 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt @@ -61,6 +61,7 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { private var prefTouchesWithOtherVisibleWindows: CheckBoxPreference? = null private var prefDownloadEverything: CheckBoxPreference? = null private var prefAutoSync: CheckBoxPreference? = null + private var prefPreferLocalOnConflict: CheckBoxPreference? = null private val enablePasscodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -139,6 +140,7 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { prefTouchesWithOtherVisibleWindows = findPreference(PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS) prefDownloadEverything = findPreference(PREFERENCE_DOWNLOAD_EVERYTHING) prefAutoSync = findPreference(PREFERENCE_AUTO_SYNC) + prefPreferLocalOnConflict = findPreference(PREFERENCE_PREFER_LOCAL_ON_CONFLICT) prefPasscode?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() prefPattern?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() @@ -277,6 +279,12 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { true } } + + // Conflict Resolution Strategy + prefPreferLocalOnConflict?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> + securityViewModel.setPreferLocalOnConflict(newValue as Boolean) + true + } } private fun enableBiometricAndLockApplication() { @@ -303,5 +311,6 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { const val PREFERENCE_LOCK_ATTEMPTS = "PrefLockAttempts" const val PREFERENCE_DOWNLOAD_EVERYTHING = "download_everything" const val PREFERENCE_AUTO_SYNC = "auto_sync_local_changes" + const val PREFERENCE_PREFER_LOCAL_ON_CONFLICT = "prefer_local_on_conflict" } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt index f53afed83..c179eb94c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt @@ -77,4 +77,11 @@ class SettingsSecurityViewModel( fun setAutoSync(enabled: Boolean) = preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, enabled) + + // Conflict Resolution Strategy + fun isPreferLocalOnConflictEnabled(): Boolean = + preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false) + + fun setPreferLocalOnConflict(enabled: Boolean) = + preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, enabled) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index 411d5c81b..d1b31bc96 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -21,11 +21,13 @@ package eu.opencloud.android.usecases.synchronization +import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.domain.BaseUseCaseWithResult import eu.opencloud.android.domain.exceptions.FileNotFoundException import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.usecases.SaveConflictUseCase +import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import eu.opencloud.android.usecases.transfers.uploads.UploadFileInConflictUseCase import kotlinx.coroutines.CoroutineScope @@ -42,6 +44,7 @@ class SynchronizeFileUseCase( private val uploadFileInConflictUseCase: UploadFileInConflictUseCase, private val saveConflictUseCase: SaveConflictUseCase, private val fileRepository: FileRepository, + private val preferencesProvider: SharedPreferencesProvider, ) : BaseUseCaseWithResult() { override fun run(params: Params): SyncType { @@ -87,30 +90,42 @@ class SynchronizeFileUseCase( Timber.i("So it has changed remotely: $changedRemotely") if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. Create conflicted copy of local, download remote. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") - val conflictedCopyPath = createConflictedCopyPath(fileToSynchronize) - val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) - if (renamed) { - Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") - // Refresh parent folder so the conflicted copy appears in the file list - try { - fileRepository.refreshFolder( - remotePath = fileToSynchronize.getParentRemotePath(), - accountName = accountName, - spaceId = fileToSynchronize.spaceId - ) - Timber.i("Parent folder refreshed after creating conflicted copy") - } catch (e: Exception) { - Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") - } - val uuid = requestForDownload(accountName, fileToSynchronize) - SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) + // 5.1 File has changed locally and remotely. + val preferLocal = preferencesProvider.getBoolean( + SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false + ) + + if (preferLocal) { + // User prefers local version - upload it (overwrites remote) + Timber.i("File ${fileToSynchronize.fileName} has conflict. User prefers local version, uploading.") + val uuid = requestForUpload(accountName, fileToSynchronize) + SyncType.UploadEnqueued(uuid) } else { - Timber.w("Failed to rename local file to conflicted copy") - // Fallback: download remote anyway, local changes may be overwritten - val uuid = requestForDownload(accountName, fileToSynchronize) - SyncType.DownloadEnqueued(uuid) + // Default: Create conflicted copy of local, download remote. + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") + val conflictedCopyPath = createConflictedCopyPath(fileToSynchronize) + val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) + if (renamed) { + Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + // Refresh parent folder so the conflicted copy appears in the file list + try { + fileRepository.refreshFolder( + remotePath = fileToSynchronize.getParentRemotePath(), + accountName = accountName, + spaceId = fileToSynchronize.spaceId + ) + Timber.i("Parent folder refreshed after creating conflicted copy") + } catch (e: Exception) { + Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") + } + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) + } else { + Timber.w("Failed to rename local file to conflicted copy") + // Fallback: download remote anyway, local changes may be overwritten + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.DownloadEnqueued(uuid) + } } } else if (changedRemotely) { // 5.2 File has changed ONLY remotely -> download new version diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 742d8d178..57df15b6a 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -866,4 +866,8 @@ Auto-Sync Local file changes will be automatically synced to the cloud. This requires a stable network connection. Continue? + + Prefer local version on conflict + When a file is modified both locally and on server, upload local version instead of creating a conflicted copy + diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index b81b6a783..3e2888145 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -63,4 +63,11 @@ app:summary="@string/prefs_auto_sync_summary" app:title="@string/prefs_auto_sync" /> + + + \ No newline at end of file From 9adf747ca80564205003ec7ff493688edbdd39d7 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:09:38 +0100 Subject: [PATCH 10/11] fix: Add conflict detection to direct upload path - UploadFileFromFileSystemWorker now checks for conflicts when forceOverwrite=true - Creates local conflicted copy before uploading when remote changed - Respects 'prefer local on conflict' setting - Works even without Auto-Sync enabled --- .../workers/UploadFileFromFileSystemWorker.kt | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) 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 218611fd6..8a7e49994 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -32,10 +32,12 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import eu.opencloud.android.R import eu.opencloud.android.data.executeRemoteOperation +import eu.opencloud.android.data.providers.SharedPreferencesProvider 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.FileRepository import eu.opencloud.android.domain.files.usecases.CleanConflictUseCase import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase @@ -54,6 +56,7 @@ import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperatio import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation import eu.opencloud.android.presentation.authentication.AccountUtils +import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID @@ -66,6 +69,9 @@ import org.koin.core.component.inject import timber.log.Timber import java.io.File import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlin.coroutines.cancellation.CancellationException class UploadFileFromFileSystemWorker( @@ -92,6 +98,8 @@ class UploadFileFromFileSystemWorker( private val saveFileOrFolderUseCase: SaveFileOrFolderUseCase by inject() private val cleanConflictUseCase: CleanConflictUseCase by inject() private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() + private val preferencesProvider: SharedPreferencesProvider by inject() + private val fileRepository: FileRepository by inject() // Etag in conflict required to overwrite files in server. Otherwise, the upload will be rejected. private var eTagInConflict: String = "" @@ -230,7 +238,40 @@ class UploadFileFromFileSystemWorker( ) ) - eTagInConflict = useCaseResult.getDataOrNull()?.etagInConflict.orEmpty() + val remoteFile = useCaseResult.getDataOrNull() + eTagInConflict = remoteFile?.etagInConflict.orEmpty() + + // Check if remote file has changed since we last synced + // If so, we have a conflict - check user preference for handling + if (remoteFile != null && remoteFile.etag != remoteFile.etagInConflict) { + val preferLocal = preferencesProvider.getBoolean( + SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false + ) + + if (!preferLocal) { + // User wants conflicted copy behavior - create a local copy before uploading + Timber.i("Conflict detected and user prefers conflicted copy. Creating copy of local file.") + val conflictedCopyPath = createConflictedCopyPath(fileSystemPath) + val copied = copyLocalFile(fileSystemPath, conflictedCopyPath) + if (copied) { + Timber.i("Local file copied to conflicted copy: $conflictedCopyPath") + // Refresh parent folder so the conflicted copy appears + try { + fileRepository.refreshFolder( + remotePath = remoteFile.getParentRemotePath(), + accountName = account.name, + spaceId = remoteFile.spaceId + ) + } catch (e: Exception) { + Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") + } + } else { + Timber.w("Failed to copy local file to conflicted copy") + } + } else { + Timber.i("Conflict detected but user prefers local version. Uploading will overwrite remote.") + } + } Timber.d("Upload will overwrite current server file with the following etag in conflict: $eTagInConflict") } else { @@ -249,6 +290,29 @@ class UploadFileFromFileSystemWorker( } } + private fun createConflictedCopyPath(originalPath: String): String { + val file = File(originalPath) + val nameWithoutExt = file.nameWithoutExtension + val extension = file.extension + val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(Date()) + val conflictedName = if (extension.isNotEmpty()) { + "${nameWithoutExt}_conflicted_copy_$timestamp.$extension" + } else { + "${nameWithoutExt}_conflicted_copy_$timestamp" + } + return File(file.parent, conflictedName).absolutePath + } + + private fun copyLocalFile(sourcePath: String, destPath: String): Boolean { + return try { + File(sourcePath).copyTo(File(destPath), overwrite = false) + true + } catch (e: Exception) { + Timber.e(e, "Failed to copy local file from $sourcePath to $destPath") + false + } + } + private fun uploadDocument(client: OpenCloudClient) { val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() val capabilitiesForAccount = getStoredCapabilitiesUseCase( From 519ec323b53f0c083dea42cf5e5aa8b5f472d43f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:20:27 +0100 Subject: [PATCH 11/11] fix: Use actual file modification time for local change detection --- .../usecases/synchronization/SynchronizeFileUseCase.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index d1b31bc96..ef147cab7 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -78,9 +78,11 @@ class SynchronizeFileUseCase( val uuid = requestForDownload(accountName = accountName, ocFile = fileToSynchronize) SyncType.DownloadEnqueued(uuid) } else { - // 3. Check if file has changed locally - val changedLocally = fileToSynchronize.localModificationTimestamp > fileToSynchronize.lastSyncDateForData!! - Timber.i("Local file modification timestamp :${fileToSynchronize.localModificationTimestamp}" + + // 3. Check if file has changed locally by reading ACTUAL file timestamp from filesystem + val localFile = File(fileToSynchronize.storagePath!!) + val actualFileModificationTime = localFile.lastModified() + val changedLocally = actualFileModificationTime > fileToSynchronize.lastSyncDateForData!! + Timber.i("Actual file modification timestamp :$actualFileModificationTime" + " and last sync date for data :${fileToSynchronize.lastSyncDateForData}") Timber.i("So it has changed locally: $changedLocally")