summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org/terst/doot/widget/data/PendingRemovals.kt
blob: 31e85cfc0245d4f434672cc2ea62994952b1fc5c (plain)
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
    }
}