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
10import android.os.IBinder
11import android.util.Log
12import android.annotation.SuppressLint
13import androidx.core.app.NotificationCompat
14import androidx.core.app.NotificationManagerCompat
15import androidx.core.app.ServiceCompat
16import soy.iko.opencode.MainActivity
17import soy.iko.opencode.R
20 * A foreground service kept alive while an opencode agent is actively running. Holding a
21 * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
22 * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
24 * The service is started when a run begins ([start]) and stopped when it goes idle
25 * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
26 * Started/stopped via Compose from the chat screen based on the `running` state.
28class RunForegroundService : Service() {
30 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
31 // The session title (when a single run is active) is passed via the intent extra so
32 // the notification can identify which session is running, instead of a generic
33 // "Agent is working…". Null/blank falls back to the generic title.
34 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
35 val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
36 val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
37 // The service is re-started per progress update (AppContainer re-sends the intent on
38 // each _runProgressText change). Capture the wall-clock start of the *first* intent
39 // of this run and reuse it across rebuilds so the chronometer's base doesn't jump
40 // back to 0:00 every time the agent advances a step. Reset to 0 in onDestroy once
41 // the run ends (stop() → onDestroy) so the next run starts fresh.
42 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
43 val notification = buildNotification(sessionTitle, sessionId, progress)
44 // startForeground can throw ForegroundServiceStartNotAllowedException on
45 // Android 12+ if the app is in the background when the service starts. Wrap
46 // it so a backgrounded start (e.g. the user navigates away at the wrong
47 // moment) doesn't crash the app — the SSE stream will just continue without
48 // foreground priority and may be killed by the system sooner.
50 ServiceCompat.startForeground(
54 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
55 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
61 Log.w(TAG, "startForeground failed; running without foreground priority", it)
64 return START_NOT_STICKY
67 private fun mainContentIntent(): PendingIntent {
68 // Tapping the notification opens the app so the user can see the running session.
69 val openIntent = Intent(this, MainActivity::class.java).apply {
70 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
72 return PendingIntent.getActivity(
74 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
78 private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
79 val pendingIntent = mainContentIntent()
80 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
81 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
82 .setSmallIcon(R.drawable.ic_stat_notify)
83 .setColor(brandColor(this))
84 .setContentTitle(getString(R.string.notif_running_title))
85 .setContentText(contentText)
86 .setContentIntent(pendingIntent)
88 // Show a live elapsed timer so the user can see how long the current run has
89 // been going — no per-tick plumbing needed; the system renders the chronometer.
90 // runStartMillis is captured once at the start of the run (see onStartCommand)
91 // and reused across progress-driven rebuilds so the chronometer base is stable.
92 .setWhen(runStartMillis)
93 .setUsesChronometer(true)
95 .setPriority(NotificationCompat.PRIORITY_LOW)
96 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
97 // An indeterminate progress bar so the notification reads as actively "in progress"
98 // at a glance (alongside the chronometer), matching the conventional run affordance.
99 .setProgress(0, 0, true)
100 // Add a Stop action so the user can cancel the run without opening the app — the
101 // core of the "kick it off and walk away" flow. Only when a specific session is
102 // known (multiple concurrent runs can't be targeted individually).
103 if (sessionId != null) {
104 val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
105 action = NotificationActionReceiver.ACTION_STOP_RUN
106 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
108 val stopPending = PendingIntent.getBroadcast(
109 this, sessionId.hashCode(), stopIntent,
110 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
113 R.drawable.ic_action_stop,
114 getString(R.string.notif_action_stop),
118 return builder.build()
121 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
122 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
123 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
124 // continues without foreground priority. Post a low-priority notification so the user
125 // knows their long-running task may be paused in the background, rather than silently
126 // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
127 override fun onTimeout(startId: Int) {
128 Log.w(TAG, "Foreground service timed out; stopping")
129 postTimeoutNotification()
133 override fun onTimeout(startId: Int, fgsType: Int) {
134 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
135 postTimeoutNotification()
139 /** Post a low-priority notification informing the user that the foreground service was
140 * timed out by the system — the run continues but may be killed by Doze without warning. */
141 @SuppressLint("MissingPermission")
142 private fun postTimeoutNotification() {
144 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
145 .setSmallIcon(R.drawable.ic_stat_notify)
146 .setColor(brandColor(this))
147 .setContentTitle(getString(R.string.notif_running_title))
148 .setContentText(getString(R.string.notif_fg_timeout_text))
149 .setContentIntent(mainContentIntent())
151 .setPriority(NotificationCompat.PRIORITY_LOW)
153 NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
157 override fun onDestroy() {
158 // Safety net: if the process is being killed while a run is in progress,
159 // ensure the notification is removed rather than lingering.
160 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
161 // Reset the run start so the next run's chronometer begins fresh instead of
162 // inheriting the previous run's elapsed base.
167 override fun onBind(intent: Intent?): IBinder? = null
170 private const val TAG = "RunForegroundService"
171 private const val NOTIF_ID = 1
172 private const val NOTIF_TIMEOUT_ID = 2
173 // Wall-clock time the current run started, captured on the first onStartCommand of
174 // the run and reused across progress-driven notification rebuilds so the chronometer
175 // base is stable. Reset to 0 in onDestroy when the run ends.
176 @Volatile private var runStartMillis: Long = 0L
177 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
178 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
179 // instead of a hardcoded constant. Lazily computed on first use from the app context.
180 private var brandColorCache: Int = 0
181 private fun brandColor(context: Context): Int {
182 if (brandColorCache == 0) {
183 brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
185 return brandColorCache
187 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
188 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
189 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
191 fun start(context: Context, sessionTitle: String? = null, sessionId: String? = null, progress: String? = null) {
192 // startForegroundService can throw ForegroundServiceStartNotAllowedException
193 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
194 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
196 val intent = Intent(context, RunForegroundService::class.java)
197 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
198 sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
199 progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
200 context.startForegroundService(intent)
202 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
206 fun stop(context: Context) {
207 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