1package com.peachpasswords.sync23import kotlinx.serialization.Serializable456789101112131415161718192021222324252627282930313233enum 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 55 fun wire(): String = name.lowercase()5657 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 8485868788 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}100101102103104105106@Serializable107data class SyncOutcomeSummary(108 val reason: SyncOutcomeReason? = null,109 110 val detail: String? = null,111 val entriesSent: Int = 0,112 val entriesReceived: Int = 0,113 114 val finishedAt: Long? = null,115 116 val syncing: Boolean = false,117)118119120121122123124125126enum 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 139 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}151152153fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =154 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"155156157158159160161fun 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}196197198199200201202internal 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 209 210 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")211 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED212 213 214 215 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}221222223224225226227internal fun isConnectionClosedError(error: String): Boolean =228 Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE)229 .containsMatchIn(error)230231232233234235236internal 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}