1package com.peachpasswords.sync23import kotlinx.serialization.Serializable4567891011121314151617181920212223242526272829enum class SyncOutcomeReason {30 COMPLETED_CHANGES,31 COMPLETED_NO_CHANGES,32 NO_PAIRED_DEVICES,33 NO_ELIGIBLE_HOST,34 SYNC_DISABLED,35 PRIVATE_RELAY_UNSET,36 VAULT_LOCKED,37 RELAY_UNREACHABLE,38 HANDSHAKE_FAILED,39 VAULT_ID_MISMATCH,40 SYNC_TIMEOUT,41 RUN_FAILED,42 ;4344 45 fun wire(): String = name.lowercase()4647 48 val isFailure: Boolean49 get() = when (this) {50 NO_ELIGIBLE_HOST,51 PRIVATE_RELAY_UNSET,52 VAULT_LOCKED,53 RELAY_UNREACHABLE,54 HANDSHAKE_FAILED,55 VAULT_ID_MISMATCH,56 SYNC_TIMEOUT,57 RUN_FAILED,58 -> true5960 COMPLETED_CHANGES,61 COMPLETED_NO_CHANGES,62 NO_PAIRED_DEVICES,63 SYNC_DISABLED,64 -> false65 }66}67686970717273@Serializable74data class SyncOutcomeSummary(75 val reason: SyncOutcomeReason? = null,76 77 val detail: String? = null,78 val entriesSent: Int = 0,79 val entriesReceived: Int = 0,80 81 val finishedAt: Long? = null,82 83 val syncing: Boolean = false,84)85868788899091enum class SyncIndicator { BUSY, OK, ERROR, IDLE }9293fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {94 if (summary.syncing) return SyncIndicator.BUSY95 val reason = summary.reason96 if (reason != null && reason.isFailure) return SyncIndicator.ERROR97 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||98 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES99 ) {100 return SyncIndicator.OK101 }102 103 104 if (pairedCount > 0 && reason == null) return SyncIndicator.OK105 return SyncIndicator.IDLE106}107108private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {109 SyncIndicator.BUSY -> "Syncing"110 SyncIndicator.OK -> "Sync healthy"111 SyncIndicator.ERROR -> "Sync problem"112 SyncIndicator.IDLE -> "Not syncing"113}114115116fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =117 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"118119120121122123124fun describeSyncOutcome(summary: SyncOutcomeSummary): String {125 val reason = summary.reason ?: return "Not synced yet"126 val base = when (reason) {127 SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"128 SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"129 SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"130 SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"131 SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"132 SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"133 SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"134 SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"135 SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"136 SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"137 SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"138 SyncOutcomeReason.RUN_FAILED -> "Sync failed"139 }140 if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {141 val moved = listOfNotNull(142 "${summary.entriesSent} sent".takeIf { summary.entriesSent > 0 },143 "${summary.entriesReceived} received".takeIf { summary.entriesReceived > 0 },144 )145 return if (moved.isEmpty()) base else "$base — ${moved.joinToString(", ")}"146 }147 return summary.detail?.let { "$base ($it)" } ?: base148}149150151152153154155internal fun classifySyncError(error: String): SyncOutcomeReason = when {156 error.contains("VAULT_LOCKED") -> SyncOutcomeReason.VAULT_LOCKED157 error.contains("vault_id_mismatch") -> SyncOutcomeReason.VAULT_ID_MISMATCH158 error.contains("timed out") || error.contains("sync_timeout") -> SyncOutcomeReason.SYNC_TIMEOUT159 160 161 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")162 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED163 Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)164 .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE165 else -> SyncOutcomeReason.RUN_FAILED166}167168169170171172173internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {174 SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(175 reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {176 SyncOutcomeReason.COMPLETED_CHANGES177 } else {178 SyncOutcomeReason.COMPLETED_NO_CHANGES179 },180 entriesSent = entriesSent,181 entriesReceived = entriesReceived,182 )183184 SyncOutcomeStatus.VAULT_MISMATCH ->185 SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)186187 SyncOutcomeStatus.TIMEOUT ->188 SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)189190 SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(191 reason = classifySyncError(error ?: "sync_failed"),192 detail = error,193 )194}