mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
7c16868175
commit
7c948a4d51
3 changed files with 110 additions and 4 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue