git.iko.soy/opencode-android
on d29e57b help sign in
101 lines 4.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 .setPriority(NotificationCompat.PRIORITY_LOW)
69 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
70 .build()
71 }
73 override fun onDestroy() {
74 // Safety net: if the process is being killed while a run is in progress,
75 // ensure the notification is removed rather than lingering.
76 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
77 super.onDestroy()
78 }
80 override fun onBind(intent: Intent?): IBinder? = null
82 companion object {
83 private const val TAG = "RunForegroundService"
84 private const val NOTIF_ID = 1
86 fun start(context: Context) {
87 // startForegroundService can throw ForegroundServiceStartNotAllowedException
88 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
89 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
90 runCatching {
91 context.startForegroundService(Intent(context, RunForegroundService::class.java))
92 }.onFailure {
93 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
94 }
95 }
97 fun stop(context: Context) {
98 context.stopService(Intent(context, RunForegroundService::class.java))
99 }
100 }