1package com.peachpasswords.sync23import kotlinx.serialization.Serializable45678910111213141516171819202122232425262728enum class SyncOutcomeReason {29 COMPLETED_CHANGES,30 COMPLETED_NO_CHANGES,31 NO_PAIRED_DEVICES,32 NO_ELIGIBLE_HOST,33 SYNC_DISABLED,34 PRIVATE_RELAY_UNSET,35 VAULT_LOCKED,36 RELAY_UNREACHABLE,37 HANDSHAKE_FAILED,38 VAULT_ID_MISMATCH,39 SYNC_TIMEOUT,40 RUN_FAILED,41 ;4243 44 val isFailure: Boolean45 get() = when (this) {46 NO_ELIGIBLE_HOST,47 PRIVATE_RELAY_UNSET,48 VAULT_LOCKED,49 RELAY_UNREACHABLE,50 HANDSHAKE_FAILED,51 VAULT_ID_MISMATCH,52 SYNC_TIMEOUT,53 RUN_FAILED,54 -> true5556 COMPLETED_CHANGES,57 COMPLETED_NO_CHANGES,58 NO_PAIRED_DEVICES,59 SYNC_DISABLED,60 -> false61 }62}63646566676869@Serializable70data class SyncOutcomeSummary(71 val reason: SyncOutcomeReason? = null,72 73 val detail: String? = null,74 val entriesSent: Int = 0,75 val entriesReceived: Int = 0,76 77 val finishedAt: Long? = null,78 79 val syncing: Boolean = false,80)81828384858687enum class SyncIndicator { BUSY, OK, ERROR, IDLE }8889fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {90 if (summary.syncing) return SyncIndicator.BUSY91 val reason = summary.reason92 if (reason != null && reason.isFailure) return SyncIndicator.ERROR93 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||94 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES95 ) {96 return SyncIndicator.OK97 }98 99 100 if (pairedCount > 0 && reason == null) return SyncIndicator.OK101 return SyncIndicator.IDLE102}103104private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {105 SyncIndicator.BUSY -> "Syncing"106 SyncIndicator.OK -> "Sync healthy"107 SyncIndicator.ERROR -> "Sync problem"108 SyncIndicator.IDLE -> "Not syncing"109}110111112fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =113 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"114115116117118119120fun describeSyncOutcome(summary: SyncOutcomeSummary): String {121 val reason = summary.reason ?: return "Not synced yet"122 val base = when (reason) {123 SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"124 SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"125 SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"126 SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"127 SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"128 SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"129 SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"130 SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"131 SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"132 SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"133 SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"134 SyncOutcomeReason.RUN_FAILED -> "Sync failed"135 }136 if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {137 val moved = listOfNotNull(138 "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },139 "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },140 )141 return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"142 }143 return summary.detail?.let { "$base ($it)" } ?: base144}145146147148149150151internal fun classifySyncError(error: String): SyncOutcomeReason = when {152 error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED153 error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH154 error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT155 156 157 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")158 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED159 Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)160 .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE161 else -> SyncOutcomeReason.RUN_FAILED162}