916249d84985

fix: Require a visible background sync notification (v2.7.6)

time
2026-07-27 22:21 UTC · 2 months ago
machine
koh2
parent
99fee5aa1ebc
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncNotificationPermission.kt+31 −0app/src/main/java/com/peachpasswords/ui/pairing/BackgroundSyncDialog.kt+80 −10app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+17 −5app/src/test/java/com/peachpasswords/ui/pairing/BackgroundSyncChoiceTest.kt+40 −5
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24013220-        versionName "2.7.5"19+        versionCode 24013320+        versionName "2.7.6"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/SyncNotificationPermission.kt+31 −0
@@ -0,0 +1,31 @@1+package com.peachpasswords.sync2+3+import android.Manifest4+import android.app.NotificationManager5+import android.content.Context6+import android.content.pm.PackageManager7+import android.os.Build8+import androidx.core.app.NotificationManagerCompat9+import androidx.core.content.ContextCompat10+11+/**12+ * A foreground service can keep running after Android notification permission13+ * is denied, but its status notification disappears from the drawer. Treat14+ * that as unavailable so the UI never calls background sync visibly "On".15+ */16+internal fun canShowBackgroundSyncNotification(context: Context): Boolean {17+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&18+        ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=19+        PackageManager.PERMISSION_GRANTED20+    ) {21+        return false22+    }23+    if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return false24+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {25+        val manager =26+            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager27+        val channel = manager.getNotificationChannel(SYNC_NOTIFICATION_CHANNEL_ID)28+        if (channel?.importance == NotificationManager.IMPORTANCE_NONE) return false29+    }30+    return true31+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/BackgroundSyncDialog.kt+80 −10
@@ -1,50 +1,120 @@11 package com.peachpasswords.ui.pairing22 3+import android.Manifest4+import android.content.Intent5+import android.os.Build6+import android.provider.Settings7+import androidx.activity.compose.rememberLauncherForActivityResult8+import androidx.activity.result.contract.ActivityResultContracts39 import androidx.activity.compose.BackHandler410 import androidx.compose.material3.AlertDialog511 import androidx.compose.material3.Text612 import androidx.compose.material3.TextButton713 import androidx.compose.runtime.Composable14+import androidx.compose.runtime.getValue15+import androidx.compose.runtime.mutableStateOf16+import androidx.compose.runtime.remember17+import androidx.compose.runtime.setValue18+import androidx.compose.ui.platform.LocalContext819 import androidx.compose.ui.text.font.FontWeight20+import com.peachpasswords.sync.canShowBackgroundSyncNotification921 import com.peachpasswords.ui.theme.LocalPeachAccent22+import com.peachpasswords.ui.theme.peachColors1023 import com.peachpasswords.ui.theme.PeachType1124 1225 enum class BackgroundSyncAction { ALLOW, MANUAL_ONLY, DISMISS }1326 1427 internal fun shouldPromptForBackgroundSync(pairingSucceeded: Boolean): Boolean = pairingSucceeded1528 16-internal fun backgroundSyncChoice(action: BackgroundSyncAction): Boolean =17-    action != BackgroundSyncAction.MANUAL_ONLY29+internal fun backgroundSyncChoice(30+    action: BackgroundSyncAction,31+    notificationsAvailable: Boolean,32+): Boolean = action != BackgroundSyncAction.MANUAL_ONLY && notificationsAvailable1833 1934 @Composable2035 fun BackgroundSyncDialog(2136     onChoice: (Boolean) -> Unit,2237 ) {23-    // Dismissal takes the recommended default so pairing cannot fall back into the broken unreachable state.24-    BackHandler { onChoice(backgroundSyncChoice(BackgroundSyncAction.DISMISS)) }38+    val context = LocalContext.current39+    var permissionDenied by remember { mutableStateOf(false) }40+    val permissionLauncher = rememberLauncherForActivityResult(41+        ActivityResultContracts.RequestPermission(),42+    ) { granted ->43+        if (granted && canShowBackgroundSyncNotification(context)) {44+            onChoice(true)45+        } else {46+            permissionDenied = true47+        }48+    }49+    val settingsLauncher = rememberLauncherForActivityResult(50+        ActivityResultContracts.StartActivityForResult(),51+    ) {52+        if (canShowBackgroundSyncNotification(context)) {53+            onChoice(true)54+        } else {55+            permissionDenied = true56+        }57+    }58+    val dismiss = {59+        onChoice(60+            backgroundSyncChoice(61+                BackgroundSyncAction.DISMISS,62+                canShowBackgroundSyncNotification(context),63+            ),64+        )65+    }66+    val allow = {67+        when {68+            canShowBackgroundSyncNotification(context) -> onChoice(true)69+            permissionDenied -> settingsLauncher.launch(70+                Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)71+                    .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName),72+            )73+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->74+                permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)75+            else -> permissionDenied = true76+        }77+    }78+79+    // Dismissal may retain the recommended default only when Android can show80+    // its required ongoing notification. Otherwise it honestly stays off.81+    BackHandler(onBack = dismiss)2582     AlertDialog(26-        onDismissRequest = { onChoice(backgroundSyncChoice(BackgroundSyncAction.DISMISS)) },83+        onDismissRequest = dismiss,2784         title = {2885             Text("Sync in the background?", fontFamily = PeachType.Display)2986         },3087         text = {3188             Text(32-                "Allow Peach to keep your devices in step in the background, even when the app isn't open. " +33-                    "This keeps a small notification visible while sync is active and uses a tiny bit of battery.",89+                if (permissionDenied) {90+                    "Android notifications are off, so background sync stayed off. " +91+                        "Open Peach's notification settings and allow notifications to keep this phone reachable."92+                } else {93+                    "Allow Peach to keep your devices in step in the background, even when the app isn't open. " +94+                        "Android will ask to show the small ongoing notification this requires."95+                },3496                 fontFamily = PeachType.Body,97+                color = if (permissionDenied) peachColors.Breach else peachColors.T1,3598             )3699         },37100         confirmButton = {38-            TextButton(onClick = { onChoice(backgroundSyncChoice(BackgroundSyncAction.ALLOW)) }) {101+            TextButton(onClick = allow) {39102                 Text(40-                    "Allow background sync",103+                    if (permissionDenied) "Open Android settings" else "Allow background sync",41104                     color = LocalPeachAccent.current,42105                     fontWeight = FontWeight.Bold,43106                 )44107             }45108         },46109         dismissButton = {47-            TextButton(onClick = { onChoice(backgroundSyncChoice(BackgroundSyncAction.MANUAL_ONLY)) }) {110+            TextButton(onClick = {111+                onChoice(112+                    backgroundSyncChoice(113+                        BackgroundSyncAction.MANUAL_ONLY,114+                        canShowBackgroundSyncNotification(context),115+                    ),116+                )117+            }) {48118                 Text("Sync manually only")49119             }50120         },

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+17 −5
@@ -32,6 +32,7 @@3232 import com.peachpasswords.feedback.FeedbackState3333 import com.peachpasswords.shared.PeachClipboard3434 import com.peachpasswords.sync.SyncMode35+import com.peachpasswords.sync.canShowBackgroundSyncNotification3536 import com.peachpasswords.sync.describeSyncOutcome3637 import com.peachpasswords.sync.privateRelayDraftOf3738 import com.peachpasswords.sync.syncIndicator@@ -687,6 +688,7 @@687688 688689 @Composable689690 private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {691+    val context = LocalContext.current690692     val syncVm: SyncViewModel = hiltViewModel()691693     val pairedDevices by syncVm.pairedDevices.collectAsState()692694     val pendingVouched by syncVm.pendingVouched.collectAsState()@@ -694,6 +696,9 @@694696     val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()695697     val syncMode by syncVm.syncMode.collectAsState()696698     val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()699+    var notificationsAvailable by remember {700+        mutableStateOf(canShowBackgroundSyncNotification(context))701+    }697702     var showBackgroundSyncPrompt by remember { mutableStateOf(false) }698703     val scope = rememberCoroutineScope()699704     var confirmUnpair by remember { mutableStateOf<String?>(null) }@@ -805,10 +810,13 @@805810             Column(Modifier.weight(1f)) {806811                 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)807812                 Text(808-                    if (backgroundSyncEnabled) {809-                        "On — keeps this phone reachable for paired devices."810-                    } else {811-                        "Off — Peach syncs only while you are using the app."813+                    when {814+                        backgroundSyncEnabled && !notificationsAvailable ->815+                            "Action needed — Android notifications are off, so background sync is not visible."816+                        backgroundSyncEnabled ->817+                            "On — keeps this phone reachable for paired devices."818+                        else ->819+                            "Off — Peach syncs only while you are using the app."812820                     },813821                     color = fg.copy(alpha = 0.7f),814822                     fontFamily = PeachType.Body,@@ -817,7 +825,10 @@817825                     modifier = Modifier.padding(top = 2.dp),818826                 )819827             }820-            SubtleButton("change", fg) { showBackgroundSyncPrompt = true }828+            SubtleButton(829+                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",830+                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,831+            ) { showBackgroundSyncPrompt = true }821832         }822833         SyncModeRow(823834             title = "Public relay",@@ -966,6 +977,7 @@966977     if (showBackgroundSyncPrompt) {967978         BackgroundSyncDialog { enabled ->968979             showBackgroundSyncPrompt = false980+            notificationsAvailable = canShowBackgroundSyncNotification(context)969981             syncVm.setBackgroundSyncEnabled(enabled)970982         }971983     }

view file

app/src/test/java/com/peachpasswords/ui/pairing/BackgroundSyncChoiceTest.kt+40 −5
@@ -11,15 +11,50 @@1111         assertFalse(shouldPromptForBackgroundSync(pairingSucceeded = false))1212     }1313 14-    @Test fun `back uses the recommended background default`() {15-        assertEquals(true, backgroundSyncChoice(BackgroundSyncAction.DISMISS))14+    @Test fun `back uses the recommended background default when notification is available`() {15+        assertEquals(16+            true,17+            backgroundSyncChoice(18+                BackgroundSyncAction.DISMISS,19+                notificationsAvailable = true,20+            ),21+        )1622     }1723 1824     @Test fun `secondary action selects manual sync`() {19-        assertEquals(false, backgroundSyncChoice(BackgroundSyncAction.MANUAL_ONLY))25+        assertEquals(26+            false,27+            backgroundSyncChoice(28+                BackgroundSyncAction.MANUAL_ONLY,29+                notificationsAvailable = true,30+            ),31+        )2032     }2133 22-    @Test fun `primary action selects background sync`() {23-        assertEquals(true, backgroundSyncChoice(BackgroundSyncAction.ALLOW))34+    @Test fun `primary action selects background sync only with a visible notification`() {35+        assertEquals(36+            true,37+            backgroundSyncChoice(38+                BackgroundSyncAction.ALLOW,39+                notificationsAvailable = true,40+            ),41+        )42+        assertEquals(43+            false,44+            backgroundSyncChoice(45+                BackgroundSyncAction.ALLOW,46+                notificationsAvailable = false,47+            ),48+        )49+    }50+51+    @Test fun `dismissal cannot silently enable background sync without notifications`() {52+        assertEquals(53+            false,54+            backgroundSyncChoice(55+                BackgroundSyncAction.DISMISS,56+                notificationsAvailable = false,57+            ),58+        )2459     }2560 }

view file