fix(push): stop IdleService dataSync FGS crash-loop on exhausted 24h cap

Root cause: after #302's runtime-cap fallBackToPeriodicSync() stops the
dataSync foreground service, IdleService was restarted (START_STICKY
null-intent redelivery + explicit startForegroundService) and onStartCommand
unconditionally called startForeground(DATA_SYNC) while the rolling-24h budget
was still exhausted. The platform rejected the start with
ForegroundServiceStartNotAllowedException; it was uncaught, the process
crashed, and START_STICKY restarted straight back into the same rejection -- a
crash loop until the 24h window freed budget (#354).

Fix (IdleService.kt):
- onStartCommand now returns START_NOT_STICKY. Push is app-managed
  (LibreMailApplication.ensurePushStarted deterministically restarts it), so the
  sticky null-intent auto-restart was redundant and fired exactly when a dataSync
  FGS start is illegal.
- Guard the foreground start via a new JVM-testable IdleForegroundStarter seam:
  a ForegroundServiceStartNotAllowedException (caught via its IllegalStateException
  supertype, so no minSdk-29 class load) degrades like the cap handler --
  schedulePeriodicSync(), keep the degraded POLLING notification, stopSelf()
  promptly (avoids the "did not call startForeground in time" ANR) -- instead of
  propagating.
- Record the cap event (elapsedRealtime); while still inside the cap window,
  onStartCommand skips the now-guaranteed-illegal foreground start entirely.
- onTimeout stop path kept fast so ForegroundServiceDidNotStopInTimeException
  stays mitigated.

PII-free AppLog.w/i on the degrade paths.

Tests:
- Unit (IdleForegroundStarterTest): onStartCommand returns START_NOT_STICKY; a
  rejected start is caught and routed to degrade without propagating; the cap
  window skips the attempt; a non-ISE propagates.
- Instrumented (IdleServiceForegroundStartInstrumentedTest): the degrade path on
  a real Context -- rejection caught, periodic-sync fallback scheduled, degraded
  "instant delivery paused" notification built, watching skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:17:19 -05:00
co-authored by Claude Opus 4.8
parent 6cb179bd8b
commit 52503c000c
4 changed files with 358 additions and 23 deletions
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.app.Notification
import android.app.Service
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.sync.PushMode
/**
* On-device coverage of the #354 dataSync-FGS degrade path that [IdleService.onStartCommand] routes
* through [IdleForegroundStarter]. When a foreground start is rejected — the runtime-cap
* `ForegroundServiceStartNotAllowedException`, surfaced as its [IllegalStateException] supertype — the
* seam must catch it, skip IDLE watching, and degrade to periodic sync plus the degraded
* ("instant delivery paused") notification, never propagating. This drives the same decision seam the
* service uses and builds the real degraded notification with a real application `Context` (a
* `ContextWrapper`, never a mocked `Context`), mirroring `PushStatusNotificationInstrumentedTest`; it
* stands up no foreground service, Hilt graph, or network, so it is deterministic — and unlike a JVM
* unit test it exercises the real `Notification` build (the unit-test `android.jar`'s
* `NotificationCompat` is a no-op stub).
*/
@RunWith(AndroidJUnit4::class)
class IdleServiceForegroundStartInstrumentedTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
@Test
fun rejectedForegroundStart_degradesToPeriodicSyncWithPausedNotification_andSkipsWatching() {
val rejection = IllegalStateException(
"Time limit already exhausted for foreground service type dataSync",
)
var watchingStarted = false
var periodicSyncScheduled = false
var degradedNotification: Notification? = null
val result = IdleForegroundStarter.startForegroundOrDegrade(
capActive = false,
enterForeground = { throw rejection },
onStarted = { watchingStarted = true },
onDegraded = { cause ->
assertSame("the runtime-cap rejection must reach the degrade path", rejection, cause)
// Mirror IdleService.degradeToPeriodicSync on a real Context: (re)assert periodic sync and
// build the degraded status notification the service would post.
periodicSyncScheduled = true
PushStatusNotification.ensureChannel(context)
degradedNotification = PushStatusNotification.build(context, PushMode.POLLING, timedOut = true)
},
)
assertEquals(Service.START_NOT_STICKY, result)
assertFalse("a rejected dataSync FGS start must not begin IDLE watching", watchingStarted)
assertTrue("the degrade path must (re)assert the 15-minute periodic sync fallback", periodicSyncScheduled)
val notification = requireNotNull(degradedNotification) { "the degrade path must build a status notification" }
assertEquals(
"the degraded notification must show the instant-delivery-paused text",
context.getString(R.string.notif_push_status_text_timed_out),
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
)
}
@Test
fun activeCapWindow_skipsForegroundStartAttempt_andStillDegrades() {
var enterForegroundAttempted = false
var watchingStarted = false
var degraded = false
val result = IdleForegroundStarter.startForegroundOrDegrade(
capActive = true,
enterForeground = { enterForegroundAttempted = true },
onStarted = { watchingStarted = true },
onDegraded = { degraded = true },
)
assertEquals(Service.START_NOT_STICKY, result)
assertFalse("must not attempt a dataSync FGS start while still inside the cap window", enterForegroundAttempted)
assertFalse(watchingStarted)
assertTrue("must fall back to periodic sync while capped", degraded)
}
}