Skip to content

BugFix - Offline Operation Conflict Handling #15027

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,6 @@
android:exported="false"
tools:replace="android:exported" />

<receiver
android:name="com.nextcloud.receiver.OfflineOperationActionReceiver"
android:exported="false" />
<receiver
android:name="com.nextcloud.client.jobs.MediaFoldersDetectionWork$NotificationReceiver"
android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

package com.nextcloud.client.database.entity

import android.content.Context
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.nextcloud.model.OfflineOperationType
import com.owncloud.android.R
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta

@Entity(tableName = ProviderTableMeta.OFFLINE_OPERATION_TABLE_NAME)
Expand All @@ -36,4 +38,17 @@ data class OfflineOperationEntity(

@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_MODIFIED_AT)
var modifiedAt: Long? = null
)
) {
fun isRenameOrRemove(): Boolean =
(type is OfflineOperationType.RenameFile || type is OfflineOperationType.RemoveFile)

fun getConflictText(context: Context): String = if (type is OfflineOperationType.RemoveFile) {
context.getString(R.string.offline_operations_worker_notification_remove_conflict_text, filename)
} else if (type is OfflineOperationType.RenameFile) {
context.getString(R.string.offline_operations_worker_notification_rename_conflict_text, filename)
} else if (type is OfflineOperationType.CreateFile) {
context.getString(R.string.offline_operations_worker_notification_create_file_conflict_text, filename)
} else {
context.getString(R.string.offline_operations_worker_notification_create_folder_conflict_text, filename)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.nextcloud.client.jobs
import android.provider.MediaStore
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
Expand Down Expand Up @@ -103,6 +104,7 @@ internal class BackgroundJobManagerImpl(
const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L
const val OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES = 5L
const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L
const val DEFAULT_BACKOFF_CRITERIA_DELAY_SEC = 300L

private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L

Expand Down Expand Up @@ -441,8 +443,19 @@ internal class BackgroundJobManagerImpl(
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

// Backoff criteria define how the system should retry the task if it fails.
// LINEAR means each retry will be delayed linearly (e.g., 10s, 20s, 30s...)
// DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES is used as the initial delay duration.
val backoffCriteriaPolicy = BackoffPolicy.LINEAR
val backoffCriteriaDelay = DEFAULT_BACKOFF_CRITERIA_DELAY_SEC

val request =
oneTimeRequestBuilder(OfflineOperationsWorker::class, JOB_OFFLINE_OPERATIONS, constraints = constraints)
.setBackoffCriteria(
backoffCriteriaPolicy,
backoffCriteriaDelay,
TimeUnit.SECONDS
)
.setInputData(inputData)
.build()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@ package com.nextcloud.client.jobs.offlineOperations

import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.receiver.OfflineOperationActionReceiver
import com.nextcloud.utils.extensions.getErrorMessage
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
Expand All @@ -34,19 +31,19 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe
companion object {
private const val ID = 121
private const val ERROR_ID = 122

private const val ONE_HUNDRED_PERCENT = 100
}

@Suppress("MagicNumber")
fun start() {
notificationBuilder.run {
setContentTitle(context.getString(R.string.offline_operations_worker_notification_start_text))
setProgress(100, 0, false)
setProgress(ONE_HUNDRED_PERCENT, 0, false)
}

showNotification()
}

@Suppress("MagicNumber")
fun update(totalOperationSize: Int, currentOperationIndex: Int, filename: String) {
val title = if (totalOperationSize > 1) {
String.format(
Expand All @@ -59,11 +56,11 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe
filename
}

val progress = (currentOperationIndex * 100) / totalOperationSize
val progress = (currentOperationIndex * ONE_HUNDRED_PERCENT) / totalOperationSize

notificationBuilder.run {
setContentTitle(title)
setProgress(100, progress, false)
setProgress(ONE_HUNDRED_PERCENT, progress, false)
}

showNotification()
Expand All @@ -80,54 +77,62 @@ class OfflineOperationsNotificationManager(private val context: Context, viewThe
}
}

fun showConflictResolveNotification(file: OCFile, entity: OfflineOperationEntity?, user: User) {
fun showConflictNotificationForDeleteOrRemoveOperation(entity: OfflineOperationEntity?) {
val id = entity?.id
if (id == null) {
return
}

val title = entity.getConflictText(context)

notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
.setContentTitle(title)

notificationManager.notify(id, notificationBuilder.build())
}

fun showConflictResolveNotification(file: OCFile, entity: OfflineOperationEntity?) {
val path = entity?.path
val id = entity?.id

if (path == null || id == null) {
return
}

val resolveConflictIntent = ConflictsResolveActivity.createIntent(file, path, context)
val resolveConflictPendingIntent = PendingIntent.getActivity(
context,
id,
resolveConflictIntent,
PendingIntent.FLAG_IMMUTABLE
)
val resolveConflictAction = NotificationCompat.Action(
R.drawable.ic_cloud_upload,
context.getString(R.string.upload_list_resolve_conflict),
resolveConflictPendingIntent
)

val deleteIntent = Intent(context, OfflineOperationActionReceiver::class.java).apply {
putExtra(OfflineOperationActionReceiver.FILE_PATH, path)
putExtra(OfflineOperationActionReceiver.USER, user)
}
val deletePendingIntent =
PendingIntent.getBroadcast(context, 0, deleteIntent, PendingIntent.FLAG_IMMUTABLE)
val deleteAction = NotificationCompat.Action(
R.drawable.ic_delete,
context.getString(R.string.offline_operations_worker_notification_delete_offline_folder),
deletePendingIntent
)
val resolveConflictAction = getResolveConflictAction(file, id, path)

val title = context.getString(
R.string.offline_operations_worker_notification_conflict_text,
file.fileName
)
val title = entity.getConflictText(context)

notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
.setContentTitle(title)
.setContentIntent(resolveConflictPendingIntent)
.addAction(deleteAction)
.setContentIntent(resolveConflictAction.actionIntent)
.addAction(resolveConflictAction)

notificationManager.notify(id, notificationBuilder.build())
}

private fun getResolveConflictAction(file: OCFile, id: Int, path: String): NotificationCompat.Action {
val intent = ConflictsResolveActivity.createIntent(file, path, context)
val pendingIntent = PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_IMMUTABLE
)

return NotificationCompat.Action(
R.drawable.ic_cloud_upload,
context.getString(R.string.upload_list_resolve_conflict),
pendingIntent
)
}

fun dismissNotification(id: Int?) {
if (id == null) return
notificationManager.cancel(id)
Expand Down
Loading
Loading