SyncOutcomeReason.kt

8.2 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.Serializable45/**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 * One browser outcome is deliberately absent, because on Android it names a18 * state that cannot occur: `runner_unavailable` — the browser injects its19 * per-device sync runner and can find it missing. Android's runner is20 * [PeachSyncService], which is always present; the only way it fails to run is21 * a locked vault, which is [VAULT_LOCKED].22 *23 * `no_eligible_host` is kept but means something narrower than the browser's:24 * the browser rejects peers it cannot dial, whereas this phone usually *waits*25 * and can talk to any peer type. What it cannot talk to is a peer with no26 * rendezvous secret, which the reconnect loop skips in silence. See27 * [weDialPeer] for why the roles differ.28 */29enum class SyncOutcomeReason {30    COMPLETED_CHANGES,31    COMPLETED_NO_CHANGES,32    NO_PAIRED_DEVICES,33    NO_ELIGIBLE_HOST,34    SYNC_DISABLED,35    PRIVATE_RELAY_UNSET,36    VAULT_LOCKED,37    RELAY_UNREACHABLE,38    HANDSHAKE_FAILED,39    VAULT_ID_MISMATCH,40    SYNC_TIMEOUT,41    RUN_FAILED,42    ;4344    /** The browser's snake_case spelling, for the screens that carry a code. */45    fun wire(): String = name.lowercase()4647    /** Outcomes where sync did not do what was asked, so the light goes red. */48    val isFailure: Boolean49        get() = when (this) {50            NO_ELIGIBLE_HOST,51            PRIVATE_RELAY_UNSET,52            VAULT_LOCKED,53            RELAY_UNREACHABLE,54            HANDSHAKE_FAILED,55            VAULT_ID_MISMATCH,56            SYNC_TIMEOUT,57            RUN_FAILED,58            -> true5960            COMPLETED_CHANGES,61            COMPLETED_NO_CHANGES,62            NO_PAIRED_DEVICES,63            SYNC_DISABLED,64            -> false65        }66}6768/**69 * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so70 * a failure that happened before the process was killed is still explicable —71 * a reason that vanishes is useless for diagnosing real hardware.72 */73@Serializable74data class SyncOutcomeSummary(75    val reason: SyncOutcomeReason? = null,76    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */77    val detail: String? = null,78    val entriesSent: Int = 0,79    val entriesReceived: Int = 0,80    /** When the attempt ended, success or not. */81    val finishedAt: Long? = null,82    /** In flight right now. Transient by definition, so never persisted. */83    val syncing: Boolean = false,84)8586/**87 * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is88 * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a89 * paired-but-unsyncable device is red, not green.90 */91enum class SyncIndicator { BUSY, OK, ERROR, IDLE }9293fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {94    if (summary.syncing) return SyncIndicator.BUSY95    val reason = summary.reason96    if (reason != null && reason.isFailure) return SyncIndicator.ERROR97    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||98        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES99    ) {100        return SyncIndicator.OK101    }102    // Paired with a healthy history but nothing attempted since launch: still a103    // successful pair, which is what the light is claiming.104    if (pairedCount > 0 && reason == null) return SyncIndicator.OK105    return SyncIndicator.IDLE106}107108private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {109    SyncIndicator.BUSY -> "Syncing"110    SyncIndicator.OK -> "Sync healthy"111    SyncIndicator.ERROR -> "Sync problem"112    SyncIndicator.IDLE -> "Not syncing"113}114115/** Colour alone is never the signal — this is the same claim in words. */116fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =117    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"118119/**120 * One plain-English line for a sync outcome, word for word the browser's copy.121 * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset122 * it is the whole diagnostic, and there is no devtools to fall back on.123 */124fun describeSyncOutcome(summary: SyncOutcomeSummary): String {125    val reason = summary.reason ?: return "Not synced yet"126    val base = when (reason) {127        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"128        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"129        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"130        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"131        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"132        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"133        SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"134        SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"135        SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"136        SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"137        SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"138        SyncOutcomeReason.RUN_FAILED -> "Sync failed"139    }140    if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {141        val moved = listOfNotNull(142            "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },143            "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },144        )145        return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"146    }147    return summary.detail?.let { "$base ($it)" } ?: base148}149150/**151 * Session errors reach us as opaque strings by contract ([SyncOutcome.error]152 * and any throw out of the relay socket). Classifying them in one place is what153 * lets the copy above key off something stable.154 */155internal fun classifySyncError(error: String): SyncOutcomeReason = when {156    error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED157    error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH158    error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT159    // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the160    // relay was fine and the peer was not who we expected.161    Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")162        .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED163    Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)164        .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE165    else -> SyncOutcomeReason.RUN_FAILED166}167168/**169 * Translate one finished session ([SyncCoordinator]'s per-round result) into the170 * user-facing vocabulary. Tombstones count as changes: a deletion that crossed171 * the wire is not "nothing to send or receive".172 */173internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {174    SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(175        reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {176            SyncOutcomeReason.COMPLETED_CHANGES177        } else {178            SyncOutcomeReason.COMPLETED_NO_CHANGES179        },180        entriesSent = entriesSent,181        entriesReceived = entriesReceived,182    )183184    SyncOutcomeStatus.VAULT_MISMATCH ->185        SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)186187    SyncOutcomeStatus.TIMEOUT ->188        SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)189190    SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(191        reason = classifySyncError(error ?: "sync_failed"),192        detail = error,193    )194}