SyncOutcomeReason.kt

10.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 * [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    CONNECTION_LOST,47    RUN_FAILED,48    ;4950    /** The browser's snake_case spelling, for the screens that carry a code. */51    fun wire(): String = name.lowercase()5253    /** Outcomes where sync did not do what was asked, so the light goes red. */54    val isFailure: Boolean55        get() = when (this) {56            NO_ELIGIBLE_HOST,57            PRIVATE_RELAY_UNSET,58            VAULT_LOCKED,59            FOREGROUND_START_BLOCKED,60            VAULT_ID_MISMATCH,61            RUN_FAILED,62            -> true6364            COMPLETED_CHANGES,65            COMPLETED_NO_CHANGES,66            NO_PAIRED_DEVICES,67            SYNC_DISABLED,68            RELAY_UNREACHABLE,69            HANDSHAKE_FAILED,70            SYNC_TIMEOUT,71            CONNECTION_LOST,72            -> false73        }7475    /**76     * Network-class outcomes — the dial did not reach the peer this attempt.77     * The background reconnect loop keeps trying, so the light goes amber78     * rather than red. Mirrors the browser's WARN tier.79     */80    val isWarning: Boolean81        get() = when (this) {82            RELAY_UNREACHABLE,83            HANDSHAKE_FAILED,84            SYNC_TIMEOUT,85            CONNECTION_LOST,86            -> true8788            else -> false89        }90}9192/**93 * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so94 * a failure that happened before the process was killed is still explicable —95 * a reason that vanishes is useless for diagnosing real hardware.96 */97@Serializable98data class SyncOutcomeSummary(99    val reason: SyncOutcomeReason? = null,100    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */101    val detail: String? = null,102    val entriesSent: Int = 0,103    val entriesReceived: Int = 0,104    /** When the attempt ended, success or not. */105    val finishedAt: Long? = null,106    /** In flight right now. Transient by definition, so never persisted. */107    val syncing: Boolean = false,108)109110/**111 * The one indicator light. [BUSY] pulses green, [OK] is solid green, [WARN] is112 * amber (a transient network miss — the background loop retries), [ERROR] is113 * red (the app is actually broken), [IDLE] is unlit. Nothing that is actually114 * broken may resolve to [OK] — a paired-but-unsyncable device is red, not115 * green.116 */117enum class SyncIndicator { BUSY, OK, WARN, ERROR, IDLE }118119fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {120    if (summary.syncing) return SyncIndicator.BUSY121    val reason = summary.reason122    if (reason != null && reason.isWarning) return SyncIndicator.WARN123    if (reason != null && reason.isFailure) return SyncIndicator.ERROR124    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||125        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES126    ) {127        return SyncIndicator.OK128    }129    // Paired with a healthy history but nothing attempted since launch: still a130    // successful pair, which is what the light is claiming.131    if (pairedCount > 0 && reason == null) return SyncIndicator.OK132    return SyncIndicator.IDLE133}134135private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {136    SyncIndicator.BUSY -> "Syncing"137    SyncIndicator.OK -> "Sync healthy"138    SyncIndicator.WARN -> "Last sync attempt missed — will retry"139    SyncIndicator.ERROR -> "Sync problem"140    SyncIndicator.IDLE -> "Not syncing"141}142143/** Colour alone is never the signal — this is the same claim in words. */144fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =145    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"146147/**148 * One plain-English line for a sync outcome, word for word the browser's copy.149 * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset150 * it is the whole diagnostic, and there is no devtools to fall back on.151 */152fun describeSyncOutcome(summary: SyncOutcomeSummary): String {153    val reason = summary.reason ?: return "Not synced yet"154    val base = when (reason) {155        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"156        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"157        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"158        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"159        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"160        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"161        SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"162        SyncOutcomeReason.FOREGROUND_START_BLOCKED ->163            "Android blocked Peach from starting sync in the background — open Peach and sync again"164        SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"165        SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"166        SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"167        SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"168        SyncOutcomeReason.CONNECTION_LOST -> "The connection dropped mid-sync — will retry"169        SyncOutcomeReason.RUN_FAILED -> "Sync failed"170    }171    if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {172        val moved = listOfNotNull(173            "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },174            "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },175        )176        return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"177    }178    return summary.detail?.let { "$base ($it)" } ?: base179}180181/**182 * Session errors reach us as opaque strings by contract ([SyncOutcome.error]183 * and any throw out of the relay socket). Classifying them in one place is what184 * lets the copy above key off something stable.185 */186internal fun classifySyncError(error: String): SyncOutcomeReason = when {187    error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED188    error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH189    error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT190    // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the191    // relay was fine and the peer was not who we expected.192    Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")193        .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED194    // A hang-up after a successful dial is a connection-lost, not a relay195    // problem — checked before the broad relay/websocket regex below so it196    // doesn't get swallowed into RELAY_UNREACHABLE. The browser does the same197    // narrow check.198    isConnectionClosedError(error) -> SyncOutcomeReason.CONNECTION_LOST199    Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)200        .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE201    else -> SyncOutcomeReason.RUN_FAILED202}203204/**205 * A transport that hung up, as opposed to a protocol or vault problem.206 * Mirrors `isConnectionClosedError` in the browser's sync-outcome.ts; the two207 * must stay in lockstep so a close on either side maps to the same outcome.208 */209internal fun isConnectionClosedError(error: String): Boolean =210    Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE)211        .containsMatchIn(error)212213/**214 * Translate one finished session ([SyncCoordinator]'s per-round result) into the215 * user-facing vocabulary. Tombstones count as changes: a deletion that crossed216 * the wire is not "nothing to send or receive".217 */218internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {219    SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(220        reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {221            SyncOutcomeReason.COMPLETED_CHANGES222        } else {223            SyncOutcomeReason.COMPLETED_NO_CHANGES224        },225        entriesSent = entriesSent,226        entriesReceived = entriesReceived,227    )228229    SyncOutcomeStatus.VAULT_MISMATCH ->230        SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)231232    SyncOutcomeStatus.TIMEOUT ->233        SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)234235    SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(236        reason = classifySyncError(error ?: "sync_failed"),237        detail = error,238    )239}