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
6 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.161+2.5.17

view file

app/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 {

view file

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"

view file

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 

view file

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 

view file

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+}

view file