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 lastProgress = progress
122 val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
124 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
125 }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
128 private fun mainContentIntent(): PendingIntent {
129 // Tapping the notification opens the app so the user can see the running session.
130 val openIntent = Intent(this, MainActivity::class.java).apply {
131 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
133 return PendingIntent.getActivity(
135 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
139 private fun buildNotification(
140 sessionTitle: String?,
142 profileId: String? = null,
143 progress: String? = null,
145 val pendingIntent = mainContentIntent()
146 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
147 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
148 .setSmallIcon(R.drawable.ic_stat_notify)
149 .setColor(brandColor(this))
150 .setContentTitle(getString(R.string.notif_running_title))
151 .setContentText(contentText)
152 .setContentIntent(pendingIntent)
154 // Show a live elapsed timer so the user can see how long the current run has
155 // been going — no per-tick plumbing needed; the system renders the chronometer.
156 // runStartMillis is captured once at the start of the run (see onStartCommand)
157 // and reused across progress-driven rebuilds so the chronometer base is stable.
158 .setWhen(runStartMillis)
159 .setUsesChronometer(true)
161 .setPriority(NotificationCompat.PRIORITY_LOW)
162 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
163 // An indeterminate progress bar so the notification reads as actively "in progress"
164 // at a glance (alongside the chronometer), matching the conventional run affordance.
165 .setProgress(0, 0, true)
166 // Add a Stop action so the user can cancel the run without opening the app — the
167 // core of the "kick it off and walk away" flow. Only when a specific session is
168 // known (multiple concurrent runs can't be targeted individually). The profile id
169 // is embedded so [AppContainer.abortRunFromNotification]'s routing guard sends the
170 // abort to the originating server even if the user has switched profiles between
171 // starting the run and tapping Stop (mirroring the other notification actions).
172 if (sessionId != null) {
173 val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
174 action = NotificationActionReceiver.ACTION_STOP_RUN
175 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
176 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
178 val stopPending = PendingIntent.getBroadcast(
179 this, sessionId.hashCode(), stopIntent,
180 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
183 R.drawable.ic_action_stop,
184 getString(R.string.notif_action_stop),
188 return builder.build()
191 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
192 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
193 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
194 // continues without foreground priority. Post a low-priority notification so the user
195 // knows their long-running task may be paused in the background, rather than silently
196 // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
197 override fun onTimeout(startId: Int) {
198 Log.w(TAG, "Foreground service timed out; stopping")
199 postTimeoutNotification()
203 override fun onTimeout(startId: Int, fgsType: Int) {
204 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
205 postTimeoutNotification()
209 /** Post a low-priority notification informing the user that the foreground service was
210 * timed out by the system — the run continues but may be killed by Doze without warning. */
211 @SuppressLint("MissingPermission")
212 private fun postTimeoutNotification() {
214 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
215 .setSmallIcon(R.drawable.ic_stat_notify)
216 .setColor(brandColor(this))
217 .setContentTitle(getString(R.string.notif_running_title))
218 .setContentText(getString(R.string.notif_fg_timeout_text))
219 .setContentIntent(mainContentIntent())
221 .setPriority(NotificationCompat.PRIORITY_LOW)
223 NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
227 override fun onDestroy() {
228 // Safety net: if the process is being killed while a run is in progress,
229 // ensure the notification is removed rather than lingering.
230 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
231 // Clear the run-start / foreground / instance state so the next run's chronometer
232 // begins fresh and startForeground is re-asserted on the first intent of the next
233 // run. These are instance fields, so a recreated service resets them by construction
234 // — but clearing here also covers the same-instance restart case.
236 isInForeground = false
245 override fun onBind(intent: Intent?): IBinder? = null
247 // Reset the brand-color cache when the configuration (notably uiMode/night mode) changes
248 // so the notification icon tint follows the app theme instead of staying pinned to whatever
249 // the first build resolved. The service isn't recreated on a config change (it's a service,
250 // not an Activity), so without this the cached color is stale until onDestroy.
251 override fun onConfigurationChanged(newConfig: Configuration) {
252 super.onConfigurationChanged(newConfig)
257 private const val TAG = "RunForegroundService"
258 private const val NOTIF_ID = 1
259 private const val NOTIF_TIMEOUT_ID = 2
260 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
261 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
262 // instead of a hardcoded constant. Lazily computed on first use from the app context
263 // and reset in onConfigurationChanged so a theme toggle mid-run doesn't pin the tint
264 // to the old theme for the rest of the run.
265 private var brandColorCache: Int = 0
266 private fun brandColor(context: Context): Int {
267 if (brandColorCache == 0) {
268 brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
270 return brandColorCache
272 // Live reference to the running service instance, published in onStartCommand and
273 // cleared in onDestroy. @Volatile so [updateProgress]'s read from the app-scope
274 // coroutine sees the most recent publication. Null when the service isn't running.
275 @Volatile private var instance: RunForegroundService? = null
276 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
277 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
278 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
279 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
281 /** Start the service (or refresh an already-running one). [profileId] is embedded in the
282 * Stop action's PendingIntent so an abort from the notification reaches the originating
283 * server even after a profile switch. */
286 sessionTitle: String? = null,
287 sessionId: String? = null,
288 profileId: String? = null,
289 progress: String? = null,
291 // If the service is already in the foreground, refresh the notification in place
292 // via NotificationManager.notify (through the live instance) instead of going
293 // through startForegroundService. On Android 12+, startForegroundService can
294 // throw ForegroundServiceStartNotAllowedException when the app is backgrounded
295 // — *even if the service is already running in the foreground* — and the
296 // runCatching below would swallow it, leaving the progress text un-updated
297 // precisely when the app is backgrounded (the FGS's whole purpose). The in-place
298 // notify never throws, so progress refreshes always land while the service runs.
301 live.updateProgress(progress)
304 // startForegroundService can throw ForegroundServiceStartNotAllowedException
305 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
306 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
308 val intent = Intent(context, RunForegroundService::class.java)
309 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
310 sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
311 profileId?.let { intent.putExtra(EXTRA_PROFILE_ID, it) }
312 progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
313 context.startForegroundService(intent)
315 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
319 fun stop(context: Context) {
320 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