99fee5aa1ebc

fix: Move Sync security alerts to notifications (v2.7.5)

time
2026-07-27 22:16 UTC · 2 months ago
machine
koh2
parent
cca4cca7f47e
7 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+19 −5app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+68 −51app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+0 −77app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+0 −8app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+29 −11app/src/test/java/com/peachpasswords/ui/vault/VaultListSyncAlertTest.kt+0 −22
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24013120-        versionName "2.7.4"19+        versionCode 24013220+        versionName "2.7.5"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+19 −5
@@ -2,8 +2,6 @@22 33 import kotlinx.serialization.Serializable44 5-private const val MAX_SYNC_ALERTS = 2006-75 @Serializable86 enum class SyncAlertType {97     REMOTE_PASSWORD_CHANGE,@@ -25,9 +23,6 @@2523     fun record(alerts: List<SyncAlert>)2624 }2725 28-internal fun boundedSyncAlerts(existing: List<SyncAlert>, incoming: List<SyncAlert>): List<SyncAlert> =29-    (existing + incoming).takeLast(MAX_SYNC_ALERTS)30-3126 internal fun isDeviceTrustAlert(alert: SyncAlert): Boolean =3227     alert.type == SyncAlertType.DEVICE_KEY_ROTATED ||3328         alert.type == SyncAlertType.DEVICE_REVOKED@@ -69,4 +64,23 @@6964         SyncAlertType.DEVICE_KEY_ROTATED ->7065             "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"7166     }67+}68+69+internal data class SyncSecurityNotificationContent(70+    val title: String,71+    val lines: List<String>,72+) {73+    val summary: String74+        get() = if (lines.size == 1) lines.single() else "${lines.size} device security updates"75+}76+77+internal fun syncSecurityNotificationContent(78+    alerts: List<SyncAlert>,79+): SyncSecurityNotificationContent? {80+    val lines = alerts.filter(::isDeviceTrustAlert).map(::syncAlertMessage)81+    if (lines.isEmpty()) return null82+    return SyncSecurityNotificationContent(83+        title = "Peach Sync security",84+        lines = lines,85+    )7286 }

view file

app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+68 −51
@@ -1,73 +1,90 @@11 package com.peachpasswords.sync22 3+import android.app.Notification4+import android.app.NotificationChannel5+import android.app.NotificationManager6+import android.app.PendingIntent37 import android.content.Context4-import androidx.security.crypto.EncryptedSharedPreferences5-import androidx.security.crypto.MasterKey8+import android.content.Intent9+import androidx.core.app.NotificationCompat10+import com.peachpasswords.MainActivity11+import com.peachpasswords.R612 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.Json1213 import javax.inject.Inject1314 import javax.inject.Singleton1415 1516 @Singleton1617 class SyncAlertStore @Inject constructor(17-    @ApplicationContext context: Context,18+    @ApplicationContext private val context: Context,1819 ) : 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-        val trustAlerts = alerts.filter(::isDeviceTrustAlert)37-        if (trustAlerts.isEmpty()) return38-        persist(boundedSyncAlerts(load(), trustAlerts))20+    init {21+        // v2.7.4 and earlier persisted security banners for the vault list.22+        // They are intentionally not migrated into surprise notifications.23+        context.deleteSharedPreferences(LEGACY_PREFS_FILE)3924     }4025 41-    fun dismiss(alert: SyncAlert) {42-        val current = load().toMutableList()43-        val index = current.indexOf(alert)44-        if (index != -1) {45-            current.removeAt(index)46-            persist(current)26+    override fun record(alerts: List<SyncAlert>) {27+        val content = syncSecurityNotificationContent(alerts) ?: return28+        val manager =29+            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager30+        runCatching {31+            manager.notify(32+                SYNC_SECURITY_NOTIFICATION_ID,33+                buildSyncSecurityNotification(context, content),34+            )4735         }4836     }4937 5038     fun clear() {51-        persist(emptyList())39+        val manager =40+            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager41+        manager.cancel(SYNC_SECURITY_NOTIFICATION_ID)5242     }43+}5344 54-    private fun load(): List<SyncAlert> {55-        val raw = prefs.getString(KEY_ALERTS, null) ?: return emptyList()56-        return runCatching { json.decodeFromString(listSerializer, raw) }57-            .getOrDefault(emptyList())58-            .filter(::isDeviceTrustAlert)59-    }45+internal const val SYNC_SECURITY_NOTIFICATION_CHANNEL_ID = "peach_sync_security"46+internal const val SYNC_SECURITY_NOTIFICATION_ID = 420247+private const val LEGACY_PREFS_FILE = "peach_sync_alerts"6048 61-    private fun persist(alerts: List<SyncAlert>) {62-        prefs.edit()63-            .putString(KEY_ALERTS, json.encodeToString(listSerializer, alerts))64-            .apply()65-        _alerts.value = alerts66-    }49+internal fun buildSyncSecurityNotification(50+    context: Context,51+    content: SyncSecurityNotificationContent,52+): Notification {53+    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager54+    manager.createNotificationChannel(55+        NotificationChannel(56+            SYNC_SECURITY_NOTIFICATION_CHANNEL_ID,57+            "Peach Sync security",58+            NotificationManager.IMPORTANCE_DEFAULT,59+        ).apply {60+            description = "Device removals and unexpected Peach Sync key changes."61+        },62+    )63+    val open = PendingIntent.getActivity(64+        context,65+        0,66+        Intent(context, MainActivity::class.java),67+        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,68+    )69+    val builder = NotificationCompat.Builder(context, SYNC_SECURITY_NOTIFICATION_CHANNEL_ID)70+        .setContentTitle(content.title)71+        .setContentText(content.summary)72+        .setSmallIcon(R.drawable.ic_launcher_monochrome)73+        .setPriority(NotificationCompat.PRIORITY_DEFAULT)74+        .setCategory(NotificationCompat.CATEGORY_STATUS)75+        .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)76+        .setAutoCancel(true)77+        .setContentIntent(open)6778 68-    private companion object {69-        const val PREFS_FILE = "peach_sync_alerts"70-        const val KEY_ALERTS = "sync_alerts"71-        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }79+    if (content.lines.size == 1) {80+        builder.setStyle(NotificationCompat.BigTextStyle().bigText(content.summary))81+    } else {82+        builder.setStyle(83+            NotificationCompat.InboxStyle()84+                .also { style -> content.lines.takeLast(5).forEach(style::addLine) }85+                .setSummaryText("${content.lines.size} device security updates"),86+        )7287     }88+89+    return builder.build()7390 }

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+0 −77
@@ -90,9 +90,7 @@9090 import androidx.compose.runtime.collectAsState9191 import com.peachpasswords.license.PendingProNotice9292 import com.peachpasswords.license.proNoticeText93-import com.peachpasswords.sync.SyncAlert9493 import com.peachpasswords.haptics.PeachHaptics95-import com.peachpasswords.sync.syncAlertMessage9694 import com.peachpasswords.sync.SyncIndicator9795 import com.peachpasswords.sync.SyncOutcomeReason9896 import com.peachpasswords.sync.SyncOutcomeSummary@@ -154,7 +152,6 @@154152     val isMultiSelect by vm.isMultiSelectMode.collectAsState()155153     val selectedIds by vm.selectedIds.collectAsState()156154     val undoEvent by vm.undoEvent.collectAsState()157-    val syncAlerts by vm.syncAlerts.collectAsState()158155     val proNotice by vm.proNotice.collectAsState()159156     val snackbarHostState = remember { SnackbarHostState() }160157     var menuEntry by remember { mutableStateOf<VaultEntry?>(null) }@@ -206,10 +203,6 @@206203                     onCancel = { vm.exitMultiSelect() },207204                 )208205             }209-            SyncAlertBanners(210-                alerts = syncAlerts,211-                onDismiss = vm::dismissSyncAlert,212-            )213206             ProNoticeBanner(214207                 notice = proNotice,215208                 onLearnMore = {@@ -382,76 +375,6 @@382375         )383376     }384377 }385-386-@Composable387-private fun SyncAlertBanners(alerts: List<SyncAlert>, onDismiss: (SyncAlert) -> Unit) {388-    if (alerts.isEmpty()) return389-    var expanded by remember(alerts) { mutableStateOf(false) }390-    Column(391-        modifier = Modifier392-            .fillMaxWidth()393-            .padding(horizontal = 16.dp, vertical = 6.dp),394-        verticalArrangement = Arrangement.spacedBy(6.dp),395-    ) {396-        if (alerts.size > 1) {397-            Row(398-                modifier = Modifier399-                    .fillMaxWidth()400-                    .heightIn(min = 48.dp)401-                    .clip(RoundedCornerShape(8.dp))402-                    .background(peachColors.SurfaceElevated)403-                    .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))404-                    .clickable { expanded = !expanded }405-                    .padding(horizontal = 12.dp),406-                verticalAlignment = Alignment.CenterVertically,407-            ) {408-                Text(409-                    text = "${alerts.size} security notices",410-                    color = peachColors.T1,411-                    style = PeachType.bodyMono.copy(fontSize = 11.sp),412-                    modifier = Modifier.weight(1f),413-                )414-                Text(415-                    text = if (expanded) "hide" else "show",416-                    color = LocalPeachAccent.current,417-                    style = PeachType.labelMono.copy(fontSize = 10.sp),418-                )419-            }420-        }421-        visibleSyncAlerts(alerts, expanded).forEach { alert ->422-            Row(423-                modifier = Modifier424-                    .fillMaxWidth()425-                    .heightIn(min = 48.dp)426-                    .clip(RoundedCornerShape(8.dp))427-                    .background(peachColors.SurfaceElevated)428-                    .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))429-                    .padding(start = 12.dp, top = 9.dp, end = 8.dp, bottom = 9.dp),430-                verticalAlignment = Alignment.CenterVertically,431-            ) {432-                Text(433-                    text = syncAlertMessage(alert),434-                    color = peachColors.T1,435-                    style = PeachType.bodyMono.copy(fontSize = 11.sp),436-                    modifier = Modifier.weight(1f),437-                )438-                Icon(439-                    Icons.Rounded.Close,440-                    contentDescription = "Dismiss sync alert",441-                    tint = peachColors.T2,442-                    modifier = Modifier443-                        .size(30.dp)444-                        .clip(CircleShape)445-                        .clickable { onDismiss(alert) }446-                        .padding(7.dp),447-                )448-            }449-        }450-    }451-}452-453-internal fun visibleSyncAlerts(alerts: List<SyncAlert>, expanded: Boolean): List<SyncAlert> =454-    if (alerts.size <= 1 || expanded) alerts else emptyList()455378 456379 internal fun shouldShowSyncOutcomeSnackbar(reason: SyncOutcomeReason): Boolean =457380     reason != SyncOutcomeReason.COMPLETED_CHANGES &&

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+0 −8
@@ -6,8 +6,6 @@66 import androidx.lifecycle.viewModelScope77 import com.peachpasswords.license.PendingProNotice88 import com.peachpasswords.license.ProNoticeStore9-import com.peachpasswords.sync.SyncAlert10-import com.peachpasswords.sync.SyncAlertStore119 import com.peachpasswords.vault.TOTPGenerator1210 import com.peachpasswords.vault.PasskeyData1311 import com.peachpasswords.vault.VaultEntry@@ -36,7 +34,6 @@3634 @HiltViewModel3735 class VaultListViewModel @Inject constructor(3836     private val vault: VaultRepository,39-    private val syncAlertStore: SyncAlertStore,4037     private val proNoticeStore: ProNoticeStore,4138     @ApplicationContext private val appContext: Context,4239     private val lockTimer: VaultLockTimer,@@ -58,7 +55,6 @@5855     val flashId: StateFlow<String?> = _flashId.asStateFlow()5956 6057     val isUnlocked: StateFlow<Boolean> = vault.isUnlocked61-    val syncAlerts: StateFlow<List<SyncAlert>> = syncAlertStore.alerts6258     val proNotice: StateFlow<PendingProNotice?> = proNoticeStore.notice6359 6460     /** Live, filtered + sorted active entries. */@@ -86,10 +82,6 @@8682     fun lock() {8783         vault.lock()8884         lockTimer.cancel()89-    }90-91-    fun dismissSyncAlert(alert: SyncAlert) {92-        syncAlertStore.dismiss(alert)9385     }9486 9587     fun dismissProNotice() {

view file

app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+29 −11
@@ -64,19 +64,37 @@6464         assertEquals("Spare Tablet", alert?.deviceName)6565     }6666 67-    @Test fun sync_alert_queue_is_bounded_to_latest_two_hundred() {68-        val existing = (0 until 199).map {69-            SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = it.toLong())70-        }71-        val incoming = listOf(72-            SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = 199L),73-            SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, timestamp = 200L),67+    @Test fun system_notification_excludes_routine_entry_activity() {68+        val content = syncSecurityNotificationContent(69+            listOf(70+                SyncAlert(71+                    SyncAlertType.REMOTE_PASSWORD_CHANGE,72+                    entryTitle = "Email",73+                    deviceName = "Phone",74+                    timestamp = 1L,75+                ),76+                SyncAlert(77+                    SyncAlertType.DEVICE_REVOKED,78+                    deviceName = "Chrome",79+                    timestamp = 2L,80+                ),81+            ),7482         )7583 76-        val bounded = boundedSyncAlerts(existing, incoming)84+        assertEquals("Peach Sync security", content?.title)85+        assertEquals(listOf("Chrome revoked a paired device"), content?.lines)86+        assertEquals("Chrome revoked a paired device", content?.summary)87+    }7788 78-        assertEquals(200, bounded.size)79-        assertEquals(1L, bounded.first().timestamp)80-        assertEquals(200L, bounded.last().timestamp)89+    @Test fun multiple_security_changes_use_a_compact_notification_summary() {90+        val content = syncSecurityNotificationContent(91+            listOf(92+                SyncAlert(SyncAlertType.DEVICE_REVOKED, deviceName = "Chrome", timestamp = 1L),93+                SyncAlert(SyncAlertType.DEVICE_KEY_ROTATED, deviceName = "Phone", timestamp = 2L),94+            ),95+        )96+97+        assertEquals(2, content?.lines?.size)98+        assertEquals("2 device security updates", content?.summary)8199     }82100 }

view file

app/src/test/java/com/peachpasswords/ui/vault/VaultListSyncAlertTest.kt+0 −22
@@ -1,33 +1,11 @@11 package com.peachpasswords.ui.vault22 3-import com.peachpasswords.sync.SyncAlert4-import com.peachpasswords.sync.SyncAlertType53 import com.peachpasswords.sync.SyncOutcomeReason6-import org.junit.Assert.assertEquals74 import org.junit.Assert.assertFalse85 import org.junit.Assert.assertTrue96 import org.junit.Test107 118 class VaultListSyncAlertTest {12-    private fun alert(timestamp: Long) = SyncAlert(13-        type = SyncAlertType.DEVICE_KEY_ROTATED,14-        deviceName = "Extension",15-        timestamp = timestamp,16-    )17-18-    @Test fun one_update_stays_visible_without_an_extra_summary_row() {19-        val alerts = listOf(alert(1L))20-21-        assertEquals(alerts, visibleSyncAlerts(alerts, expanded = false))22-    }23-24-    @Test fun multiple_updates_are_compact_until_expanded() {25-        val alerts = listOf(alert(1L), alert(2L), alert(3L))26-27-        assertTrue(visibleSyncAlerts(alerts, expanded = false).isEmpty())28-        assertEquals(alerts, visibleSyncAlerts(alerts, expanded = true))29-    }30-319     @Test fun successful_sync_is_quiet_but_failures_remain_actionable() {3210         assertFalse(shouldShowSyncOutcomeSnackbar(SyncOutcomeReason.COMPLETED_CHANGES))3311         assertFalse(shouldShowSyncOutcomeSnackbar(SyncOutcomeReason.COMPLETED_NO_CHANGES))

view file