f7bb934c823b

add SyncOutcomeReason vocabulary and persistent SyncOutcomeStore mirroring the browser's sync-outcome contract

time
2026-07-26 19:09 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
android sync indicator
parent
729a9f19fd13
2 files app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+162 −0app/src/main/java/com/peachpasswords/sync/SyncOutcomeStore.kt+94 −0
app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+162 −0
@@ -0,0 +1,162 @@1+package com.peachpasswords.sync2+3+import kotlinx.serialization.Serializable4+5+/**6+ * Why a sync attempt ended, in the same vocabulary the browser uses7+ * (`src/shared/sync-outcome.ts`). June runs both clients side by side, so the8+ * names, the state machine and the user-facing wording are deliberately kept9+ * identical; only the Kotlin spelling differs.10+ *11+ * Before this existed Android had [SyncOutcomeSummary]'s ancestor `SyncStatus`,12+ * whose `LastSynced` was set the instant the sync *service* was asked to start13+ * — a claim of success it had not earned. Every real failure below it was14+ * swallowed by the reconnect loop's `runCatching`, so "synced fine", "the peer15+ * refused us" and "the vault was locked" were one indistinguishable state.16+ *17+ * Two browser outcomes are deliberately absent:18+ *  - `runner_unavailable` — the browser injects its per-device sync runner and19+ *    can find it missing. Android's runner is [PeachSyncService], which is20+ *    always present; the only way it fails to run is a locked vault, which is21+ *    [VAULT_LOCKED].22+ *  - `no_eligible_host` means something different here and is kept for that23+ *    meaning: the browser rejects peers it cannot dial, whereas this phone24+ *    usually *waits* and can talk to any peer type. What it cannot talk to is a25+ *    peer with no rendezvous secret, which the reconnect loop skips in silence.26+ *    See [weDialPeer] for why the roles differ.27+ */28+enum class SyncOutcomeReason {29+    COMPLETED_CHANGES,30+    COMPLETED_NO_CHANGES,31+    NO_PAIRED_DEVICES,32+    NO_ELIGIBLE_HOST,33+    SYNC_DISABLED,34+    PRIVATE_RELAY_UNSET,35+    VAULT_LOCKED,36+    RELAY_UNREACHABLE,37+    HANDSHAKE_FAILED,38+    VAULT_ID_MISMATCH,39+    SYNC_TIMEOUT,40+    RUN_FAILED,41+    ;42+43+    /** Outcomes where sync did not do what was asked, so the light goes red. */44+    val isFailure: Boolean45+        get() = when (this) {46+            NO_ELIGIBLE_HOST,47+            PRIVATE_RELAY_UNSET,48+            VAULT_LOCKED,49+            RELAY_UNREACHABLE,50+            HANDSHAKE_FAILED,51+            VAULT_ID_MISMATCH,52+            SYNC_TIMEOUT,53+            RUN_FAILED,54+            -> true55+56+            COMPLETED_CHANGES,57+            COMPLETED_NO_CHANGES,58+            NO_PAIRED_DEVICES,59+            SYNC_DISABLED,60+            -> false61+        }62+}63+64+/**65+ * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so66+ * a failure that happened before the process was killed is still explicable —67+ * a reason that vanishes is useless for diagnosing real hardware.68+ */69+@Serializable70+data class SyncOutcomeSummary(71+    val reason: SyncOutcomeReason? = null,72+    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */73+    val detail: String? = null,74+    val entriesSent: Int = 0,75+    val entriesReceived: Int = 0,76+    /** When the attempt ended, success or not. */77+    val finishedAt: Long? = null,78+    /** In flight right now. Transient by definition, so never persisted. */79+    val syncing: Boolean = false,80+)81+82+/**83+ * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is84+ * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a85+ * paired-but-unsyncable device is red, not green.86+ */87+enum class SyncIndicator { BUSY, OK, ERROR, IDLE }88+89+fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {90+    if (summary.syncing) return SyncIndicator.BUSY91+    val reason = summary.reason92+    if (reason != null && reason.isFailure) return SyncIndicator.ERROR93+    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||94+        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES95+    ) {96+        return SyncIndicator.OK97+    }98+    // Paired with a healthy history but nothing attempted since launch: still a99+    // successful pair, which is what the light is claiming.100+    if (pairedCount > 0 && reason == null) return SyncIndicator.OK101+    return SyncIndicator.IDLE102+}103+104+private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {105+    SyncIndicator.BUSY -> "Syncing"106+    SyncIndicator.OK -> "Sync healthy"107+    SyncIndicator.ERROR -> "Sync problem"108+    SyncIndicator.IDLE -> "Not syncing"109+}110+111+/** Colour alone is never the signal — this is the same claim in words. */112+fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =113+    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"114+115+/**116+ * One plain-English line for a sync outcome, word for word the browser's copy.117+ * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset118+ * it is the whole diagnostic, and there is no devtools to fall back on.119+ */120+fun describeSyncOutcome(summary: SyncOutcomeSummary): String {121+    val reason = summary.reason ?: return "Not synced yet"122+    val base = when (reason) {123+        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"124+        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"125+        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"126+        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"127+        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"128+        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"129+        SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"130+        SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"131+        SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"132+        SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"133+        SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"134+        SyncOutcomeReason.RUN_FAILED -> "Sync failed"135+    }136+    if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {137+        val moved = listOfNotNull(138+            "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },139+            "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },140+        )141+        return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"142+    }143+    return summary.detail?.let { "$base ($it)" } ?: base144+}145+146+/**147+ * Session errors reach us as opaque strings by contract ([SyncOutcome.error]148+ * and any throw out of the relay socket). Classifying them in one place is what149+ * lets the copy above key off something stable.150+ */151+internal fun classifySyncError(error: String): SyncOutcomeReason = when {152+    error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED153+    error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH154+    error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT155+    // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the156+    // relay was fine and the peer was not who we expected.157+    Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")158+        .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED159+    Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)160+        .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE161+    else -> SyncOutcomeReason.RUN_FAILED162+}

view file

app/src/main/java/com/peachpasswords/sync/SyncOutcomeStore.kt+94 −0
@@ -0,0 +1,94 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import androidx.security.crypto.EncryptedSharedPreferences5+import androidx.security.crypto.MasterKey6+import dagger.hilt.android.qualifiers.ApplicationContext7+import kotlinx.coroutines.flow.MutableStateFlow8+import kotlinx.coroutines.flow.StateFlow9+import kotlinx.coroutines.flow.asStateFlow10+import kotlinx.serialization.json.Json11+import javax.inject.Inject12+import javax.inject.Singleton13+14+/**15+ * The single place a sync attempt is allowed to end. Everything that can16+ * terminate a run — the settings card's "sync now", the pull-to-refresh, the17+ * relay reconnect loop, the service that could not start — reports here, and18+ * the indicator light reads nothing else.19+ *20+ * Encrypted at rest like [SyncAlertStore]: [SyncOutcomeSummary.detail] can21+ * carry the peer inventory, which is device names.22+ */23+@Singleton24+class SyncOutcomeStore @Inject constructor(25+    @ApplicationContext context: Context,26+) {27+    private val masterKey = MasterKey.Builder(context)28+        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)29+        .build()30+31+    private val prefs = EncryptedSharedPreferences.create(32+        context,33+        PREFS_FILE,34+        masterKey,35+        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,36+        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,37+    )38+39+    private val _summary = MutableStateFlow(decodeSyncOutcome(prefs.getString(KEY_LAST, null)))40+    val summary: StateFlow<SyncOutcomeSummary> = _summary.asStateFlow()41+42+    /** A run has started. Keeps the previous reason visible until one replaces it. */43+    fun begin() {44+        _summary.value = _summary.value.copy(syncing = true)45+    }46+47+    /**48+     * A run ended. The counterpart of the browser's `finish()`: no sync path may49+     * terminate without passing through here.50+     */51+    fun report(52+        reason: SyncOutcomeReason,53+        detail: String? = null,54+        entriesSent: Int = 0,55+        entriesReceived: Int = 0,56+    ) {57+        val next = SyncOutcomeSummary(58+            reason = reason,59+            detail = detail,60+            entriesSent = entriesSent,61+            entriesReceived = entriesReceived,62+            finishedAt = System.currentTimeMillis(),63+        )64+        prefs.edit().putString(KEY_LAST, encodeSyncOutcome(next)).apply()65+        _summary.value = next66+    }67+68+    /** Rotating the sync identity forgets every peer, so the history means nothing. */69+    fun reset() {70+        prefs.edit().remove(KEY_LAST).apply()71+        _summary.value = SyncOutcomeSummary()72+    }73+74+    private companion object {75+        const val PREFS_FILE = "peach_sync_outcome"76+        const val KEY_LAST = "last_outcome"77+    }78+}79+80+private val outcomeJson = Json { encodeDefaults = true; ignoreUnknownKeys = true }81+82+internal fun encodeSyncOutcome(summary: SyncOutcomeSummary): String =83+    outcomeJson.encodeToString(SyncOutcomeSummary.serializer(), summary.copy(syncing = false))84+85+/**86+ * Never restores `syncing`: a process killed mid-sync would otherwise come back87+ * claiming to be syncing forever, with nothing left running to finish it.88+ */89+internal fun decodeSyncOutcome(raw: String?): SyncOutcomeSummary {90+    if (raw == null) return SyncOutcomeSummary()91+    return runCatching {92+        outcomeJson.decodeFromString(SyncOutcomeSummary.serializer(), raw).copy(syncing = false)93+    }.getOrDefault(SyncOutcomeSummary())94+}

view file