Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7e7f11e
fix/internal-104835: do not overwrite existent file metadata
daniele-verducci Sep 4, 2026
7f3e094
wip
daniele-verducci Sep 4, 2026
f02720b
fix/internal-104835: avoid showing conflict window for an unmodified,…
daniele-verducci Sep 8, 2026
7501e93
fix/internal-104835: check size as well
daniele-verducci Sep 8, 2026
73898f7
fix/internal-104835: lint
daniele-verducci Sep 8, 2026
96307e0
Refactored java function: moved into FileExtensions
daniele-verducci Sep 9, 2026
137175a
Removed wrongly-placed file update code from uploadworker and updagin…
daniele-verducci Sep 9, 2026
d9280f5
Fixed detekt
daniele-verducci Sep 9, 2026
018cd57
Working conflict resolution for "keep both" in offline upload, aligne…
daniele-verducci Sep 9, 2026
2384752
Applied suggestions from PR
daniele-verducci Sep 16, 2026
a362930
Applied some proposed fixes from PR
daniele-verducci Sep 17, 2026
96baff8
Implemented file conflict name generator specific for offlineOperations
daniele-verducci Sep 17, 2026
e2cce8c
Tests for file conflict name generator specific for offlineOperations
daniele-verducci Sep 17, 2026
f812528
Fixed bug in duplicate files renaming generator, fixed test
daniele-verducci Sep 21, 2026
ede6036
Moved regexp outside of recurrency
daniele-verducci Sep 24, 2026
859a320
Removed unreliable file creation date check
daniele-verducci Sep 25, 2026
102680e
Tests for OCFile.isTheSameAs() extension
daniele-verducci Sep 25, 2026
a18cc7d
Fixed lint
daniele-verducci Sep 25, 2026
ff885ae
de-duplicate tests
alperozturk96 Sep 25, 2026
11fff0a
Fixed getRemotePathForConflictResolution (broken after refactoring), …
daniele-verducci Sep 25, 2026
ebb969e
Fixed lint
daniele-verducci Sep 25, 2026
5364eac
add missing backports
alperozturk96 Oct 14, 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 @@ -7,10 +7,15 @@
*/
package com.owncloud.android.datamodel;

import android.util.Pair;

import com.nextcloud.utils.extensions.FileDataStorageManagerExtensionsKt;
import com.owncloud.android.db.ProviderMeta;

import org.junit.Test;

import java.util.Arrays;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

Expand Down Expand Up @@ -44,4 +49,30 @@ public void saveFile() {

assertEquals(file.getRemotePath(), read.getRemotePath());
}

@Test
public void testGenerateFileNameForConflictResolution() {
final var names = Arrays.asList(
// 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"),
new Pair<>(".hello", ".hello (1)"),

// 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)/"),
new Pair<>(".hello/", ".hello (1)/")
);

for (final var name: names) {
String gen = FileDataStorageManagerExtensionsKt.generateFileNameForConflictResolution(name.first);
assertEquals(gen, name.second);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ public void before() {
// make sure everything is removed
sut.deleteAllFiles();
sut.deleteVirtuals(VirtualFolderType.GALLERY);

assertEquals(0, sut.getAllFiles().size());

capability = new GetCapabilitiesRemoteOperation(null).execute(client).getResultData();
final var result = new GetCapabilitiesRemoteOperation(null)
.execute(client);
assertTrue(result.isSuccess());
capability = result.getResultData();
}

@After
Expand Down Expand Up @@ -352,5 +353,4 @@ public void testOCCapability() {

assertEquals(capability.getUserStatus(), newCapability.getUserStatus());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
package com.owncloud.android.files.services

import com.nextcloud.client.account.UserAccountManagerImpl
import com.nextcloud.utils.PowerManagementFactory
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.utils.PowerManagementFactory
import com.nextcloud.utils.extensions.getRemotePathForConflictResolution
import com.nextcloud.utils.extensions.isTheSameAs
import com.owncloud.android.AbstractOnServerIT
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
Expand Down Expand Up @@ -106,19 +109,71 @@ class FileUploaderIT : AbstractOnServerIT() {
assertRemoteFileLength(REMOTE_PATH, originalFile.length())
}

@Test
fun uploadAndCheckFileIsTheSame() {
val filename = NON_EMPTY_FILE
val remotePath = OCFile.PATH_SEPARATOR + filename

// Upload file
val originalFile = getDummyFile(filename)
val operation = uploadOperation(
originalFile,
NameCollisionPolicy.DEFAULT,
remotePath
).setRemoteFolderToBeCreated()
assertTrue(operation.execute(client).isSuccess)

// Check file metadata against remote one
val ocFile = storageManager.getFileByRemotePath(remotePath)
assertTrue(ocFile.isTheSameAs(originalFile))
}

@Test
fun uploadAndCheckFileIsNotTheSame() {
val filename = NON_EMPTY_FILE

// Upload file
val originalFile = getDummyFile(filename)
val operation = uploadOperation(
originalFile,
NameCollisionPolicy.DEFAULT
).setRemoteFolderToBeCreated()
assertTrue(operation.execute(client).isSuccess)

// Check file metadata against remote one
val ocFile = storageManager.getFileByRemotePath(REMOTE_PATH)
assertFalse(ocFile.isTheSameAs(originalFile))
}

@Test
fun uploadAndCheckConflictingName() {
val originalFile = getDummyFile(EMPTY_FILE)
uploadOriginalFile(originalFile)
val newPath = getRemotePathForConflictResolution(
client,
OCFile.PATH_SEPARATOR,
REMOTE_FILE
)
assertEquals(newPath, "${OCFile.PATH_SEPARATOR}$REMOTE_FILE_NAME (1).$REMOTE_FILE_EXTENSION")
}

private fun uploadOriginalFile(originalFile: File) {
val operation = uploadOperation(originalFile, NameCollisionPolicy.DEFAULT).setRemoteFolderToBeCreated()
assertTrue(operation.execute(client).isSuccess)
assertRemoteFileLength(REMOTE_PATH, originalFile.length())
}

private fun uploadOperation(localFile: File, nameCollisionPolicy: NameCollisionPolicy) = UploadFileOperation(
private fun uploadOperation(
localFile: File,
nameCollisionPolicy: NameCollisionPolicy,
remotePath: String = REMOTE_PATH
) = UploadFileOperation(
uploadsStorageManager,
connectivityServiceMock,
powerManagementServiceMock,
user,
null,
OCUpload(localFile.absolutePath, REMOTE_PATH, account.name),
OCUpload(localFile.absolutePath, remotePath, account.name),
nameCollisionPolicy,
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
targetContext,
Expand All @@ -134,7 +189,10 @@ class FileUploaderIT : AbstractOnServerIT() {
}

companion object {
private const val REMOTE_PATH = "/testFile.txt"
private const val REMOTE_FILE_NAME = "testFile"
private const val REMOTE_FILE_EXTENSION = "txt"
private const val REMOTE_FILE = "$REMOTE_FILE_NAME.$REMOTE_FILE_EXTENSION"
private const val REMOTE_PATH = "${OCFile.PATH_SEPARATOR}$REMOTE_FILE"
private const val REMOTE_PATH_RENAMED = "/testFile (2).txt"
private const val CHUNKED_FILE = "chunkedFile.txt"
private const val NON_EMPTY_FILE = "nonEmpty.txt"
Expand Down
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 @@ -15,4 +15,5 @@ interface OfflineOperationsRepositoryType {
fun deleteOperation(file: OCFile)
fun updateNextOperations(operation: OfflineOperationEntity)
fun convertToOCFiles(fileId: Long): List<OCFile>
fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,24 @@ package com.nextcloud.utils.extensions
import com.nextcloud.client.database.dao.FileDao
import com.nextcloud.client.database.entity.model.ShareeKey
import com.nextcloud.client.database.entity.toOCCapability
import com.nextcloud.model.HTTPStatusCodes
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
import kotlinx.coroutines.withContext
import java.io.File

private const val SHARE_PATH_QUERY_CHUNK_SIZE = 400
private val conflictNameCounterSelectionRegex = Regex("""(.*)\((\d+)\)$""", RegexOption.MULTILINE)

/**
* Detects sharee additions/removals (by userId + shareType) for [remoteFiles], compared to what is stored
Expand Down Expand Up @@ -185,6 +190,73 @@ 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
*/
private const val MAX_CONFLICT_RESOLUTION_ATTEMPTS = 10

@Suppress("ReturnCount")
fun getRemotePathForConflictResolution(client: OwnCloudClient, remotePath: String, fileName: String): String? {
var name = fileName
repeat(MAX_CONFLICT_RESOLUTION_ATTEMPTS) {
name = generateFileNameForConflictResolution(name)
val newPath = "$remotePath$name"

val result = ExistenceCheckRemoteOperation(newPath, false).execute(client)
// 404 (File not found) case is a valid one in this case
if (!result.isSuccess && result.httpCode != HTTPStatusCodes.NOT_FOUND.code) return null

when (RemoteFileExistence.fromExistenceCheck(result)) {
RemoteFileExistence.DOES_NOT_EXIST -> return newPath
RemoteFileExistence.UNAUTHORIZED -> return null
else -> Unit // exists, try next name
}
}
return null
}

fun generateFileNameForConflictResolution(fileName: String): String {
val isFolder = fileName.endsWith(OCFile.PATH_SEPARATOR)
val separator =
if (isFolder) {
OCFile.PATH_SEPARATOR
} else if (fileName.indexOf(".") > 0) {
"."
} else {
""
}
var nameFirstPart =
if (separator.isNotEmpty()) {
fileName.substringBeforeLast(separator)
} else {
fileName
}
var nameLastPart =
if (separator.isNotEmpty()) {
fileName.substringAfterLast(separator, "")
} else {
"" // Extension or path separator
}
if (nameLastPart.isNotEmpty() || isFolder) nameLastPart = "$separator$nameLastPart"
nameFirstPart = if (conflictNameCounterSelectionRegex.matches(nameFirstPart)) {
// Already a resolved conflict (i.e. "file (1).txt"). Update the number.
conflictNameCounterSelectionRegex.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 (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
26 changes: 26 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,26 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

@file: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.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 +118,22 @@ 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 localModified = attr.lastModifiedTime().toMillis() / MS_IN_SECOND // Unix time in milliseconds
val remoteModified = this.modificationTimestamp / MS_IN_SECOND // Unix time in milliseconds
remoteName == localName &&
remoteSize == localSize &&
remoteModified == localModified
} catch (e: IOException) {
Log.e(TAG, "fileIsTheSame: unable to obtain local file attributes for comparing: $e")
false
}
Loading
Loading