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 SYNC_TIMEOUT,46 CONNECTION_LOST,47 RUN_FAILED,48 ;4950 51 fun wire(): String = name.lowercase()5253 54 val isFailure: Boolean55 get() = when (this) {56 NO_ELIGIBLE_HOST,57 PRIVATE_RELAY_UNSET,58 VAULT_LOCKED,59 FOREGROUND_START_BLOCKED,60 VAULT_ID_MISMATCH,61 RUN_FAILED,62 -> true6364 COMPLETED_CHANGES,65 COMPLETED_NO_CHANGES,66 NO_PAIRED_DEVICES,67 SYNC_DISABLED,68 RELAY_UNREACHABLE,69 HANDSHAKE_FAILED,70 SYNC_TIMEOUT,71 CONNECTION_LOST,72 -> false73 }7475 7677787980 val isWarning: Boolean81 get() = when (this) {82 RELAY_UNREACHABLE,83 HANDSHAKE_FAILED,84 SYNC_TIMEOUT,85 CONNECTION_LOST,86 -> true8788 else -> false89 }90}91929394959697@Serializable98data class SyncOutcomeSummary(99 val reason: SyncOutcomeReason? = null,100 101 val detail: String? = null,102 val entriesSent: Int = 0,103 val entriesReceived: Int = 0,104 105 val finishedAt: Long? = null,106 107 val syncing: Boolean = false,108)109110111112113114115116117enum class SyncIndicator { BUSY, OK, WARN, ERROR, IDLE }118119fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {120 if (summary.syncing) return SyncIndicator.BUSY121 val reason = summary.reason122 if (reason != null && reason.isWarning) return SyncIndicator.WARN123 if (reason != null && reason.isFailure) return SyncIndicator.ERROR124 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||125 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES126 ) {127 return SyncIndicator.OK128 }129 130 131 if (pairedCount > 0 && reason == null) return SyncIndicator.OK132 return SyncIndicator.IDLE133}134135private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {136 SyncIndicator.BUSY -> "Syncing"137 SyncIndicator.OK -> "Sync healthy"138 SyncIndicator.WARN -> "Last sync attempt missed — will retry"139 SyncIndicator.ERROR -> "Sync problem"140 SyncIndicator.IDLE -> "Not syncing"141}142143144fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =145 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"146147148149150151152fun describeSyncOutcome(summary: SyncOutcomeSummary): String {153 val reason = summary.reason ?: return "Not synced yet"154 val base = when (reason) {155 SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"156 SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"157 SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"158 SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"159 SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"160 SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"161 SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"162 SyncOutcomeReason.FOREGROUND_START_BLOCKED ->163 "Android blocked Peach from starting sync in the background — open Peach and sync again"164 SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"165 SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"166 SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"167 SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"168 SyncOutcomeReason.CONNECTION_LOST -> "The connection dropped mid-sync — will retry"169 SyncOutcomeReason.RUN_FAILED -> "Sync failed"170 }171 if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {172 val moved = listOfNotNull(173 "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },174 "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },175 )176 return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"177 }178 return summary.detail?.let { "$base ($it)" } ?: base179}180181182183184185186internal fun classifySyncError(error: String): SyncOutcomeReason = when {187 error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED188 error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH189 error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT190 191 192 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")193 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED194 195 196 197 198 isConnectionClosedError(error) -> SyncOutcomeReason.CONNECTION_LOST199 Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)200 .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE201 else -> SyncOutcomeReason.RUN_FAILED202}203204205206207208209internal fun isConnectionClosedError(error: String): Boolean =210 Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE)211 .containsMatchIn(error)212213214215216217218internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {219 SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(220 reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {221 SyncOutcomeReason.COMPLETED_CHANGES222 } else {223 SyncOutcomeReason.COMPLETED_NO_CHANGES224 },225 entriesSent = entriesSent,226 entriesReceived = entriesReceived,227 )228229 SyncOutcomeStatus.VAULT_MISMATCH ->230 SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)231232 SyncOutcomeStatus.TIMEOUT ->233 SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)234235 SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(236 reason = classifySyncError(error ?: "sync_failed"),237 detail = error,238 )239}