ec914e23f6d4
fix: Restore dismissed sync notification (v2.5.17)
- time
- 2026-07-27 17:28 UTC · 2 months ago
- machine
- koh2
- agent
- codex-gpt-5
- session
- peach-sync-e2e
- parent
- 2dfe4d9c3398
↓ peach-android-ec914e23.tar.gzatlas at this save
files changed6 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/AndroidManifest.xml+4 −0app/src/main/java/com/peachpasswords/sync/BackgroundSyncPreference.kt+10 −3app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+76 −30app/src/test/java/com/peachpasswords/sync/SyncNotificationPersistenceTest.kt+37 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24011620- versionName "2.5.16"19+ versionCode 24011720+ versionName "2.5.17"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/AndroidManifest.xml+4 −0
@@ -103,6 +103,10 @@103103 android:exported="false"104104 android:foregroundServiceType="dataSync" />105105 106+ <receiver107+ android:name=".sync.SyncNotificationDismissedReceiver"108+ android:exported="false" />109+106110 <service107111 android:name=".autofill.PeachAutofillService"108112 android:permission="android.permission.BIND_AUTOFILL_SERVICE"app/src/main/java/com/peachpasswords/sync/BackgroundSyncPreference.kt+10 −3
@@ -23,9 +23,16 @@2323 _enabled.value = enabled2424 }2525 26- private companion object {27- const val PREFS_FILE = "peach_background_sync"28- const val KEY_ENABLED = "enabled"26+ companion object {27+ private const val PREFS_FILE = "peach_background_sync"28+ private const val KEY_ENABLED = "enabled"29+30+ internal fun isEnabled(context: Context): Boolean {31+ val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)32+ return readEnabled(prefs.contains(KEY_ENABLED)) {33+ prefs.getBoolean(KEY_ENABLED, true)34+ }35+ }2936 }3037 }3138 app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+76 −30
@@ -5,6 +5,7 @@55 import android.app.NotificationManager66 import android.app.PendingIntent77 import android.app.Service8+import android.content.BroadcastReceiver89 import android.content.Context910 import android.content.Intent1011 import android.os.Build@@ -63,7 +64,7 @@6364 // exception, so stop deliberately and leave the reported reason6465 // standing rather than come back to be refused every time.6566 if (!startOrReportBlocked(syncOutcomes::report) {66- startForeground(NOTIFICATION_ID, buildNotification())67+ startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))6768 }6869 ) {6970 stopSelf()@@ -119,36 +120,7 @@119120 }120121 }121122 122- private fun buildNotification(): Notification {123- val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager124- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {125- manager.createNotificationChannel(126- NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {127- description = "Shown while Peach is reachable for encrypted device sync."128- setShowBadge(false)129- },130- )131- }132- val open = PendingIntent.getActivity(133- this,134- 0,135- Intent(this, MainActivity::class.java),136- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,137- )138- return NotificationCompat.Builder(this, CHANNEL_ID)139- .setContentTitle("Peach sync is active")140- .setContentText("Your other devices can reach this phone for encrypted sync.")141- .setSmallIcon(R.drawable.ic_launcher_monochrome)142- .setOngoing(true)143- .setPriority(NotificationCompat.PRIORITY_LOW)144- .setContentIntent(open)145- .build()146- }147-148123 companion object {149- private const val CHANNEL_ID = "peach_sync"150- private const val NOTIFICATION_ID = 4201151-152124 fun start(context: Context) {153125 val intent = Intent(context, PeachSyncService::class.java)154126 startOrReportBlocked({ context.reportSyncOutcome(it) }) {@@ -163,6 +135,80 @@163135 fun stop(context: Context) {164136 context.stopService(Intent(context, PeachSyncService::class.java))165137 }138+ }139+}140+141+internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"142+internal const val SYNC_NOTIFICATION_ID = 4201143+internal const val ACTION_RESTORE_SYNC_NOTIFICATION =144+ "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"145+146+internal fun buildSyncNotification(context: Context): Notification {147+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager148+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {149+ manager.createNotificationChannel(150+ NotificationChannel(151+ SYNC_NOTIFICATION_CHANNEL_ID,152+ "Peach sync",153+ NotificationManager.IMPORTANCE_LOW,154+ ).apply {155+ description = "Shown while Peach is reachable for encrypted device sync."156+ setShowBadge(false)157+ },158+ )159+ }160+ val open = PendingIntent.getActivity(161+ context,162+ 0,163+ Intent(context, MainActivity::class.java),164+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,165+ )166+ val restore = PendingIntent.getBroadcast(167+ context,168+ 0,169+ Intent(context, SyncNotificationDismissedReceiver::class.java)170+ .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),171+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,172+ )173+ return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)174+ .setContentTitle("Peach sync is active")175+ .setContentText("Your other devices can reach this phone for encrypted sync.")176+ .setSmallIcon(R.drawable.ic_launcher_monochrome)177+ .setOngoing(true)178+ .setPriority(NotificationCompat.PRIORITY_LOW)179+ .setContentIntent(open)180+ .setDeleteIntent(restore)181+ .build()182+ .also {183+ // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows184+ // users to swipe ongoing notifications individually, so the185+ // deleteIntent above restores this status surface while the user186+ // preference still says background sync is active.187+ it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR188+ }189+}190+191+internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =192+ action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled193+194+/**195+ * Android 14 permits an ongoing foreground-service notification to be swiped196+ * away while its service keeps running. Peach's notification is the only197+ * visible indication that the phone remains reachable, so restore it after a198+ * user dismissal whenever background sync is still enabled.199+ */200+class SyncNotificationDismissedReceiver : BroadcastReceiver() {201+ override fun onReceive(context: Context, intent: Intent?) {202+ if (!shouldRestoreSyncNotification(203+ intent?.action,204+ BackgroundSyncPreference.isEnabled(context),205+ )206+ ) {207+ return208+ }209+ val manager =210+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager211+ manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))166212 }167213 }168214 app/src/test/java/com/peachpasswords/sync/SyncNotificationPersistenceTest.kt+37 −0
@@ -0,0 +1,37 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+class SyncNotificationPersistenceTest {8+ @Test9+ fun `user dismissal restores the notification while background sync is enabled`() {10+ assertTrue(11+ shouldRestoreSyncNotification(12+ ACTION_RESTORE_SYNC_NOTIFICATION,13+ backgroundSyncEnabled = true,14+ ),15+ )16+ }17+18+ @Test19+ fun `disabled background sync never restores a dismissed notification`() {20+ assertFalse(21+ shouldRestoreSyncNotification(22+ ACTION_RESTORE_SYNC_NOTIFICATION,23+ backgroundSyncEnabled = false,24+ ),25+ )26+ }27+28+ @Test29+ fun `unrelated broadcasts cannot restore the notification`() {30+ assertFalse(31+ shouldRestoreSyncNotification(32+ "com.peachpasswords.action.UNRELATED",33+ backgroundSyncEnabled = true,34+ ),35+ )36+ }37+}