1package soy.iko.opencode.notification
3import android.app.Notification
4import android.app.PendingIntent
5import android.app.Service
6import android.content.Context
7import android.content.Intent
8import android.content.pm.ServiceInfo
9import android.content.res.Configuration
10import android.os.Build
11import android.os.IBinder
12import android.util.Log
13import android.annotation.SuppressLint
14import androidx.core.app.NotificationCompat
15import androidx.core.app.NotificationManagerCompat
16import androidx.core.app.ServiceCompat
17import soy.iko.opencode.MainActivity
18import soy.iko.opencode.R
21 * A foreground service kept alive while an opencode agent is actively running. Holding a
22 * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
23 * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
25 * The service is started when a run begins ([start]) and stopped when it goes idle
26 * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
27 * Started/stopped via Compose from the chat screen based on the `running` state.
29class RunForegroundService : Service() {
31 // Wall-clock time the current run started, captured on the first onStartCommand of
32 // the run and reused across progress-driven notification rebuilds so the chronometer's
33 // base doesn't jump back to 0:00 every time the agent advances a step. Reset to 0 in
34 // onDestroy once the run ends (stop() → onDestroy) so the next run starts fresh.
36 // Instance field (not companion/static): a static field would survive a stopped-then-
37 // recreated service instance and leave the chronometer pinned to a previous run's start
38 // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
39 // field it's reset by construction on every new Service instance.
40 private var runStartMillis: Long = 0L
41 // Whether *this* service instance has successfully called startForeground. Tracked so
42 // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
43 // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
44 // ForegroundServiceStartNotAllowedException even though the service is already in the
45 // foreground. After the first successful startForeground, progress updates refresh the
46 // notification via NotificationManager.notify directly (see [updateProgress]), which
47 // never throws the background-start exception.
48 private var isInForeground: Boolean = false
49 // The most recent session title/id/profile/progress handed to onStartCommand, retained so
50 // [updateProgress] can rebuild the notification with a new progress string without a fresh
51 // startForegroundService intent (which can throw when backgrounded).
52 private var lastTitle: String? = null
53 private var lastSessionId: String? = null
54 private var lastProfileId: String? = null
55 private var lastProgress: String? = null
57 @android.annotation.SuppressLint("MissingPermission")
58 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
60 // The session title (when a single run is active) is passed via the intent extra so
61 // the notification can identify which session is running, instead of a generic
62 // "Agent is working…". Null/blank falls back to the generic title.
63 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
64 val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
65 val profileId = intent?.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
66 val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
67 lastTitle = sessionTitle
68 lastSessionId = sessionId
69 lastProfileId = profileId
70 lastProgress = progress
71 // Capture the wall-clock start of the *first* intent of this run and reuse it across
72 // rebuilds so the chronometer's base doesn't jump back to 0:00 every time the agent
73 // advances a step. As an instance field it's 0 on a fresh service, so a recreated
74 // service (after onDestroy reset) starts the clock fresh too.
75 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
76 val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
77 // Only call startForeground once per service lifetime — re-calling it on every
78 // progress-driven re-start re-binds the "must call startForeground within 5s"
79 // obligation each time, and if the app is backgrounded between two progress
80 // updates the new call can throw ForegroundServiceStartNotAllowedException even
81 // though the service is already in the foreground. That would drop foreground
82 // priority mid-run (the catch below stopSelf()s, killing the FGS while
83 // anyRunActive is still true). After the first successful startForeground,
84 // progress updates refresh the notification via NotificationManager.notify
85 // directly, which never throws the background-start exception.
86 if (!isInForeground) {
88 ServiceCompat.startForeground(
92 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
93 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
99 Log.w(TAG, "startForeground failed; running without foreground priority", it)
101 }.also { isInForeground = it.isSuccess }
103 // Already in the foreground: refresh the notification in place. NotificationManager
104 // updates an ongoing notification without re-asserting foreground priority.
106 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
107 }.onFailure { Log.w(TAG, "Failed to update foreground notification", it) }
109 return START_NOT_STICKY
112 /** Rebuild the notification with an updated progress string and re-notify in place, *without*
113 * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
114 * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
115 * the app is backgrounded — even if the service is already in the foreground). Called directly
116 * by the companion [updateProgress] when a progress refresh is needed while the service is
117 * already running; falls back to [start] (which goes through startForegroundService) when the
118 * service isn't alive yet, so the first progress update still lands. */
119 @SuppressLint("MissingPermission")
120 private fun updateProgress(progress: String?) {
121 // Guard against the companion start() → instance race: start() reads @Volatile instance
122 // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
123 // can null instance and call stopForeground between the read and this notify(). isInForeground
124 // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
125 // and avoids touching NotificationManager during teardown.
126 if (!isInForeground) return
127 lastProgress = progress
128 val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
130 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
131 }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
134 private fun mainContentIntent(): PendingIntent {
135 // Tapping the notification opens the app so the user can see the running session.
136 val openIntent = Intent(this, MainActivity::class.java).apply {
137 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
139 return PendingIntent.getActivity(
141 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
145 private fun buildNotification(
146 sessionTitle: String?,
148 profileId: String? = null,
149 progress: String? = null,
151 val pendingIntent = mainContentIntent()
152 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
153 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
154 .setSmallIcon(R.drawable.ic_stat_notify)
155 .setColor(brandColor(this))
156 .setContentTitle(getString(R.string.notif_running_title))
157 .setContentText(contentText)
158 .setContentIntent(pendingIntent)
160 // Show a live elapsed timer so the user can see how long the current run has
161 // been going — no per-tick plumbing needed; the system renders the chronometer.
162 // runStartMillis is captured once at the start of the run (see onStartCommand)
163 // and reused across progress-driven rebuilds so the chronometer base is stable.
164 .setWhen(runStartMillis)
165 .setUsesChronometer(true)
167 .setPriority(NotificationCompat.PRIORITY_LOW)
168 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
169 // An indeterminate progress bar so the notification reads as actively "in progress"
170 // at a glance (alongside the chronometer), matching the conventional run affordance.
171 .setProgress(0, 0, true)
172 // Add a Stop action so the user can cancel the run without opening the app — the
173 // core of the "kick it off and walk away" flow. Only when a specific session is
174 // known (multiple concurrent runs can't be targeted individually). The profile id
175 // is embedded so [AppContainer.abortRunFromNotification]'s routing guard sends the
176 // abort to the originating server even if the user has switched profiles between
177 // starting the run and tapping Stop (mirroring the other notification actions).
178 if (sessionId != null) {
179 val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
180 action = NotificationActionReceiver.ACTION_STOP_RUN
181 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
182 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
184 val stopPending = PendingIntent.getBroadcast(
185 this, sessionId.hashCode(), stopIntent,
186 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
189 R.drawable.ic_action_stop,
190 getString(R.string.notif_action_stop),
194 return builder.build()
197 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
198 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
199 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
200 // continues without foreground priority. Post a low-priority notification so the user
201 // knows their long-running task may be paused in the background, rather than silently
202 // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
203 override fun onTimeout(startId: Int) {
204 Log.w(TAG, "Foreground service timed out; stopping")
205 postTimeoutNotification()
209 override fun onTimeout(startId: Int, fgsType: Int) {
210 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
211 postTimeoutNotification()
215 /** Post a low-priority notification informing the user that the foreground service was
216 * timed out by the system — the run continues but may be killed by Doze without warning. */
217 @SuppressLint("MissingPermission")
218 private fun postTimeoutNotification() {
220 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
221 .setSmallIcon(R.drawable.ic_stat_notify)
222 .setColor(brandColor(this))
223 .setContentTitle(getString(R.string.notif_running_title))
224 .setContentText(getString(R.string.notif_fg_timeout_text))
225 .setContentIntent(mainContentIntent())
227 .setPriority(NotificationCompat.PRIORITY_LOW)
229 NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
233 override fun onDestroy() {
234 // Safety net: if the process is being killed while a run is in progress,
235 // ensure the notification is removed rather than lingering.
236 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
237 // Clear the run-start / foreground / instance state so the next run's chronometer
238 // begins fresh and startForeground is re-asserted on the first intent of the next
239 // run. These are instance fields, so a recreated service resets them by construction
240 // — but clearing here also covers the same-instance restart case.
242 isInForeground = false
251 override fun onBind(intent: Intent?): IBinder? = null
253 // Reset the brand-color cache when the configuration (notably uiMode/night mode) changes
254 // so the notification icon tint follows the app theme instead of staying pinned to whatever
255 // the first build resolved. The service isn't recreated on a config change (it's a service,
256 // not an Activity), so without this the cached color is stale until onDestroy.
257 override fun onConfigurationChanged(newConfig: Configuration) {
258 super.onConfigurationChanged(newConfig)
263 private const val TAG = "RunForegroundService"
264 private const val NOTIF_ID = 1
265 private const val NOTIF_TIMEOUT_ID = 2
266 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
267 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
268 // instead of a hardcoded constant. Lazily computed on first use from the app context
269 // and reset in onConfigurationChanged so a theme toggle mid-run doesn't pin the tint
270 // to the old theme for the rest of the run.
271 private var brandColorCache: Int = 0
272 private fun brandColor(context: Context): Int {
273 if (brandColorCache == 0) {
274 brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
276 return brandColorCache
278 // Live reference to the running service instance, published in onStartCommand and
279 // cleared in onDestroy. @Volatile so [updateProgress]'s read from the app-scope
280 // coroutine sees the most recent publication. Null when the service isn't running.
281 @Volatile private var instance: RunForegroundService? = null
282 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
283 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
284 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
285 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
287 /** Start the service (or refresh an already-running one). [profileId] is embedded in the
288 * Stop action's PendingIntent so an abort from the notification reaches the originating
289 * server even after a profile switch. */
292 sessionTitle: String? = null,
293 sessionId: String? = null,
294 profileId: String? = null,
295 progress: String? = null,
297 // If the service is already in the foreground, refresh the notification in place
298 // via NotificationManager.notify (through the live instance) instead of going
299 // through startForegroundService. On Android 12+, startForegroundService can
300 // throw ForegroundServiceStartNotAllowedException when the app is backgrounded
301 // — *even if the service is already running in the foreground* — and the
302 // runCatching below would swallow it, leaving the progress text un-updated
303 // precisely when the app is backgrounded (the FGS's whole purpose). The in-place
304 // notify never throws, so progress refreshes always land while the service runs.
307 live.updateProgress(progress)
310 // startForegroundService can throw ForegroundServiceStartNotAllowedException
311 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
312 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
314 val intent = Intent(context, RunForegroundService::class.java)
315 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
316 sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
317 profileId?.let { intent.putExtra(EXTRA_PROFILE_ID, it) }
318 progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
319 context.startForegroundService(intent)
321 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
325 fun stop(context: Context) {
326 context.stopService(Intent(context, RunForegroundService::class.java))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list