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
↓ peach-android-916249d8.tar.gzatlas at this save
files changed5 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 −5app/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 {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+}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 },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 }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 }