fb46840009b2

feat: Keep routine sync quiet (v2.6.1)

time
2026-07-27 20:12 UTC · 2 months ago
machine
koh2
parent
ccf5e6f8b042
7 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+3 −42app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+6 −3app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+2 −24app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+6 −2app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+12 −69app/src/test/java/com/peachpasswords/ui/vault/VaultListSyncAlertTest.kt+9 −2
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24012420-        versionName "2.6.0"19+        versionCode 24012520+        versionName "2.6.1"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+3 −42
@@ -1,7 +1,5 @@11 package com.peachpasswords.sync22 3-import com.peachpasswords.vault.Tombstone4-import com.peachpasswords.vault.VaultEntry53 import kotlinx.serialization.Serializable64 75 private const val MAX_SYNC_ALERTS = 200@@ -30,46 +28,9 @@3028 internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =3129     (existing + incoming).takeLast(MAX_SYNC_ALERTS)3230 33-internal fun remoteEntrySyncAlerts(34-    localEntries: List<VaultEntry>,35-    remoteEntries: List<VaultEntry>,36-    deviceName: String,37-    nowMs: Long,38-): List<SyncAlert> {39-    val localById = localEntries.associateBy { it.id }40-    return remoteEntries.mapNotNull { remote ->41-        val local = localById[remote.id] ?: return@mapNotNull null42-        if (remote.modifiedAt <= local.modifiedAt || remote.deletedAt != null) return@mapNotNull null43-        if (remote.password == local.password) return@mapNotNull null44-        SyncAlert(45-            type = SyncAlertType.REMOTE_PASSWORD_CHANGE,46-            entryId = remote.id,47-            entryTitle = remote.title.ifBlank { local.title },48-            deviceName = deviceName,49-            timestamp = nowMs,50-        )51-    }52-}53-54-internal fun remoteTombstoneSyncAlerts(55-    localEntries: List<VaultEntry>,56-    remoteTombstones: List<Tombstone>,57-    deviceName: String,58-    nowMs: Long,59-): List<SyncAlert> {60-    val localById = localEntries.associateBy { it.id }61-    return remoteTombstones.mapNotNull { tombstone ->62-        val local = localById[tombstone.id] ?: return@mapNotNull null63-        if (tombstone.deletedAt < local.modifiedAt) return@mapNotNull null64-        SyncAlert(65-            type = SyncAlertType.REMOTE_DELETE,66-            entryId = local.id,67-            entryTitle = local.title,68-            deviceName = deviceName,69-            timestamp = nowMs,70-        )71-    }72-}31+internal fun isDeviceTrustAlert(alert: SyncAlert): Boolean =32+    alert.type == SyncAlertType.DEVICE_KEY_ROTATED ||33+        alert.type == SyncAlertType.DEVICE_REVOKED7334 7435 internal fun deviceKeyRotationAlert(7536     existing: PairedDevice,

view file

app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+6 −3
@@ -33,8 +33,9 @@3333     val alerts: StateFlow<List<SyncAlert>> = _alerts.asStateFlow()3434 3535     override fun record(alerts: List<SyncAlert>) {36-        if (alerts.isEmpty()) return37-        persist(boundedSyncAlerts(load(), alerts))36+        val trustAlerts = alerts.filter(::isDeviceTrustAlert)37+        if (trustAlerts.isEmpty()) return38+        persist(boundedSyncAlerts(load(), trustAlerts))3839     }3940 4041     fun dismiss(alert: SyncAlert) {@@ -52,7 +53,9 @@5253 5354     private fun load(): List<SyncAlert> {5455         val raw = prefs.getString(KEY_ALERTS, null) ?: return emptyList()55-        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())56+        return runCatching { json.decodeFromString(listSerializer, raw) }57+            .getOrDefault(emptyList())58+            .filter(::isDeviceTrustAlert)5659     }5760 5861     private fun persist(alerts: List<SyncAlert>) {

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+2 −24
@@ -66,7 +66,6 @@6666     }6767 6868     override suspend fun applyEntries(entries: Map<String, String>) {69-        val before = if (syncAlerts != null) repo.getEntries() else emptyList()7069         val master = requireMasterKey()7170         val decrypted = try {7271             entries.mapNotNull { (id, envelope) ->@@ -79,37 +78,16 @@7978             master.fill(0)8079         }8180         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-            }81+            repo.applyMergedRemoteEntries(decrypted, emptyList())9382         }9483     }9584 9685     override suspend fun applyTombstones(tombstones: Map<String, Long>) {97-        val before = if (syncAlerts != null) repo.getEntries() else emptyList()9886         val cleaned = tombstones9987             .filterValues { it > 0L }10088             .map { (id, ts) -> Tombstone(id, ts) }10189         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-            }90+            repo.applyMergedRemoteEntries(emptyList(), cleaned)11391         }11492     }11593 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+6 −2
@@ -185,7 +185,7 @@185185         val finishedAt = syncStatus.finishedAt ?: return@LaunchedEffect186186         if (finishedAt < openedAt) return@LaunchedEffect187187         val reason = syncStatus.reason ?: return@LaunchedEffect188-        if (reason == SyncOutcomeReason.COMPLETED_NO_CHANGES) return@LaunchedEffect188+        if (!shouldShowSyncOutcomeSnackbar(reason)) return@LaunchedEffect189189         snackbarHostState.showSnackbar(190190             message = if (reason == SyncOutcomeReason.NO_PAIRED_DEVICES) {191191                 "No devices paired yet — add one in Settings › Sync"@@ -406,7 +406,7 @@406406                 verticalAlignment = Alignment.CenterVertically,407407             ) {408408                 Text(409-                    text = "${alerts.size} sync updates",409+                    text = "${alerts.size} security notices",410410                     color = peachColors.T1,411411                     style = PeachType.bodyMono.copy(fontSize = 11.sp),412412                     modifier = Modifier.weight(1f),@@ -452,6 +452,10 @@452452 453453 internal fun visibleSyncAlerts(alerts: List<SyncAlert>, expanded: Boolean): List<SyncAlert> =454454     if (alerts.size <= 1 || expanded) alerts else emptyList()455+456+internal fun shouldShowSyncOutcomeSnackbar(reason: SyncOutcomeReason): Boolean =457+    reason != SyncOutcomeReason.COMPLETED_CHANGES &&458+        reason != SyncOutcomeReason.COMPLETED_NO_CHANGES455459 456460 private fun pluralEntries(total: Int): String = if (total == 1) "1 entry" else "$total entries"457461 

view file

app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+12 −69
@@ -1,78 +1,19 @@11 package com.peachpasswords.sync22 3-import com.peachpasswords.vault.Tombstone4-import com.peachpasswords.vault.VaultEntry53 import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse65 import org.junit.Assert.assertNull6+import org.junit.Assert.assertTrue77 import org.junit.Test88 99 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()))10+    @Test fun routine_entry_activity_is_not_a_user_facing_alert() {11+        assertFalse(isDeviceTrustAlert(SyncAlert(SyncAlertType.REMOTE_PASSWORD_CHANGE, timestamp = 1L)))12+        assertFalse(isDeviceTrustAlert(SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 2L)))13+        assertTrue(isDeviceTrustAlert(SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = 3L)))14+        assertTrue(isDeviceTrustAlert(SyncAlert(SyncAlertType.DEVICE_REVOKED, timestamp = 4L)))3115     }3216 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-7617     @Test fun device_key_rotation_detects_existing_peer_key_changes() {7718         val existing = PairedDevice(7819             deviceId = "browser",@@ -124,10 +65,12 @@12465     }12566 12667     @Test fun sync_alert_queue_is_bounded_to_latest_two_hundred() {127-        val existing = (0 until 199).map { SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = it.toLong()) }68+        val existing = (0 until 199).map {69+            SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = it.toLong())70+        }12871         val incoming = listOf(129-            SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 199L),130-            SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 200L),72+            SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = 199L),73+            SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = 200L),13174         )13275 13376         val bounded = boundedSyncAlerts(existing, incoming)

view file

app/src/test/java/com/peachpasswords/ui/vault/VaultListSyncAlertTest.kt+9 −2
@@ -2,14 +2,15 @@22 33 import com.peachpasswords.sync.SyncAlert44 import com.peachpasswords.sync.SyncAlertType5+import com.peachpasswords.sync.SyncOutcomeReason56 import org.junit.Assert.assertEquals7+import org.junit.Assert.assertFalse68 import org.junit.Assert.assertTrue79 import org.junit.Test810 911 class VaultListSyncAlertTest {1012     private fun alert(timestamp: Long) = SyncAlert(11-        type = SyncAlertType.REMOTE_DELETE,12-        entryTitle = "Entry $timestamp",13+        type = SyncAlertType.DEVICE_KEY_ROTATED,1314         deviceName = "Extension",1415         timestamp = timestamp,1516     )@@ -25,5 +26,11 @@2526 2627         assertTrue(visibleSyncAlerts(alerts, expanded = false).isEmpty())2728         assertEquals(alerts, visibleSyncAlerts(alerts, expanded = true))29+    }30+31+    @Test fun successful_sync_is_quiet_but_failures_remain_actionable() {32+        assertFalse(shouldShowSyncOutcomeSnackbar(SyncOutcomeReason.COMPLETED_CHANGES))33+        assertFalse(shouldShowSyncOutcomeSnackbar(SyncOutcomeReason.COMPLETED_NO_CHANGES))34+        assertTrue(shouldShowSyncOutcomeSnackbar(SyncOutcomeReason.SYNC_TIMEOUT))2835     }2936 }

view file