fix(android): prevent FGS start-timeout crash from start/stop race (#7991)

ForegroundServiceDidNotStartInTimeException was reported on Play Console
(Android 14+, OEM-throttled devices, app in foreground). The stop helpers
stopTrackingService()/stopFocusModeService() used Activity.stopService(),
which bypasses onStartCommand and tears the service down directly. When a
focus/tracking session is started and quickly cancelled, AOSP
bringDownServiceLocked() crashes the process if the service is destroyed
while fgRequired is still true, i.e. before its first startForeground().

Add an isStartPending flag to each service companion (set before
startForegroundService(), cleared after promotion in onStartCommand and in
onDestroy) and route stops through ACTION_STOP via startService() while a
start is pending or the service is running, so onStartCommand promotes the
service before tearing it down. Cold no-op stops still use stopService().
A backgrounded stop (startService disallowed) falls back to stopService()
only when no start is pending; a pending start is left for onStartCommand
to promote and a later foreground sync to stop, so the fallback can never
re-trigger the same crash.
This commit is contained in:
Johannes Millan 2026-06-03 17:45:17 +02:00 committed by GitHub
parent 7c16868175
commit 7c948a4d51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 4 deletions

View file

@ -34,6 +34,25 @@ class FocusModeForegroundService : Service() {
var isRunning: Boolean = false
private set
// Marks the window between startForegroundService() and the first
// startForeground() inside onStartCommand(). A stop arriving in that
// window must NOT use stopService() — tearing down a start-foreground
// service before it promotes crashes the process with
// ForegroundServiceDidNotStartInTimeException (AOSP bringDownServiceLocked,
// fired while fgRequired is still true). JavaScriptInterface reads this to
// route such stops through onStartCommand (ACTION_STOP) instead.
@Volatile
var isStartPending: Boolean = false
private set
fun markStartPending() {
isStartPending = true
}
fun clearStartPending() {
isStartPending = false
}
// Live timer state mirrored into the companion so JavaScriptInterface
// can read it back after the WebView is recreated (app reopened from
// recents). Mirrors TrackingForegroundService's static-state pattern so
@ -128,10 +147,12 @@ class FocusModeForegroundService : Service() {
// after startForegroundService(). Promote before handling actions so
// newly started services satisfy that contract.
if (!ensureForegroundNotification()) {
clearStartPending()
reportForegroundFailure()
stopAfterForegroundFailure(startId)
return START_NOT_STICKY
}
clearStartPending()
when (intent?.action) {
ACTION_START -> {
@ -351,6 +372,10 @@ class FocusModeForegroundService : Service() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
isRunning = false
// Heal a never-promoted start: if the service was created but torn down
// before onStartCommand cleared it, drop the stale flag so the next cold
// stop uses stopService() rather than needlessly re-spawning the service.
clearStartPending()
handler.removeCallbacks(updateRunnable)
}

View file

@ -41,6 +41,25 @@ class TrackingForegroundService : Service() {
var isTracking: Boolean = false
private set
// Marks the window between startForegroundService() and the first
// startForeground() inside onStartCommand(). A stop arriving in that
// window must NOT use stopService() — tearing down a start-foreground
// service before it promotes crashes the process with
// ForegroundServiceDidNotStartInTimeException (AOSP bringDownServiceLocked,
// fired while fgRequired is still true). JavaScriptInterface reads this to
// route such stops through onStartCommand (ACTION_STOP) instead.
@Volatile
var isStartPending: Boolean = false
private set
fun markStartPending() {
isStartPending = true
}
fun clearStartPending() {
isStartPending = false
}
fun getElapsedMs(): Long {
return if (isTracking && startTimestamp > 0) {
(System.currentTimeMillis() - startTimestamp) + accumulatedMs
@ -75,10 +94,12 @@ class TrackingForegroundService : Service() {
// after startForegroundService(). Promote before handling actions so
// newly started services satisfy that contract.
if (!ensureForegroundNotification()) {
clearStartPending()
reportForegroundFailure()
stopAfterForegroundFailure(startId)
return START_NOT_STICKY
}
clearStartPending()
when (intent?.action) {
ACTION_START -> {
@ -260,6 +281,10 @@ class TrackingForegroundService : Service() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
isTracking = false
// Heal a never-promoted start: if the service was created but torn down
// before onStartCommand cleared it, drop the stale flag so the next cold
// stop uses stopService() rather than needlessly re-spawning the service.
clearStartPending()
handler.removeCallbacks(updateRunnable)
}

View file

@ -148,7 +148,13 @@ class JavaScriptInterface(
putExtra(TrackingForegroundService.EXTRA_TASK_TITLE, taskTitle)
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
}
ContextCompat.startForegroundService(activity, intent)
TrackingForegroundService.markStartPending()
try {
ContextCompat.startForegroundService(activity, intent)
} catch (e: Exception) {
TrackingForegroundService.clearStartPending()
throw e
}
}
}
@ -157,7 +163,29 @@ class JavaScriptInterface(
fun stopTrackingService() {
safeCall("Failed to stop tracking service") {
val intent = Intent(activity, TrackingForegroundService::class.java)
activity.stopService(intent)
if (TrackingForegroundService.isStartPending || TrackingForegroundService.isTracking) {
// A startForegroundService() may still be promoting: stopping via
// stopService() now could tear it down before startForeground()
// runs and crash with ForegroundServiceDidNotStartInTimeException.
// Routing as ACTION_STOP through onStartCommand lets it promote
// first, then stop cleanly.
intent.action = TrackingForegroundService.ACTION_STOP
try {
activity.startService(intent)
} catch (e: IllegalStateException) {
// App is in the background: startService() is disallowed here.
// Only fall back to stopService() if no start is still pending
// — stopping a not-yet-promoted service would re-trigger the
// same crash. If a start IS pending, leave it: the pending
// start promotes and a later foreground sync stops it cleanly.
Log.d(TAG, "stopTrackingService: app backgrounded, falling back to stopService()", e)
if (!TrackingForegroundService.isStartPending) {
activity.stopService(Intent(activity, TrackingForegroundService::class.java))
}
}
} else {
activity.stopService(intent)
}
}
}
@ -209,7 +237,13 @@ class JavaScriptInterface(
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
}
ContextCompat.startForegroundService(activity, intent)
FocusModeForegroundService.markStartPending()
try {
ContextCompat.startForegroundService(activity, intent)
} catch (e: Exception) {
FocusModeForegroundService.clearStartPending()
throw e
}
}
}
@ -218,7 +252,29 @@ class JavaScriptInterface(
fun stopFocusModeService() {
safeCall("Failed to stop focus mode service") {
val intent = Intent(activity, FocusModeForegroundService::class.java)
activity.stopService(intent)
if (FocusModeForegroundService.isStartPending || FocusModeForegroundService.isRunning) {
// A startForegroundService() may still be promoting: stopping via
// stopService() now could tear it down before startForeground()
// runs and crash with ForegroundServiceDidNotStartInTimeException.
// Routing as ACTION_STOP through onStartCommand lets it promote
// first, then stop cleanly.
intent.action = FocusModeForegroundService.ACTION_STOP
try {
activity.startService(intent)
} catch (e: IllegalStateException) {
// App is in the background: startService() is disallowed here.
// Only fall back to stopService() if no start is still pending
// — stopping a not-yet-promoted service would re-trigger the
// same crash. If a start IS pending, leave it: the pending
// start promotes and a later foreground sync stops it cleanly.
Log.d(TAG, "stopFocusModeService: app backgrounded, falling back to stopService()", e)
if (!FocusModeForegroundService.isStartPending) {
activity.stopService(Intent(activity, FocusModeForegroundService::class.java))
}
}
} else {
activity.stopService(intent)
}
}
}