From 8d7dfdb07a5c23031a2af5b9ad8ad8cd99aa03a5 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 12:44:24 +0200 Subject: [PATCH 01/18] fix/internal-104835: do not overwrite existent file metadata Signed-off-by: daniele-verducci --- .../datamodel/FileDataStorageManager.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 42d5db1feb35..05d4cddbc750 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -230,8 +230,19 @@ public OfflineOperationEntity addCreateFolderOfflineOperation(String path, Strin return entity; } - public void createPendingFile(String path, String mimeType, long createdAt, long modificationTimestamp) { - OCFile file = new OCFile(path); + public void createPendingFile(String remotePath, String mimeType, long createdAt, long modificationTimestamp) { + final OCFile existingFile = getFileByRemotePath(remotePath); + final boolean existingFileIsTheSame = + existingFile != null && + existingFile.getMimeType().equals(mimeType) && + existingFile.getCreationTimestamp() == createdAt && + existingFile.getModificationTimestamp() == modificationTimestamp; + if (existingFileIsTheSame) { + // In case the same file was already uploaded, do not overwrite it to avoid triggering a conflict + return; + } + + OCFile file = new OCFile(remotePath); file.setMimeType(mimeType); file.setCreationTimestamp(createdAt); file.setModificationTimestamp(modificationTimestamp); @@ -541,7 +552,7 @@ public List getFolderImagesAndVideos(OCFile folder, boolean onlyOnDevice } public boolean saveFile(OCFile ocFile) { - Log_OC.d(TAG, "saving file: " + ocFile.getRemotePath()); + Log_OC.d(TAG, "saving file " + ocFile.getFileName() + " into " + ocFile.getRemotePath()); boolean overridden = false; final ContentValues cv = createContentValuesForFile(ocFile); From 98f7dcdf42bf9cd566fd17f7b7753cca4ec060ad Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 14:07:33 +0200 Subject: [PATCH 02/18] wip Signed-off-by: daniele-verducci --- .../com/nextcloud/client/jobs/upload/FileUploadWorker.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index e698f3850869..bd5975f4d7f1 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -368,6 +368,11 @@ class FileUploadWorker( Log_OC.e(TAG, "credentials are no longer valid, stopping uploads") break } + + if (result.isSuccess && result.resultData is String) { + // TODO: Save this to avoid the conflict, both in online and offline uploads + operation.file.etagOnServer = result.resultData.toString() + } } val uploadFilesResult = when { From 6b641b8233807580b33e90d63bef8f7c4694758a Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Tue, 8 Sep 2026 11:55:18 +0200 Subject: [PATCH 03/18] fix/internal-104835: avoid showing conflict window for an unmodified, already uploaded file Signed-off-by: daniele-verducci --- .../client/jobs/upload/FileUploadWorker.kt | 6 ++- .../datamodel/FileDataStorageManager.java | 42 ++++++++++++++----- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index bd5975f4d7f1..1051d23bcc1f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -370,8 +370,10 @@ class FileUploadWorker( } if (result.isSuccess && result.resultData is String) { - // TODO: Save this to avoid the conflict, both in online and offline uploads - operation.file.etagOnServer = result.resultData.toString() + // Update the remote etag to avoid conflict when uploading same file, both in online and offline uploads + val file = operation.storageManager.getFileByRemotePath(upload.remotePath) + file?.etagOnServer = result.resultData.toString() + operation.storageManager.saveFile(file) } } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 05d4cddbc750..51c8151cf0c3 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -28,6 +28,7 @@ import android.os.RemoteException; import android.provider.MediaStore; import android.text.TextUtils; +import android.util.Log; import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; @@ -78,6 +79,8 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -200,7 +203,7 @@ public void addCreateFileOfflineOperation(String[] localPaths, String[] remotePa } offlineOperationDao.insert(entity); - createPendingFile(remotePath, mimeType, createdAt, modificationTimestamp); + createPendingFile(remotePath, mimeType, createdAt, modificationTimestamp, localPath); } } @@ -230,16 +233,15 @@ public OfflineOperationEntity addCreateFolderOfflineOperation(String path, Strin return entity; } - public void createPendingFile(String remotePath, String mimeType, long createdAt, long modificationTimestamp) { + public void createPendingFile(String remotePath, String mimeType, long createdAt, long modificationTimestamp, String localPath) { final OCFile existingFile = getFileByRemotePath(remotePath); - final boolean existingFileIsTheSame = - existingFile != null && - existingFile.getMimeType().equals(mimeType) && - existingFile.getCreationTimestamp() == createdAt && - existingFile.getModificationTimestamp() == modificationTimestamp; - if (existingFileIsTheSame) { - // In case the same file was already uploaded, do not overwrite it to avoid triggering a conflict - return; + if (existingFile != null) { + final File localFile = new File(localPath); + if (fileIsTheSame(existingFile, localFile)) { + // In case the same file was already uploaded, do not overwrite it to avoid triggering a conflict + Log_OC.i(TAG, "Creating pendingFile for an already uploaded file: keeping metadata"); + return; + } } OCFile file = new OCFile(remotePath); @@ -249,6 +251,26 @@ public void createPendingFile(String remotePath, String mimeType, long createdAt saveFileWithParent(file, MainApp.getAppContext()); } + private boolean fileIsTheSame(OCFile ocFile, File localFile) { + try { + BasicFileAttributes attr = Files.readAttributes(localFile.toPath(), BasicFileAttributes.class); + String localName = localFile.getName(); + String remoteName = ocFile.getFileName(); + long localCreated = attr.creationTime().toMillis() / 1000; // Unix time in milliseconds + long localModified = attr.lastModifiedTime().toMillis() / 1000; // Unix time in milliseconds + long remoteCreated = ocFile.getCreationTimestamp(); // Unix time in seconds! + long remoteModified = ocFile.getModificationTimestamp() / 1000; // Unix time in milliseconds + final boolean existingFileIsTheSame = + remoteName.equals(localName) && + remoteCreated == localCreated && + remoteModified == localModified; + return existingFileIsTheSame; + } catch (IOException e) { + Log.e(TAG, "fileIsTheSame: unable to obtain local file attributes for comparing"); + return false; + } + } + public void createPendingDirectory(String path, long createdAt, long modificationTimestamp) { OCFile directory = new OCFile(path); directory.setMimeType(MimeType.DIRECTORY); From 5896d05fb40dac0a47400e76e002ae049cf9bc18 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Tue, 8 Sep 2026 13:05:57 +0200 Subject: [PATCH 04/18] fix/internal-104835: check size as well Signed-off-by: daniele-verducci --- .../owncloud/android/datamodel/FileDataStorageManager.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 51c8151cf0c3..c2e082335f43 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -256,12 +256,16 @@ private boolean fileIsTheSame(OCFile ocFile, File localFile) { BasicFileAttributes attr = Files.readAttributes(localFile.toPath(), BasicFileAttributes.class); String localName = localFile.getName(); String remoteName = ocFile.getFileName(); + long localSize = localFile.length(); + long remoteSize = ocFile.getFileLength(); long localCreated = attr.creationTime().toMillis() / 1000; // Unix time in milliseconds long localModified = attr.lastModifiedTime().toMillis() / 1000; // Unix time in milliseconds long remoteCreated = ocFile.getCreationTimestamp(); // Unix time in seconds! long remoteModified = ocFile.getModificationTimestamp() / 1000; // Unix time in milliseconds + Log.d(TAG, "localCreated:" + localCreated + " localModified:" + localModified + " remoteCreated" + remoteCreated + " remoteModified:" + remoteModified); final boolean existingFileIsTheSame = remoteName.equals(localName) && + remoteSize == localSize && remoteCreated == localCreated && remoteModified == localModified; return existingFileIsTheSame; From 21d24396ae7f9018c9b4869c1535ef9e7c9e88a5 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Tue, 8 Sep 2026 18:05:01 +0200 Subject: [PATCH 05/18] fix/internal-104835: lint Signed-off-by: daniele-verducci --- .../android/datamodel/FileDataStorageManager.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index c2e082335f43..b2a721b36900 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -233,7 +233,13 @@ public OfflineOperationEntity addCreateFolderOfflineOperation(String path, Strin return entity; } - public void createPendingFile(String remotePath, String mimeType, long createdAt, long modificationTimestamp, String localPath) { + public void createPendingFile( + String remotePath, + String mimeType, + long createdAt, + long modificationTimestamp, + String localPath + ) { final OCFile existingFile = getFileByRemotePath(remotePath); if (existingFile != null) { final File localFile = new File(localPath); @@ -262,13 +268,11 @@ private boolean fileIsTheSame(OCFile ocFile, File localFile) { long localModified = attr.lastModifiedTime().toMillis() / 1000; // Unix time in milliseconds long remoteCreated = ocFile.getCreationTimestamp(); // Unix time in seconds! long remoteModified = ocFile.getModificationTimestamp() / 1000; // Unix time in milliseconds - Log.d(TAG, "localCreated:" + localCreated + " localModified:" + localModified + " remoteCreated" + remoteCreated + " remoteModified:" + remoteModified); - final boolean existingFileIsTheSame = + return remoteName.equals(localName) && remoteSize == localSize && remoteCreated == localCreated && remoteModified == localModified; - return existingFileIsTheSame; } catch (IOException e) { Log.e(TAG, "fileIsTheSame: unable to obtain local file attributes for comparing"); return false; From 085327f169bd07b3e0a12cf644f3ace5c1a30a40 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 12:09:31 +0200 Subject: [PATCH 06/18] Refactored java function: moved into FileExtensions Signed-off-by: daniele-verducci --- .../utils/extensions/FileExtensions.kt | 27 ++++++++++++++ .../datamodel/FileDataStorageManager.java | 37 +++---------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index 138af0d0060c..fa5e5d9085ff 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -8,14 +8,19 @@ package com.nextcloud.utils.extensions import android.graphics.Bitmap +import android.util.Log import androidx.exifinterface.media.ExifInterface +import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.model.ServerFileInterface import com.owncloud.android.utils.DisplayUtils import java.io.File +import java.io.IOException +import java.nio.file.Files import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes private const val TAG = "FileExtensions" @@ -111,3 +116,25 @@ fun String.getBitmapSize(): Pair? = try { } catch (_: Exception) { null } + +fun OCFile?.isTheSameAs(localFile: File?): Boolean = try { + this ?: return false + localFile ?: return false + + val attr = Files.readAttributes(localFile.toPath(), BasicFileAttributes::class.java) + val localName = localFile.getName() + val remoteName = this.fileName + val localSize = localFile.length() + val remoteSize = this.fileLength + val localCreated = attr.creationTime().toMillis() / 1000 // Unix time in milliseconds + val localModified = attr.lastModifiedTime().toMillis() / 1000 // Unix time in milliseconds + val remoteCreated = this.creationTimestamp // Unix time in seconds! + val remoteModified = this.modificationTimestamp / 1000 // Unix time in milliseconds + remoteName == localName && + remoteSize == localSize && + remoteCreated == localCreated && + remoteModified == localModified +} catch (e: IOException) { + Log.e(FileDataStorageManager.TAG, "fileIsTheSame: unable to obtain local file attributes for comparing: $e") + false +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index b2a721b36900..0dbcef72b5c3 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -28,7 +28,6 @@ import android.os.RemoteException; import android.provider.MediaStore; import android.text.TextUtils; -import android.util.Log; import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; @@ -79,8 +78,6 @@ import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -241,13 +238,11 @@ public void createPendingFile( String localPath ) { final OCFile existingFile = getFileByRemotePath(remotePath); - if (existingFile != null) { - final File localFile = new File(localPath); - if (fileIsTheSame(existingFile, localFile)) { - // In case the same file was already uploaded, do not overwrite it to avoid triggering a conflict - Log_OC.i(TAG, "Creating pendingFile for an already uploaded file: keeping metadata"); - return; - } + final File localFile = FileExtensionsKt.toFile(localPath); + if (FileExtensionsKt.isTheSameAs(existingFile, localFile)) { + // In case the same file was already uploaded, do not overwrite it to avoid triggering a conflict + Log_OC.i(TAG, "Creating pendingFile for an already uploaded file: keeping metadata"); + return; } OCFile file = new OCFile(remotePath); @@ -257,28 +252,6 @@ public void createPendingFile( saveFileWithParent(file, MainApp.getAppContext()); } - private boolean fileIsTheSame(OCFile ocFile, File localFile) { - try { - BasicFileAttributes attr = Files.readAttributes(localFile.toPath(), BasicFileAttributes.class); - String localName = localFile.getName(); - String remoteName = ocFile.getFileName(); - long localSize = localFile.length(); - long remoteSize = ocFile.getFileLength(); - long localCreated = attr.creationTime().toMillis() / 1000; // Unix time in milliseconds - long localModified = attr.lastModifiedTime().toMillis() / 1000; // Unix time in milliseconds - long remoteCreated = ocFile.getCreationTimestamp(); // Unix time in seconds! - long remoteModified = ocFile.getModificationTimestamp() / 1000; // Unix time in milliseconds - return - remoteName.equals(localName) && - remoteSize == localSize && - remoteCreated == localCreated && - remoteModified == localModified; - } catch (IOException e) { - Log.e(TAG, "fileIsTheSame: unable to obtain local file attributes for comparing"); - return false; - } - } - public void createPendingDirectory(String path, long createdAt, long modificationTimestamp) { OCFile directory = new OCFile(path); directory.setMimeType(MimeType.DIRECTORY); From b2a8db6f27f9c40e93b03fa8f831cb9b4a57b728 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 12:11:32 +0200 Subject: [PATCH 07/18] Removed wrongly-placed file update code from uploadworker and updaging etagOnServer field properly in UploadFileOperation Signed-off-by: daniele-verducci --- .../com/nextcloud/client/jobs/upload/FileUploadWorker.kt | 7 ------- .../owncloud/android/operations/UploadFileOperation.java | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 1051d23bcc1f..e698f3850869 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -368,13 +368,6 @@ class FileUploadWorker( Log_OC.e(TAG, "credentials are no longer valid, stopping uploads") break } - - if (result.isSuccess && result.resultData is String) { - // Update the remote etag to avoid conflict when uploading same file, both in online and offline uploads - val file = operation.storageManager.getFileByRemotePath(upload.remotePath) - file?.etagOnServer = result.resultData.toString() - operation.storageManager.saveFile(file) - } } val uploadFilesResult = when { diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index c71fe7d504a6..be3f3fc7f4b1 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -1797,6 +1797,7 @@ private void updateOCFile(OCFile file, RemoteFile remoteFile) { file.setModificationTimestamp(remoteFile.getModifiedTimestamp()); file.setModificationTimestampAtLastSyncForData(remoteFile.getModifiedTimestamp()); file.setEtag(remoteFile.getEtag()); + file.setEtagOnServer(remoteFile.getEtag()); file.setRemoteId(remoteFile.getRemoteId()); file.setPermissions(remoteFile.getPermissions()); file.setUploadTimestamp(remoteFile.getUploadTimestamp()); From c6525124e6b62ca37c01da424cb332a857a8cf7f Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 12:19:34 +0200 Subject: [PATCH 08/18] Fixed detekt Signed-off-by: daniele-verducci --- .../java/com/nextcloud/utils/extensions/FileExtensions.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index fa5e5d9085ff..4b7187f3760c 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -23,6 +23,7 @@ import java.nio.file.Path import java.nio.file.attribute.BasicFileAttributes private const val TAG = "FileExtensions" +private const val MS_IN_SECOND = 1000 fun OCFile?.logFileSize(tag: String) { val size = DisplayUtils.bytesToHumanReadable(this?.fileLength ?: -1) @@ -126,10 +127,10 @@ fun OCFile?.isTheSameAs(localFile: File?): Boolean = try { val remoteName = this.fileName val localSize = localFile.length() val remoteSize = this.fileLength - val localCreated = attr.creationTime().toMillis() / 1000 // Unix time in milliseconds - val localModified = attr.lastModifiedTime().toMillis() / 1000 // Unix time in milliseconds + val localCreated = attr.creationTime().toMillis() / MS_IN_SECOND // Unix time in milliseconds + val localModified = attr.lastModifiedTime().toMillis() / MS_IN_SECOND // Unix time in milliseconds val remoteCreated = this.creationTimestamp // Unix time in seconds! - val remoteModified = this.modificationTimestamp / 1000 // Unix time in milliseconds + val remoteModified = this.modificationTimestamp / MS_IN_SECOND // Unix time in milliseconds remoteName == localName && remoteSize == localSize && remoteCreated == localCreated && From 0c44112be4ec40e6117cc3bbf68575a63434a000 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 16:20:49 +0200 Subject: [PATCH 09/18] Working fix proof of concept, to be refactored and not taking account edge cases Signed-off-by: daniele-verducci --- .../repository/OfflineOperationsRepository.kt | 20 +++++++++++++++++++ .../OfflineOperationsRepositoryType.kt | 1 + .../datamodel/FileDataStorageManager.java | 5 +++-- .../ui/activity/ConflictsResolveActivity.kt | 7 ++++--- .../conflict/ConflictResolveDialogFactory.kt | 13 +++++++++++- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt index 86170e455de3..653712c40bd8 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt @@ -101,6 +101,26 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt .forEach { dao.update(it) } } + override fun updateOperationForMove(operation: OfflineOperationEntity, file: OCFile, newPath: String) { + operation.apply { + type = when (type) { + is OfflineOperationType.CreateFile -> + (type as OfflineOperationType.CreateFile).copy( + remotePath = newPath + ) + + is OfflineOperationType.CreateFolder -> + (type as OfflineOperationType.CreateFolder).copy( + path = newPath + ) + + else -> type + } + path = newPath + } + dao.update(operation) + } + override fun convertToOCFiles(fileId: Long): List = dao.getSubEntitiesByParentOCFileId(fileId).map { entity -> OCFile(entity.path).apply { diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt index b6509093fac9..a735a365ca8c 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt @@ -14,5 +14,6 @@ interface OfflineOperationsRepositoryType { fun getAllSubEntities(fileId: Long): List fun deleteOperation(file: OCFile) fun updateNextOperations(operation: OfflineOperationEntity) + fun updateOperationForMove(operation: OfflineOperationEntity, file: OCFile, newPath: String) fun convertToOCFiles(fileId: Long): List } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 0dbcef72b5c3..81b115606261 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -371,9 +371,10 @@ public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCF String currentDateTime = DateExtensionsKt.currentDateRepresentation(new Date(), formatPattern); String newFolderName = oldFileName + " - " + currentDateTime; - String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR; + String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + + (file.isFolder() ? OCFile.PATH_SEPARATOR : ""); + offlineOperationsRepository.updateOperationForMove(entity, file, newPath); moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath()); - offlineOperationsRepository.updateNextOperations(entity); } @Nullable diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt index 5f9d0caed0db..abeee2fe4b73 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -37,9 +37,9 @@ import com.owncloud.android.files.services.NameCollisionPolicy import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.ui.dialog.conflict.ConflictResolveDialogFactory import com.owncloud.android.ui.dialog.conflict.ConflictsResolveDialog.Decision import com.owncloud.android.ui.dialog.conflict.ConflictsResolveDialog.OnConflictDecisionMadeListener -import com.owncloud.android.ui.dialog.conflict.ConflictResolveDialogFactory import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.FileStorageUtils import kotlinx.coroutines.Dispatchers @@ -272,11 +272,12 @@ class ConflictsResolveActivity : return } - val (ft, _) = prepareDialogTransaction() + val (ft, user) = prepareDialogTransaction() ConflictResolveDialogFactory.forOffline( context = this, leftFile = offlineOperation, - rightFile = newFile!! + rightFile = newFile!!, + user = user ).show(ft, "conflictDialog") } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt index 9a6fd6a32a98..aa8e6b9e3497 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt @@ -12,6 +12,8 @@ import android.os.Bundle import androidx.annotation.StringRes import com.nextcloud.client.account.User import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.model.OfflineOperationType +import com.nextcloud.utils.extensions.toFile import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.dialog.conflict.model.ConflictDialogData @@ -25,7 +27,8 @@ object ConflictResolveDialogFactory { private const val SECONDS_TO_MILLIS = 1000L private const val UNKNOWN_FOLDER_SIZE = 0L - fun forOffline(context: Context, leftFile: OfflineOperationEntity, rightFile: OCFile): ConflictsResolveDialog { + fun forOffline(context: Context, leftFile: OfflineOperationEntity, rightFile: OCFile, user: User?): + ConflictsResolveDialog { val data = ConflictDialogData( headline = context.getString(R.string.conflict_folder_headline), description = context.getString(R.string.conflict_message_description_for_folder), @@ -37,8 +40,16 @@ object ConflictResolveDialogFactory { serverFile = context.conflictFileData(R.string.prefs_synced_folders_remote_path_title, rightFile) ) + val localFile = + if (leftFile.type is OfflineOperationType.CreateFile) + (leftFile.type as OfflineOperationType.CreateFile).localPath.toFile() + else + null + return createDialog(ConflictDialogType.Offline(data)) { + putSerializable(ConflictsResolveDialog.ARG_LEFT_FILE, localFile) putParcelable(ConflictsResolveDialog.ARG_RIGHT_FILE, rightFile) + putParcelable(ConflictsResolveDialog.ARG_USER, user) } } From 180c0cfbdd391cecaa55942e29ead9cb7d3bef5b Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 17:02:14 +0200 Subject: [PATCH 10/18] Working conflict resolution for "keep both" in offline upload, aligned new name to the one used for online Signed-off-by: daniele-verducci --- .../repository/OfflineOperationsRepository.kt | 35 +++++++------------ .../OfflineOperationsRepositoryType.kt | 2 +- .../datamodel/FileDataStorageManager.java | 22 ++++++------ .../ui/activity/ConflictsResolveActivity.kt | 6 +++- 4 files changed, 28 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt index 653712c40bd8..02821e438543 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt @@ -73,27 +73,7 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt fileDataStorageManager.getFileById(parentId)?.let { ocFile -> ocFile.decryptedRemotePath?.let { updatedPath -> val newPath = updatedPath + nextOperation.filename + pathSeparator - - if (newPath != nextOperation.path) { - nextOperation.apply { - type = when (type) { - is OfflineOperationType.CreateFile -> - (type as OfflineOperationType.CreateFile).copy( - remotePath = newPath - ) - - is OfflineOperationType.CreateFolder -> - (type as OfflineOperationType.CreateFolder).copy( - path = newPath - ) - - else -> type - } - path = newPath - } - } else { - null - } + updateOperationPath(newPath, nextOperation) } } } @@ -101,8 +81,11 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt .forEach { dao.update(it) } } - override fun updateOperationForMove(operation: OfflineOperationEntity, file: OCFile, newPath: String) { - operation.apply { + private fun updateOperationPath( + newPath: String, + nextOperation: OfflineOperationEntity + ): OfflineOperationEntity? = if (newPath != nextOperation.path) { + nextOperation.apply { type = when (type) { is OfflineOperationType.CreateFile -> (type as OfflineOperationType.CreateFile).copy( @@ -118,6 +101,12 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt } path = newPath } + } else { + null + } + + override fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String) { + updateOperationPath(newPath, operation) dao.update(operation) } diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt index a735a365ca8c..51be196fffe7 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepositoryType.kt @@ -14,6 +14,6 @@ interface OfflineOperationsRepositoryType { fun getAllSubEntities(fileId: Long): List fun deleteOperation(file: OCFile) fun updateNextOperations(operation: OfflineOperationEntity) - fun updateOperationForMove(operation: OfflineOperationEntity, file: OCFile, newPath: String) + fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String) fun convertToOCFiles(fileId: Long): List } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 81b115606261..e6f60968ecfa 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -54,6 +54,7 @@ import com.owncloud.android.MainApp; import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; import com.owncloud.android.db.ProviderMeta.ProviderTableMeta; +import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.network.WebdavEntry; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; @@ -70,6 +71,7 @@ import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.tags.Tag; import com.owncloud.android.operations.RemoteOperationFailedException; +import com.owncloud.android.operations.UploadFileOperation; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.MimeType; import com.owncloud.android.utils.MimeTypeUtil; @@ -354,10 +356,7 @@ public void renameOfflineOperation(OCFile file, String newFolderName) { moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath()); } - @SuppressLint("SimpleDateFormat") - public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file) { - if (file == null) return; - + public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file, OwnCloudClient client) { String oldFileName = entity.getFilename(); if (oldFileName == null) return; @@ -367,14 +366,13 @@ public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCF OCFile parentFolder = getFileById(parentOCFileId); if (parentFolder == null) return; - DateFormatPattern formatPattern = DateFormatPattern.FullDateWithHours; - String currentDateTime = DateExtensionsKt.currentDateRepresentation(new Date(), formatPattern); - - String newFolderName = oldFileName + " - " + currentDateTime; - String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + - (file.isFolder() ? OCFile.PATH_SEPARATOR : ""); - offlineOperationsRepository.updateOperationForMove(entity, file, newPath); - moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath()); + final String newPath = UploadFileOperation.getNewAvailableRemotePath( + client, + (entity.getPath() != null) ? entity.getPath() : file.getDecryptedRemotePath(), + List.of(oldFileName), + file.isEncrypted() + ); + offlineOperationsRepository.updateOperationForKeepBoth(entity, newPath); } @Nullable diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt index abeee2fe4b73..280623aece4e 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -201,7 +201,11 @@ class ConflictsResolveActivity : private suspend fun keepBothFolder(offlineOperation: OfflineOperationEntity?, serverFile: OCFile?) { offlineOperation ?: return - fileDataStorageManager.keepOfflineOperationAndServerFile(offlineOperation, serverFile) + fileDataStorageManager.keepOfflineOperationAndServerFile( + offlineOperation, + serverFile, + clientRepository.getOwncloudClient() + ) backgroundJobManager.startOfflineOperations() withContext(Dispatchers.Main) { offlineOperationNotificationManager.dismissNotification(offlineOperation.id) From 0c3ffbd0fa969b36ac700f60dee9fc9758d23c7d Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 17:29:21 +0200 Subject: [PATCH 11/18] Fixed conflict resolution dialog content for offline uploads Signed-off-by: daniele-verducci --- .../conflict/ConflictResolveDialogFactory.kt | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt index aa8e6b9e3497..ad54d70cb079 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt @@ -24,21 +24,8 @@ import java.io.File object ConflictResolveDialogFactory { - private const val SECONDS_TO_MILLIS = 1000L - private const val UNKNOWN_FOLDER_SIZE = 0L - fun forOffline(context: Context, leftFile: OfflineOperationEntity, rightFile: OCFile, user: User?): ConflictsResolveDialog { - val data = ConflictDialogData( - headline = context.getString(R.string.conflict_folder_headline), - description = context.getString(R.string.conflict_message_description_for_folder), - localFile = context.conflictFileData( - titleId = R.string.prefs_synced_folders_local_path_title, - timestamp = (leftFile.createdAt ?: 0L) * SECONDS_TO_MILLIS, - fileLength = UNKNOWN_FOLDER_SIZE - ), - serverFile = context.conflictFileData(R.string.prefs_synced_folders_remote_path_title, rightFile) - ) val localFile = if (leftFile.type is OfflineOperationType.CreateFile) @@ -46,6 +33,17 @@ object ConflictResolveDialogFactory { else null + val data = ConflictDialogData( + headline = context.getString(R.string.choose_which_file), + description = context.getString(R.string.conflict_message_description), + localFile = context.conflictFileData( + titleId = R.string.conflict_local_file, + timestamp = (localFile?.lastModified() ?: 0L), + fileLength = (localFile?.length() ?: 0L) + ), + serverFile = context.conflictFileData(R.string.prefs_synced_folders_remote_path_title, rightFile) + ) + return createDialog(ConflictDialogType.Offline(data)) { putSerializable(ConflictsResolveDialog.ARG_LEFT_FILE, localFile) putParcelable(ConflictsResolveDialog.ARG_RIGHT_FILE, rightFile) From a43c25ea8f980091be83cbf402ef836507915ea7 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 9 Sep 2026 17:46:57 +0200 Subject: [PATCH 12/18] Fixed lint Signed-off-by: daniele-verducci --- .../repository/OfflineOperationsRepository.kt | 40 +++++++++---------- .../conflict/ConflictResolveDialogFactory.kt | 14 ++++--- app/src/main/res/values/strings.xml | 2 - 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt index 02821e438543..cc9e7e645d6c 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt @@ -81,29 +81,27 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt .forEach { dao.update(it) } } - private fun updateOperationPath( - newPath: String, - nextOperation: OfflineOperationEntity - ): OfflineOperationEntity? = if (newPath != nextOperation.path) { - nextOperation.apply { - type = when (type) { - is OfflineOperationType.CreateFile -> - (type as OfflineOperationType.CreateFile).copy( - remotePath = newPath - ) - - is OfflineOperationType.CreateFolder -> - (type as OfflineOperationType.CreateFolder).copy( - path = newPath - ) - - else -> type + private fun updateOperationPath(newPath: String, nextOperation: OfflineOperationEntity): OfflineOperationEntity? = + if (newPath != nextOperation.path) { + nextOperation.apply { + type = when (type) { + is OfflineOperationType.CreateFile -> + (type as OfflineOperationType.CreateFile).copy( + remotePath = newPath + ) + + is OfflineOperationType.CreateFolder -> + (type as OfflineOperationType.CreateFolder).copy( + path = newPath + ) + + else -> type + } + path = newPath } - path = newPath + } else { + null } - } else { - null - } override fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String) { updateOperationPath(newPath, operation) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt index ad54d70cb079..ec01acb3ca0e 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt @@ -24,14 +24,18 @@ import java.io.File object ConflictResolveDialogFactory { - fun forOffline(context: Context, leftFile: OfflineOperationEntity, rightFile: OCFile, user: User?): - ConflictsResolveDialog { - + fun forOffline( + context: Context, + leftFile: OfflineOperationEntity, + rightFile: OCFile, + user: User? + ): ConflictsResolveDialog { val localFile = - if (leftFile.type is OfflineOperationType.CreateFile) + if (leftFile.type is OfflineOperationType.CreateFile) { (leftFile.type as OfflineOperationType.CreateFile).localPath.toFile() - else + } else { null + } val data = ConflictDialogData( headline = context.getString(R.string.choose_which_file), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa3557334b9e..ba7336cc554c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1267,8 +1267,6 @@ Free up space %1$s is %2$s, but there is only %3$s available on device. Not enough space - Folder conflict - If you select both versions, the local folder will have a number appended to its name. If you select both versions, the local file will have a number appended to its name. Local file Server file From ee235092748a4a5c2d8df2af8acc46e52ffc880f Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 16 Sep 2026 09:21:14 +0200 Subject: [PATCH 13/18] Applied suggestions from PR Signed-off-by: daniele-verducci --- .../repository/OfflineOperationsRepository.kt | 33 ++++++++----------- .../datamodel/FileDataStorageManager.java | 4 +-- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt index cc9e7e645d6c..b2532bd32015 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/repository/OfflineOperationsRepository.kt @@ -81,28 +81,21 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt .forEach { dao.update(it) } } - private fun updateOperationPath(newPath: String, nextOperation: OfflineOperationEntity): OfflineOperationEntity? = - if (newPath != nextOperation.path) { - nextOperation.apply { - type = when (type) { - is OfflineOperationType.CreateFile -> - (type as OfflineOperationType.CreateFile).copy( - remotePath = newPath - ) - - is OfflineOperationType.CreateFolder -> - (type as OfflineOperationType.CreateFolder).copy( - path = newPath - ) - - else -> type - } - path = newPath - } - } else { - null + private fun updateOperationPath(newPath: String, nextOperation: OfflineOperationEntity): OfflineOperationEntity? { + if (newPath == nextOperation.path) return null + + val updatedType = when (val currentType = nextOperation.type) { + is OfflineOperationType.CreateFile -> currentType.copy(remotePath = newPath) + is OfflineOperationType.CreateFolder -> currentType.copy(path = newPath) + else -> currentType } + return nextOperation.apply { + type = updatedType + path = newPath + } + } + override fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String) { updateOperationPath(newPath, operation) dao.update(operation) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index e6f60968ecfa..5ea8bceb1e5a 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -242,8 +242,8 @@ public void createPendingFile( final OCFile existingFile = getFileByRemotePath(remotePath); final File localFile = FileExtensionsKt.toFile(localPath); if (FileExtensionsKt.isTheSameAs(existingFile, localFile)) { - // In case the same file was already uploaded, do not overwrite it to avoid triggering a conflict - Log_OC.i(TAG, "Creating pendingFile for an already uploaded file: keeping metadata"); + Log_OC.i(TAG, "Creating pendingFile for an already uploaded file: " + + "keeping metadata to avoid triggering a conflict"); return; } From 46aaf24baff7290e5d87d8313a001711b3be00a8 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 17 Sep 2026 12:16:33 +0200 Subject: [PATCH 14/18] Applied some proposed fixes from PR Signed-off-by: daniele-verducci --- .../com/owncloud/android/datamodel/FileDataStorageManager.java | 2 ++ .../owncloud/android/ui/activity/ConflictsResolveActivity.kt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 5ea8bceb1e5a..1fdcee53c90a 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -357,6 +357,8 @@ public void renameOfflineOperation(OCFile file, String newFolderName) { } public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file, OwnCloudClient client) { + if (file == null) return; + String oldFileName = entity.getFilename(); if (oldFileName == null) return; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt index 280623aece4e..4851cbb31246 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -201,10 +201,11 @@ class ConflictsResolveActivity : private suspend fun keepBothFolder(offlineOperation: OfflineOperationEntity?, serverFile: OCFile?) { offlineOperation ?: return + val client = clientRepository.getOwncloudClient() ?: return fileDataStorageManager.keepOfflineOperationAndServerFile( offlineOperation, serverFile, - clientRepository.getOwncloudClient() + client ) backgroundJobManager.startOfflineOperations() withContext(Dispatchers.Main) { From a6ab15a18d77b2e79e5e9cf71e6f1a66ca17ebe3 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 17 Sep 2026 12:17:30 +0200 Subject: [PATCH 15/18] Fixes in conflict resolve dialog, see https://github.com/nextcloud/android/pull/17590/changes\#r4034298542 Signed-off-by: daniele-verducci --- .../conflict/ConflictResolveDialogFactory.kt | 22 ++++++++++++++----- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt index ec01acb3ca0e..a5222ccb30a7 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt @@ -23,6 +23,7 @@ import com.owncloud.android.utils.DisplayUtils import java.io.File object ConflictResolveDialogFactory { + private const val SECONDS_TO_MILLIS = 1000L fun forOffline( context: Context, @@ -30,26 +31,35 @@ object ConflictResolveDialogFactory { rightFile: OCFile, user: User? ): ConflictsResolveDialog { - val localFile = + val offlineCreatedLocalFile = if (leftFile.type is OfflineOperationType.CreateFile) { (leftFile.type as OfflineOperationType.CreateFile).localPath.toFile() } else { null } + val isFolderConflict = leftFile.type is OfflineOperationType.CreateFolder + val data = ConflictDialogData( - headline = context.getString(R.string.choose_which_file), - description = context.getString(R.string.conflict_message_description), + headline = context.getString( + if (isFolderConflict) R.string.conflict_folder_headline + else R.string.choose_which_file + ), + description = context.getString( + if (isFolderConflict) R.string.conflict_message_description_for_folder + else R.string.conflict_message_description + ), localFile = context.conflictFileData( titleId = R.string.conflict_local_file, - timestamp = (localFile?.lastModified() ?: 0L), - fileLength = (localFile?.length() ?: 0L) + timestamp = (offlineCreatedLocalFile?.lastModified() + ?: leftFile.createdAt?.times(SECONDS_TO_MILLIS) ?: 0L), + fileLength = (offlineCreatedLocalFile?.length() ?: 0L) ), serverFile = context.conflictFileData(R.string.prefs_synced_folders_remote_path_title, rightFile) ) return createDialog(ConflictDialogType.Offline(data)) { - putSerializable(ConflictsResolveDialog.ARG_LEFT_FILE, localFile) + putSerializable(ConflictsResolveDialog.ARG_LEFT_FILE, offlineCreatedLocalFile) putParcelable(ConflictsResolveDialog.ARG_RIGHT_FILE, rightFile) putParcelable(ConflictsResolveDialog.ARG_USER, user) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba7336cc554c..fa3557334b9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1267,6 +1267,8 @@ Free up space %1$s is %2$s, but there is only %3$s available on device. Not enough space + Folder conflict + If you select both versions, the local folder will have a number appended to its name. If you select both versions, the local file will have a number appended to its name. Local file Server file From 0c93a959516da230eb8369f0f3bfea67b96ed8da Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 17 Sep 2026 17:00:56 +0200 Subject: [PATCH 16/18] Implemented file conflict name generator specific for offlineOperations Signed-off-by: daniele-verducci --- .../FileDataStorageManagerExtensions.kt | 46 +++++++++++++++++++ .../datamodel/FileDataStorageManager.java | 15 +++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt index fab3fe145c54..a60a19d36d6b 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -12,10 +12,13 @@ import com.nextcloud.client.database.entity.model.ShareeKey import com.nextcloud.client.database.entity.toOCCapability import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.lib.resources.shares.OCShare import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.operations.upload.RemoteFileExistence import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.MimeTypeUtil import kotlinx.coroutines.Dispatchers @@ -185,6 +188,49 @@ fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, target } } +/** + * Finds a suitable file name to resolve a conflict. + * Tries to concatenate a number to the name until it finds a non-existent one. + * E.g. for "file.txt" it will propose "file (2).txt". If that exists, then "file (3).txt" and so on. + * E.g. for "folder" it will propose "folder (2)/". If that exists, then "folder (3)/" and so on. + * + * @return the new remote path, or null if the user is unauthorized in the provided path + */ +fun getRemotePathForConflictResolution(client: OwnCloudClient, remotePath: String, fileName: String): String? { + val newName = generateFileNameForConflictResolution(fileName) + val newPath = "$remotePath$newName" + + // Check if new name exists + val operation = ExistenceCheckRemoteOperation(newPath, false) + val existence = RemoteFileExistence.fromExistenceCheck(operation.execute(client)) + if (existence == RemoteFileExistence.UNAUTHORIZED) + return null + if (existence == RemoteFileExistence.DOES_NOT_EXIST) + return newPath + return getRemotePathForConflictResolution(client, remotePath, newName) +} + +fun generateFileNameForConflictResolution(fileName: String): String { + val isFolder = fileName.endsWith(OCFile.PATH_SEPARATOR) + val separator = if (isFolder) OCFile.PATH_SEPARATOR else "." + var nameFirstPart = fileName.substringBeforeLast(separator) + var nameLastPart = fileName.substringAfterLast(separator, "") // Extension or path separator + if (nameLastPart.isNotEmpty()) nameLastPart = "$separator$nameLastPart" + val regex = Regex("""(.*)\((\d+)\)$""", RegexOption.MULTILINE) + if (regex.matches(nameFirstPart)) { + // Already a resolved conflict (i.e. "file (1).txt"). Update the number. + nameFirstPart = regex.replace(nameFirstPart, transform = { m -> + val baseName = m.groups[1]?.value + val number = m.groups[2]?.value?.toInt() ?: 0 + "$baseName(${number + 1})" + }) + } else { + // Add the number + nameFirstPart = "$nameFirstPart (1)" + } + return "$nameFirstPart$nameLastPart" +} + @Suppress("ReturnCount") private fun moveLocalFiles(accountName: String, ocFile: OCFile, defaultSavePath: String, targetPath: String): Boolean { val localFile = File(FileStorageUtils.getDefaultSavePathFor(accountName, ocFile)) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 1fdcee53c90a..1b0a58218a38 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -362,17 +362,14 @@ public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCF String oldFileName = entity.getFilename(); if (oldFileName == null) return; - Long parentOCFileId = entity.getParentOCFileId(); - if (parentOCFileId == null) return; - - OCFile parentFolder = getFileById(parentOCFileId); - if (parentFolder == null) return; + String parentRemotePath = file.getParentRemotePath(); + if (parentRemotePath == null || parentRemotePath.isEmpty()) + return; - final String newPath = UploadFileOperation.getNewAvailableRemotePath( + final String newPath = FileDataStorageManagerExtensionsKt.getRemotePathForConflictResolution( client, - (entity.getPath() != null) ? entity.getPath() : file.getDecryptedRemotePath(), - List.of(oldFileName), - file.isEncrypted() + parentRemotePath, + oldFileName ); offlineOperationsRepository.updateOperationForKeepBoth(entity, newPath); } From 870ec84be1c40211b97703a062bcdb241a9ebfde Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 17 Sep 2026 17:14:25 +0200 Subject: [PATCH 17/18] Tests for file conflict name generator specific for offlineOperations Signed-off-by: daniele-verducci --- .../datamodel/FileDataStorageManagerIT.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java index f8b04338182d..218cfd38460f 100644 --- a/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/FileDataStorageManagerIT.java @@ -8,7 +8,9 @@ package com.owncloud.android.datamodel; import android.content.ContentValues; +import android.util.Pair; +import com.nextcloud.utils.extensions.FileDataStorageManagerExtensionsKt; import com.owncloud.android.AbstractOnServerIT; import com.owncloud.android.db.ProviderMeta; import com.owncloud.android.lib.common.operations.RemoteOperationResult; @@ -353,4 +355,27 @@ public void testOCCapability() { assertEquals(capability.getUserStatus(), newCapability.getUserStatus()); } + @Test + public void testGenerateFileNameForConflictResolution() { + Pair[] names = new Pair[]{ + // Files + new Pair("hello", "hello (1)"), + new Pair("hello.txt", "hello (1).txt"), + new Pair("hello (1).txt", "hello (2).txt"), + new Pair("hello (18y5).txt", "hello (18y5) (1).txt"), + new Pair("hello (hey)", "hello (hey) (1)"), + new Pair("hello (hey).txt", "hello (hey) (1).txt"), + // Folders + new Pair("hello/", "hello (1)/"), + new Pair("hello (1)/", "hello (2)/"), + new Pair("hello.hello/", "hello.hello (1)/"), + new Pair("hello.hello (y)/", "hello.hello (y) (1)/"), + }; + + for (Pair name : names) { + String gen = FileDataStorageManagerExtensionsKt.generateFileNameForConflictResolution(name.first); + assertEquals(gen, name.second); + } + } + } From 6de6620356f0adc2774ea523eff0dfc3355de75a Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 17 Sep 2026 17:36:59 +0200 Subject: [PATCH 18/18] Fix lint Signed-off-by: daniele-verducci --- .../FileDataStorageManagerExtensions.kt | 9 ++++++--- .../utils/extensions/FileExtensions.kt | 2 ++ .../conflict/ConflictResolveDialogFactory.kt | 20 +++++++++++++------ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt index a60a19d36d6b..726b00744eab 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -196,6 +196,7 @@ fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, target * * @return the new remote path, or null if the user is unauthorized in the provided path */ +@Suppress("ReturnCount") fun getRemotePathForConflictResolution(client: OwnCloudClient, remotePath: String, fileName: String): String? { val newName = generateFileNameForConflictResolution(fileName) val newPath = "$remotePath$newName" @@ -203,10 +204,12 @@ fun getRemotePathForConflictResolution(client: OwnCloudClient, remotePath: Strin // Check if new name exists val operation = ExistenceCheckRemoteOperation(newPath, false) val existence = RemoteFileExistence.fromExistenceCheck(operation.execute(client)) - if (existence == RemoteFileExistence.UNAUTHORIZED) + if (existence == RemoteFileExistence.UNAUTHORIZED) { return null - if (existence == RemoteFileExistence.DOES_NOT_EXIST) + } + if (existence == RemoteFileExistence.DOES_NOT_EXIST) { return newPath + } return getRemotePathForConflictResolution(client, remotePath, newName) } @@ -214,7 +217,7 @@ fun generateFileNameForConflictResolution(fileName: String): String { val isFolder = fileName.endsWith(OCFile.PATH_SEPARATOR) val separator = if (isFolder) OCFile.PATH_SEPARATOR else "." var nameFirstPart = fileName.substringBeforeLast(separator) - var nameLastPart = fileName.substringAfterLast(separator, "") // Extension or path separator + var nameLastPart = fileName.substringAfterLast(separator, "") // Extension or path separator if (nameLastPart.isNotEmpty()) nameLastPart = "$separator$nameLastPart" val regex = Regex("""(.*)\((\d+)\)$""", RegexOption.MULTILINE) if (regex.matches(nameFirstPart)) { diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index 4b7187f3760c..ff0025e392c0 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -5,6 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +@Suppress("TooManyFunctions") package com.nextcloud.utils.extensions import android.graphics.Bitmap @@ -22,6 +23,7 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.attribute.BasicFileAttributes + private const val TAG = "FileExtensions" private const val MS_IN_SECOND = 1000 diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt index a5222ccb30a7..2d7954e6b862 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt @@ -42,17 +42,25 @@ object ConflictResolveDialogFactory { val data = ConflictDialogData( headline = context.getString( - if (isFolderConflict) R.string.conflict_folder_headline - else R.string.choose_which_file + if (isFolderConflict) { + R.string.conflict_folder_headline + } else { + R.string.choose_which_file + } ), description = context.getString( - if (isFolderConflict) R.string.conflict_message_description_for_folder - else R.string.conflict_message_description + if (isFolderConflict) { + R.string.conflict_message_description_for_folder + } else { + R.string.conflict_message_description + } ), localFile = context.conflictFileData( titleId = R.string.conflict_local_file, - timestamp = (offlineCreatedLocalFile?.lastModified() - ?: leftFile.createdAt?.times(SECONDS_TO_MILLIS) ?: 0L), + timestamp = ( + offlineCreatedLocalFile?.lastModified() + ?: leftFile.createdAt?.times(SECONDS_TO_MILLIS) ?: 0L + ), fileLength = (offlineCreatedLocalFile?.length() ?: 0L) ), serverFile = context.conflictFileData(R.string.prefs_synced_folders_remote_path_title, rightFile)