diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt index 272fd2b8be..a85ccd2214 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt @@ -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) } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt index 2d8846583c..3bbac5edd6 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt @@ -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) } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index fb58be5d14..e805195ec8 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -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) + } } }