454b3951f979
feat: Add sync alert banners (v2.3.47)
- time
- 2026-06-26 14:01 UTC · 4 months ago
- machine
- koh2
- parent
- e9158e9ae9dc
↓ peach-android-454b3951.tar.gzatlas at this save
files changed8 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 −0app/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 {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+}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+}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,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 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 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 ────────────────────────────────────────────────────────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+}