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 FOREGROUND_START_BLOCKED,59 VAULT_ID_MISMATCH,60 RUN_FAILED,61 -> true6263 COMPLETED_CHANGES,64 COMPLETED_NO_CHANGES,65 NO_PAIRED_DEVICES,66 SYNC_DISABLED,67 VAULT_LOCKED,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 VAULT_LOCKED,87 -> true8889 else -> false90 }91}92939495969798@Serializable99data class SyncOutcomeSummary(100 val reason: SyncOutcomeReason? = null,101 102 val detail: String? = null,103 val entriesSent: Int = 0,104 val entriesReceived: Int = 0,105 106 val finishedAt: Long? = null,107 108 val syncing: Boolean = false,109)110111112113114115116117118enum class SyncIndicator { BUSY, OK, WARN, ERROR, IDLE }119120fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {121 if (summary.syncing) return SyncIndicator.BUSY122 val reason = summary.reason123 if (reason != null && reason.isWarning) return SyncIndicator.WARN124 if (reason != null && reason.isFailure) return SyncIndicator.ERROR125 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||126 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES127 ) {128 return SyncIndicator.OK129 }130 131 132 if (pairedCount > 0 && reason == null) return SyncIndicator.OK133 return SyncIndicator.IDLE134}135136private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {137 SyncIndicator.BUSY -> "Syncing"138 SyncIndicator.OK -> "Sync healthy"139 SyncIndicator.WARN -> "Last sync attempt missed — will retry"140 SyncIndicator.ERROR -> "Sync problem"141 SyncIndicator.IDLE -> "Not syncing"142}143144145fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =146 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"147148149150151152153fun describeSyncOutcome(summary: SyncOutcomeSummary): String {154 val reason = summary.reason ?: return "Not synced yet"155 val base = when (reason) {156 SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"157 SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"158 SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"159 SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"160 SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"161 SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"162 SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"163 SyncOutcomeReason.FOREGROUND_START_BLOCKED ->164 "Android blocked Peach from starting sync in the background — open Peach and sync again"165 SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"166 SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"167 SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"168 SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"169 SyncOutcomeReason.CONNECTION_LOST -> "The connection dropped mid-sync — will retry"170 SyncOutcomeReason.RUN_FAILED -> "Sync failed"171 }172 if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {173 val moved = listOfNotNull(174 "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },175 "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },176 )177 return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"178 }179 return summary.detail?.let { "$base ($it)" } ?: base180}181182183184185186187internal fun classifySyncError(error: String): SyncOutcomeReason = when {188 error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED189 error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH190 error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT191 192 193 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")194 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED195 196 197 198 199 isConnectionClosedError(error) -> SyncOutcomeReason.CONNECTION_LOST200 Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)201 .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE202 else -> SyncOutcomeReason.RUN_FAILED203}204205206207208209210internal fun isConnectionClosedError(error: String): Boolean =211 Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE)212 .containsMatchIn(error)213214215216217218219internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {220 SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(221 reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {222 SyncOutcomeReason.COMPLETED_CHANGES223 } else {224 SyncOutcomeReason.COMPLETED_NO_CHANGES225 },226 entriesSent = entriesSent,227 entriesReceived = entriesReceived,228 )229230 SyncOutcomeStatus.VAULT_MISMATCH ->231 SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)232233 SyncOutcomeStatus.TIMEOUT ->234 SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)235236 SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(237 reason = classifySyncError(error ?: "sync_failed"),238 detail = error,239 )240}