package com.peachpasswords.sync import kotlinx.serialization.Serializable /** * Why a sync attempt ended, in the same vocabulary the browser uses * (`src/shared/sync-outcome.ts`). June runs both clients side by side, so the * names, the state machine and the user-facing wording are deliberately kept * identical; only the Kotlin spelling differs. * * Before this existed Android had [SyncOutcomeSummary]'s ancestor `SyncStatus`, * whose `LastSynced` was set the instant the sync *service* was asked to start * — a claim of success it had not earned. Every real failure below it was * swallowed by the reconnect loop's `runCatching`, so "synced fine", "the peer * refused us" and "the vault was locked" were one indistinguishable state. * * Two browser outcomes are deliberately absent, because on Android they name * states that cannot occur: * - `runner_unavailable` — the browser injects its per-device sync runner and * can find it missing. Android's runner is [PeachSyncService], which is * always present; the only way it fails to run is a locked vault, which is * [VAULT_LOCKED]. * - `private_relay_unset` — the browser treats "private mode, no address" as * terminal. `RelaySettingsStore.relayUrl()` always falls back to the public * relay instead, so here that configuration syncs rather than failing, and * a private URL equal to the public one is indistinguishable from none. * Reporting it would be a guess. * * `no_eligible_host` is kept but means something narrower than the browser's: * the browser rejects peers it cannot dial, whereas this phone usually *waits* * and can talk to any peer type. What it cannot talk to is a peer with no * rendezvous secret, which the reconnect loop skips in silence. See * [weDialPeer] for why the roles differ. */ enum class SyncOutcomeReason { COMPLETED_CHANGES, COMPLETED_NO_CHANGES, NO_PAIRED_DEVICES, NO_ELIGIBLE_HOST, SYNC_DISABLED, VAULT_LOCKED, RELAY_UNREACHABLE, HANDSHAKE_FAILED, VAULT_ID_MISMATCH, SYNC_TIMEOUT, RUN_FAILED, ; /** Outcomes where sync did not do what was asked, so the light goes red. */ val isFailure: Boolean get() = when (this) { NO_ELIGIBLE_HOST, VAULT_LOCKED, RELAY_UNREACHABLE, HANDSHAKE_FAILED, VAULT_ID_MISMATCH, SYNC_TIMEOUT, RUN_FAILED, -> true COMPLETED_CHANGES, COMPLETED_NO_CHANGES, NO_PAIRED_DEVICES, SYNC_DISABLED, -> false } } /** * The last sync attempt, whatever happened. Persisted by [SyncOutcomeStore] so * a failure that happened before the process was killed is still explicable — * a reason that vanishes is useless for diagnosing real hardware. */ @Serializable data class SyncOutcomeSummary( val reason: SyncOutcomeReason? = null, /** Raw internal code, or the peer inventory behind [SyncOutcomeReason.NO_ELIGIBLE_HOST]. */ val detail: String? = null, val entriesSent: Int = 0, val entriesReceived: Int = 0, /** When the attempt ended, success or not. */ val finishedAt: Long? = null, /** In flight right now. Transient by definition, so never persisted. */ val syncing: Boolean = false, ) /** * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a * paired-but-unsyncable device is red, not green. */ enum class SyncIndicator { BUSY, OK, ERROR, IDLE } fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator { if (summary.syncing) return SyncIndicator.BUSY val reason = summary.reason if (reason != null && reason.isFailure) return SyncIndicator.ERROR if (reason == SyncOutcomeReason.COMPLETED_CHANGES || reason == SyncOutcomeReason.COMPLETED_NO_CHANGES ) { return SyncIndicator.OK } // Paired with a healthy history but nothing attempted since launch: still a // successful pair, which is what the light is claiming. if (pairedCount > 0 && reason == null) return SyncIndicator.OK return SyncIndicator.IDLE } private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) { SyncIndicator.BUSY -> "Syncing" SyncIndicator.OK -> "Sync healthy" SyncIndicator.ERROR -> "Sync problem" SyncIndicator.IDLE -> "Not syncing" } /** Colour alone is never the signal — this is the same claim in words. */ fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String = "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}" /** * One plain-English line for a sync outcome, word for word the browser's copy. * [SyncOutcomeSummary.detail] is appended rather than hidden: on a real handset * it is the whole diagnostic, and there is no devtools to fall back on. */ fun describeSyncOutcome(summary: SyncOutcomeSummary): String { val reason = summary.reason ?: return "Not synced yet" val base = when (reason) { SyncOutcomeReason.COMPLETED_CHANGES -> "Synced" SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive" SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet" SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync" SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off" SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again" SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay" SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer" SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault" SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync" SyncOutcomeReason.RUN_FAILED -> "Sync failed" } if (reason == SyncOutcomeReason.COMPLETED_CHANGES) { val moved = listOfNotNull( "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 }, "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 }, ) return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}" } return summary.detail?.let { "$base ($it)" } ?: base } /** * Session errors reach us as opaque strings by contract ([SyncOutcome.error] * and any throw out of the relay socket). Classifying them in one place is what * lets the copy above key off something stable. */ internal fun classifySyncError(error: String): SyncOutcomeReason = when { error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT // A KK failure, a mismatched PAIR_IDENTITY or a wrong room all mean the // relay was fine and the peer was not who we expected. Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew") .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE) .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE else -> SyncOutcomeReason.RUN_FAILED } /** * Translate one finished session ([SyncCoordinator]'s per-round result) into the * user-facing vocabulary. Tombstones count as changes: a deletion that crossed * the wire is not "nothing to send or receive". */ internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) { SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary( reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) { SyncOutcomeReason.COMPLETED_CHANGES } else { SyncOutcomeReason.COMPLETED_NO_CHANGES }, entriesSent = entriesSent, entriesReceived = entriesReceived, ) SyncOutcomeStatus.VAULT_MISMATCH -> SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error) SyncOutcomeStatus.TIMEOUT -> SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error) SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary( reason = classifySyncError(error ?: "sync_failed"), detail = error, ) }