SyncOutcomeReason.kt

8.3 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 * Two browser outcomes are deliberately absent, because on Android they name18 * states that cannot occur:19 *  - `runner_unavailable` — the browser injects its per-device sync runner and20 *    can find it missing. Android's runner is [PeachSyncService], which is21 *    always present; the only way it fails to run is a locked vault, which is22 *    [VAULT_LOCKED].23 *  - `private_relay_unset` — the browser treats "private mode, no address" as24 *    terminal. `RelaySettingsStore.relayUrl()` always falls back to the public25 *    relay instead, so here that configuration syncs rather than failing, and26 *    a private URL equal to the public one is indistinguishable from none.27 *    Reporting it would be a guess.28 *29 * `no_eligible_host` is kept but means something narrower than the browser's:30 * the browser rejects peers it cannot dial, whereas this phone usually *waits*31 * and can talk to any peer type. What it cannot talk to is a peer with no32 * rendezvous secret, which the reconnect loop skips in silence. See33 * [weDialPeer] for why the roles differ.34 */35enum class SyncOutcomeReason {36    COMPLETED_CHANGES,37    COMPLETED_NO_CHANGES,38    NO_PAIRED_DEVICES,39    NO_ELIGIBLE_HOST,40    SYNC_DISABLED,41    VAULT_LOCKED,42    RELAY_UNREACHABLE,43    HANDSHAKE_FAILED,44    VAULT_ID_MISMATCH,45    SYNC_TIMEOUT,46    RUN_FAILED,47    ;4849    /** Outcomes where sync did not do what was asked, so the light goes red. */50    val isFailure: Boolean51        get() = when (this) {52            NO_ELIGIBLE_HOST,53            VAULT_LOCKED,54            RELAY_UNREACHABLE,55            HANDSHAKE_FAILED,56            VAULT_ID_MISMATCH,57            SYNC_TIMEOUT,58            RUN_FAILED,59            -> true6061            COMPLETED_CHANGES,62            COMPLETED_NO_CHANGES,63            NO_PAIRED_DEVICES,64            SYNC_DISABLED,65            -> false66        }67}6869/**70 * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so71 * a failure that happened before the process was killed is still explicable —72 * a reason that vanishes is useless for diagnosing real hardware.73 */74@Serializable75data class SyncOutcomeSummary(76    val reason: SyncOutcomeReason? = null,77    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */78    val detail: String? = null,79    val entriesSent: Int = 0,80    val entriesReceived: Int = 0,81    /** When the attempt ended, success or not. */82    val finishedAt: Long? = null,83    /** In flight right now. Transient by definition, so never persisted. */84    val syncing: Boolean = false,85)8687/**88 * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is89 * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a90 * paired-but-unsyncable device is red, not green.91 */92enum class SyncIndicator { BUSY, OK, ERROR, IDLE }9394fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {95    if (summary.syncing) return SyncIndicator.BUSY96    val reason = summary.reason97    if (reason != null && reason.isFailure) return SyncIndicator.ERROR98    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||99        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES100    ) {101        return SyncIndicator.OK102    }103    // Paired with a healthy history but nothing attempted since launch: still a104    // successful pair, which is what the light is claiming.105    if (pairedCount > 0 && reason == null) return SyncIndicator.OK106    return SyncIndicator.IDLE107}108109private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {110    SyncIndicator.BUSY -> "Syncing"111    SyncIndicator.OK -> "Sync healthy"112    SyncIndicator.ERROR -> "Sync problem"113    SyncIndicator.IDLE -> "Not syncing"114}115116/** Colour alone is never the signal — this is the same claim in words. */117fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =118    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"119120/**121 * One plain-English line for a sync outcome, word for word the browser's copy.122 * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset123 * it is the whole diagnostic, and there is no devtools to fall back on.124 */125fun describeSyncOutcome(summary: SyncOutcomeSummary): String {126    val reason = summary.reason ?: return "Not synced yet"127    val base = when (reason) {128        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"129        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"130        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"131        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"132        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"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}