454b3951f979

feat: Add sync alert banners (v2.3.47)

- Record remote password changes, deletes, and device key rotation alerts during sync - Persist a bounded encrypted alert queue and render dismissible vault-list banners - Cover alert planning and queue bounds with unit tests

time
2026-06-26 14:01 UTC · 4 months ago
machine
koh2
parent
e9158e9ae9dc
8 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+99 −0app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+70 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+33 −2app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+3 −0app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+48 −0app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+8 −0app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+114 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23460020-        versionName "2.3.46"19+        versionCode 23470020+        versionName "2.3.47"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+99 −0
@@ -0,0 +1,99 @@1+package com.peachpasswords.sync2+3+import com.peachpasswords.vault.Tombstone4+import com.peachpasswords.vault.VaultEntry5+import kotlinx.serialization.Serializable6+7+private const val MAX_SYNC_ALERTS = 2008+9+@Serializable10+enum class SyncAlertType {11+    REMOTE_PASSWORD_CHANGE,12+    REMOTE_DELETE,13+    DEVICE_KEY_ROTATED,14+}15+16+@Serializable17+data 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+)24+25+interface SyncAlertRecorder {26+    fun record(alerts: List<SyncAlert>)27+}28+29+internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =30+    (existing + incoming).takeLast(MAX_SYNC_ALERTS)31+32+internal 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+}52+53+internal 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+}72+73+internal 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+}89+90+fun 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+}

view file

app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+70 −0
@@ -0,0 +1,70 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import androidx.security.crypto.EncryptedSharedPreferences5+import androidx.security.crypto.MasterKey6+import dagger.hilt.android.qualifiers.ApplicationContext7+import kotlinx.coroutines.flow.MutableStateFlow8+import kotlinx.coroutines.flow.StateFlow9+import kotlinx.coroutines.flow.asStateFlow10+import kotlinx.serialization.builtins.ListSerializer11+import kotlinx.serialization.json.Json12+import javax.inject.Inject13+import javax.inject.Singleton14+15+@Singleton16+class SyncAlertStore @Inject constructor(17+    @ApplicationContext context: Context,18+) : SyncAlertRecorder {19+    private val masterKey = MasterKey.Builder(context)20+        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)21+        .build()22+23+    private val prefs = EncryptedSharedPreferences.create(24+        context,25+        PREFS_FILE,26+        masterKey,27+        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,28+        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,29+    )30+31+    private val listSerializer = ListSerializer(SyncAlert.serializer())32+    private val _alerts = MutableStateFlow(load())33+    val alerts: StateFlow<List<SyncAlert>> = _alerts.asStateFlow()34+35+    override fun record(alerts: List<SyncAlert>) {36+        if (alerts.isEmpty()) return37+        persist(boundedSyncAlerts(load(), alerts))38+    }39+40+    fun dismiss(alert: SyncAlert) {41+        val current = load().toMutableList()42+        val index = current.indexOf(alert)43+        if (index != -1) {44+            current.removeAt(index)45+            persist(current)46+        }47+    }48+49+    fun clear() {50+        persist(emptyList())51+    }52+53+    private fun load(): List<SyncAlert> {54+        val raw = prefs.getString(KEY_ALERTS, null) ?: return emptyList()55+        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())56+    }57+58+    private fun persist(alerts: List<SyncAlert>) {59+        prefs.edit()60+            .putString(KEY_ALERTS, json.encodeToString(listSerializer, alerts))61+            .apply()62+        _alerts.value = alerts63+    }64+65+    private companion object {66+        const val PREFS_FILE = "peach_sync_alerts"67+        const val KEY_ALERTS = "sync_alerts"68+        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }69+    }70+}

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+33 −2
@@ -33,6 +33,8 @@3333     private val ourDeviceId: String,3434     private val pairedDevices: PairedDeviceRegistry? = null,3535     private val pendingVouched: PendingVouchedRegistry? = null,36+    private val syncAlerts: SyncAlertRecorder? = null,37+    private val remoteDeviceName: String = "paired device",3638     override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,3739 ) : ChirpSyncVaultAdapter {3840 @@ -64,6 +66,7 @@6466     }6567 6668     override suspend fun applyEntries(entries: Map<String, String>) {69+        val before = if (syncAlerts != null) repo.getEntries() else emptyList()6770         val master = requireMasterKey()6871         val decrypted = try {6972             entries.mapNotNull { (id, envelope) ->@@ -75,14 +78,39 @@7578         } finally {7679             master.fill(0)7780         }78-        if (decrypted.isNotEmpty()) repo.applyMergedRemoteEntries(decrypted, emptyList())81+        if (decrypted.isNotEmpty()) {82+            val applied = repo.applyMergedRemoteEntries(decrypted, emptyList())83+            if (applied > 0) {84+                syncAlerts?.record(85+                    remoteEntrySyncAlerts(86+                        localEntries = before,87+                        remoteEntries = decrypted,88+                        deviceName = remoteDeviceName,89+                        nowMs = System.currentTimeMillis(),90+                    ),91+                )92+            }93+        }7994     }8095 8196     override suspend fun applyTombstones(tombstones: Map<String, Long>) {97+        val before = if (syncAlerts != null) repo.getEntries() else emptyList()8298         val cleaned = tombstones8399             .filterValues { it > 0L }84100             .map { (id, ts) -> Tombstone(id, ts) }85-        if (cleaned.isNotEmpty()) repo.applyMergedRemoteEntries(emptyList(), cleaned)101+        if (cleaned.isNotEmpty()) {102+            val applied = repo.applyMergedRemoteEntries(emptyList(), cleaned)103+            if (applied > 0) {104+                syncAlerts?.record(105+                    remoteTombstoneSyncAlerts(106+                        localEntries = before,107+                        remoteTombstones = cleaned,108+                        deviceName = remoteDeviceName,109+                        nowMs = System.currentTimeMillis(),110+                    ),111+                )112+            }113+        }86114     }87115 88116     override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {@@ -125,6 +153,9 @@125153                     ),126154                 )127155             } else {156+                deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->157+                    syncAlerts?.record(listOf(alert))158+                }128159                 registry.upsert(129160                     existing.copy(130161                         deviceName = entry.deviceName,

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+3 −0
@@ -10,6 +10,7 @@1010 import com.peachpasswords.sync.PendingVouchedDeviceStore1111 import com.peachpasswords.sync.PairingDiscoveryClient1212 import com.peachpasswords.sync.SyncEndpoint13+import com.peachpasswords.sync.SyncAlertStore1314 import com.peachpasswords.sync.SyncOutcomeStatus1415 import com.peachpasswords.sync.SyncStatus1516 import com.peachpasswords.sync.VaultSyncAdapter@@ -50,6 +51,7 @@5051     private val deviceStore: PairedDeviceStore,5152     private val pendingVouchedStore: PendingVouchedDeviceStore,5253     private val discoveryClient: PairingDiscoveryClient,54+    private val syncAlertStore: SyncAlertStore,5355 ) : ViewModel() {5456 5557     private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)@@ -106,6 +108,7 @@106108             ourDeviceId = getOrCreateDeviceId(context),107109             pairedDevices = deviceStore,108110             pendingVouched = pendingVouchedStore,111+            syncAlerts = syncAlertStore,109112         )110113     }111114 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+48 −0
@@ -39,6 +39,7 @@3939 import androidx.compose.material.icons.outlined.Lock4040 import androidx.compose.material.icons.outlined.SelectAll4141 import androidx.compose.material.icons.rounded.Add42+import androidx.compose.material.icons.rounded.Close4243 import androidx.compose.material.icons.rounded.Search4344 import androidx.compose.material.icons.rounded.Settings4445 import androidx.compose.material3.Icon@@ -81,7 +82,9 @@8182 import androidx.compose.ui.unit.sp8283 import androidx.hilt.navigation.compose.hiltViewModel8384 import androidx.compose.runtime.collectAsState85+import com.peachpasswords.sync.SyncAlert8486 import com.peachpasswords.haptics.PeachHaptics87+import com.peachpasswords.sync.syncAlertMessage8588 import com.peachpasswords.sync.SyncStatus8689 import com.peachpasswords.ui.sync.SyncViewModel8790 import com.peachpasswords.ui.PeachNav@@ -113,6 +116,7 @@113116     val isMultiSelect by vm.isMultiSelectMode.collectAsState()114117     val selectedIds by vm.selectedIds.collectAsState()115118     val undoEvent by vm.undoEvent.collectAsState()119+    val syncAlerts by vm.syncAlerts.collectAsState()116120     val snackbarHostState = remember { SnackbarHostState() }117121     var menuEntry by remember { mutableStateOf<VaultEntry?>(null) }118122     val view = LocalView.current@@ -148,6 +152,10 @@148152                     onLock = { vm.lock() },149153                 )150154             }155+            SyncAlertBanners(156+                alerts = syncAlerts,157+                onDismiss = vm::dismissSyncAlert,158+            )151159             SearchBar(152160                 value = search,153161                 onChange = vm::setSearch,@@ -227,6 +235,46 @@227235             onMoveToTrash = { vm.softDelete(e) },228236             onDismiss = { menuEntry = null },229237         )238+    }239+}240+241+@Composable242+private fun SyncAlertBanners(alerts: List<SyncAlert>, onDismiss: (SyncAlert) -> Unit) {243+    if (alerts.isEmpty()) return244+    Column(245+        modifier = Modifier246+            .fillMaxWidth()247+            .padding(horizontal = 16.dp, vertical = 6.dp),248+        verticalArrangement = Arrangement.spacedBy(6.dp),249+    ) {250+        alerts.forEach { alert ->251+            Row(252+                modifier = Modifier253+                    .fillMaxWidth()254+                    .clip(RoundedCornerShape(8.dp))255+                    .background(peachColors.SurfaceElevated)256+                    .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))257+                    .padding(start = 12.dp, top = 9.dp, end = 8.dp, bottom = 9.dp),258+                verticalAlignment = Alignment.CenterVertically,259+            ) {260+                Text(261+                    text = syncAlertMessage(alert),262+                    color = peachColors.T1,263+                    style = PeachType.bodyMono.copy(fontSize = 11.sp),264+                    modifier = Modifier.weight(1f),265+                )266+                Icon(267+                    Icons.Rounded.Close,268+                    contentDescription = "Dismiss sync alert",269+                    tint = peachColors.T2,270+                    modifier = Modifier271+                        .size(30.dp)272+                        .clip(CircleShape)273+                        .clickable { onDismiss(alert) }274+                        .padding(7.dp),275+                )276+            }277+        }230278     }231279 }232280 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+8 −0
@@ -4,6 +4,8 @@44 import com.peachpasswords.shared.PeachClipboard55 import androidx.lifecycle.ViewModel66 import androidx.lifecycle.viewModelScope7+import com.peachpasswords.sync.SyncAlert8+import com.peachpasswords.sync.SyncAlertStore79 import com.peachpasswords.vault.TOTPGenerator810 import com.peachpasswords.vault.VaultEntry911 import com.peachpasswords.vault.VaultLockTimer@@ -32,6 +34,7 @@3234 class VaultListViewModel @Inject constructor(3335     private val vault: VaultRepository,3436     private val lockTimer: VaultLockTimer,37+    private val syncAlertStore: SyncAlertStore,3538     @ApplicationContext private val appContext: Context,3639 ) : ViewModel() {3740 @@ -51,6 +54,7 @@5154     val flashId: StateFlow<String?> = _flashId.asStateFlow()5255 5356     val isUnlocked: StateFlow<Boolean> = vault.isUnlocked57+    val syncAlerts: StateFlow<List<SyncAlert>> = syncAlertStore.alerts5458 5559     /** Live, filtered + sorted active entries. */5660     val entries: StateFlow<List<VaultEntry>> =@@ -76,6 +80,10 @@7680     fun lock() {7781         lockTimer.cancel()7882         vault.lock()83+    }84+85+    fun dismissSyncAlert(alert: SyncAlert) {86+        syncAlertStore.dismiss(alert)7987     }8088 8189     // ── Multi-select ────────────────────────────────────────────────────────

view file

app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+114 −0
@@ -0,0 +1,114 @@1+package com.peachpasswords.sync2+3+import com.peachpasswords.vault.Tombstone4+import com.peachpasswords.vault.VaultEntry5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertNull7+import org.junit.Test8+9+class SyncAlertTest {10+    @Test fun remote_newer_password_change_creates_browser_style_alert() {11+        val local = VaultEntry(12+            id = "login-1",13+            title = "GitHub",14+            password = "old",15+            createdAt = 1L,16+            modifiedAt = 10L,17+        )18+        val remote = local.copy(password = "new", modifiedAt = 20L)19+20+        val alerts = remoteEntrySyncAlerts(21+            localEntries = listOf(local),22+            remoteEntries = listOf(remote),23+            deviceName = "MacBook",24+            nowMs = 30L,25+        )26+27+        assertEquals(1, alerts.size)28+        assertEquals(SyncAlertType.REMOTE_PASSWORD_CHANGE, alerts.single().type)29+        assertEquals("GitHub", alerts.single().entryTitle)30+        assertEquals("MacBook changed the password for GitHub", syncAlertMessage(alerts.single()))31+    }32+33+    @Test fun stale_or_same_password_entries_do_not_alert() {34+        val local = VaultEntry(35+            id = "login-1",36+            title = "Mail",37+            password = "same",38+            createdAt = 1L,39+            modifiedAt = 10L,40+        )41+42+        val alerts = remoteEntrySyncAlerts(43+            localEntries = listOf(local),44+            remoteEntries = listOf(45+                local.copy(password = "different", modifiedAt = 9L),46+                local.copy(password = "same", modifiedAt = 20L),47+            ),48+            deviceName = "paired device",49+            nowMs = 30L,50+        )51+52+        assertEquals(emptyList<SyncAlert>(), alerts)53+    }54+55+    @Test fun winning_remote_delete_creates_delete_alert() {56+        val local = VaultEntry(57+            id = "login-1",58+            title = "Bank",59+            password = "pw",60+            createdAt = 1L,61+            modifiedAt = 10L,62+        )63+64+        val alerts = remoteTombstoneSyncAlerts(65+            localEntries = listOf(local),66+            remoteTombstones = listOf(Tombstone("login-1", deletedAt = 10L)),67+            deviceName = "Browser",68+            nowMs = 40L,69+        )70+71+        assertEquals(1, alerts.size)72+        assertEquals(SyncAlertType.REMOTE_DELETE, alerts.single().type)73+        assertEquals("Browser deleted Bank", syncAlertMessage(alerts.single()))74+    }75+76+    @Test fun device_key_rotation_detects_existing_peer_key_changes() {77+        val existing = PairedDevice(78+            deviceId = "browser",79+            deviceName = "Browser",80+            deviceType = "extension",81+            longTermPublicKeyHex = "aa".repeat(32),82+            signingPublicKeyHex = "bb".repeat(32),83+            pairedAt = 1L,84+        )85+        val incoming = ManifestDeviceEntry(86+            deviceId = "browser",87+            deviceName = "Browser",88+            deviceType = "extension",89+            longTermPublicKeyHex = "aa".repeat(32),90+            signingPublicKeyHex = "cc".repeat(32),91+            pairedAt = 1L,92+        )93+94+        val alert = deviceKeyRotationAlert(existing, incoming, nowMs = 99L)95+96+        assertEquals(SyncAlertType.DEVICE_KEY_ROTATED, alert?.type)97+        assertEquals("Browser", alert?.deviceName)98+        assertNull(deviceKeyRotationAlert(existing, incoming.copy(signingPublicKeyHex = "bb".repeat(32)), nowMs = 99L))99+    }100+101+    @Test fun sync_alert_queue_is_bounded_to_latest_two_hundred() {102+        val existing = (0 until 199).map { SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = it.toLong()) }103+        val incoming = listOf(104+            SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 199L),105+            SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 200L),106+        )107+108+        val bounded = boundedSyncAlerts(existing, incoming)109+110+        assertEquals(200, bounded.size)111+        assertEquals(1L, bounded.first().timestamp)112+        assertEquals(200L, bounded.last().timestamp)113+    }114+}

view file