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
↓ peach-android-99fee5aa.tar.gzatlas at this save
files changed7 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 −22app/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 {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 }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 }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 &&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() {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 }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))