An Offline Swipe Queue Where a Later Verdict Replaces an Earlier One

Photo Sweep's Android client writes every verdict to a local queue before sending it anywhere. Changing your mind twice about the same photo shouldn't send two contradictory instructions — so a later entry silently replaces an earlier one.

An Offline Swipe Queue Where a Later Verdict Replaces an Earlier One

Swiping has to feel instant. Waiting on a network round trip between every photo is what turns going through a thousand of them from tedious into unbearable, and a phone going through a tunnel shouldn't end the review session. VerdictOutbox is the class that makes both of those true: every verdict is written locally first, sent later, and the local write is what the UI actually waits on.

Add dedups by fileId and writes atomically; flush clears only what send() accepted
Add dedups by fileId and writes atomically; flush clears only what send() accepted

The review screen drains the outbox first, and says so if anything is still in flight — never confirms a list missing your latest swipe.

Deduplication on write, not on read

The obvious way to queue verdicts is to append each one as it happens and sort out duplicates when sending. VerdictOutbox does the opposite — dedup happens at the moment of writing:

suspend fun add(verdict: PendingVerdict) = mutex.withLock {
    val current = readUnlocked().filterNot { it.fileId == verdict.fileId }
    writeUnlocked((current + verdict).takeLast(MAX_ENTRIES))
}

Any existing entry for the same file is dropped before the new one is appended. If you swipe delete on a photo, then five photos later swipe back to it and change your mind to keep, the queue never contains both instructions — only the second one exists, because the first was removed the instant the second was added. The comment states the reasoning directly: "changing your mind twice should not send two contradictory instructions." Sending both and letting the server sort out "last write wins" would work too, but it would mean every client has to reason about ordering and conflict resolution on the receiving end. Deduping on write means the server only ever sees one verdict per file per sync, and never has to adjudicate between two.

Crash safety for the queue itself

The queue is a JSON file on disk, and it has to survive the process being killed mid-write — a phone yanked from charging, an OOM kill, anything. Every write goes through a temp file and an atomic rename:

val temp = File(file.parentFile, "${file.name}.tmp")
temp.writeText(json.encodeToString(verdicts))
if (!temp.renameTo(file)) {
    file.writeText(temp.readText())
    temp.delete()
}

A kill during the temp-file write leaves the previous, intact queue file untouched; a kill during the rename either completes it or doesn't, but never leaves a half-written file. And on the read side, a queue file that somehow does end up corrupted — the comment names the actual scenario, "a truncated file from a hard kill" — is treated as an empty queue rather than a crash, with the contents recoverable simply by reviewing the month again:

} catch (e: Exception) {
    Log.w(TAG, "Discarding an unreadable outbox", e)
    emptyList()
}

Flush doesn't assume its own success

Sending the queue to the server and clearing it are two different operations, separated deliberately:

suspend fun flush(send: suspend (List<PendingVerdict>) -> Unit) {
    val batch = mutex.withLock { readUnlocked() }
    if (batch.isEmpty()) return

    send(batch)

    mutex.withLock {
        val sent = batch.map { it.fileId }.toHashSet()
        writeUnlocked(readUnlocked().filterNot { it.fileId in sent })
    }
}

flush() reads the queue, hands it to the network call, and only clears the entries it captured at the start — anything the user swipes during that network round trip is added to the queue concurrently and is deliberately not touched by the clear. Clearing the whole file after a send would throw away a verdict given half a second too late to make the batch that just went out.

Where the queue meets the UI

None of this matters if the review-and-confirm screen can show a stale list. The Android client drains the outbox before that screen renders, and tells the user explicitly if anything is still in flight — the one moment where "trust me, it's fine" isn't good enough, because that screen is the one that acts on what it shows. An offline queue is only as trustworthy as the screen that finally has to read it back.

Series: Photo Sweep.