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 val notification = buildNotification(sessionTitle, sessionId, progress)
38 // startForeground can throw ForegroundServiceStartNotAllowedException on
39 // Android 12+ if the app is in the background when the service starts. Wrap
40 // it so a backgrounded start (e.g. the user navigates away at the wrong
41 // moment) doesn't crash the app — the SSE stream will just continue without
42 // foreground priority and may be killed by the system sooner.
44 ServiceCompat.startForeground(
48 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
49 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
55 Log.w(TAG, "startForeground failed; running without foreground priority", it)
58 return START_NOT_STICKY
61 private fun mainContentIntent(): PendingIntent {
62 // Tapping the notification opens the app so the user can see the running session.
63 val openIntent = Intent(this, MainActivity::class.java).apply {
64 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
66 return PendingIntent.getActivity(
68 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
72 private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
73 val pendingIntent = mainContentIntent()
74 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
75 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
76 .setSmallIcon(R.drawable.ic_stat_notify)
77 .setColor(BRAND_COLOR)
78 .setContentTitle(getString(R.string.notif_running_title))
79 .setContentText(contentText)
80 .setContentIntent(pendingIntent)
82 // Show a live elapsed timer so the user can see how long the current run has
83 // been going — no per-tick plumbing needed; the system renders the chronometer.
84 .setWhen(System.currentTimeMillis())
85 .setUsesChronometer(true)
87 .setPriority(NotificationCompat.PRIORITY_LOW)
88 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
89 // An indeterminate progress bar so the notification reads as actively "in progress"
90 // at a glance (alongside the chronometer), matching the conventional run affordance.
91 .setProgress(0, 0, true)
92 // Add a Stop action so the user can cancel the run without opening the app — the
93 // core of the "kick it off and walk away" flow. Only when a specific session is
94 // known (multiple concurrent runs can't be targeted individually).
95 if (sessionId != null) {
96 val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
97 action = NotificationActionReceiver.ACTION_STOP_RUN
98 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
100 val stopPending = PendingIntent.getBroadcast(
101 this, sessionId.hashCode(), stopIntent,
102 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
105 R.drawable.ic_action_stop,
106 getString(R.string.notif_action_stop),
110 return builder.build()
113 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
114 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
115 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
116 // continues without foreground priority. Post a low-priority notification so the user
117 // knows their long-running task may be paused in the background, rather than silently
118 // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
119 override fun onTimeout(startId: Int) {
120 Log.w(TAG, "Foreground service timed out; stopping")
121 postTimeoutNotification()
125 override fun onTimeout(startId: Int, fgsType: Int) {
126 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
127 postTimeoutNotification()
131 /** Post a low-priority notification informing the user that the foreground service was
132 * timed out by the system — the run continues but may be killed by Doze without warning. */
133 @SuppressLint("MissingPermission")
134 private fun postTimeoutNotification() {
136 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
137 .setSmallIcon(R.drawable.ic_stat_notify)
138 .setColor(BRAND_COLOR)
139 .setContentTitle(getString(R.string.notif_running_title))
140 .setContentText(getString(R.string.notif_fg_timeout_text))
141 .setContentIntent(mainContentIntent())
143 .setPriority(NotificationCompat.PRIORITY_LOW)
145 NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
149 override fun onDestroy() {
150 // Safety net: if the process is being killed while a run is in progress,
151 // ensure the notification is removed rather than lingering.
152 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
156 override fun onBind(intent: Intent?): IBinder? = null
159 private const val TAG = "RunForegroundService"
160 private const val NOTIF_ID = 1
161 private const val NOTIF_TIMEOUT_ID = 2
162 // Brand accent for the foreground notifications' small-icon tint circle.
163 private val BRAND_COLOR = 0xFF34548A.toInt()
164 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
165 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
166 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
168 fun start(context: Context, sessionTitle: String? = null, sessionId: String? = null, progress: String? = null) {
169 // startForegroundService can throw ForegroundServiceStartNotAllowedException
170 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
171 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
173 val intent = Intent(context, RunForegroundService::class.java)
174 sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
175 sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
176 progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
177 context.startForegroundService(intent)
179 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
183 fun stop(context: Context) {
184 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