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
↓ peach-android-f7bb934c.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+162 −0app/src/main/java/com/peachpasswords/sync/SyncOutcomeStore.kt+94 −0app/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+}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+}