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
↓ peach-android-0c5ab4df.tar.gzatlas at this save
files changed4 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 −0app/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 }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"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))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+}