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. * * One browser outcome is deliberately absent, because on Android it names a * state 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]. * * [FOREGROUND_START_BLOCKED] is the other way round — Android-only, with no * browser counterpart, because only Android can refuse to start the runner at * all (background foreground-service restrictions, API 31+). * * `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, PRIVATE_RELAY_UNSET, VAULT_LOCKED, FOREGROUND_START_BLOCKED, RELAY_UNREACHABLE, HANDSHAKE_FAILED, VAULT_ID_MISMATCH, SYNC_TIMEOUT, CONNECTION_LOST, RUN_FAILED, ; /** The browser's snake_case spelling, for the screens that carry a code. */ fun wire(): String = name.lowercase() /** Outcomes where sync did not do what was asked, so the light goes red. */ val isFailure: Boolean get() = when (this) { NO_ELIGIBLE_HOST, PRIVATE_RELAY_UNSET, FOREGROUND_START_BLOCKED, VAULT_ID_MISMATCH, RUN_FAILED, -> true COMPLETED_CHANGES, COMPLETED_NO_CHANGES, NO_PAIRED_DEVICES, SYNC_DISABLED, VAULT_LOCKED, RELAY_UNREACHABLE, HANDSHAKE_FAILED, SYNC_TIMEOUT, CONNECTION_LOST, -> false } /** * Network-class outcomes — the dial did not reach the peer this attempt. * The background reconnect loop keeps trying, so the light goes amber * rather than red. Mirrors the browser's WARN tier. */ val isWarning: Boolean get() = when (this) { RELAY_UNREACHABLE, HANDSHAKE_FAILED, SYNC_TIMEOUT, CONNECTION_LOST, VAULT_LOCKED, -> true else -> 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, [WARN] is * amber (a transient network miss — the background loop retries), [ERROR] is * red (the app is actually broken), [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, WARN, ERROR, IDLE } fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator { if (summary.syncing) return SyncIndicator.BUSY val reason = summary.reason if (reason != null && reason.isWarning) return SyncIndicator.WARN 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.WARN -> "Last sync attempt missed — will retry" 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.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set" SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again" SyncOutcomeReason.FOREGROUND_START_BLOCKED -> "Android blocked Peach from starting sync in the background — open Peach 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.CONNECTION_LOST -> "The connection dropped mid-sync — will retry" 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 // A hang-up after a successful dial is a connection-lost, not a relay // problem — checked before the broad relay/websocket regex below so it // doesn't get swallowed into RELAY_UNREACHABLE. The browser does the same // narrow check. isConnectionClosedError(error) -> SyncOutcomeReason.CONNECTION_LOST Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE) .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE else -> SyncOutcomeReason.RUN_FAILED } /** * A transport that hung up, as opposed to a protocol or vault problem. * Mirrors `isConnectionClosedError` in the browser's sync-outcome.ts; the two * must stay in lockstep so a close on either side maps to the same outcome. */ internal fun isConnectionClosedError(error: String): Boolean = Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE) .containsMatchIn(error) /** * 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, ) }