From 9fa2ad84bfeb00e162161b9eb34f60a764772a9e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 6 Nov 2026 12:04:21 +0300 Subject: [PATCH 1/6] handle create file operation Signed-off-by: alperozturk96 --- .../OfflineOperationsWorker.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 cc13f4e667db..4302d1fe500b 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 @@ -126,12 +126,15 @@ class OfflineOperationsWorker( totalOperations: Int, currentSuccessfulOperationIndex: Int ) { - if (operation.type is OfflineOperationType.RemoveFile) { - val operationType = operation.type as OfflineOperationType.RemoveFile + val operationType = operation.type + if (operationType is OfflineOperationType.RemoveFile) { fileDataStorageManager.getFileByDecryptedRemotePath(operationType.path)?.let { ocFile -> repository.deleteOperation(ocFile) } } else { + if (operationType is OfflineOperationType.CreateFile) { + saveCreatedFile(operationType.remotePath) + } repository.updateNextOperations(operation) } @@ -139,6 +142,20 @@ class OfflineOperationsWorker( notificationManager.update(totalOperations, currentSuccessfulOperationIndex + 1, operation.filename ?: "") } + private fun saveCreatedFile(remotePath: String) { + val remoteFile = getRemoteFile(remotePath) ?: return + val parentPath = FileStorageUtils.getParentPath(remotePath) ?: return + val parent = fileDataStorageManager.getFileByDecryptedRemotePath(parentPath) ?: return + val existingFile = fileDataStorageManager.getFileByDecryptedRemotePath(remotePath) + + val createdFile = FileStorageUtils.fillOCFile(remoteFile).apply { + existingFile?.let { fileId = it.fileId } + parentId = parent.fileId + } + + fileDataStorageManager.saveFile(createdFile) + } + private fun handleErrorResult(id: Int?, result: OfflineOperationResult) { val operationResult = result?.first ?: return val operation = result.second ?: return From cde6c3966eedafdad73694153c73c4a2d7f16ae2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 6 Nov 2026 12:13:06 +0300 Subject: [PATCH 2/6] prevent screen dismisses if the operation no longer exists Signed-off-by: alperozturk96 --- .../OfflineOperationsNotificationManager.kt | 2 +- .../ui/activity/ConflictsResolveActivity.kt | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt index ae677f5dfbc2..afef7e688083 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt @@ -126,7 +126,7 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe } private fun getResolveConflictAction(file: OCFile, id: Int, path: String): NotificationCompat.Action { - val intent = ConflictsResolveActivity.createIntent(file, path, context) + val intent = ConflictsResolveActivity.createIntent(file, id, path, context) val pendingIntent = PendingIntent.getActivity( context, id, 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 ec1ad6d800a2..7f91084fb454 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 @@ -59,6 +59,7 @@ class ConflictsResolveActivity : private var conflictUploadId: Long = 0 private var offlineOperationPath: String? = null + private var offlineOperationId: Int? = null private var existingFile: OCFile? = null private var newFile: OCFile? = null private var localBehaviour = FileUploadWorker.LOCAL_BEHAVIOUR_FORGET @@ -105,11 +106,13 @@ class ConflictsResolveActivity : existingFile = savedInstanceState.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) localBehaviour = savedInstanceState.getInt(EXTRA_LOCAL_BEHAVIOUR) offlineOperationPath = savedInstanceState.getString(EXTRA_OFFLINE_OPERATION_PATH) + offlineOperationId = savedInstanceState.getOfflineOperationId() } else { conflictUploadId = intent.getLongExtra(EXTRA_CONFLICT_UPLOAD_ID, -1) existingFile = intent.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) localBehaviour = intent.getIntExtra(EXTRA_LOCAL_BEHAVIOUR, localBehaviour) offlineOperationPath = intent.getStringExtra(EXTRA_OFFLINE_OPERATION_PATH) + offlineOperationId = intent.extras?.getOfflineOperationId() } } @@ -123,8 +126,12 @@ class ConflictsResolveActivity : EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath ) + offlineOperationId?.let { outState.putInt(EXTRA_OFFLINE_OPERATION_ID, it) } } + private fun Bundle.getOfflineOperationId(): Int? = + if (containsKey(EXTRA_OFFLINE_OPERATION_ID)) getInt(EXTRA_OFFLINE_OPERATION_ID) else null + private fun setupDecisionListener(upload: OCUpload?) { listener = OnConflictDecisionMadeListener { decision -> val file = newFile @@ -271,6 +278,7 @@ class ConflictsResolveActivity : private fun showOfflineOperationConflictDialog(path: String) { val offlineOperation = fileDataStorageManager.offlineOperationDao.getByPath(path) if (offlineOperation == null) { + offlineOperationNotificationManager.dismissNotification(offlineOperationId) showErrorAndFinish() return } @@ -375,6 +383,7 @@ class ConflictsResolveActivity : const val EXTRA_LOCAL_BEHAVIOUR = "LOCAL_BEHAVIOUR" const val EXTRA_EXISTING_FILE = "EXISTING_FILE" private const val EXTRA_OFFLINE_OPERATION_PATH = "EXTRA_OFFLINE_OPERATION_PATH" + private const val EXTRA_OFFLINE_OPERATION_ID = "EXTRA_OFFLINE_OPERATION_ID" private val TAG = ConflictsResolveActivity::class.java.simpleName @JvmStatic @@ -387,10 +396,15 @@ class ConflictsResolveActivity : } @JvmStatic - fun createIntent(file: OCFile, offlineOperationPath: String, context: Context): Intent = - Intent(context, ConflictsResolveActivity::class.java).apply { - putExtra(EXTRA_FILE, file) - putExtra(EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath) - } + fun createIntent( + file: OCFile, + offlineOperationId: Int, + offlineOperationPath: String, + context: Context + ): Intent = Intent(context, ConflictsResolveActivity::class.java).apply { + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_OFFLINE_OPERATION_ID, offlineOperationId) + putExtra(EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath) + } } } From b0d443353ecf4d2772aa7c39eded94cc080fc4bc Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 6 Nov 2026 12:13:28 +0300 Subject: [PATCH 3/6] delete operation after success Signed-off-by: alperozturk96 --- .../OfflineOperationsWorker.kt | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) 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 4302d1fe500b..897d4a06d184 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 @@ -17,6 +17,7 @@ import com.nextcloud.client.network.ClientFactoryImpl import com.nextcloud.client.network.ConnectivityService import com.nextcloud.model.OfflineOperationType import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended +import com.nextcloud.utils.extensions.isTheSameAs import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.OwnCloudClient @@ -36,6 +37,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import com.nextcloud.utils.AppConstants.MILLIS_PER_SECOND +import java.io.File private typealias OfflineOperationResult = Pair?, RemoteOperation<*>?>? @@ -133,17 +135,40 @@ class OfflineOperationsWorker( } } else { if (operationType is OfflineOperationType.CreateFile) { - saveCreatedFile(operationType.remotePath) + getRemoteFile(operationType.remotePath)?.let { saveRemoteFile(it) } } repository.updateNextOperations(operation) } - fileDataStorageManager.offlineOperationDao.delete(operation) + deleteOperation(operation) notificationManager.update(totalOperations, currentSuccessfulOperationIndex + 1, operation.filename ?: "") } - private fun saveCreatedFile(remotePath: String) { - val remoteFile = getRemoteFile(remotePath) ?: return + private fun deleteOperation(operation: OfflineOperationEntity) { + fileDataStorageManager.offlineOperationDao.delete(operation) + notificationManager.dismissNotification(operation.id) + } + + private fun handleExistingRemoteFile( + operation: OfflineOperationEntity, + localPath: String, + remoteFile: RemoteFile, + ocFile: OCFile? + ) { + val serverFile = FileStorageUtils.fillOCFile(remoteFile) + if (serverFile.isTheSameAs(File(localPath))) { + Log_OC.d(TAG, "Skipping, same file already exists on server: ${operation.path}") + saveRemoteFile(remoteFile) + deleteOperation(operation) + return + } + + Log_OC.w(TAG, "⚠️ Conflict detected: different file already exists on server, operation id=${operation.id}") + notificationManager.showConflictResolveNotification(ocFile ?: serverFile, operation) + } + + private fun saveRemoteFile(remoteFile: RemoteFile) { + val remotePath = remoteFile.remotePath ?: return val parentPath = FileStorageUtils.getParentPath(remotePath) ?: return val parent = fileDataStorageManager.getFileByDecryptedRemotePath(parentPath) ?: return val existingFile = fileDataStorageManager.getFileByDecryptedRemotePath(remotePath) @@ -193,8 +218,7 @@ class OfflineOperationsWorker( } repository.updateNextOperations(operation) - fileDataStorageManager.offlineOperationDao.delete(operation) - notificationManager.dismissNotification(operation.id) + deleteOperation(operation) } // region Operation Execution @@ -218,6 +242,12 @@ class OfflineOperationsWorker( return@withContext null } + val operationType = operation.type + if (operationType is OfflineOperationType.CreateFile && remoteFile != null) { + handleExistingRemoteFile(operation, operationType.localPath, remoteFile, ocFile) + return@withContext null + } + if (remoteFile != null && ocFile != null && isFileChanged(remoteFile, ocFile)) { Log_OC.w(TAG, "⚠️ Conflict detected: File already exists on server. Skipping operation id=${operation.id}") @@ -235,13 +265,7 @@ class OfflineOperationsWorker( if (operation.isRenameOrRemove() && ocFile == null) { Log_OC.d(TAG, "Skipping, attempting to delete or rename non-existing file") - fileDataStorageManager.offlineOperationDao.delete(operation) - return@withContext null - } - - if (operation.isCreate() && remoteFile != null && ocFile != null && !isFileChanged(remoteFile, ocFile)) { - Log_OC.d(TAG, "Skipping, attempting to create same file creation") - fileDataStorageManager.offlineOperationDao.delete(operation) + deleteOperation(operation) return@withContext null } From 763d40f688c0888426c0ca0af01315d77ea1ac61 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 6 Nov 2026 12:19:37 +0300 Subject: [PATCH 4/6] deleteOfflineOperation and cancel notifications Signed-off-by: alperozturk96 --- .../OfflineOperationsNotificationManager.kt | 2 +- .../ui/activity/ConflictsResolveActivity.kt | 24 ++++--------------- .../ui/dialog/RemoveFilesDialogFragment.kt | 10 +++++++- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt index afef7e688083..ae677f5dfbc2 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt @@ -126,7 +126,7 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe } private fun getResolveConflictAction(file: OCFile, id: Int, path: String): NotificationCompat.Action { - val intent = ConflictsResolveActivity.createIntent(file, id, path, context) + val intent = ConflictsResolveActivity.createIntent(file, path, context) val pendingIntent = PendingIntent.getActivity( context, id, 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 7f91084fb454..ec1ad6d800a2 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 @@ -59,7 +59,6 @@ class ConflictsResolveActivity : private var conflictUploadId: Long = 0 private var offlineOperationPath: String? = null - private var offlineOperationId: Int? = null private var existingFile: OCFile? = null private var newFile: OCFile? = null private var localBehaviour = FileUploadWorker.LOCAL_BEHAVIOUR_FORGET @@ -106,13 +105,11 @@ class ConflictsResolveActivity : existingFile = savedInstanceState.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) localBehaviour = savedInstanceState.getInt(EXTRA_LOCAL_BEHAVIOUR) offlineOperationPath = savedInstanceState.getString(EXTRA_OFFLINE_OPERATION_PATH) - offlineOperationId = savedInstanceState.getOfflineOperationId() } else { conflictUploadId = intent.getLongExtra(EXTRA_CONFLICT_UPLOAD_ID, -1) existingFile = intent.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) localBehaviour = intent.getIntExtra(EXTRA_LOCAL_BEHAVIOUR, localBehaviour) offlineOperationPath = intent.getStringExtra(EXTRA_OFFLINE_OPERATION_PATH) - offlineOperationId = intent.extras?.getOfflineOperationId() } } @@ -126,12 +123,8 @@ class ConflictsResolveActivity : EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath ) - offlineOperationId?.let { outState.putInt(EXTRA_OFFLINE_OPERATION_ID, it) } } - private fun Bundle.getOfflineOperationId(): Int? = - if (containsKey(EXTRA_OFFLINE_OPERATION_ID)) getInt(EXTRA_OFFLINE_OPERATION_ID) else null - private fun setupDecisionListener(upload: OCUpload?) { listener = OnConflictDecisionMadeListener { decision -> val file = newFile @@ -278,7 +271,6 @@ class ConflictsResolveActivity : private fun showOfflineOperationConflictDialog(path: String) { val offlineOperation = fileDataStorageManager.offlineOperationDao.getByPath(path) if (offlineOperation == null) { - offlineOperationNotificationManager.dismissNotification(offlineOperationId) showErrorAndFinish() return } @@ -383,7 +375,6 @@ class ConflictsResolveActivity : const val EXTRA_LOCAL_BEHAVIOUR = "LOCAL_BEHAVIOUR" const val EXTRA_EXISTING_FILE = "EXISTING_FILE" private const val EXTRA_OFFLINE_OPERATION_PATH = "EXTRA_OFFLINE_OPERATION_PATH" - private const val EXTRA_OFFLINE_OPERATION_ID = "EXTRA_OFFLINE_OPERATION_ID" private val TAG = ConflictsResolveActivity::class.java.simpleName @JvmStatic @@ -396,15 +387,10 @@ class ConflictsResolveActivity : } @JvmStatic - fun createIntent( - file: OCFile, - offlineOperationId: Int, - offlineOperationPath: String, - context: Context - ): Intent = Intent(context, ConflictsResolveActivity::class.java).apply { - putExtra(EXTRA_FILE, file) - putExtra(EXTRA_OFFLINE_OPERATION_ID, offlineOperationId) - putExtra(EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath) - } + fun createIntent(file: OCFile, offlineOperationPath: String, context: Context): Intent = + Intent(context, ConflictsResolveActivity::class.java).apply { + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath) + } } } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt index 0c7b3446c225..9d2ac37ca7f6 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt @@ -13,6 +13,8 @@ package com.owncloud.android.ui.dialog import android.app.Dialog +import android.app.NotificationManager +import android.content.Context import android.os.Bundle import android.view.ActionMode import androidx.lifecycle.lifecycleScope @@ -88,7 +90,7 @@ class RemoveFilesDialogFragment : val fileActivity = getTypedActivity(FileActivity::class.java) fileActivity?.lifecycleScope?.launch(Dispatchers.IO) { - offlineFiles.forEach(fileDataStorageManager::deleteOfflineOperation) + offlineFiles.forEach { deleteOfflineOperation(it, fileActivity) } val (autoUploadEntities, filesToRemove) = FileUploadHelper.instance().splitFilesByAutoUpload(files, userAccountManager.user.accountName) @@ -109,6 +111,12 @@ class RemoveFilesDialogFragment : } } + private fun deleteOfflineOperation(file: OCFile, context: Context) { + val operationId = fileDataStorageManager.getOfflineEntityFromOCFile(file)?.id + fileDataStorageManager.deleteOfflineOperation(file) + operationId?.let { context.getSystemService(NotificationManager::class.java)?.cancel(it) } + } + override fun onNeutral(callerTag: String?) = Unit private fun setActionMode(actionMode: ActionMode?) { From a6647d3bc0f29a168b7c79e107cab8b80222016e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 6 Nov 2026 12:48:28 +0300 Subject: [PATCH 5/6] handle edge cases Signed-off-by: alperozturk96 --- .../database/dao/OfflineOperationDao.kt | 14 ++++++++- .../OfflineOperationsNotificationManager.kt | 2 +- .../OfflineOperationsWorker.kt | 30 +++++++++++++------ .../receiver/OfflineOperationReceiver.kt | 9 ++++++ .../repository/OfflineOperationsRepository.kt | 22 +++++++++++--- .../OfflineOperationsRepositoryType.kt | 1 + .../FileDataStorageManagerExtensions.kt | 8 ++++- .../datamodel/FileDataStorageManager.java | 16 +++++++++- .../operations/RefreshFolderOperation.java | 5 ++++ .../conflict/ConflictResolveDialogFactory.kt | 5 +++- 10 files changed, 94 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt index 50817daf28ee..244e2d510d6c 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt @@ -17,9 +17,12 @@ import com.nextcloud.client.database.entity.OfflineOperationEntity @Dao interface OfflineOperationDao { - @Query("SELECT * FROM offline_operations") + @Query("SELECT * FROM offline_operations ORDER BY _id ASC") fun getAll(): List + @Query("SELECT * FROM offline_operations WHERE _id = :id LIMIT 1") + fun getById(id: Int): OfflineOperationEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg entity: OfflineOperationEntity) @@ -38,6 +41,15 @@ interface OfflineOperationDao { @Query("SELECT * FROM offline_operations WHERE offline_operations_parent_oc_file_id = :parentOCFileId") fun getSubEntitiesByParentOCFileId(parentOCFileId: Long): List + @Query( + "SELECT * FROM offline_operations WHERE offline_operations_path != :folderPath AND " + + "substr(offline_operations_path, 1, length(:folderPath)) = :folderPath" + ) + fun getAllInsideFolder(folderPath: String): List + + @Query("SELECT * FROM offline_operations WHERE offline_operations_path = :path") + fun getAllByPath(path: String): List + @Query("DELETE FROM offline_operations") fun clearTable() diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt index ae677f5dfbc2..58e444169935 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt @@ -131,7 +131,7 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe context, id, intent, - PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Action( 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 897d4a06d184..0da4b2d82c85 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 @@ -32,6 +32,7 @@ import com.owncloud.android.operations.CreateFolderOperation import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.RenameFileOperation import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.FileUtil import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers @@ -95,7 +96,13 @@ class OfflineOperationsWorker( // region Handle offline operations private suspend fun processOperations(operations: List, client: OwnCloudClient) { val totalOperationSize = operations.size - operations.forEachIndexed { index, operation -> + operations.forEachIndexed { index, snapshot -> + val operation = snapshot.id?.let { fileDataStorageManager.offlineOperationDao.getById(it) } + if (operation == null) { + Log_OC.d(TAG, "Skipping, operation was removed during this run, id=${snapshot.id}") + return@forEachIndexed + } + try { Log_OC.d(TAG, "Processing operation, path: ${operation.path}") val result = executeOperation(operation, client) @@ -130,12 +137,13 @@ class OfflineOperationsWorker( ) { val operationType = operation.type if (operationType is OfflineOperationType.RemoveFile) { - fileDataStorageManager.getFileByDecryptedRemotePath(operationType.path)?.let { ocFile -> - repository.deleteOperation(ocFile) + if (operationType.path.endsWith(OCFile.PATH_SEPARATOR)) { + repository.deleteOperationsInsideFolder(operationType.path) + .forEach { notificationManager.dismissNotification(it) } } } else { - if (operationType is OfflineOperationType.CreateFile) { - getRemoteFile(operationType.remotePath)?.let { saveRemoteFile(it) } + if (operation.isCreate()) { + getExecutionPath(operation)?.let { getRemoteFile(it) }?.let { saveRemoteFile(it) } } repository.updateNextOperations(operation) } @@ -164,7 +172,11 @@ class OfflineOperationsWorker( } Log_OC.w(TAG, "⚠️ Conflict detected: different file already exists on server, operation id=${operation.id}") - notificationManager.showConflictResolveNotification(ocFile ?: serverFile, operation) + ocFile?.let { + serverFile.fileId = it.fileId + serverFile.parentId = it.parentId + } + notificationManager.showConflictResolveNotification(serverFile, operation) } private fun saveRemoteFile(remoteFile: RemoteFile) { @@ -307,14 +319,14 @@ class OfflineOperationsWorker( @Suppress("DEPRECATION") private fun createFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult { val operationType = (operation.type as OfflineOperationType.CreateFile) - val lastModificationDate = System.currentTimeMillis() / MILLIS_PER_SECOND + val localFile = File(operationType.localPath) val createFileOperation = UploadFileRemoteOperation( operationType.localPath, operationType.remotePath, operationType.mimeType, "", - operation.modifiedAt ?: lastModificationDate, - operation.createdAt ?: System.currentTimeMillis(), + localFile.lastModified() / MILLIS_PER_SECOND, + FileUtil.getCreationTimestamp(localFile), true ) return createFileOperation.execute(client) to createFileOperation diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt index 54179edeaa32..410f6133f20c 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/receiver/OfflineOperationReceiver.kt @@ -12,6 +12,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager +import com.nextcloud.model.OfflineOperationType import com.owncloud.android.MainApp import com.owncloud.android.datamodel.FileDataStorageManager import javax.inject.Inject @@ -32,8 +33,16 @@ class OfflineOperationReceiver : BroadcastReceiver() { return } + val operation = storageManager.offlineOperationDao.getById(id) storageManager.offlineOperationDao.deleteById(id) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val folderPath = operation?.path?.takeIf { operation.type is OfflineOperationType.CreateFolder } + folderPath?.let { path -> + storageManager.offlineOperationsRepository.deleteOperationsInsideFolder(path) + .forEach { notificationManager.cancel(it) } + } + notificationManager.cancel( OfflineOperationsNotificationManager.ERROR_ID ) 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 b2532bd32015..aaca7e241feb 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 @@ -71,22 +71,30 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt .mapNotNull { nextOperation -> nextOperation.parentOCFileId?.let { parentId -> fileDataStorageManager.getFileById(parentId)?.let { ocFile -> - ocFile.decryptedRemotePath?.let { updatedPath -> - val newPath = updatedPath + nextOperation.filename + pathSeparator - updateOperationPath(newPath, nextOperation) - } + ocFile.decryptedRemotePath + ?.let { parentPath -> nextOperation.getPathInside(parentPath) } + ?.let { newPath -> updateOperationPath(newPath, nextOperation) } } } } .forEach { dao.update(it) } } + private fun OfflineOperationEntity.getPathInside(parentPath: String): String? { + val currentPath = path ?: return null + val isFolder = currentPath.endsWith(pathSeparator) + val name = currentPath.removeSuffix(pathSeparator.toString()).substringAfterLast(pathSeparator) + val suffix = if (isFolder) pathSeparator.toString() else "" + return parentPath + name + suffix + } + 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) + is OfflineOperationType.RemoveFile -> currentType.copy(path = newPath) else -> currentType } @@ -96,6 +104,12 @@ class OfflineOperationsRepository(private val fileDataStorageManager: FileDataSt } } + override fun deleteOperationsInsideFolder(folderPath: String): List { + val operations = dao.getAllInsideFolder(folderPath) + operations.forEach { dao.delete(it) } + return operations.mapNotNull { it.id } + } + 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 51be196fffe7..3b78667a5cf3 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,7 @@ interface OfflineOperationsRepositoryType { fun getAllSubEntities(fileId: Long): List fun deleteOperation(file: OCFile) fun updateNextOperations(operation: OfflineOperationEntity) + fun deleteOperationsInsideFolder(folderPath: String): List fun updateOperationForKeepBoth(operation: OfflineOperationEntity, newPath: String) fun convertToOCFiles(fileId: Long): List } 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 55e5923e2af1..a495be548e41 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -369,7 +369,7 @@ fun FileDataStorageManager.saveFolder(folder: OCFile, updatedFiles: List "${filesToRemove.size} files to remove" ) - val removedChildren = filesToRemove.filter { it.parentId == folder.fileId } + val removedChildren = filesToRemove.filter { it.parentId == folder.fileId && !isPendingOfflineCreation(it) } removedChildren.forEach { deleteLocalCopy(it) } db.runInTransaction { @@ -381,6 +381,12 @@ fun FileDataStorageManager.saveFolder(folder: OCFile, updatedFiles: List MainApp.getAppContext().contentResolver.notifyChange(ProviderTableMeta.CONTENT_URI, null) } +fun FileDataStorageManager.isPendingOfflineCreation(file: OCFile): Boolean { + if (!file.isOfflineOperation) return false + val path = file.decryptedRemotePath ?: return false + return offlineOperationDao.getByPath(path)?.isCreate() == true +} + private fun FileDataStorageManager.upsertFolderContent(folder: OCFile, files: List) { files.forEach { file -> val existingFile = findExistingFile(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 f9e6e0bda1d8..dd892465424c 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -275,6 +275,8 @@ public void deleteOfflineOperation(OCFile file) { } public void addRenameFileOfflineOperation(OCFile file, String newName) { + deletePendingRenameOperations(file.getDecryptedRemotePath()); + OfflineOperationEntity entity = new OfflineOperationEntity(); entity.setFilename(newName); @@ -316,10 +318,18 @@ public String getFilenameConsideringOfflineOperation(OCFile file) { return filename; } + private void deletePendingRenameOperations(String path) { + offlineOperationDao.getAllByPath(path) + .stream() + .filter(operation -> operation.getType() instanceof OfflineOperationType.RenameFile) + .forEach(offlineOperationDao::delete); + } + public void addRemoveFileOfflineOperation(@NonNull OCFile file) { OfflineOperationEntity entity = new OfflineOperationEntity(); String path = file.getDecryptedRemotePath(); + deletePendingRenameOperations(path); entity.setFilename(file.getFileName()); entity.setParentOCFileId(file.getParentId()); @@ -347,7 +357,10 @@ public void renameOfflineOperation(OCFile file, String newFolderName) { return; } - String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR; + String newPath = parentFolder.getDecryptedRemotePath() + newFolderName; + if (file.isFolder()) { + newPath += OCFile.PATH_SEPARATOR; + } if (entity.getType() instanceof OfflineOperationType.CreateFolder createFolderType) { createFolderType.setPath(newPath); @@ -362,6 +375,7 @@ public void renameOfflineOperation(OCFile file, String newFolderName) { offlineOperationDao.update(entity); moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath()); + offlineOperationsRepository.updateNextOperations(entity); } public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file, OwnCloudClient client) { diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index 8f9f6937d3c3..e58e1ffc3c24 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -516,6 +516,11 @@ private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) { } private void removeLocalFolder() { + if (FileDataStorageManagerExtensionsKt.isPendingOfflineCreation(fileDataStorageManager, mLocalFolder)) { + Log_OC.d(TAG, "Keeping " + mLocalFolder.getRemotePath() + ", it is not created on the server yet"); + return; + } + if (fileDataStorageManager.fileExists(mLocalFolder.getFileId())) { String currentSavePath = FileStorageUtils.getSavePath(user.getAccountName()); fileDataStorageManager.removeFolder( 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 5235ed3080f7..b53af455cfcb 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 @@ -63,7 +63,10 @@ object ConflictResolveDialogFactory { ), fileLength = (offlineCreatedLocalFile?.length() ?: 0L) ), - serverFile = context.conflictFileData(R.string.prefs_synced_folders_remote_path_title, rightFile) + serverFile = context.conflictFileData( + if (isFolderConflict) R.string.prefs_synced_folders_remote_path_title else R.string.conflict_server_file, + rightFile + ) ) return createDialog(ConflictDialogType.Offline(data)) { From d332b3ee69d06adbe46318517056120b247728b3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 6 Nov 2026 12:54:57 +0300 Subject: [PATCH 6/6] use client repository Signed-off-by: alperozturk96 --- .../client/jobs/BackgroundJobFactory.kt | 5 +++- .../OfflineOperationsWorker.kt | 28 +++++++++++-------- .../conflict/ConflictResolveDialogFactory.kt | 10 ++++--- .../client/jobs/BackgroundJobFactoryTest.kt | 6 +++- 4 files changed, 32 insertions(+), 17 deletions(-) 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 b1eb3fec92f1..f5cfe1aa3c66 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -37,6 +37,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.repository.ClientRepository import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.datamodel.UploadsStorageManager @@ -71,7 +72,8 @@ class BackgroundJobFactory @Inject constructor( private val generatePdfUseCase: GeneratePDFUseCase, private val syncedFolderProvider: SyncedFolderProvider, private val database: NextcloudDatabase, - private val uploadFileOperationFactory: UploadFileOperationFactory + private val uploadFileOperationFactory: UploadFileOperationFactory, + private val clientRepository: ClientRepository ) : WorkerFactory() { @SuppressLint("NewApi") @@ -123,6 +125,7 @@ class BackgroundJobFactory @Inject constructor( accountManager.user, context, connectivityService, + clientRepository, viewThemeUtils.get(), 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 0da4b2d82c85..5a5b4325b78c 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,9 +13,9 @@ 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.network.ClientFactoryImpl import com.nextcloud.client.network.ConnectivityService import com.nextcloud.model.OfflineOperationType +import com.nextcloud.repository.ClientRepository import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended import com.nextcloud.utils.extensions.isTheSameAs import com.owncloud.android.datamodel.FileDataStorageManager @@ -46,6 +46,7 @@ class OfflineOperationsWorker( private val user: User, private val context: Context, private val connectivityService: ConnectivityService, + private val clientRepository: ClientRepository, viewThemeUtils: ViewThemeUtils, params: WorkerParameters ) : CoroutineWorker(context, params) { @@ -56,7 +57,6 @@ class OfflineOperationsWorker( } private val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver) - private val clientFactory = ClientFactoryImpl(context) private val notificationManager = OfflineOperationsNotificationManager(context, viewThemeUtils) private var repository = OfflineOperationsRepository(fileDataStorageManager) @@ -79,8 +79,13 @@ class OfflineOperationsWorker( } // process offline operations + val client = clientRepository.getOwncloudClient() + if (client == null) { + Log_OC.e(TAG, "💥 Client is not available, operations are not processed") + return@withContext Result.failure() + } + notificationManager.start() - val client = clientFactory.create(user) processOperations(operations, client) Log_OC.d(TAG, "🏁 Worker finished with result") @@ -106,7 +111,7 @@ class OfflineOperationsWorker( try { Log_OC.d(TAG, "Processing operation, path: ${operation.path}") val result = executeOperation(operation, client) - handleResult(operation, totalOperationSize, index, result) + handleResult(operation, totalOperationSize, index, result, client) } catch (e: Exception) { Log_OC.e(TAG, "💥 Exception while processing operation id=${operation.id}: ${e.message}") } @@ -117,14 +122,15 @@ class OfflineOperationsWorker( operation: OfflineOperationEntity, totalOperations: Int, currentSuccessfulOperationIndex: Int, - result: OfflineOperationResult + result: OfflineOperationResult, + client: OwnCloudClient ) { val operationResult = result?.first ?: return val logMessage = if (operationResult.isSuccess) "Operation completed" else "Operation failed" Log_OC.d(TAG, "$logMessage filename: ${operation.filename}, type: ${operation.type}") return if (result.first?.isSuccess == true) { - handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex) + handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex, client) } else { handleErrorResult(operation.id, result) } @@ -133,7 +139,8 @@ class OfflineOperationsWorker( private fun handleSuccessResult( operation: OfflineOperationEntity, totalOperations: Int, - currentSuccessfulOperationIndex: Int + currentSuccessfulOperationIndex: Int, + client: OwnCloudClient ) { val operationType = operation.type if (operationType is OfflineOperationType.RemoveFile) { @@ -143,7 +150,7 @@ class OfflineOperationsWorker( } } else { if (operation.isCreate()) { - getExecutionPath(operation)?.let { getRemoteFile(it) }?.let { saveRemoteFile(it) } + getExecutionPath(operation)?.let { getRemoteFile(it, client) }?.let { saveRemoteFile(it) } } repository.updateNextOperations(operation) } @@ -245,7 +252,7 @@ class OfflineOperationsWorker( return@withContext null } - val remoteFile = getRemoteFile(path) + val remoteFile = getRemoteFile(path, client) val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(path) if (operation.type is OfflineOperationType.CreateFolder && remoteFile != null) { @@ -347,10 +354,9 @@ class OfflineOperationsWorker( // endregion @Suppress("DEPRECATION") - private fun getRemoteFile(remotePath: String): RemoteFile? { + private fun getRemoteFile(remotePath: String, client: OwnCloudClient): 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 { 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 b53af455cfcb..dae111442238 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 @@ -39,6 +39,11 @@ object ConflictResolveDialogFactory { } val isFolderConflict = leftFile.type is OfflineOperationType.CreateFolder + val serverFileTitleId = if (isFolderConflict) { + R.string.prefs_synced_folders_remote_path_title + } else { + R.string.conflict_server_file + } val data = ConflictDialogData( headline = context.getString( @@ -63,10 +68,7 @@ object ConflictResolveDialogFactory { ), fileLength = (offlineCreatedLocalFile?.length() ?: 0L) ), - serverFile = context.conflictFileData( - if (isFolderConflict) R.string.prefs_synced_folders_remote_path_title else R.string.conflict_server_file, - rightFile - ) + serverFile = context.conflictFileData(serverFileTitleId, rightFile) ) return createDialog(ConflictDialogType.Offline(data)) { diff --git a/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt b/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt index e636c304bb82..02396d758669 100644 --- a/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt +++ b/app/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt @@ -25,6 +25,7 @@ import com.nextcloud.client.integrations.deck.DeckApi import com.nextcloud.client.logger.Logger import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.repository.ClientRepository import com.owncloud.android.MainApp import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.SyncedFolderProvider @@ -115,6 +116,8 @@ class BackgroundJobFactoryTest { @Mock private lateinit var uploadFileOperationFactory: UploadFileOperationFactory + @Mock private lateinit var clientRepository: ClientRepository + private lateinit var factory: BackgroundJobFactory @Before @@ -147,7 +150,8 @@ class BackgroundJobFactoryTest { generatePDFUseCase, syncedFolderProvider, db, - uploadFileOperationFactory + uploadFileOperationFactory, + clientRepository ) }