0c5ab4dfdcdf

a foreground-service start Android refuses becomes a reported FOREGROUND_START_BLOCKED instead of a crash or silence: PeachSyncService.start and onStartCommand's own startForeground both route through startOrReportBlocked, which catches IllegalStateException (the supertype of API 31's ForegroundServiceStartNotAllowedException, so no minSdk guard is needed) and reports through SyncOutcomeStore, fetched via a Hilt EntryPoint so all six call sites are covered by the one guard; a refused START_STICKY restart now stops itself rather than returning to be refused again

time
2026-07-26 19:51 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
android sync service reliability
parent
cf0e3a2db9dd
4 files app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+78 −5app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+8 −0app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+8 −0app/src/test/java/com/peachpasswords/sync/SyncServiceStartTest.kt+63 −0
app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+78 −5
@@ -12,7 +12,11 @@1212 import androidx.core.app.NotificationCompat1313 import com.peachpasswords.MainActivity1414 import com.peachpasswords.R15+import dagger.hilt.EntryPoint16+import dagger.hilt.InstallIn1517 import dagger.hilt.android.AndroidEntryPoint18+import dagger.hilt.android.EntryPointAccessors19+import dagger.hilt.components.SingletonComponent1620 import com.peachpasswords.vault.VaultCrypto1721 import com.peachpasswords.vault.VaultRepository1822 import kotlinx.coroutines.CoroutineScope@@ -45,13 +49,26 @@4549     @Inject lateinit var deviceStore: PairedDeviceStore4650     @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore4751     @Inject lateinit var syncAlertStore: SyncAlertStore52+    @Inject lateinit var syncOutcomes: SyncOutcomeStore4853 4954     private val scope = CoroutineScope(SupervisorJob())5055 5156     override fun onBind(intent: Intent?): IBinder? = null5257 5358     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {54-        startForeground(NOTIFICATION_ID, buildNotification())59+        // The same restriction that can refuse [start] applies again here, and60+        // a START_STICKY restart is exactly when it bites: the system brings us61+        // back while the app is in the background. A service that stays up62+        // without its notification is killed a second later by a different63+        // exception, so stop deliberately and leave the reported reason64+        // standing rather than come back to be refused every time.65+        if (!startOrReportBlocked(syncOutcomes::report) {66+                startForeground(NOTIFICATION_ID, buildNotification())67+            }68+        ) {69+            stopSelf()70+            return START_NOT_STICKY71+        }5572         startReconnectListener()5673         // A start command means something changed — a new pairing, a manual5774         // "sync now", the app coming forward. Re-read the device list now@@ -134,10 +151,12 @@134151 135152         fun start(context: Context) {136153             val intent = Intent(context, PeachSyncService::class.java)137-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {138-                context.startForegroundService(intent)139-            } else {140-                context.startService(intent)154+            startOrReportBlocked({ context.reportSyncOutcome(it) }) {155+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {156+                    context.startForegroundService(intent)157+                } else {158+                    context.startService(intent)159+                }141160             }142161         }143162 @@ -145,4 +164,58 @@145164             context.stopService(Intent(context, PeachSyncService::class.java))146165         }147166     }167+}168+169+/**170+ * Every sync path asks for this service first, so a throw here is a sync that171+ * never happened — and it used to take the caller with it: a crash from the172+ * pairing and unlock screens, nothing at all from the fire-and-forget173+ * coroutines. Returns false when the start was refused, having said why.174+ *175+ * [IllegalStateException] rather than API 31's176+ * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a177+ * subclass of this one and does not exist on this app's minSdk, and 26..30178+ * throws the plain one for the same background start. Catching the supertype179+ * covers both without an SDK_INT guard around a class that may not be there.180+ */181+internal fun startOrReportBlocked(182+    report: (SyncOutcomeSummary) -> Unit,183+    start: () -> Unit,184+): Boolean = try {185+    start()186+    true187+} catch (blocked: IllegalStateException) {188+    report(189+        SyncOutcomeSummary(190+            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,191+            detail = blocked.message,192+        ),193+    )194+    false195+} catch (error: Exception) {196+    // Nothing about asking for a service is worth crashing the app over; the197+    // reason is the only part of this a person can act on.198+    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))199+    false200+}201+202+/**203+ * [PeachSyncService.start] is called from five screens and coordinators that204+ * hold no injector of their own, so the store is fetched here rather than205+ * threaded through every one of them. Wrapped because a store that cannot even206+ * be opened must not become the crash the guard above exists to prevent.207+ */208+private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {209+    runCatching {210+        EntryPointAccessors211+            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)212+            .syncOutcomes()213+            .report(summary)214+    }215+}216+217+@EntryPoint218+@InstallIn(SingletonComponent::class)219+internal interface SyncOutcomeEntryPoint {220+    fun syncOutcomes(): SyncOutcomeStore148221 }

view file

app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+8 −0
@@ -20,6 +20,10 @@2020  * [PeachSyncService], which is always present; the only way it fails to run is2121  * a locked vault, which is [VAULT_LOCKED].2222  *23+ * [FOREGROUND_START_BLOCKED] is the other way round — Android-only, with no24+ * browser counterpart, because only Android can refuse to start the runner at25+ * all (background foreground-service restrictions, API 31+).26+ *2327  * `no_eligible_host` is kept but means something narrower than the browser's:2428  * the browser rejects peers it cannot dial, whereas this phone usually *waits*2529  * and can talk to any peer type. What it cannot talk to is a peer with no@@ -34,6 +38,7 @@3438     SYNC_DISABLED,3539     PRIVATE_RELAY_UNSET,3640     VAULT_LOCKED,41+    FOREGROUND_START_BLOCKED,3742     RELAY_UNREACHABLE,3843     HANDSHAKE_FAILED,3944     VAULT_ID_MISMATCH,@@ -50,6 +55,7 @@5055             NO_ELIGIBLE_HOST,5156             PRIVATE_RELAY_UNSET,5257             VAULT_LOCKED,58+            FOREGROUND_START_BLOCKED,5359             RELAY_UNREACHABLE,5460             HANDSHAKE_FAILED,5561             VAULT_ID_MISMATCH,@@ -131,6 +137,8 @@131137         SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"132138         SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"133139         SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"140+        SyncOutcomeReason.FOREGROUND_START_BLOCKED ->141+            "Android blocked Peach from starting sync in the background — open Peach and sync again"134142         SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"135143         SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"136144         SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"

view file

app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+8 −0
@@ -41,6 +41,9 @@4141                 SyncOutcomeReason.NO_ELIGIBLE_HOST,4242                 SyncOutcomeReason.PRIVATE_RELAY_UNSET,4343                 SyncOutcomeReason.VAULT_LOCKED,44+                // Android refused to start the runner: nothing synced, and the45+                // person can do something about it.46+                SyncOutcomeReason.FOREGROUND_START_BLOCKED,4447                 SyncOutcomeReason.RELAY_UNREACHABLE,4548                 SyncOutcomeReason.HANDSHAKE_FAILED,4649                 SyncOutcomeReason.VAULT_ID_MISMATCH,@@ -105,6 +108,11 @@105108         assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST))106109         assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED))107110         assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED))111+        // Android-only, so there is no browser line to match — see the enum.112+        assertEquals(113+            "Android blocked Peach from starting sync in the background — open Peach and sync again",114+            copy(SyncOutcomeReason.FOREGROUND_START_BLOCKED),115+        )108116         assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE))109117         assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))110118         assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))

view file

app/src/test/java/com/peachpasswords/sync/SyncServiceStartTest.kt+63 −0
@@ -0,0 +1,63 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+/**8+ * The service start is the first step of every sync path, and it was the one9+ * step that could throw straight out of a button press: Android 12+ refuses a10+ * foreground service started from the background, and `startForegroundService`11+ * was called bare.12+ */13+class SyncServiceStartTest {14+15+    /** The real thing on API 31+, whose class is not on this app's minSdk. */16+    private fun backgroundStartRefused(): IllegalStateException = IllegalStateException(17+        "Not allowed to start service Intent { cmp=com.peachpasswords/.sync.PeachSyncService }: " +18+            "app is in background",19+    )20+21+    @Test fun `a start Android refuses is reported, not thrown`() {22+        val reported = mutableListOf<SyncOutcomeSummary>()23+        val started = startOrReportBlocked({ reported += it }) { throw backgroundStartRefused() }24+25+        assertEquals(false, started)26+        assertEquals(listOf(SyncOutcomeReason.FOREGROUND_START_BLOCKED), reported.map { it.reason })27+        assertTrue(reported.single().detail?.contains("app is in background") == true)28+    }29+30+    @Test fun `the refusal says what the person holding the phone can do`() {31+        assertEquals(32+            "Android blocked Peach from starting sync in the background — open Peach and sync again",33+            describeSyncOutcome(SyncOutcomeSummary(reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED)),34+        )35+        assertEquals(36+            SyncIndicator.ERROR,37+            syncIndicator(38+                SyncOutcomeSummary(reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED),39+                pairedCount = 1,40+            ),41+        )42+    }43+44+    /** A start that fails some other way is still a sync that did not happen. */45+    @Test fun `anything else the platform throws is reported too, and never escapes`() {46+        val reported = mutableListOf<SyncOutcomeSummary>()47+        val started = startOrReportBlocked({ reported += it }) {48+            throw SecurityException("missing FOREGROUND_SERVICE_DATA_SYNC")49+        }50+51+        assertEquals(false, started)52+        assertEquals(listOf(SyncOutcomeReason.RUN_FAILED), reported.map { it.reason })53+    }54+55+    @Test fun `a start that works reports nothing`() {56+        val reported = mutableListOf<SyncOutcomeSummary>()57+        var ran = false58+59+        assertEquals(true, startOrReportBlocked({ reported += it }) { ran = true })60+        assertTrue(ran)61+        assertTrue(reported.isEmpty())62+    }63+}

view file