Skip to content

Commit c96f5b6

Browse files
Merge pull request #15491 from nextcloud/backport/15487/stable-3.33
[stable-3.33] fix: offline operations worker
2 parents 6ad653e + e8d0d4e commit c96f5b6

File tree

4 files changed

+161
-139
lines changed

4 files changed

+161
-139
lines changed

app/src/main/java/com/nextcloud/client/database/entity/OfflineOperationEntity.kt

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,27 @@ data class OfflineOperationEntity(
4242
fun isRenameOrRemove(): Boolean =
4343
(type is OfflineOperationType.RenameFile || type is OfflineOperationType.RemoveFile)
4444

45-
fun getConflictText(context: Context): String = if (type is OfflineOperationType.RemoveFile) {
46-
context.getString(R.string.offline_operations_worker_notification_remove_conflict_text, filename)
47-
} else if (type is OfflineOperationType.RenameFile) {
48-
context.getString(R.string.offline_operations_worker_notification_rename_conflict_text, filename)
49-
} else if (type is OfflineOperationType.CreateFile) {
50-
context.getString(R.string.offline_operations_worker_notification_create_file_conflict_text, filename)
51-
} else {
52-
context.getString(R.string.offline_operations_worker_notification_create_folder_conflict_text, filename)
45+
fun isCreate(): Boolean = (type is OfflineOperationType.CreateFile || type is OfflineOperationType.CreateFolder)
46+
47+
fun getConflictText(context: Context): String {
48+
val resId = when (type) {
49+
is OfflineOperationType.RemoveFile -> {
50+
R.string.offline_operations_worker_notification_remove_conflict_text
51+
}
52+
53+
is OfflineOperationType.RenameFile -> {
54+
R.string.offline_operations_worker_notification_rename_conflict_text
55+
}
56+
57+
is OfflineOperationType.CreateFile -> {
58+
R.string.offline_operations_worker_notification_create_file_conflict_text
59+
}
60+
61+
else -> {
62+
R.string.offline_operations_worker_notification_create_folder_conflict_text
63+
}
64+
}
65+
66+
return context.getString(resId, filename)
5367
}
5468
}

app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ internal class BackgroundJobManagerImpl(
466466

467467
workManager.enqueueUniqueWork(
468468
JOB_OFFLINE_OPERATIONS,
469-
ExistingWorkPolicy.REPLACE,
469+
ExistingWorkPolicy.KEEP,
470470
request
471471
)
472472
}

app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsNotificationManager.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe
3939
private const val ONE_HUNDRED_PERCENT = 100
4040
}
4141

42+
init {
43+
notificationBuilder.apply {
44+
setSound(null)
45+
setVibrate(null)
46+
setOnlyAlertOnce(true)
47+
setSilent(true)
48+
}
49+
}
50+
4251
fun start() {
4352
notificationBuilder.run {
4453
setContentTitle(context.getString(R.string.offline_operations_worker_notification_start_text))

app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt

Lines changed: 129 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ import com.owncloud.android.operations.RenameFileOperation
3434
import com.owncloud.android.utils.MimeTypeUtil
3535
import com.owncloud.android.utils.theme.ViewThemeUtils
3636
import kotlinx.coroutines.Dispatchers
37-
import kotlinx.coroutines.NonCancellable
38-
import kotlinx.coroutines.delay
3937
import kotlinx.coroutines.withContext
4038
import kotlin.coroutines.resume
4139
import kotlin.coroutines.suspendCoroutine
@@ -62,55 +60,106 @@ class OfflineOperationsWorker(
6260
private val notificationManager = OfflineOperationsNotificationManager(context, viewThemeUtils)
6361
private var repository = OfflineOperationsRepository(fileDataStorageManager)
6462

63+
@Suppress("TooGenericExceptionCaught")
6564
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
66-
val jobName = inputData.getString(JOB_NAME)
67-
Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}")
68-
69-
if (!isNetworkAndServerAvailable()) {
70-
Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...")
71-
return@withContext Result.retry()
72-
}
73-
74-
val client = clientFactory.create(user)
65+
try {
66+
val jobName = inputData.getString(JOB_NAME)
67+
Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}")
68+
69+
// check network connection
70+
if (!isNetworkAndServerAvailable()) {
71+
Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...")
72+
return@withContext Result.retry()
73+
}
7574

76-
notificationManager.start()
77-
val operations = fileDataStorageManager.offlineOperationDao.getAll()
78-
Log_OC.d(TAG, "📋 Found ${operations.size} offline operations to process")
75+
// check offline operations
76+
val operations = fileDataStorageManager.offlineOperationDao.getAll()
77+
if (operations.isEmpty()) {
78+
Log_OC.d(TAG, "Skipping, no offline operation found")
79+
return@withContext Result.success()
80+
}
7981

80-
val result = processOperations(operations, client)
81-
notificationManager.dismissNotification()
82+
// process offline operations
83+
notificationManager.start()
84+
val client = clientFactory.create(user)
85+
processOperations(operations, client)
8286

83-
Log_OC.d(TAG, "🏁 Worker finished with result: $result")
84-
return@withContext result
87+
// finish
88+
WorkerStateLiveData.instance().setWorkState(WorkerState.OfflineOperationsCompleted)
89+
Log_OC.d(TAG, "🏁 Worker finished with result")
90+
return@withContext Result.success()
91+
} catch (e: Exception) {
92+
Log_OC.e(TAG, "💥 ProcessOperations failed: ${e.message}")
93+
return@withContext Result.failure()
94+
} finally {
95+
notificationManager.dismissNotification()
96+
}
8597
}
8698

99+
// region Handle offline operations
87100
@Suppress("TooGenericExceptionCaught")
88-
private suspend fun processOperations(operations: List<OfflineOperationEntity>, client: OwnCloudClient): Result {
101+
private suspend fun processOperations(operations: List<OfflineOperationEntity>, client: OwnCloudClient) {
89102
val totalOperationSize = operations.size
103+
operations.forEachIndexed { index, operation ->
104+
try {
105+
Log_OC.d(TAG, "Processing operation, path: ${operation.path}")
106+
val result = executeOperation(operation, client)
107+
handleResult(operation, totalOperationSize, index, result)
108+
} catch (e: Exception) {
109+
Log_OC.e(TAG, "💥 Exception while processing operation id=${operation.id}: ${e.message}")
110+
}
111+
}
112+
}
90113

91-
return try {
92-
operations.forEachIndexed { index, operation ->
93-
try {
94-
Log_OC.d(TAG, "Processing operation, path: ${operation.path}")
95-
val result = executeOperation(operation, client)
96-
val success = handleResult(operation, totalOperationSize, index, result)
97-
98-
if (!success) {
99-
Log_OC.e(TAG, "❌ Operation failed: id=${operation.id}, type=${operation.type}")
100-
}
101-
} catch (e: Exception) {
102-
Log_OC.e(TAG, "💥 Exception while processing operation id=${operation.id}: ${e.message}")
103-
}
114+
private fun handleResult(
115+
operation: OfflineOperationEntity,
116+
totalOperations: Int,
117+
currentSuccessfulOperationIndex: Int,
118+
result: OfflineOperationResult
119+
) {
120+
val operationResult = result?.first ?: return
121+
val logMessage = if (operationResult.isSuccess) "Operation completed" else "Operation failed"
122+
Log_OC.d(TAG, "$logMessage filename: ${operation.filename}, type: ${operation.type}")
123+
124+
return if (result.first?.isSuccess == true) {
125+
handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex)
126+
} else {
127+
handleErrorResult(operation.id, result)
128+
}
129+
}
130+
131+
private fun handleSuccessResult(
132+
operation: OfflineOperationEntity,
133+
totalOperations: Int,
134+
currentSuccessfulOperationIndex: Int
135+
) {
136+
if (operation.type is OfflineOperationType.RemoveFile) {
137+
val operationType = operation.type as OfflineOperationType.RemoveFile
138+
fileDataStorageManager.getFileByDecryptedRemotePath(operationType.path)?.let { ocFile ->
139+
repository.deleteOperation(ocFile)
104140
}
141+
} else {
142+
repository.updateNextOperations(operation)
143+
}
105144

106-
Log_OC.i(TAG, "✅ All offline operations completed successfully.")
107-
WorkerStateLiveData.instance().setWorkState(WorkerState.OfflineOperationsCompleted)
108-
Result.success()
109-
} catch (e: Exception) {
110-
Log_OC.e(TAG, "💥 ProcessOperations failed: ${e.message}")
111-
Result.failure()
145+
fileDataStorageManager.offlineOperationDao.delete(operation)
146+
notificationManager.update(totalOperations, currentSuccessfulOperationIndex + 1, operation.filename ?: "")
147+
}
148+
149+
private fun handleErrorResult(id: Int?, result: OfflineOperationResult) {
150+
val operationResult = result?.first ?: return
151+
val operation = result.second ?: return
152+
Log_OC.e(TAG, "❌ Operation failed [id=$id]: code=${operationResult.code}, message=${operationResult.message}")
153+
val excludedErrorCodes =
154+
listOf(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS, RemoteOperationResult.ResultCode.LOCKED)
155+
156+
if (!excludedErrorCodes.contains(operationResult.code)) {
157+
notificationManager.showNewNotification(id, operationResult, operation)
158+
} else {
159+
Log_OC.d(TAG, "ℹ️ Ignored error: ${operationResult.code}")
112160
}
113161
}
162+
// endregion
114163

115164
private suspend fun isNetworkAndServerAvailable(): Boolean = suspendCoroutine { continuation ->
116165
connectivityService.isNetworkAndServerAvailable { result ->
@@ -119,19 +168,28 @@ class OfflineOperationsWorker(
119168
}
120169

121170
// region Operation Execution
122-
@Suppress("ComplexCondition")
171+
@Suppress("ComplexCondition", "LongMethod")
123172
private suspend fun executeOperation(
124173
operation: OfflineOperationEntity,
125174
client: OwnCloudClient
126175
): OfflineOperationResult? = withContext(Dispatchers.IO) {
127-
val path = (operation.path)
176+
var path = (operation.path)
128177
if (path == null) {
129178
Log_OC.w(TAG, "⚠️ Skipped: path is null for operation id=${operation.id}")
130179
return@withContext null
131180
}
132181

182+
if (operation.type is OfflineOperationType.CreateFile && path.endsWith(OCFile.PATH_SEPARATOR)) {
183+
Log_OC.w(
184+
TAG,
185+
"Create file operation should not ends with path separator removing suffix, " +
186+
"operation id=${operation.id}"
187+
)
188+
path = path.removeSuffix(OCFile.PATH_SEPARATOR)
189+
}
190+
133191
val remoteFile = getRemoteFile(path)
134-
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(operation.path)
192+
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(path)
135193

136194
if (remoteFile != null && ocFile != null && isFileChanged(remoteFile, ocFile)) {
137195
Log_OC.w(TAG, "⚠️ Conflict detected: File already exists on server. Skipping operation id=${operation.id}")
@@ -148,6 +206,18 @@ class OfflineOperationsWorker(
148206
return@withContext null
149207
}
150208

209+
if (operation.isRenameOrRemove() && ocFile == null) {
210+
Log_OC.d(TAG, "Skipping, attempting to delete or rename non-existing file")
211+
fileDataStorageManager.offlineOperationDao.delete(operation)
212+
return@withContext null
213+
}
214+
215+
if (operation.isCreate() && remoteFile != null && ocFile != null && !isFileChanged(remoteFile, ocFile)) {
216+
Log_OC.d(TAG, "Skipping, attempting to create same file creation")
217+
fileDataStorageManager.offlineOperationDao.delete(operation)
218+
return@withContext null
219+
}
220+
151221
return@withContext when (val type = operation.type) {
152222
is OfflineOperationType.CreateFolder -> {
153223
Log_OC.d(TAG, "📂 Creating folder at ${type.path}")
@@ -173,113 +243,42 @@ class OfflineOperationsWorker(
173243
}
174244

175245
@Suppress("DEPRECATION")
176-
private suspend fun createFolder(
177-
operation: OfflineOperationEntity,
178-
client: OwnCloudClient
179-
): OfflineOperationResult {
246+
private fun createFolder(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
180247
val operationType = (operation.type as OfflineOperationType.CreateFolder)
181-
val createFolderOperation = withContext(NonCancellable) {
182-
CreateFolderOperation(operationType.path, user, context, fileDataStorageManager)
183-
}
184-
248+
val createFolderOperation = CreateFolderOperation(operationType.path, user, context, fileDataStorageManager)
185249
return createFolderOperation.execute(client) to createFolderOperation
186250
}
187251

188252
@Suppress("DEPRECATION")
189-
private suspend fun createFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
253+
private fun createFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
190254
val operationType = (operation.type as OfflineOperationType.CreateFile)
191-
192-
val createFileOperation = withContext(NonCancellable) {
193-
val lastModificationDate = System.currentTimeMillis() / ONE_SECOND
194-
195-
UploadFileRemoteOperation(
196-
operationType.localPath,
197-
operationType.remotePath,
198-
operationType.mimeType,
199-
"",
200-
operation.modifiedAt ?: lastModificationDate,
201-
operation.createdAt ?: System.currentTimeMillis(),
202-
true
203-
)
204-
}
205-
255+
val lastModificationDate = System.currentTimeMillis() / ONE_SECOND
256+
val createFileOperation = UploadFileRemoteOperation(
257+
operationType.localPath,
258+
operationType.remotePath,
259+
operationType.mimeType,
260+
"",
261+
operation.modifiedAt ?: lastModificationDate,
262+
operation.createdAt ?: System.currentTimeMillis(),
263+
true
264+
)
206265
return createFileOperation.execute(client) to createFileOperation
207266
}
208267

209268
@Suppress("DEPRECATION")
210-
private suspend fun renameFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
211-
val renameFileOperation = withContext(NonCancellable) {
212-
val operationType = (operation.type as OfflineOperationType.RenameFile)
213-
RenameFileOperation(operation.path, operationType.newName, fileDataStorageManager)
214-
}
215-
269+
private fun renameFile(operation: OfflineOperationEntity, client: OwnCloudClient): OfflineOperationResult {
270+
val operationType = (operation.type as OfflineOperationType.RenameFile)
271+
val renameFileOperation = RenameFileOperation(operation.path, operationType.newName, fileDataStorageManager)
216272
return renameFileOperation.execute(client) to renameFileOperation
217273
}
218274

219275
@Suppress("DEPRECATION")
220-
private suspend fun removeFile(ocFile: OCFile, client: OwnCloudClient): OfflineOperationResult {
221-
val removeFileOperation = withContext(NonCancellable) {
222-
RemoveFileOperation(ocFile, false, user, true, context, fileDataStorageManager)
223-
}
224-
276+
private fun removeFile(ocFile: OCFile, client: OwnCloudClient): OfflineOperationResult {
277+
val removeFileOperation = RemoveFileOperation(ocFile, false, user, true, context, fileDataStorageManager)
225278
return removeFileOperation.execute(client) to removeFileOperation
226279
}
227280
// endregion
228281

229-
private suspend fun handleResult(
230-
operation: OfflineOperationEntity,
231-
totalOperations: Int,
232-
currentSuccessfulOperationIndex: Int,
233-
result: OfflineOperationResult
234-
): Boolean {
235-
val operationResult = result?.first ?: return false
236-
237-
val logMessage = if (operationResult.isSuccess) "Operation completed" else "Operation failed"
238-
Log_OC.d(TAG, "$logMessage filename: ${operation.filename}, type: ${operation.type}")
239-
240-
return if (result.first?.isSuccess == true) {
241-
handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex)
242-
true
243-
} else {
244-
handleErrorResult(operation.id, result)
245-
false
246-
}
247-
}
248-
249-
private suspend fun handleSuccessResult(
250-
operation: OfflineOperationEntity,
251-
totalOperations: Int,
252-
currentSuccessfulOperationIndex: Int
253-
) {
254-
if (operation.type is OfflineOperationType.RemoveFile) {
255-
val operationType = operation.type as OfflineOperationType.RemoveFile
256-
fileDataStorageManager.getFileByDecryptedRemotePath(operationType.path)?.let { ocFile ->
257-
repository.deleteOperation(ocFile)
258-
}
259-
} else {
260-
repository.updateNextOperations(operation)
261-
}
262-
263-
fileDataStorageManager.offlineOperationDao.delete(operation)
264-
265-
notificationManager.update(totalOperations, currentSuccessfulOperationIndex, operation.filename ?: "")
266-
delay(ONE_SECOND)
267-
notificationManager.dismissNotification(operation.id)
268-
}
269-
270-
private fun handleErrorResult(id: Int?, result: OfflineOperationResult) {
271-
val operationResult = result?.first ?: return
272-
val operation = result.second ?: return
273-
Log_OC.e(TAG, "❌ Operation failed [id=$id]: code=${operationResult.code}, message=${operationResult.message}")
274-
val excludedErrorCodes = listOf(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS)
275-
276-
if (!excludedErrorCodes.contains(operationResult.code)) {
277-
notificationManager.showNewNotification(id, operationResult, operation)
278-
} else {
279-
Log_OC.d(TAG, "ℹ️ Ignored error: ${operationResult.code}")
280-
}
281-
}
282-
283282
@Suppress("DEPRECATION")
284283
private fun getRemoteFile(remotePath: String): RemoteFile? {
285284
val mimeType = MimeTypeUtil.getMimeTypeFromPath(remotePath)

0 commit comments

Comments
 (0)