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 RUN_FAILED,47 ;4849 50 fun wire(): String = name.lowercase()5152 53 val isFailure: Boolean54 get() = when (this) {55 NO_ELIGIBLE_HOST,56 PRIVATE_RELAY_UNSET,57 VAULT_LOCKED,58 FOREGROUND_START_BLOCKED,59 RELAY_UNREACHABLE,60 HANDSHAKE_FAILED,61 VAULT_ID_MISMATCH,62 SYNC_TIMEOUT,63 RUN_FAILED,64 -> true6566 COMPLETED_CHANGES,67 COMPLETED_NO_CHANGES,68 NO_PAIRED_DEVICES,69 SYNC_DISABLED,70 -> false71 }72}73747576777879@Serializable80data class SyncOutcomeSummary(81 val reason: SyncOutcomeReason? = null,82 83 val detail: String? = null,84 val entriesSent: Int = 0,85 val entriesReceived: Int = 0,86 87 val finishedAt: Long? = null,88 89 val syncing: Boolean = false,90)91929394959697enum class SyncIndicator { BUSY, OK, ERROR, IDLE }9899fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {100 if (summary.syncing) return SyncIndicator.BUSY101 val reason = summary.reason102 if (reason != null && reason.isFailure) return SyncIndicator.ERROR103 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||104 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES105 ) {106 return SyncIndicator.OK107 }108 109 110 if (pairedCount > 0 && reason == null) return SyncIndicator.OK111 return SyncIndicator.IDLE112}113114private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {115 SyncIndicator.BUSY -> "Syncing"116 SyncIndicator.OK -> "Sync healthy"117 SyncIndicator.ERROR -> "Sync problem"118 SyncIndicator.IDLE -> "Not syncing"119}120121122fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =123 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"124125126127128129130fun describeSyncOutcome(summary: SyncOutcomeSummary): String {131 val reason = summary.reason ?: return "Not synced yet"132 val base = when (reason) {133 SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"134 SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"135 SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"136 SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"137 SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"138 SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"139 SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"140 SyncOutcomeReason.FOREGROUND_START_BLOCKED ->141 "Android blocked Peach from starting sync in the background — open Peach and sync again"142 SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"143 SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"144 SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"145 SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"146 SyncOutcomeReason.RUN_FAILED -> "Sync failed"147 }148 if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {149 val moved = listOfNotNull(150 "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },151 "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },152 )153 return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"154 }155 return summary.detail?.let { "$base ($it)" } ?: base156}157158159160161162163internal fun classifySyncError(error: String): SyncOutcomeReason = when {164 error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED165 error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH166 error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT167 168 169 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")170 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED171 Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)172 .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE173 else -> SyncOutcomeReason.RUN_FAILED174}175176177178179180181internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {182 SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(183 reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {184 SyncOutcomeReason.COMPLETED_CHANGES185 } else {186 SyncOutcomeReason.COMPLETED_NO_CHANGES187 },188 entriesSent = entriesSent,189 entriesReceived = entriesReceived,190 )191192 SyncOutcomeStatus.VAULT_MISMATCH ->193 SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)194195 SyncOutcomeStatus.TIMEOUT ->196 SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)197198 SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(199 reason = classifySyncError(error ?: "sync_failed"),200 detail = error,201 )202}