1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultEntry5import kotlinx.serialization.Serializable67private const val MAX_SYNC_ALERTS = 20089@Serializable10enum class SyncAlertType {11 REMOTE_PASSWORD_CHANGE,12 REMOTE_DELETE,13 DEVICE_KEY_ROTATED,14}1516@Serializable17data class SyncAlert(18 val type: SyncAlertType,19 val entryId: String? = null,20 val entryTitle: String? = null,21 val deviceName: String? = null,22 val timestamp: Long,23)2425interface SyncAlertRecorder {26 fun record(alerts: List<SyncAlert>)27}2829internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =30 (existing + incoming).takeLast(MAX_SYNC_ALERTS)3132internal fun remoteEntrySyncAlerts(33 localEntries: List<VaultEntry>,34 remoteEntries: List<VaultEntry>,35 deviceName: String,36 nowMs: Long,37): List<SyncAlert> {38 val localById = localEntries.associateBy { it.id }39 return remoteEntries.mapNotNull { remote ->40 val local = localById[remote.id] ?: return@mapNotNull null41 if (remote.modifiedAt <= local.modifiedAt || remote.deletedAt != null) return@mapNotNull null42 if (remote.password == local.password) return@mapNotNull null43 SyncAlert(44 type = SyncAlertType.REMOTE_PASSWORD_CHANGE,45 entryId = remote.id,46 entryTitle = remote.title.ifBlank { local.title },47 deviceName = deviceName,48 timestamp = nowMs,49 )50 }51}5253internal fun remoteTombstoneSyncAlerts(54 localEntries: List<VaultEntry>,55 remoteTombstones: List<Tombstone>,56 deviceName: String,57 nowMs: Long,58): List<SyncAlert> {59 val localById = localEntries.associateBy { it.id }60 return remoteTombstones.mapNotNull { tombstone ->61 val local = localById[tombstone.id] ?: return@mapNotNull null62 if (tombstone.deletedAt < local.modifiedAt) return@mapNotNull null63 SyncAlert(64 type = SyncAlertType.REMOTE_DELETE,65 entryId = local.id,66 entryTitle = local.title,67 deviceName = deviceName,68 timestamp = nowMs,69 )70 }71}7273internal fun deviceKeyRotationAlert(74 existing: PairedDevice,75 incoming: ManifestDeviceEntry,76 nowMs: Long,77): SyncAlert? {78 val nextLongTerm = incoming.longTermPublicKeyHex.lowercase()79 val nextSigning = incoming.signingPublicKeyHex.lowercase()80 val longTermChanged = existing.longTermPublicKeyHex?.lowercase()?.let { it != nextLongTerm } == true81 val signingChanged = existing.signingPublicKeyHex?.lowercase()?.let { it != nextSigning } == true82 if (!longTermChanged && !signingChanged) return null83 return SyncAlert(84 type = SyncAlertType.DEVICE_KEY_ROTATED,85 deviceName = incoming.deviceName.ifBlank { existing.deviceName ?: "paired device" },86 timestamp = nowMs,87 )88}8990fun syncAlertMessage(alert: SyncAlert): String {91 val device = alert.deviceName?.takeIf { it.isNotBlank() } ?: "paired device"92 val title = alert.entryTitle?.takeIf { it.isNotBlank() } ?: "an entry"93 return when (alert.type) {94 SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"95 SyncAlertType.REMOTE_DELETE -> "$device deleted $title"96 SyncAlertType.DEVICE_KEY_ROTATED ->97 "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"98 }99}