git.iko.soy/opencode-android
on 066cded help sign in
121 lines 5.3 kB history raw
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.os.Build
10import android.os.IBinder
11import android.util.Log
12import androidx.core.app.NotificationCompat
13import androidx.core.app.ServiceCompat
14import soy.iko.opencode.MainActivity
15import soy.iko.opencode.R
17/**
18 * A foreground service kept alive while an opencode agent is actively running. Holding a
19 * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
20 * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
21 *
22 * The service is started when a run begins ([start]) and stopped when it goes idle
23 * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
24 * Started/stopped via Compose from the chat screen based on the `running` state.
25 */
26class RunForegroundService : Service() {
28 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29 val notification = buildNotification()
30 // startForeground can throw ForegroundServiceStartNotAllowedException on
31 // Android 12+ if the app is in the background when the service starts. Wrap
32 // it so a backgrounded start (e.g. the user navigates away at the wrong
33 // moment) doesn't crash the app — the SSE stream will just continue without
34 // foreground priority and may be killed by the system sooner.
35 runCatching {
36 ServiceCompat.startForeground(
37 this,
38 NOTIF_ID,
39 notification,
40 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
41 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
42 } else {
43 0
44 },
45 )
46 }.onFailure {
47 Log.w(TAG, "startForeground failed; running without foreground priority", it)
48 stopSelf()
49 }
50 return START_NOT_STICKY
51 }
53 private fun buildNotification(): Notification {
54 // Tapping the notification opens the app so the user can see the running session.
55 val openIntent = Intent(this, MainActivity::class.java).apply {
56 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
57 }
58 val pendingIntent = PendingIntent.getActivity(
59 this, 0, openIntent,
60 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61 )
62 return NotificationCompat.Builder(this, NotificationChannels.STATUS)
63 .setSmallIcon(R.drawable.ic_launcher_foreground)
64 .setContentTitle(getString(R.string.notif_running_title))
65 .setContentText(getString(R.string.notif_running_text))
66 .setContentIntent(pendingIntent)
67 .setOngoing(true)
68 // Show a live elapsed timer so the user can see how long the current run has
69 // been going — no per-tick plumbing needed; the system renders the chronometer.
70 .setWhen(System.currentTimeMillis())
71 .setUsesChronometer(true)
72 .setShowWhen(true)
73 .setPriority(NotificationCompat.PRIORITY_LOW)
74 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
75 .build()
76 }
78 // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
79 // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
80 // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
81 // continues without foreground priority. Both overloads are covered (the 2-arg form is
82 // API 35+).
83 override fun onTimeout(startId: Int) {
84 Log.w(TAG, "Foreground service timed out; stopping")
85 stopSelf()
86 }
88 override fun onTimeout(startId: Int, fgsType: Int) {
89 Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
90 stopSelf()
91 }
93 override fun onDestroy() {
94 // Safety net: if the process is being killed while a run is in progress,
95 // ensure the notification is removed rather than lingering.
96 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
97 super.onDestroy()
98 }
100 override fun onBind(intent: Intent?): IBinder? = null
102 companion object {
103 private const val TAG = "RunForegroundService"
104 private const val NOTIF_ID = 1
106 fun start(context: Context) {
107 // startForegroundService can throw ForegroundServiceStartNotAllowedException
108 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
109 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
110 runCatching {
111 context.startForegroundService(Intent(context, RunForegroundService::class.java))
112 }.onFailure {
113 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
114 }
115 }
117 fun stop(context: Context) {
118 context.stopService(Intent(context, RunForegroundService::class.java))
119 }
120 }