1package com.peachpasswords.sync23import kotlinx.serialization.Serializable4567891011121314151617181920212223242526272829303132333435enum class SyncOutcomeReason {36 COMPLETED_CHANGES,37 COMPLETED_NO_CHANGES,38 NO_PAIRED_DEVICES,39 NO_ELIGIBLE_HOST,40 SYNC_DISABLED,41 VAULT_LOCKED,42 RELAY_UNREACHABLE,43 HANDSHAKE_FAILED,44 VAULT_ID_MISMATCH,45 SYNC_TIMEOUT,46 RUN_FAILED,47 ;4849 50 val isFailure: Boolean51 get() = when (this) {52 NO_ELIGIBLE_HOST,53 VAULT_LOCKED,54 RELAY_UNREACHABLE,55 HANDSHAKE_FAILED,56 VAULT_ID_MISMATCH,57 SYNC_TIMEOUT,58 RUN_FAILED,59 -> true6061 COMPLETED_CHANGES,62 COMPLETED_NO_CHANGES,63 NO_PAIRED_DEVICES,64 SYNC_DISABLED,65 -> false66 }67}68697071727374@Serializable75data class SyncOutcomeSummary(76 val reason: SyncOutcomeReason? = null,77 78 val detail: String? = null,79 val entriesSent: Int = 0,80 val entriesReceived: Int = 0,81 82 val finishedAt: Long? = null,83 84 val syncing: Boolean = false,85)86878889909192enum class SyncIndicator { BUSY, OK, ERROR, IDLE }9394fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {95 if (summary.syncing) return SyncIndicator.BUSY96 val reason = summary.reason97 if (reason != null && reason.isFailure) return SyncIndicator.ERROR98 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||99 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES100 ) {101 return SyncIndicator.OK102 }103 104 105 if (pairedCount > 0 && reason == null) return SyncIndicator.OK106 return SyncIndicator.IDLE107}108109private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {110 SyncIndicator.BUSY -> "Syncing"111 SyncIndicator.OK -> "Sync healthy"112 SyncIndicator.ERROR -> "Sync problem"113 SyncIndicator.IDLE -> "Not syncing"114}115116117fun syncIndicatorLabel(indicator: SyncIndicator, summary: SyncOutcomeSummary): String =118 "${indicatorHeadline(indicator)} — ${describeSyncOutcome(summary)}"119120121122123124125fun describeSyncOutcome(summary: SyncOutcomeSummary): String {126 val reason = summary.reason ?: return "Not synced yet"127 val base = when (reason) {128 SyncOutcomeReason.COMPLETED_CHANGES -> "Synced"129 SyncOutcomeReason.COMPLETED_NO_CHANGES -> "Up to date — nothing to send or receive"130 SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"131 SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"132 SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"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}