SyncOutcomeReason.kt

7.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 * Two browser outcomes are deliberately absent:18 *  - `runner_unavailable` — the browser injects its per-device sync runner and19 *    can find it missing. Android's runner is [PeachSyncService], which is20 *    always present; the only way it fails to run is a locked vault, which is21 *    [VAULT_LOCKED].22 *  - `no_eligible_host` means something different here and is kept for that23 *    meaning: the browser rejects peers it cannot dial, whereas this phone24 *    usually *waits* and can talk to any peer type. What it cannot talk to is a25 *    peer with no rendezvous secret, which the reconnect loop skips in silence.26 *    See [weDialPeer] for why the roles differ.27 */28enum class SyncOutcomeReason {29    COMPLETED_CHANGES,30    COMPLETED_NO_CHANGES,31    NO_PAIRED_DEVICES,32    NO_ELIGIBLE_HOST,33    SYNC_DISABLED,34    PRIVATE_RELAY_UNSET,35    VAULT_LOCKED,36    RELAY_UNREACHABLE,37    HANDSHAKE_FAILED,38    VAULT_ID_MISMATCH,39    SYNC_TIMEOUT,40    RUN_FAILED,41    ;4243    /** Outcomes where sync did not do what was asked, so the light goes red. */44    val isFailure: Boolean45        get() = when (this) {46            NO_ELIGIBLE_HOST,47            PRIVATE_RELAY_UNSET,48            VAULT_LOCKED,49            RELAY_UNREACHABLE,50            HANDSHAKE_FAILED,51            VAULT_ID_MISMATCH,52            SYNC_TIMEOUT,53            RUN_FAILED,54            -> true5556            COMPLETED_CHANGES,57            COMPLETED_NO_CHANGES,58            NO_PAIRED_DEVICES,59            SYNC_DISABLED,60            -> false61        }62}6364/**65 * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so66 * a failure that happened before the process was killed is still explicable —67 * a reason that vanishes is useless for diagnosing real hardware.68 */69@Serializable70data class SyncOutcomeSummary(71    val reason: SyncOutcomeReason? = null,72    /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */73    val detail: String? = null,74    val entriesSent: Int = 0,75    val entriesReceived: Int = 0,76    /** When the attempt ended, success or not. */77    val finishedAt: Long? = null,78    /** In flight right now. Transient by definition, so never persisted. */79    val syncing: Boolean = false,80)8182/**83 * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is84 * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a85 * paired-but-unsyncable device is red, not green.86 */87enum class SyncIndicator { BUSY, OK, ERROR, IDLE }8889fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {90    if (summary.syncing) return SyncIndicator.BUSY91    val reason = summary.reason92    if (reason != null && reason.isFailure) return SyncIndicator.ERROR93    if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||94        reason == SyncOutcomeReason.COMPLETED_NO_CHANGES95    ) {96        return SyncIndicator.OK97    }98    // Paired with a healthy history but nothing attempted since launch: still a99    // successful pair, which is what the light is claiming.100    if (pairedCount > 0 && reason == null) return SyncIndicator.OK101    return SyncIndicator.IDLE102}103104private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {105    SyncIndicator.BUSY -> "Syncing"106    SyncIndicator.OK -> "Sync healthy"107    SyncIndicator.ERROR -> "Sync problem"108    SyncIndicator.IDLE -> "Not syncing"109}110111/** Colour alone is never the signal — this is the same claim in words. */112fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =113    "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"114115/**116 * One plain-English line for a sync outcome, word for word the browser's copy.117 * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset118 * it is the whole diagnostic, and there is no devtools to fall back on.119 */120fun describeSyncOutcome(summary: SyncOutcomeSummary): String {121    val reason = summary.reason ?: return "Not synced yet"122    val base = when (reason) {123        SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"124        SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"125        SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"126        SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"127        SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"128        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"129        SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"130        SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"131        SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"132        SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"133        SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"134        SyncOutcomeReason.RUN_FAILED -> "Sync failed"135    }136    if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {137        val moved = listOfNotNull(138            "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },139            "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },140        )141        return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"142    }143    return summary.detail?.let { "$base ($it)" } ?: base144}145146/**147 * Session errors reach us as opaque strings by contract ([SyncOutcome.error]148 * and any throw out of the relay socket). Classifying them in one place is what149 * lets the copy above key off something stable.150 */151internal fun classifySyncError(error: String): SyncOutcomeReason = when {152    error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED153    error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH154    error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT155    // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the156    // relay was fine and the peer was not who we expected.157    Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")158        .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED159    Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)160        .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE161    else -> SyncOutcomeReason.RUN_FAILED162}