SyncOutcomeReason.kt

8.7 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 * [FOREGROUND_START_BLOCKED] is the other way round — Android-only, with no24 * browser counterpart, because only Android can refuse to start the runner at25 * all (background foreground-service restrictions, API 31+).26 *27 * `no_eligible_host` is kept but means something narrower than the browser's:28 * the browser rejects peers it cannot dial, whereas this phone usually *waits*29 * and can talk to any peer type. What it cannot talk to is a peer with no30 * rendezvous secret, which the reconnect loop skips in silence. See31 * [weDialPeer] for why the roles differ.32 */33enum class SyncOutcomeReason {34    COMPLETED_CHANGES,35    COMPLETED_NO_CHANGES,36    NO_PAIRED_DEVICES,37    NO_ELIGIBLE_HOST,38    SYNC_DISABLED,39    PRIVATE_RELAY_UNSET,40    VAULT_LOCKED,41    FOREGROUND_START_BLOCKED,42    RELAY_UNREACHABLE,43    HANDSHAKE_FAILED,44    VAULT_ID_MISMATCH,45    SYNC_TIMEOUT,46    RUN_FAILED,47    ;4849    /** The browser's snake_case spelling, for the screens that carry a code. */50    fun wire(): String = name.lowercase()5152    /** Outcomes where sync did not do what was asked, so the light goes red. */53    val isFailure: Boolean54        get() = when (this) {55            NO_ELIGIBLE_HOST,56            PRIVATE_RELAY_UNSET,57            VAULT_LOCKED,58            FOREGROUND_START_BLOCKED,59            RELAY_UNREACHABLE,60            HANDSHAKE_FAILED,61            VAULT_ID_MISMATCH,62            SYNC_TIMEOUT,63            RUN_FAILED,64            -> true6566            COMPLETED_CHANGES,67            COMPLETED_NO_CHANGES,68            NO_PAIRED_DEVICES,69            SYNC_DISABLED,70            -> false71        }72}7374/**75 * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so76 * a failure that happened before the process was killed is still explicable —77 * a reason that vanishes is useless for diagnosing real hardware.78 */79@Serializable80data class SyncOutcomeSummary(81    val reason: SyncOutcomeReason? = null,82    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */83    val detail: String? = null,84    val entriesSent: Int = 0,85    val entriesReceived: Int = 0,86    /** When the attempt ended, success or not. */87    val finishedAt: Long? = null,88    /** In flight right now. Transient by definition, so never persisted. */89    val syncing: Boolean = false,90)9192/**93 * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is94 * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a95 * paired-but-unsyncable device is red, not green.96 */97enum class SyncIndicator { BUSY, OK, ERROR, IDLE }9899fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {100    if (summary.syncing) return SyncIndicator.BUSY101    val reason = summary.reason102    if (reason != null && reason.isFailure) return SyncIndicator.ERROR103    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||104        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES105    ) {106        return SyncIndicator.OK107    }108    // Paired with a healthy history but nothing attempted since launch: still a109    // successful pair, which is what the light is claiming.110    if (pairedCount > 0 && reason == null) return SyncIndicator.OK111    return SyncIndicator.IDLE112}113114private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {115    SyncIndicator.BUSY -> "Syncing"116    SyncIndicator.OK -> "Sync healthy"117    SyncIndicator.ERROR -> "Sync problem"118    SyncIndicator.IDLE -> "Not syncing"119}120121/** Colour alone is never the signal — this is the same claim in words. */122fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =123    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"124125/**126 * One plain-English line for a sync outcome, word for word the browser's copy.127 * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset128 * it is the whole diagnostic, and there is no devtools to fall back on.129 */130fun describeSyncOutcome(summary: SyncOutcomeSummary): String {131    val reason = summary.reason ?: return "Not synced yet"132    val base = when (reason) {133        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"134        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"135        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"136        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"137        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"138        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"139        SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"140        SyncOutcomeReason.FOREGROUND_START_BLOCKED ->141            "Android blocked Peach from starting sync in the background — open Peach and sync again"142        SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"143        SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"144        SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"145        SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"146        SyncOutcomeReason.RUN_FAILED -> "Sync failed"147    }148    if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {149        val moved = listOfNotNull(150            "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },151            "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },152        )153        return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"154    }155    return summary.detail?.let { "$base ($it)" } ?: base156}157158/**159 * Session errors reach us as opaque strings by contract ([SyncOutcome.error]160 * and any throw out of the relay socket). Classifying them in one place is what161 * lets the copy above key off something stable.162 */163internal fun classifySyncError(error: String): SyncOutcomeReason = when {164    error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED165    error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH166    error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT167    // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the168    // relay was fine and the peer was not who we expected.169    Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")170        .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED171    Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)172        .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE173    else -> SyncOutcomeReason.RUN_FAILED174}175176/**177 * Translate one finished session ([SyncCoordinator]'s per-round result) into the178 * user-facing vocabulary. Tombstones count as changes: a deletion that crossed179 * the wire is not "nothing to send or receive".180 */181internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {182    SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(183        reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {184            SyncOutcomeReason.COMPLETED_CHANGES185        } else {186            SyncOutcomeReason.COMPLETED_NO_CHANGES187        },188        entriesSent = entriesSent,189        entriesReceived = entriesReceived,190    )191192    SyncOutcomeStatus.VAULT_MISMATCH ->193        SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)194195    SyncOutcomeStatus.TIMEOUT ->196        SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)197198    SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(199        reason = classifySyncError(error ?: "sync_failed"),200        detail = error,201    )202}