git.iko.soy/opencode-android
on 0bc1eda help sign in
84 lines 3.5 kB history raw
1package soy.iko.opencode.notification
3import android.app.Notification
4import android.app.Service
5import android.content.Context
6import android.content.Intent
7import android.content.pm.ServiceInfo
8import android.os.Build
9import android.os.IBinder
10import android.util.Log
11import androidx.core.app.NotificationCompat
12import androidx.core.app.ServiceCompat
13import soy.iko.opencode.R
15/**
16 * A foreground service kept alive while an opencode agent is actively running. Holding a
17 * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
18 * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
19 *
20 * The service is started when a run begins ([start]) and stopped when it goes idle
21 * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
22 * Started/stopped via Compose from the chat screen based on the `running` state.
23 */
24class RunForegroundService : Service() {
26 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
27 val notification = buildNotification()
28 // startForeground can throw ForegroundServiceStartNotAllowedException on
29 // Android 12+ if the app is in the background when the service starts. Wrap
30 // it so a backgrounded start (e.g. the user navigates away at the wrong
31 // moment) doesn't crash the app — the SSE stream will just continue without
32 // foreground priority and may be killed by the system sooner.
33 runCatching {
34 ServiceCompat.startForeground(
35 this,
36 NOTIF_ID,
37 notification,
38 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
39 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
40 } else {
41 0
42 },
43 )
44 }.onFailure { Log.w(TAG, "startForeground failed; running without foreground priority", it) }
45 return START_NOT_STICKY
46 }
48 private fun buildNotification(): Notification =
49 NotificationCompat.Builder(this, NotificationChannels.STATUS)
50 .setSmallIcon(R.drawable.ic_launcher_foreground)
51 .setContentTitle(getString(R.string.notif_running_title))
52 .setContentText(getString(R.string.notif_running_text))
53 .setOngoing(true)
54 .setPriority(NotificationCompat.PRIORITY_LOW)
55 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
56 .build()
58 override fun onDestroy() {
59 // Safety net: if the process is being killed while a run is in progress,
60 // ensure the notification is removed rather than lingering.
61 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
62 super.onDestroy()
63 }
65 override fun onBind(intent: Intent?): IBinder? = null
67 companion object {
68 private const val TAG = "RunForegroundService"
69 private const val NOTIF_ID = 1
71 fun start(context: Context) {
72 val intent = Intent(context, RunForegroundService::class.java)
73 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
74 context.startForegroundService(intent)
75 } else {
76 context.startService(intent)
77 }
78 }
80 fun stop(context: Context) {
81 context.stopService(Intent(context, RunForegroundService::class.java))
82 }
83 }