Skip to content

Commit eea9580

Browse files
Merge pull request nextcloud#15451 from nextcloud/feat/better-offline-operation-notification-handling
feat: better offline operations notification handling
2 parents a5b988c + 75d6bec commit eea9580

File tree

10 files changed

+130
-24
lines changed

10 files changed

+130
-24
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@
269269
android:exported="false"
270270
tools:replace="android:exported" />
271271

272+
<receiver
273+
android:name="com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver"
274+
android:exported="false" />
272275
<receiver
273276
android:name=".operations.upload.UploadFileBroadcastReceiver"
274277
android:exported="false" />

app/src/main/java/com/nextcloud/client/database/dao/OfflineOperationDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ interface OfflineOperationDao {
4040

4141
@Query("DELETE FROM offline_operations")
4242
fun clearTable()
43+
44+
@Query("DELETE FROM offline_operations WHERE _id = :id")
45+
fun deleteById(id: Int)
4346
}

app/src/main/java/com/nextcloud/client/di/AppComponent.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.nextcloud.client.integrations.IntegrationsModule;
1717
import com.nextcloud.client.jobs.JobsModule;
1818
import com.nextcloud.client.jobs.download.FileDownloadHelper;
19+
import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver;
1920
import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver;
2021
import com.nextcloud.client.jobs.upload.FileUploadHelper;
2122
import com.nextcloud.client.media.BackgroundPlayerService;
@@ -72,6 +73,8 @@ public interface AppComponent {
7273

7374
void inject(FileUploadBroadcastReceiver fileUploadBroadcastReceiver);
7475

76+
void inject(OfflineOperationReceiver offlineOperationReceiver);
77+
7578
@Component.Builder
7679
interface Builder {
7780
@BindsInstance

app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ open class WorkerNotificationManager(
2222
private val id: Int,
2323
private val context: Context,
2424
viewThemeUtils: ViewThemeUtils,
25-
private val tickerId: Int
25+
private val tickerId: Int,
26+
private val channelId: String = NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS
2627
) {
2728
var currentOperationTitle: String? = null
2829

@@ -31,15 +32,14 @@ open class WorkerNotificationManager(
3132
var notificationBuilder: NotificationCompat.Builder =
3233
NotificationUtils.newNotificationBuilder(
3334
context,
34-
NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS,
35+
channelId,
3536
viewThemeUtils
3637
).apply {
3738
setTicker(context.getString(tickerId))
3839
setSmallIcon(R.drawable.notification_icon)
3940
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
4041
setStyle(NotificationCompat.BigTextStyle())
4142
priority = NotificationCompat.PRIORITY_LOW
42-
setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD)
4343
}
4444

4545
fun showNotification() {

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,32 @@ package com.nextcloud.client.jobs.offlineOperations
99

1010
import android.app.PendingIntent
1111
import android.content.Context
12+
import android.content.Intent
1213
import androidx.core.app.NotificationCompat
1314
import com.nextcloud.client.database.entity.OfflineOperationEntity
1415
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
16+
import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver
1517
import com.nextcloud.utils.extensions.getErrorMessage
1618
import com.owncloud.android.R
1719
import com.owncloud.android.datamodel.OCFile
1820
import com.owncloud.android.lib.common.operations.RemoteOperation
1921
import com.owncloud.android.lib.common.operations.RemoteOperationResult
2022
import com.owncloud.android.ui.activity.ConflictsResolveActivity
23+
import com.owncloud.android.ui.notifications.NotificationUtils
2124
import com.owncloud.android.utils.theme.ViewThemeUtils
2225

2326
class OfflineOperationsNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) :
2427
WorkerNotificationManager(
2528
ID,
2629
context,
2730
viewThemeUtils,
28-
R.string.offline_operations_worker_notification_manager_ticker
31+
tickerId = R.string.offline_operations_worker_notification_manager_ticker,
32+
channelId = NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS
2933
) {
3034

3135
companion object {
3236
private const val ID = 121
33-
private const val ERROR_ID = 122
37+
const val ERROR_ID = 122
3438

3539
private const val ONE_HUNDRED_PERCENT = 100
3640
}
@@ -66,13 +70,18 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe
6670
showNotification()
6771
}
6872

69-
fun showNewNotification(result: RemoteOperationResult<*>, operation: RemoteOperation<*>) {
73+
fun showNewNotification(id: Int?, result: RemoteOperationResult<*>, operation: RemoteOperation<*>) {
7074
val reason = (result to operation).getErrorMessage()
7175
val text = context.getString(R.string.offline_operations_worker_notification_error_text, reason)
76+
val cancelOfflineOperationAction = id?.let { getCancelOfflineOperationAction(it) }
7277

7378
notificationBuilder.run {
79+
cancelOfflineOperationAction?.let {
80+
addAction(it)
81+
}
7482
setContentTitle(text)
7583
setOngoing(false)
84+
setProgress(0, 0, false)
7685
notificationManager.notify(ERROR_ID, this.build())
7786
}
7887
}
@@ -133,6 +142,25 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe
133142
)
134143
}
135144

145+
private fun getCancelOfflineOperationAction(id: Int): NotificationCompat.Action {
146+
val intent = Intent(context, OfflineOperationReceiver::class.java).apply {
147+
putExtra(OfflineOperationReceiver.ID, id)
148+
}
149+
150+
val pendingIntent = PendingIntent.getBroadcast(
151+
context,
152+
id,
153+
intent,
154+
PendingIntent.FLAG_IMMUTABLE
155+
)
156+
157+
return NotificationCompat.Action(
158+
R.drawable.ic_delete,
159+
context.getString(R.string.common_cancel),
160+
pendingIntent
161+
)
162+
}
163+
136164
fun dismissNotification(id: Int?) {
137165
if (id == null) return
138166
notificationManager.cancel(id)

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

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,23 @@ class OfflineOperationsWorker(
6464

6565
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
6666
val jobName = inputData.getString(JOB_NAME)
67-
Log_OC.d(TAG, "$jobName --- OfflineOperationsWorker started ---")
67+
Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}")
6868

6969
if (!isNetworkAndServerAvailable()) {
70-
Log_OC.w(TAG, "No internet connection. Retrying later.")
70+
Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...")
7171
return@withContext Result.retry()
7272
}
7373

7474
val client = clientFactory.create(user)
7575

7676
notificationManager.start()
7777
val operations = fileDataStorageManager.offlineOperationDao.getAll()
78+
Log_OC.d(TAG, "📋 Found ${operations.size} offline operations to process")
79+
7880
val result = processOperations(operations, client)
7981
notificationManager.dismissNotification()
8082

83+
Log_OC.d(TAG, "🏁 Worker finished with result: $result")
8184
return@withContext result
8285
}
8386

@@ -88,22 +91,23 @@ class OfflineOperationsWorker(
8891
return try {
8992
operations.forEachIndexed { index, operation ->
9093
try {
94+
Log_OC.d(TAG, "Processing operation, path: ${operation.path}")
9195
val result = executeOperation(operation, client)
9296
val success = handleResult(operation, totalOperationSize, index, result)
9397

9498
if (!success) {
95-
Log_OC.e(TAG, "Skipped (failed to handle result): $operation")
99+
Log_OC.e(TAG, "❌ Operation failed: id=${operation.id}, type=${operation.type}")
96100
}
97101
} catch (e: Exception) {
98-
Log_OC.e(TAG, "Skipped (exception): $e")
102+
Log_OC.e(TAG, "💥 Exception while processing operation id=${operation.id}: ${e.message}")
99103
}
100104
}
101105

102-
Log_OC.i(TAG, "OfflineOperationsWorker completed successfully.")
106+
Log_OC.i(TAG, "✅ All offline operations completed successfully.")
103107
WorkerStateLiveData.instance().setWorkState(WorkerState.OfflineOperationsCompleted)
104108
Result.success()
105109
} catch (e: Exception) {
106-
Log_OC.e(TAG, "Processing failed: $e")
110+
Log_OC.e(TAG, "💥 ProcessOperations failed: ${e.message}")
107111
Result.failure()
108112
}
109113
}
@@ -122,33 +126,47 @@ class OfflineOperationsWorker(
122126
): OfflineOperationResult? = withContext(Dispatchers.IO) {
123127
val path = (operation.path)
124128
if (path == null) {
125-
Log_OC.w(TAG, "Offline operation skipped, file path is null: $operation")
129+
Log_OC.w(TAG, "⚠️ Skipped: path is null for operation id=${operation.id}")
126130
return@withContext null
127131
}
128132

129133
val remoteFile = getRemoteFile(path)
130134
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(operation.path)
131135

132136
if (remoteFile != null && ocFile != null && isFileChanged(remoteFile, ocFile)) {
133-
Log_OC.w(TAG, "Offline operation skipped, file already exists: $operation")
137+
Log_OC.w(TAG, "⚠️ Conflict detected: File already exists on server. Skipping operation id=${operation.id}")
134138

135139
if (operation.isRenameOrRemove()) {
140+
Log_OC.d(TAG, "🗑 Removing conflicting rename/remove operation id=${operation.id}")
136141
fileDataStorageManager.offlineOperationDao.delete(operation)
137142
notificationManager.showConflictNotificationForDeleteOrRemoveOperation(operation)
138143
} else {
144+
Log_OC.d(TAG, "📌 Showing conflict resolution for operation id=${operation.id}")
139145
notificationManager.showConflictResolveNotification(ocFile, operation)
140146
}
141147

142148
return@withContext null
143149
}
144150

145151
return@withContext when (val type = operation.type) {
146-
is OfflineOperationType.CreateFolder -> createFolder(operation, client)
147-
is OfflineOperationType.CreateFile -> createFile(operation, client)
148-
is OfflineOperationType.RenameFile -> renameFile(operation, client)
149-
is OfflineOperationType.RemoveFile -> ocFile?.let { removeFile(it, client) }
152+
is OfflineOperationType.CreateFolder -> {
153+
Log_OC.d(TAG, "📂 Creating folder at ${type.path}")
154+
createFolder(operation, client)
155+
}
156+
is OfflineOperationType.CreateFile -> {
157+
Log_OC.d(TAG, "📤 Uploading file: local=${type.localPath} → remote=${type.remotePath}")
158+
createFile(operation, client)
159+
}
160+
is OfflineOperationType.RenameFile -> {
161+
Log_OC.d(TAG, "✏️ Renaming ${operation.path}${type.newName}")
162+
renameFile(operation, client)
163+
}
164+
is OfflineOperationType.RemoveFile -> {
165+
Log_OC.d(TAG, "🗑 Removing file: ${operation.path}")
166+
ocFile?.let { removeFile(it, client) }
167+
}
150168
else -> {
151-
Log_OC.d(TAG, "Unsupported operation type: $type")
169+
Log_OC.d(TAG, "⚠️ Unsupported operation type: $type")
152170
null
153171
}
154172
}
@@ -216,14 +234,14 @@ class OfflineOperationsWorker(
216234
): Boolean {
217235
val operationResult = result?.first ?: return false
218236

219-
val logMessage = if (operationResult.isSuccess == true) "Operation completed" else "Operation failed"
237+
val logMessage = if (operationResult.isSuccess) "Operation completed" else "Operation failed"
220238
Log_OC.d(TAG, "$logMessage filename: ${operation.filename}, type: ${operation.type}")
221239

222240
return if (result.first?.isSuccess == true) {
223241
handleSuccessResult(operation, totalOperations, currentSuccessfulOperationIndex)
224242
true
225243
} else {
226-
handleErrorResult(result)
244+
handleErrorResult(operation.id, result)
227245
false
228246
}
229247
}
@@ -249,14 +267,16 @@ class OfflineOperationsWorker(
249267
notificationManager.dismissNotification(operation.id)
250268
}
251269

252-
private fun handleErrorResult(result: OfflineOperationResult) {
270+
private fun handleErrorResult(id: Int?, result: OfflineOperationResult) {
253271
val operationResult = result?.first ?: return
254272
val operation = result.second ?: return
255-
273+
Log_OC.e(TAG, "❌ Operation failed [id=$id]: code=${operationResult.code}, message=${operationResult.message}")
256274
val excludedErrorCodes = listOf(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS)
257275

258276
if (!excludedErrorCodes.contains(operationResult.code)) {
259-
notificationManager.showNewNotification(operationResult, operation)
277+
notificationManager.showNewNotification(id, operationResult, operation)
278+
} else {
279+
Log_OC.d(TAG, "ℹ️ Ignored error: ${operationResult.code}")
260280
}
261281
}
262282

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.client.jobs.offlineOperations.receiver
9+
10+
import android.app.NotificationManager
11+
import android.content.BroadcastReceiver
12+
import android.content.Context
13+
import android.content.Intent
14+
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager
15+
import com.owncloud.android.MainApp
16+
import com.owncloud.android.datamodel.FileDataStorageManager
17+
import javax.inject.Inject
18+
19+
class OfflineOperationReceiver : BroadcastReceiver() {
20+
companion object {
21+
const val ID = "id"
22+
}
23+
24+
@Inject
25+
lateinit var storageManager: FileDataStorageManager
26+
27+
override fun onReceive(context: Context, intent: Intent) {
28+
MainApp.getAppComponent().inject(this)
29+
30+
val id = intent.getIntExtra(ID, -1)
31+
if (id == -1) {
32+
return
33+
}
34+
35+
storageManager.offlineOperationDao.deleteById(id)
36+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
37+
notificationManager.cancel(
38+
OfflineOperationsNotificationManager.ERROR_ID
39+
)
40+
}
41+
}

app/src/main/java/com/owncloud/android/MainApp.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,10 @@ public static void notificationChannels() {
702702
createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_GENERAL, R.string
703703
.notification_channel_general_name, R.string.notification_channel_general_description,
704704
context, NotificationManager.IMPORTANCE_DEFAULT);
705+
706+
createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS,
707+
R.string.notification_channel_offline_operations_name_short,
708+
R.string.notification_channel_offline_operations_description, context);
705709
} else {
706710
Log_OC.e(TAG, "Notification manager is null");
707711
}

app/src/main/java/com/owncloud/android/ui/notifications/NotificationUtils.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public final class NotificationUtils {
3434
public static final String NOTIFICATION_CHANNEL_FILE_OBSERVER = "NOTIFICATION_CHANNEL_FILE_OBSERVER";
3535
public static final String NOTIFICATION_CHANNEL_PUSH = "NOTIFICATION_CHANNEL_PUSH";
3636
public static final String NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS = "NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS";
37+
public static final String NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS = "NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS";
3738

3839
private NotificationUtils() {
3940
// utility class -> private constructor

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,9 @@
815815
<string name="notification_channel_media_description">Music player progress</string>
816816
<string name="notification_channel_file_sync_name">File sync</string>
817817
<string name="notification_channel_file_sync_description">Shows file sync progress and results</string>
818+
<string name="notification_channel_offline_operations_name_short">Offline operations</string>
819+
<string name="notification_channel_offline_operations_description">Shows progress of offline file operations</string>
820+
818821

819822
<string name="account_not_found">Account not found!</string>
820823

0 commit comments

Comments
 (0)