1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
package org.terst.doot.widget.data
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val json = Json { ignoreUnknownKeys = true }
// How long a locally-optimistic removal is trusted over a possibly-stale
// server fetch before it's allowed to expire. Bounded, not permanent: if a
// completion never actually confirms (e.g. the device goes offline and
// CompleteWorker/DeferWorker retry forever), the item must still eventually
// reappear rather than staying hidden forever -- the existing periodic
// RefreshWorker tick already relies on exactly this kind of self-healing
// (see the "Widget periodic-refresh self-healing fix" worklog entry), and a
// non-expiring pending-removal would silently break that property.
const val PENDING_REMOVAL_TTL_MS = 2 * 60 * 1000L
fun pendingKey(id: String, source: String) = "$id|$source"
fun decodePendingRemovals(raw: String?): Map<String, Long> =
raw?.let { runCatching { json.decodeFromString<Map<String, Long>>(it) }.getOrNull() } ?: emptyMap()
fun encodePendingRemovals(pending: Map<String, Long>): String = json.encodeToString(pending)
fun Map<String, Long>.pruneExpired(now: Long): Map<String, Long> =
filterValues { now - it < PENDING_REMOVAL_TTL_MS }
/**
* Excludes items whose (id, source) has an unexpired pending local removal.
*
* This exists to close a race in fetchAndPersist: it does a full overwrite
* of the cached item list on every successful fetch, and CompleteWorker /
* DeferWorker run one instance per task with no ordering guarantee between
* different tasks' workers. Tapping complete on task A starts a GET that's
* still in flight; tapping complete on task B before A's GET returns
* optimistically removes B locally -- but A's GET response was captured
* before B's completion landed server-side, so if A's slower write lands
* after B's, it would silently resurrect B. This is the root cause of the
* 2026-08-05 report: "check to complete works once, then doesn't work
* anymore" -- it wasn't that later taps failed (server logs confirmed every
* completion request succeeded), it's that a slower, earlier-started fetch
* kept clobbering faster, later optimistic removals.
*/
fun List<WidgetItem>.excludingUnexpiredPendingRemovals(pending: Map<String, Long>, now: Long): List<WidgetItem> {
if (pending.isEmpty()) return this
return filterNot { item ->
val removedAt = pending[pendingKey(item.id, item.source)] ?: return@filterNot false
now - removedAt < PENDING_REMOVAL_TTL_MS
}
}
|