Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8d7dfdb
fix/internal-104835: do not overwrite existent file metadata
daniele-verducci Sep 4, 2026
98f7dcd
wip
daniele-verducci Sep 4, 2026
6b641b8
fix/internal-104835: avoid showing conflict window for an unmodified,…
daniele-verducci Sep 8, 2026
5896d05
fix/internal-104835: check size as well
daniele-verducci Sep 8, 2026
21d2439
fix/internal-104835: lint
daniele-verducci Sep 8, 2026
085327f
Refactored java function: moved into FileExtensions
daniele-verducci Sep 9, 2026
b2a8db6
Removed wrongly-placed file update code from uploadworker and updagin…
daniele-verducci Sep 9, 2026
c652512
Fixed detekt
daniele-verducci Sep 9, 2026
0c44112
Working fix proof of concept, to be refactored and not taking account…
daniele-verducci Sep 9, 2026
180c0cf
Working conflict resolution for "keep both" in offline upload, aligne…
daniele-verducci Sep 9, 2026
0c3ffbd
Fixed conflict resolution dialog content for offline uploads
daniele-verducci Sep 9, 2026
a43c25e
Fixed lint
daniele-verducci Sep 9, 2026
ee23509
Applied suggestions from PR
daniele-verducci Sep 16, 2026
46aaf24
Applied some proposed fixes from PR
daniele-verducci Sep 17, 2026
a6ab15a
Fixes in conflict resolve dialog, see https://github.com/nextcloud/an…
daniele-verducci Sep 17, 2026
0c93a95
Implemented file conflict name generator specific for offlineOperations
daniele-verducci Sep 17, 2026
870ec84
Tests for file conflict name generator specific for offlineOperations
daniele-verducci Sep 17, 2026
6de6620
Fix lint
daniele-verducci Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -353,4 +355,27 @@ public void testOCCapability() {
assertEquals(capability.getUserStatus(), newCapability.getUserStatus());
}

@Test
public void testGenerateFileNameForConflictResolution() {
Pair<String, String>[] names = new Pair[]{
// Files
new Pair<String, String>("hello", "hello (1)"),
new Pair<String, String>("hello.txt", "hello (1).txt"),
new Pair<String, String>("hello (1).txt", "hello (2).txt"),
new Pair<String, String>("hello (18y5).txt", "hello (18y5) (1).txt"),
new Pair<String, String>("hello (hey)", "hello (hey) (1)"),
new Pair<String, String>("hello (hey).txt", "hello (hey) (1).txt"),
// Folders
new Pair<String, String>("hello/", "hello (1)/"),
new Pair<String, String>("hello (1)/", "hello (2)/"),
new Pair<String, String>("hello.hello/", "hello.hello (1)/"),
new Pair<String, String>("hello.hello (y)/", "hello.hello (y) (1)/"),
};

for (Pair<String, String> name : names) {
String gen = FileDataStorageManagerExtensionsKt.generateFileNameForConflictResolution(name.first);
assertEquals(gen, name.second);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -73,34 +73,34 @@ 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)
}
}
}
}
.forEach { dao.update(it) }
}

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)
}

override fun convertToOCFiles(fileId: Long): List<OCFile> =
dao.getSubEntitiesByParentOCFileId(fileId).map { entity ->
OCFile(entity.path).apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ interface OfflineOperationsRepositoryType {
fun getAllSubEntities(fileId: Long): List<OfflineOperationEntity>
fun deleteOperation(file: OCFile)
fun updateNextOperations(operation: OfflineOperationEntity)
fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String)
fun convertToOCFiles(fileId: Long): List<OCFile>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -185,6 +188,52 @@ 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
*/
@Suppress("ReturnCount")
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))
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

@Suppress("TooManyFunctions")
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"
private const val MS_IN_SECOND = 1000

fun OCFile?.logFileSize(tag: String) {
val size = DisplayUtils.bytesToHumanReadable(this?.fileLength ?: -1)
Expand Down Expand Up @@ -111,3 +119,25 @@ fun String.getBitmapSize(): Pair<Int, Int>? = 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() / 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 / MS_IN_SECOND // Unix time in milliseconds
remoteName == localName &&
remoteSize == localSize &&
remoteCreated == localCreated &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How come remoteCreated and localCreated time can be same? We receive the remote file from server then write into our DB.

Scenario 1:

File created from other client, remote creating date written in the server's DB and Android client writes that and compare against the potentially exists local file this can be same file but creating time can be different.

Scenario 2:

File created from Android client does server stores Android's creation time exactly or stores based on server's creation time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Not sure about this, i looked into it with the debugger:

  • file created from other client and overwritten by android client while offline: remoteCreated = 0, localCreated = 1789640828
  • file created from android client, then re-uploaded while offline: remoteCreated = 1789392966, localCreated = 1789392966
    (This is called by createPendingFile, so it's never called if the app is online)

I checked why remoteCreated isn't populated: it is, but it's already 0 in RemoteFile

remoteModified == localModified
} catch (e: IOException) {
Log.e(FileDataStorageManager.TAG, "fileIsTheSame: unable to obtain local file attributes for comparing: $e")
false
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -200,7 +202,7 @@ public void addCreateFileOfflineOperation(String[] localPaths, String[] remotePa
}

offlineOperationDao.insert(entity);
createPendingFile(remotePath, mimeType, createdAt, modificationTimestamp);
createPendingFile(remotePath, mimeType, createdAt, modificationTimestamp, localPath);
}
}

Expand Down Expand Up @@ -230,8 +232,22 @@ 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,
String localPath
) {
final OCFile existingFile = getFileByRemotePath(remotePath);
final File localFile = FileExtensionsKt.toFile(localPath);
if (FileExtensionsKt.isTheSameAs(existingFile, localFile)) {
Log_OC.i(TAG, "Creating pendingFile for an already uploaded file: " +
"keeping metadata to avoid triggering a conflict");
return;
}

OCFile file = new OCFile(remotePath);
file.setMimeType(mimeType);
file.setCreationTimestamp(createdAt);
file.setModificationTimestamp(modificationTimestamp);
Expand Down Expand Up @@ -340,26 +356,22 @@ public void renameOfflineOperation(OCFile file, String newFolderName) {
moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath());
}

@SuppressLint("SimpleDateFormat")
public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file) {
public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file, OwnCloudClient client) {
Comment thread
daniele-verducci marked this conversation as resolved.
if (file == null) return;

String oldFileName = entity.getFilename();
if (oldFileName == null) return;

Long parentOCFileId = entity.getParentOCFileId();
if (parentOCFileId == null) return;

OCFile parentFolder = getFileById(parentOCFileId);
if (parentFolder == null) return;

DateFormatPattern formatPattern = DateFormatPattern.FullDateWithHours;
String currentDateTime = DateExtensionsKt.currentDateRepresentation(new Date(), formatPattern);
String parentRemotePath = file.getParentRemotePath();
if (parentRemotePath == null || parentRemotePath.isEmpty())
return;

String newFolderName = oldFileName + " - " + currentDateTime;
String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR;
moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath());
offlineOperationsRepository.updateNextOperations(entity);
final String newPath = FileDataStorageManagerExtensionsKt.getRemotePathForConflictResolution(
client,
parentRemotePath,
oldFileName
);
offlineOperationsRepository.updateOperationForKeepBoth(entity, newPath);
}

@Nullable
Expand Down Expand Up @@ -541,7 +553,7 @@ public List<OCFile> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -201,7 +201,12 @@ class ConflictsResolveActivity :

private suspend fun keepBothFolder(offlineOperation: OfflineOperationEntity?, serverFile: OCFile?) {
offlineOperation ?: return
fileDataStorageManager.keepOfflineOperationAndServerFile(offlineOperation, serverFile)
val client = clientRepository.getOwncloudClient() ?: return
fileDataStorageManager.keepOfflineOperationAndServerFile(
offlineOperation,
serverFile,
client
)
backgroundJobManager.startOfflineOperations()
withContext(Dispatchers.Main) {
offlineOperationNotificationManager.dismissNotification(offlineOperation.id)
Expand Down Expand Up @@ -272,11 +277,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")
}

Expand Down
Loading
Loading