From a43cc7b8f007a86742756bd4b67f9ba1c204bbd0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 06:09:29 +0000 Subject: fix(widget): dedup rapid completeTask taps on the same task CompleteWorker.enqueue used a plain WorkManager.enqueue(), which allows unlimited concurrent OneTimeWorkRequests. A rapid double-tap on the same row (plausible since the checkbox doesn't visually update until the full async round-trip -- complete() -> fetchAndPersist() -> updateAll() -- finishes) could spawn two independent, unordered CompleteWorker runs for the same task, each doing its own fetchAndPersist(); a second worker's fetch started before the first worker's complete() call had actually landed server-side could persist a stale snapshot after the first worker's correct one. Now uses enqueueUniqueWork("complete_$id", KEEP, ...) so a tap on a task that already has a completion in flight is dropped rather than racing a second worker. Different task ids remain independent. --- .../org/terst/doot/widget/work/CompleteWorker.kt | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt index d3017ff..51ad727 100644 --- a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt +++ b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt @@ -35,12 +35,31 @@ class CompleteWorker(context: Context, params: WorkerParameters) : const val KEY_ID = "item_id" const val KEY_SOURCE = "item_source" + // enqueueUniqueWork(id, KEEP, ...) instead of a plain enqueue(): a + // rapid double-tap on the same row (plausible since the checkbox + // doesn't visually update until this worker's full async round-trip + // -- complete() -> fetchAndPersist() -> updateAll() -- finishes) + // previously spawned two independent, unordered CompleteWorker runs + // for the same task. Each does its own fetchAndPersist(), so a + // second worker's fetch (started before the first worker's + // complete() call had actually landed server-side) could persist a + // stale snapshot after the first worker's correct one, leaving the + // widget showing outdated data. KEEP means a tap on a task that + // already has a completion in flight is simply dropped -- the first + // request's result (including its fetchAndPersist/updateAll) is what + // takes effect, with no race between two workers touching the same + // task. Different task ids still run independently/concurrently, + // which is fine since they don't share a row. fun enqueue(context: Context, id: String, source: String) { val data = workDataOf(KEY_ID to id, KEY_SOURCE to source) val request = OneTimeWorkRequestBuilder() .setInputData(data) .build() - WorkManager.getInstance(context).enqueue(request) + WorkManager.getInstance(context).enqueueUniqueWork( + "complete_$id", + ExistingWorkPolicy.KEEP, + request + ) } } } -- cgit v1.2.3