diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt index 6c27e85983bd..3a4e4b965701 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt @@ -8,24 +8,25 @@ package com.owncloud.android.utils import com.owncloud.android.AbstractIT import org.junit.Assert +import org.junit.Assert.assertEquals import org.junit.Test import java.io.File class FileUtilTest : AbstractIT() { @Test fun assertNullInput() { - Assert.assertEquals("", FileUtil.getFilenameFromPathString(null)) + assertEquals("", FileUtil.getFilenameFromPathString(null)) } @Test fun assertEmptyInput() { - Assert.assertEquals("", FileUtil.getFilenameFromPathString("")) + assertEquals("", FileUtil.getFilenameFromPathString("")) } @Test fun assertFileInput() { val file = getDummyFile("empty.txt") - Assert.assertEquals("empty.txt", FileUtil.getFilenameFromPathString(file.absolutePath)) + assertEquals("empty.txt", FileUtil.getFilenameFromPathString(file.absolutePath)) } @Test @@ -34,13 +35,13 @@ class FileUtilTest : AbstractIT() { if (!tempPath.exists()) { Assert.assertTrue(tempPath.mkdirs()) } - Assert.assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath)) + assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath)) } @Test fun assertDotFileInput() { val file = getDummyFile(".dotfile.ext") - Assert.assertEquals(".dotfile.ext", FileUtil.getFilenameFromPathString(file.absolutePath)) + assertEquals(".dotfile.ext", FileUtil.getFilenameFromPathString(file.absolutePath)) } @Test @@ -50,12 +51,52 @@ class FileUtilTest : AbstractIT() { Assert.assertTrue(tempPath.mkdirs()) } - Assert.assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath)) + assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath)) } @Test fun assertNoFileExtensionInput() { val file = getDummyFile("file") - Assert.assertEquals("file", FileUtil.getFilenameFromPathString(file.absolutePath)) + assertEquals("file", FileUtil.getFilenameFromPathString(file.absolutePath)) + } + + @Test + fun testGetRemotePathVariantsWithUppercaseExtension() { + val path = "/TesTFolder/abc.JPG" + val expected = Pair("/TesTFolder/abc.jpg", "/TesTFolder/abc.JPG") + val actual = FileUtil.getRemotePathVariants(path) + assertEquals(expected, actual) + } + + @Test + fun testGetRemotePathVariantsWithLowercaseExtension() { + val path = "/TesTFolder/abc.png" + val expected = Pair("/TesTFolder/abc.png", "/TesTFolder/abc.PNG") + val actual = FileUtil.getRemotePathVariants(path) + assertEquals(expected, actual) + } + + @Test + fun testGetRemotePathVariantsMixedCaseExtension() { + val path = "/TesTFolder/abc.JpEg" + val expected = Pair("/TesTFolder/abc.jpeg", "/TesTFolder/abc.JPEG") + val actual = FileUtil.getRemotePathVariants(path) + assertEquals(expected, actual) + } + + @Test + fun testGetRemotePathVariantsNoExtension() { + val path = "/TesTFolder/abc" + val expected = Pair(path, path) + val actual = FileUtil.getRemotePathVariants(path) + assertEquals(expected, actual) + } + + @Test + fun testGetRemotePathVariantsDotAtEnd() { + val path = "/TesTFolder/abc." + val expected = Pair(path, path) + val actual = FileUtil.getRemotePathVariants(path) + assertEquals(expected, actual) } } diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.java b/app/src/main/java/com/nextcloud/client/di/AppModule.java index 32a0150aa0f3..f0ab192287cd 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.java @@ -253,7 +253,7 @@ PassCodeManager passCodeManager(AppPreferences preferences, Clock clock) { @Provides FileOperationHelper fileOperationHelper(CurrentAccountProvider currentAccountProvider, Context context) { - return new FileOperationHelper(currentAccountProvider.getUser(), context, fileDataStorageManager(currentAccountProvider, context)); + return new FileOperationHelper(currentAccountProvider.getUser(), context); } @Provides diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index e58d8b8ab1d1..abd469ef3822 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -24,6 +24,7 @@ import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork import com.nextcloud.client.integrations.deck.DeckApi import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.operation.FileOperationHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService @@ -61,7 +62,8 @@ class BackgroundJobFactory @Inject constructor( private val viewThemeUtils: Provider, private val localBroadcastManager: Provider, private val generatePdfUseCase: GeneratePDFUseCase, - private val syncedFolderProvider: SyncedFolderProvider + private val syncedFolderProvider: SyncedFolderProvider, + private val fileOperationHelper: FileOperationHelper ) : WorkerFactory() { @SuppressLint("NewApi") @@ -108,6 +110,7 @@ class BackgroundJobFactory @Inject constructor( accountManager.user, context, connectivityService, + fileOperationHelper, viewThemeUtils.get(), params ) @@ -230,6 +233,7 @@ class BackgroundJobFactory @Inject constructor( localBroadcastManager.get(), backgroundJobManager.get(), preferences, + fileOperationHelper, context, params ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt index 45f8d79fd31e..a614f848d491 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt @@ -13,6 +13,7 @@ import androidx.work.WorkerParameters import com.nextcloud.client.account.User import com.nextcloud.client.database.entity.OfflineOperationEntity import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository +import com.nextcloud.client.jobs.operation.FileOperationHelper import com.nextcloud.client.network.ClientFactoryImpl import com.nextcloud.client.network.ConnectivityService import com.nextcloud.model.OfflineOperationType @@ -24,14 +25,10 @@ import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation -import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation -import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.operations.CreateFolderOperation import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.RenameFileOperation -import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -42,10 +39,12 @@ import kotlin.coroutines.suspendCoroutine private typealias OfflineOperationResult = Pair?, RemoteOperation<*>?>? +@Suppress("LongParameterList") class OfflineOperationsWorker( private val user: User, private val context: Context, private val connectivityService: ConnectivityService, + private val fileOperationHelper: FileOperationHelper, viewThemeUtils: ViewThemeUtils, params: WorkerParameters ) : CoroutineWorker(context, params) { @@ -126,10 +125,10 @@ class OfflineOperationsWorker( return@withContext null } - val remoteFile = getRemoteFile(path) + val remoteFile = fileOperationHelper.getRemoteFile(path, client) val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(operation.path) - if (remoteFile != null && ocFile != null && isFileChanged(remoteFile, ocFile)) { + if (ocFile != null && fileOperationHelper.isFileChanged(remoteFile, ocFile)) { Log_OC.w(TAG, "Offline operation skipped, file already exists: $operation") if (operation.isRenameOrRemove()) { @@ -259,24 +258,4 @@ class OfflineOperationsWorker( notificationManager.showNewNotification(operationResult, operation) } } - - @Suppress("DEPRECATION") - private fun getRemoteFile(remotePath: String): RemoteFile? { - val mimeType = MimeTypeUtil.getMimeTypeFromPath(remotePath) - val isFolder = MimeTypeUtil.isFolder(mimeType) - val client = ClientFactoryImpl(context).create(user) - val result = if (isFolder) { - ReadFolderRemoteOperation(remotePath).execute(client) - } else { - ReadFileRemoteOperation(remotePath).execute(client) - } - - return if (result.isSuccess) { - result.data[0] as? RemoteFile - } else { - null - } - } - - private fun isFileChanged(remoteFile: RemoteFile, ocFile: OCFile): Boolean = remoteFile.etag != ocFile.etagOnServer } diff --git a/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt index 6cbcb7766893..dbcc2842f8b2 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/operation/FileOperationHelper.kt @@ -10,27 +10,77 @@ package com.nextcloud.client.jobs.operation import android.content.Context import com.nextcloud.client.account.User import com.nextcloud.utils.extensions.getErrorMessage +import com.nextcloud.utils.extensions.toFile import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.operations.RemoveFileOperation +import com.owncloud.android.utils.FileUtil +import com.owncloud.android.utils.MimeTypeUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.withContext -class FileOperationHelper( - private val user: User, - private val context: Context, - private val fileDataStorageManager: FileDataStorageManager -) { +class FileOperationHelper(private val user: User, private val context: Context) { companion object { private val TAG = FileOperationHelper::class.java.simpleName } + /** + * Checks if a file with the same remote path (case-insensitive) and unchanged content + * already exists in local storage by considering both lowercase and uppercase variants + * of the file extension. + * + * ### Example: + * ``` + * On the server, 0001.WEBP exists and the user tries to upload the same file + * with the lowercased version 0001.webp — in that case, this will return true. + * ``` + */ + fun isSameRemoteFileAlreadyPresent(upload: OCUpload, storageManager: FileDataStorageManager): Boolean { + val (lc, uc) = FileUtil.getRemotePathVariants(upload.remotePath) + + val remoteFile = storageManager.run { + getFileByDecryptedRemotePath(lc) ?: getFileByDecryptedRemotePath(uc) + } + + if (upload.toFile()?.length() == remoteFile?.fileLength) { + Log_OC.w(TAG, "Same file already exists due to lowercase/uppercase extension") + return true + } + + return false + } + + @Suppress("DEPRECATION") + fun getRemoteFile(remotePath: String, client: OwnCloudClient): RemoteFile? { + val mimeType = MimeTypeUtil.getMimeTypeFromPath(remotePath) + val isFolder = MimeTypeUtil.isFolder(mimeType) + val result = if (isFolder) { + ReadFolderRemoteOperation(remotePath).execute(client) + } else { + ReadFileRemoteOperation(remotePath).execute(client) + } + + return if (result.isSuccess) { + result.data[0] as? RemoteFile + } else { + null + } + } + + fun isFileChanged(remoteFile: RemoteFile?, ocFile: OCFile?): Boolean = + (remoteFile != null && ocFile != null && remoteFile.etag != ocFile.etagOnServer) + @Suppress("TooGenericExceptionCaught", "Deprecation") suspend fun removeFile( file: OCFile, + storageManager: FileDataStorageManager, onlyLocalCopy: Boolean, inBackground: Boolean, client: OwnCloudClient @@ -44,7 +94,7 @@ class FileOperationHelper( user, inBackground, context, - fileDataStorageManager + storageManager ) } val operationResult = operation.await() 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 f7cfb0b0ba25..151fa65accb7 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 @@ -17,6 +17,7 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.jobs.BackgroundJobManagerImpl +import com.nextcloud.client.jobs.operation.FileOperationHelper import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.model.WorkerState @@ -26,7 +27,7 @@ import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload -import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.operations.RemoteOperationResult @@ -48,6 +49,7 @@ class FileUploadWorker( val localBroadcastManager: LocalBroadcastManager, private val backgroundJobManager: BackgroundJobManager, val preferences: AppPreferences, + private val fileOperationHelper: FileOperationHelper, val context: Context, params: WorkerParameters ) : Worker(context, params), @@ -130,6 +132,13 @@ class FileUploadWorker( val uploadIds = inputData.getLongArray(UPLOAD_IDS) ?: return Result.success() val uploads = uploadIds.map { id -> uploadsStorageManager.getUploadById(id) }.filterNotNull() val totalUploadSize = uploadIds.size + val optionalUser = userAccountManager.getUser(accountName) + if (!optionalUser.isPresent) { + return Result.failure() + } + val user = optionalUser.get() + val client = + OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(user.toOwnCloudAccount(), context) for ((index, upload) in uploads.withIndex()) { if (preferences.isGlobalUploadPaused) { @@ -145,19 +154,18 @@ class FileUploadWorker( return Result.failure() } - val user = userAccountManager.getUser(accountName) - if (!user.isPresent) { - uploadsStorageManager.removeUpload(upload.uploadId) + if (isStopped) { continue } - if (isStopped) { + setWorkerState(user) + val operation = createUploadFileOperation(upload, user) + val storageManager = FileDataStorageManager(user, context.contentResolver) + if (fileOperationHelper.isSameRemoteFileAlreadyPresent(upload, storageManager)) { + uploadsStorageManager.removeUpload(upload.uploadId) continue } - setWorkerState(user.get()) - - val operation = createUploadFileOperation(upload, user.get()) currentUploadFileOperation = operation notificationManager.prepareForStart( @@ -168,7 +176,7 @@ class FileUploadWorker( totalUploadSize = totalUploadSize ) - val result = upload(operation, user.get()) + val result = upload(client, operation, user) currentUploadFileOperation = null fileUploaderDelegate.sendBroadcastUploadFinished( @@ -216,13 +224,15 @@ class FileUploadWorker( } @Suppress("TooGenericExceptionCaught", "DEPRECATION") - private fun upload(uploadFileOperation: UploadFileOperation, user: User): RemoteOperationResult { + private fun upload( + uploadClient: OwnCloudClient, + uploadFileOperation: UploadFileOperation, + user: User + ): RemoteOperationResult { lateinit var result: RemoteOperationResult try { val storageManager = uploadFileOperation.storageManager - val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) - val uploadClient = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) result = uploadFileOperation.execute(uploadClient) val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt index 6b2550df1e60..3e6bcf163058 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCUploadExtensions.kt @@ -8,7 +8,22 @@ package com.nextcloud.utils.extensions import com.owncloud.android.db.OCUpload +import java.io.File fun List.getUploadIds(): LongArray = map { it.uploadId }.toLongArray() fun Array.getUploadIds(): LongArray = map { it.uploadId }.toLongArray() + +@Suppress("ReturnCount") +fun OCUpload.toFile(): File? { + if (localPath.isNullOrEmpty()) { + return null + } + + val result = File(localPath) + if (!result.exists()) { + return null + } + + return result +} 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 05b49d622c0b..1ce11a71eed5 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 @@ -177,7 +177,8 @@ class ConflictsResolveActivity : lifecycleScope.launch(Dispatchers.IO) { val client = clientRepository.getOwncloudClient() ?: return@launch val isSuccess = fileOperationHelper.removeFile( - serverFile, + file = serverFile, + storageManager = fileDataStorageManager, onlyLocalCopy = false, inBackground = false, client = client diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.java b/app/src/main/java/com/owncloud/android/utils/FileUtil.java deleted file mode 100644 index d41411db69e3..000000000000 --- a/app/src/main/java/com/owncloud/android/utils/FileUtil.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.utils; - -import android.text.TextUtils; - -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.concurrent.TimeUnit; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public final class FileUtil { - - private FileUtil() { - // utility class -> private constructor - } - - /** - * returns the file name of a given path. - * - * @param filePath (absolute) file path - * @return the filename including its file extension, empty String for invalid input values - */ - public static @NonNull - String getFilenameFromPathString(@Nullable String filePath) { - if (!TextUtils.isEmpty(filePath)) { - File file = new File(filePath); - if (file.isFile()) { - return file.getName(); - } else { - return ""; - } - } else { - return ""; - } - } - - public static @Nullable - Long getCreationTimestamp(File file) { - try { - return Files.readAttributes(file.toPath(), BasicFileAttributes.class) - .creationTime() - .to(TimeUnit.SECONDS); - } catch (IOException e) { - Log_OC.e(UploadFileRemoteOperation.class.getSimpleName(), - "Failed to read creation timestamp for file: " + file.getName()); - return null; - } - } -} diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt new file mode 100644 index 000000000000..04aa83b99bcd --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import com.nextcloud.utils.extensions.StringConstants +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.TimeUnit + +object FileUtil { + /** + * returns the file name of a given path. + * + * @param filePath (absolute) file path + * @return the filename including its file extension, `empty String` for invalid input values + */ + fun getFilenameFromPathString(filePath: String?): String = if (!filePath.isNullOrBlank()) { + val file = File(filePath) + if (file.isFile()) { + file.getName() + } else { + "" + } + } else { + "" + } + + @JvmStatic + fun getCreationTimestamp(file: File): Long? { + try { + return Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) + .creationTime() + .to(TimeUnit.SECONDS) + } catch (e: IOException) { + Log_OC.e( + UploadFileRemoteOperation::class.java.getSimpleName(), + "Failed to read creation timestamp for file: " + file.getName() + ) + return null + } + } + + /** + * Returns remote path variants (lowercase and uppercase extension) for the given path. + * + * Example: + * ``` + * If you pass "/TesTFolder/abc.JPG", it will return: + * "/TesTFolder/abc.jpg" and "/TesTFolder/abc.JPG" + * ``` + */ + fun getRemotePathVariants(path: String): Pair { + val lastDot = path.lastIndexOf(StringConstants.DOT) + if (lastDot == -1 || lastDot == path.length - 1) { + return Pair(path, path) + } + + val base = path.substring(0, lastDot) + val ext = path.substring(lastDot + 1) + + val lower = "$base.${ext.lowercase()}" + val upper = "$base.${ext.uppercase()}" + + return Pair(lower, upper) + } +}