@@ -28,6 +28,7 @@ import soy.iko.opencode.R
29class RunForegroundService : Service() {
29class RunForegroundService : Service() {
31+ @android.annotation.SuppressLint("MissingPermission")
31 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
32 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
32 // The session title (when a single run is active) is passed via the intent extra so
33 // The session title (when a single run is active) is passed via the intent extra so
33 // the notification can identify which session is running, instead of a generic
34 // the notification can identify which session is running, instead of a generic
@@ -42,25 +43,37 @@ class RunForegroundService : Service() {
42 // the run ends (stop() → onDestroy) so the next run starts fresh.
43 // the run ends (stop() → onDestroy) so the next run starts fresh.
43 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
44 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
44 val notification = buildNotification(sessionTitle, sessionId, progress)
45 val notification = buildNotification(sessionTitle, sessionId, progress)
46+ // Only call startForeground once per service lifetime — re-calling it on every
47+ // progress-driven re-start re-binds the "must call startForeground within 5s"
48+ // obligation each time, and if the app is backgrounded between two progress
45− // startForeground can throw ForegroundServiceStartNotAllowedException on
49+ // updates the new call can throw ForegroundServiceStartNotAllowedException even
46− // Android 12+ if the app is in the background when the service starts. Wrap
50+ // though the service is already in the foreground. That would drop foreground
47− // it so a backgrounded start (e.g. the user navigates away at the wrong
51+ // priority mid-run (the catch below stopSelf()s, killing the FGS while
48− // moment) doesn't crash the app — the SSE stream will just continue without
52+ // anyRunActive is still true). After the first successful startForeground,
49− // foreground priority and may be killed by the system sooner.
53+ // progress updates refresh the notification via NotificationManager.notify
54+ // directly, which never throws the background-start exception.
55+ if (!isInForeground) {
50− runCatching {
56+ runCatching {
51− ServiceCompat.startForeground(
57+ ServiceCompat.startForeground(
53− NOTIF_ID,
59+ NOTIF_ID,
54− notification,
60+ notification,
55− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
61+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
56− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
62+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
61− }.onFailure {
67+ }.onFailure {
62− Log.w(TAG, "startForeground failed; running without foreground priority", it)
68+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
63− stopSelf()
69+ stopSelf()
70+ }.also { isInForeground = it.isSuccess }
72+ // Already in the foreground: refresh the notification in place. NotificationManager
73+ // updates an ongoing notification without re-asserting foreground priority.
75+ NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
76+ }.onFailure { Log.w(TAG, "Failed to update foreground notification", it) }
65 return START_NOT_STICKY
78 return START_NOT_STICKY
@@ -159,9 +172,10 @@ class RunForegroundService : Service() {
159 // Safety net: if the process is being killed while a run is in progress,
172 // Safety net: if the process is being killed while a run is in progress,
160 // ensure the notification is removed rather than lingering.
173 // ensure the notification is removed rather than lingering.
161 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
174 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
162− // Reset the run start so the next run's chronometer begins fresh instead of
175+ // Reset the run start and foreground state so the next run's chronometer begins
163− // inheriting the previous run's elapsed base.
176+ // fresh and startForeground is re-asserted on the first intent of the next run.
164 runStartMillis = 0L
177 runStartMillis = 0L
178+ isInForeground = false
165 super.onDestroy()
179 super.onDestroy()
@@ -184,6 +198,11 @@ class RunForegroundService : Service() {
184 // the run and reused across progress-driven notification rebuilds so the chronometer
198 // the run and reused across progress-driven notification rebuilds so the chronometer
185 // base is stable. Reset to 0 in onDestroy when the run ends.
199 // base is stable. Reset to 0 in onDestroy when the run ends.
186 @Volatile private var runStartMillis: Long = 0L
200 @Volatile private var runStartMillis: Long = 0L
201+ // Whether the service has successfully called startForeground and is currently in
202+ // the foreground. Tracked so progress-driven re-starts (which re-enter
203+ // onStartCommand) don't re-call startForeground and re-bind the 5s obligation each
204+ // time — that would let a backgrounded re-start throw and stopSelf() the FGS mid-run.
205+ @Volatile private var isInForeground: Boolean = false
187 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
206 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
188 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
207 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
189 // instead of a hardcoded constant. Lazily computed on first use from the app context
208 // instead of a hardcoded constant. Lazily computed on first use from the app context