fb46840009b2
feat: Keep routine sync quiet (v2.6.1)
- time
- 2026-07-27 20:12 UTC · 2 months ago
- machine
- koh2
- parent
- ccf5e6f8b042
↓ peach-android-fb468400.tar.gzatlas at this save
files changed7 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 −2app/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 {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,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>) {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 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 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)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 }