SyncOutcomeReason.kt

11.1 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    PEER_PROTOCOL_OUTDATED,46    RESET_REQUIRED,47    SYNC_GENERATION_UNKNOWN,48    SYNC_TIMEOUT,49    CONNECTION_LOST,50    TRUST_DATA_CORRUPTED,51    RUN_FAILED,52    ;5354    /** The browser's snake_case spelling, for the screens that carry a code. */55    fun wire(): String = name.lowercase()5657    /** Outcomes where sync did not do what was asked, so the light goes red. */58    val isFailure: Boolean59        get() = when (this) {60            NO_ELIGIBLE_HOST,61            PRIVATE_RELAY_UNSET,62            FOREGROUND_START_BLOCKED,63            VAULT_ID_MISMATCH,64            PEER_PROTOCOL_OUTDATED,65            RESET_REQUIRED,66            SYNC_GENERATION_UNKNOWN,67            TRUST_DATA_CORRUPTED,68            RUN_FAILED,69            -> true7071            COMPLETED_CHANGES,72            COMPLETED_NO_CHANGES,73            NO_PAIRED_DEVICES,74            SYNC_DISABLED,75            VAULT_LOCKED,76            RELAY_UNREACHABLE,77            HANDSHAKE_FAILED,78            SYNC_TIMEOUT,79            CONNECTION_LOST,80            -> false81        }8283    /**84     * Network-class outcomes — the dial did not reach the peer this attempt.85     * The background reconnect loop keeps trying, so the light goes amber86     * rather than red. Mirrors the browser's WARN tier.87     */88    val isWarning: Boolean89        get() = when (this) {90            RELAY_UNREACHABLE,91            HANDSHAKE_FAILED,92            SYNC_TIMEOUT,93            CONNECTION_LOST,94            VAULT_LOCKED,95            -> true9697            else -> false98        }99}100101/**102 * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so103 * a failure that happened before the process was killed is still explicable —104 * a reason that vanishes is useless for diagnosing real hardware.105 */106@Serializable107data class SyncOutcomeSummary(108    val reason: SyncOutcomeReason? = null,109    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */110    val detail: String? = null,111    val entriesSent: Int = 0,112    val entriesReceived: Int = 0,113    /** When the attempt ended, success or not. */114    val finishedAt: Long? = null,115    /** In flight right now. Transient by definition, so never persisted. */116    val syncing: Boolean = false,117)118119/**120 * The one indicator light. [BUSY] pulses green, [OK] is solid green, [WARN] is121 * amber (a transient network miss — the background loop retries), [ERROR] is122 * red (the app is actually broken), [IDLE] is unlit. Nothing that is actually123 * broken may resolve to [OK] — a paired-but-unsyncable device is red, not124 * green.125 */126enum class SyncIndicator { BUSY, OK, WARN, ERROR, IDLE }127128fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {129    if (summary.syncing) return SyncIndicator.BUSY130    val reason = summary.reason131    if (reason != null && reason.isWarning) return SyncIndicator.WARN132    if (reason != null && reason.isFailure) return SyncIndicator.ERROR133    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||134        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES135    ) {136        return SyncIndicator.OK137    }138    // Paired with a healthy history but nothing attempted since launch: still a139    // successful pair, which is what the light is claiming.140    if (pairedCount > 0 && reason == null) return SyncIndicator.OK141    return SyncIndicator.IDLE142}143144private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {145    SyncIndicator.BUSY -> "Syncing"146    SyncIndicator.OK -> "Sync healthy"147    SyncIndicator.WARN -> "Last sync attempt missed — will retry"148    SyncIndicator.ERROR -> "Sync problem"149    SyncIndicator.IDLE -> "Not syncing"150}151152/** Colour alone is never the signal — this is the same claim in words. */153fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =154    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"155156/**157 * One plain-English line for a sync outcome, word for word the browser's copy.158 * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset159 * it is the whole diagnostic, and there is no devtools to fall back on.160 */161fun describeSyncOutcome(summary: SyncOutcomeSummary): String {162    val reason = summary.reason ?: return "Not synced yet"163    val base = when (reason) {164        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"165        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"166        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"167        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"168        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"169        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"170        SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"171        SyncOutcomeReason.FOREGROUND_START_BLOCKED ->172            "Android blocked Peach from starting sync in the background — open Peach and sync again"173        SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"174        SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"175        SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"176        SyncOutcomeReason.PEER_PROTOCOL_OUTDATED ->177            "Update Peach on both devices, then re-pair them"178        SyncOutcomeReason.RESET_REQUIRED ->179            "Sync is reset on this device — update Peach everywhere, then re-pair each device"180        SyncOutcomeReason.SYNC_GENERATION_UNKNOWN ->181            "This device's sync generation is unreadable — sync is stopped"182        SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"183        SyncOutcomeReason.CONNECTION_LOST -> "The connection dropped mid-sync — will retry"184        SyncOutcomeReason.TRUST_DATA_CORRUPTED -> "Device trust data is damaged — re-pair your devices after updating"185        SyncOutcomeReason.RUN_FAILED -> "Sync failed"186    }187    if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {188        val moved = listOfNotNull(189            "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },190            "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },191        )192        return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"193    }194    return summary.detail?.let { "$base ($it)" } ?: base195}196197/**198 * Session errors reach us as opaque strings by contract ([SyncOutcome.error]199 * and any throw out of the relay socket). Classifying them in one place is what200 * lets the copy above key off something stable.201 */202internal fun classifySyncError(error: String): SyncOutcomeReason = when {203    error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED204    error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH205    error.contains("trust_data_corrupted") -> SyncOutcomeReason.TRUST_DATA_CORRUPTED206    error.contains("PEER_PROTOCOL_OUTDATED") -> SyncOutcomeReason.PEER_PROTOCOL_OUTDATED207    error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT208    // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the209    // relay was fine and the peer was not who we expected.210    Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")211        .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED212    // A hang-up after a successful dial is a connection-lost, not a relay213    // problem — checked before the broad relay/websocket regex below so it214    // doesn't get swallowed into RELAY_UNREACHABLE. The browser does the same215    // narrow check.216    isConnectionClosedError(error) -> SyncOutcomeReason.CONNECTION_LOST217    Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)218        .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE219    else -> SyncOutcomeReason.RUN_FAILED220}221222/**223 * A transport that hung up, as opposed to a protocol or vault problem.224 * Mirrors `isConnectionClosedError` in the browser's sync-outcome.ts; the two225 * must stay in lockstep so a close on either side maps to the same outcome.226 */227internal fun isConnectionClosedError(error: String): Boolean =228    Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE)229        .containsMatchIn(error)230231/**232 * Translate one finished session ([SyncCoordinator]'s per-round result) into the233 * user-facing vocabulary. Tombstones count as changes: a deletion that crossed234 * the wire is not "nothing to send or receive".235 */236internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {237    SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(238        reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {239            SyncOutcomeReason.COMPLETED_CHANGES240        } else {241            SyncOutcomeReason.COMPLETED_NO_CHANGES242        },243        entriesSent = entriesSent,244        entriesReceived = entriesReceived,245    )246247    SyncOutcomeStatus.VAULT_MISMATCH ->248        SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)249250    SyncOutcomeStatus.TIMEOUT ->251        SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)252253    SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(254        reason = classifySyncError(error ?: "sync_failed"),255        detail = error,256    )257}