git.iko.soy/opencode-android
on 2794fda help sign in
opencode-android/app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt
195 lines 10 kB history raw
1package soy.iko.opencode.notification
3import android.content.BroadcastReceiver
4import android.content.Context
5import android.content.Intent
6import androidx.core.app.RemoteInput
7import soy.iko.opencode.OpencodeApp
8import soy.iko.opencode.R
9import soy.iko.opencode.data.model.PermissionResponse
10import soy.iko.opencode.data.network.NetworkConfig
11import java.util.concurrent.atomic.AtomicBoolean
13/**
14 * Handles the action buttons on the app's notifications so the user can respond without
15 * opening the app — the core of the "kick it off and walk away" flow:
16 *
17 * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
18 * request (Allow once / Always / Reject) via the active connection.
19 * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
20 * that just finished a run.
21 *
22 * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
23 * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
24 * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
25 *
26 * A watchdog finishes the [PendingResult] within [NetworkConfig.notificationReceiverTimeoutMs]
27 * so a slow network call (e.g. a permission respond retried with exponential backoff) can't
28 * ANR the receiver. The underlying call keeps running on the app scope; only the receiver's
29 * lifetime is bounded. [finishOnce] guards against a double finish() (which would throw).
30 */
31class NotificationActionReceiver : BroadcastReceiver() {
33 override fun onReceive(context: Context, intent: Intent) {
34 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
35 if (intent.action == ACTION_STOP_RUN) {
36 handleStopRun(context, container, intent)
37 return
38 }
39 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
40 when (intent.action) {
41 ACTION_MARK_READ -> {
42 // "Mark read" from a completion notification — clear the unread badge and
43 // dismiss the notification without opening the session.
44 container.markSessionRead(sessionId)
45 SessionNotifications.cancel(context, sessionId)
46 return
47 }
48 ACTION_PERMISSION -> {
49 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
50 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
51 val response = PermissionResponse.entries.firstOrNull {
52 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
53 } ?: return
54 val pending = goAsync()
55 val finished = AtomicBoolean(false)
56 var watchdogThread: Thread? = null
57 val finishOnce = {
58 if (finished.compareAndSet(false, true)) {
59 watchdogThread?.interrupt()
60 pending.finish()
61 }
62 }
63 watchdogThread = watchdog(finishOnce)
64 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
65 // Only dismiss the notification once the tool was actually answered. If
66 // there was no live connection (respond is a no-op) or the call failed,
67 // leave it up so the user can retry — otherwise it vanishes with the
68 // permission request left unanswered.
69 if (success) SessionNotifications.cancelPermission(context, sessionId)
70 finishOnce()
71 }
72 }
73 ACTION_REPLY -> {
74 val text = RemoteInput.getResultsFromIntent(intent)
75 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
76 ?.takeIf { it.isNotEmpty() }
77 if (text == null) {
78 // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
79 // inline field in a "sending…" state until the notification is updated or
80 // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
81 SessionNotifications.cancel(context, sessionId)
82 return
83 }
84 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
85 val pending = goAsync()
86 val finished = AtomicBoolean(false)
87 var watchdogThread: Thread? = null
88 val finishOnce = {
89 if (finished.compareAndSet(false, true)) {
90 watchdogThread?.interrupt()
91 pending.finish()
92 }
93 }
94 watchdogThread = watchdog(finishOnce)
95 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
96 // The reply is durably queued (and flushed now if online). Show a brief
97 // "Sent" confirmation by updating the completion notification's text, then
98 // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
99 // failed, post a "reply not sent" error: leaving the completion notification
100 // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
101 // that can't be resubmitted, so "leave it up to retry" doesn't actually let
102 // the user retry.
103 if (enqueued) {
104 SessionNotifications.postReplySent(context, sessionId)
105 } else {
106 SessionNotifications.postReplyFailed(context, sessionId, profileId)
107 }
108 finishOnce()
109 }
110 }
111 ACTION_RETRY_LAST -> {
112 // "Retry last" from an error notification: re-send the most recent user prompt
113 // for this session without opening the app. Cancels the error notification on
114 // success so the user sees the retry took effect.
115 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
116 val pending = goAsync()
117 val finished = AtomicBoolean(false)
118 var watchdogThread: Thread? = null
119 val finishOnce = {
120 if (finished.compareAndSet(false, true)) {
121 watchdogThread?.interrupt()
122 pending.finish()
123 }
124 }
125 watchdogThread = watchdog(finishOnce)
126 container.retryLastFromNotification(sessionId, profileId) { success ->
127 if (success) SessionNotifications.cancel(context, sessionId)
128 finishOnce()
129 }
130 }
131 }
132 }
134 /** Handle the Stop action from the foreground-service notification. Extracted from
135 * [onReceive] to keep that function's cyclomatic complexity under detekt's threshold. */
136 private fun handleStopRun(context: Context, container: soy.iko.opencode.di.AppContainer, intent: Intent) {
137 // The session id may be absent if multiple runs were active (generic notification);
138 // in that case we can't target a specific run — just let it ride.
139 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
140 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
141 val pending = goAsync()
142 val finished = AtomicBoolean(false)
143 var watchdogThread: Thread? = null
144 val finishOnce = {
145 if (finished.compareAndSet(false, true)) {
146 watchdogThread?.interrupt()
147 pending.finish()
148 }
149 }
150 watchdogThread = watchdog(finishOnce)
151 container.abortRunFromNotification(sessionId, profileId) { success ->
152 // On a failed abort (transient network error), the run continues but the FGS
153 // notification will disappear once anyRunActive clears — leaving the user with
154 // no signal that their Stop tap didn't take. Post a quick "couldn't stop"
155 // notification so they know to open the app. On success, the FGS disappearing
156 // is sufficient feedback.
157 if (!success) {
158 SessionNotifications.postError(
159 context, sessionId,
160 context.getString(R.string.couldnt_stop_run),
161 profileId,
162 )
163 }
164 finishOnce()
165 }
166 }
168 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
169 * the network call hasn't resolved, so the system can't ANR the receiver. The
170 * underlying work continues on the app scope. Returns the watchdog thread so the
171 * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
172 * the sleeping thread immediately instead of leaving it for the full timeout. */
173 private fun watchdog(finishOnce: () -> Unit): Thread =
174 Thread {
175 try {
176 Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
177 } catch (_: InterruptedException) {
178 return@Thread
179 }
180 finishOnce()
181 }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
183 companion object {
184 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
185 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
186 const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
187 const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
188 const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
189 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
190 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
191 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
192 const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
193 const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
194 }