@@ -28,6 +28,7 @@ import soy.iko.opencode.R
2828 */
2929class RunForegroundService : Service() {
3030
31+ @android.annotation.SuppressLint("MissingPermission")
3132 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
3233 // The session title (when a single run is active) is passed via the intent extra so
3334 // the notification can identify which session is running, instead of a generic
@@ -42,25 +43,37 @@ class RunForegroundService : Service() {
4243 // the run ends (stop() → onDestroy) so the next run starts fresh.
4344 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
4445 val notification = buildNotification(sessionTitle, sessionId, progress)
45− // startForeground can throw ForegroundServiceStartNotAllowedException on
46− // Android 12+ if the app is in the background when the service starts. Wrap
47− // it so a backgrounded start (e.g. the user navigates away at the wrong
48− // moment) doesn't crash the app — the SSE stream will just continue without
49− // foreground priority and may be killed by the system sooner.
50− runCatching {
51− ServiceCompat.startForeground(
52− this,
53− NOTIF_ID,
54− notification,
55− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
56− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
57− } else {
58− 0
59− },
60− )
61− }.onFailure {
62− Log.w(TAG, "startForeground failed; running without foreground priority", it)
63− stopSelf()
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
49+ // updates the new call can throw ForegroundServiceStartNotAllowedException even
50+ // though the service is already in the foreground. That would drop foreground
51+ // priority mid-run (the catch below stopSelf()s, killing the FGS while
52+ // anyRunActive is still true). After the first successful startForeground,
53+ // progress updates refresh the notification via NotificationManager.notify
54+ // directly, which never throws the background-start exception.
55+ if (!isInForeground) {
56+ runCatching {
57+ ServiceCompat.startForeground(
58+ this,
59+ NOTIF_ID,
60+ notification,
61+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
62+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
63+ } else {
64+ 0
65+ },
66+ )
67+ }.onFailure {
68+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
69+ stopSelf()
70+ }.also { isInForeground = it.isSuccess }
71+ } else {
72+ // Already in the foreground: refresh the notification in place. NotificationManager
73+ // updates an ongoing notification without re-asserting foreground priority.
74+ runCatching {
75+ NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
76+ }.onFailure { Log.w(TAG, "Failed to update foreground notification", it) }
6477 }
6578 return START_NOT_STICKY
6679 }
@@ -159,9 +172,10 @@ class RunForegroundService : Service() {
159172 // Safety net: if the process is being killed while a run is in progress,
160173 // ensure the notification is removed rather than lingering.
161174 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
162− // Reset the run start so the next run's chronometer begins fresh instead of
163− // inheriting the previous run's elapsed base.
175+ // Reset the run start and foreground state so the next run's chronometer begins
176+ // fresh and startForeground is re-asserted on the first intent of the next run.
164177 runStartMillis = 0L
178+ isInForeground = false
165179 super.onDestroy()
166180 }
167181
@@ -184,6 +198,11 @@ class RunForegroundService : Service() {
184198 // the run and reused across progress-driven notification rebuilds so the chronometer
185199 // base is stable. Reset to 0 in onDestroy when the run ends.
186200 @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
187206 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
188207 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
189208 // instead of a hardcoded constant. Lazily computed on first use from the app context