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.data.model.PermissionResponse
9import soy.iko.opencode.data.network.NetworkConfig
10import java.util.concurrent.atomic.AtomicBoolean
13 * Handles the action buttons on the app's notifications so the user can respond without
14 * opening the app — the core of the "kick it off and walk away" flow:
16 * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
17 * request (Allow once / Always / Reject) via the active connection.
18 * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
19 * that just finished a run.
21 * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
22 * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
23 * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
25 * A watchdog finishes the [PendingResult] within [NetworkConfig.notificationReceiverTimeoutMs]
26 * so a slow network call (e.g. a permission respond retried with exponential backoff) can't
27 * ANR the receiver. The underlying call keeps running on the app scope; only the receiver's
28 * lifetime is bounded. [finishOnce] guards against a double finish() (which would throw).
30class NotificationActionReceiver : BroadcastReceiver() {
32 override fun onReceive(context: Context, intent: Intent) {
33 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34 if (intent.action == ACTION_STOP_RUN) {
35 // Stop action from the foreground-service notification. The session id may be
36 // absent if multiple runs were active (generic notification); in that case we
37 // can't target a specific run — just let it ride.
38 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
39 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
40 val pending = goAsync()
41 val finished = AtomicBoolean(false)
42 var watchdogThread: Thread? = null
44 if (finished.compareAndSet(false, true)) {
45 watchdogThread?.interrupt()
49 watchdogThread = watchdog(finishOnce)
50 container.abortRunFromNotification(sessionId, profileId) { _ -> finishOnce() }
53 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
54 when (intent.action) {
56 // "Mark read" from a completion notification — clear the unread badge and
57 // dismiss the notification without opening the session.
58 container.markSessionRead(sessionId)
59 SessionNotifications.cancel(context, sessionId)
62 ACTION_PERMISSION -> {
63 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
64 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
65 val response = PermissionResponse.entries.firstOrNull {
66 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
68 val pending = goAsync()
69 val finished = AtomicBoolean(false)
70 var watchdogThread: Thread? = null
72 if (finished.compareAndSet(false, true)) {
73 watchdogThread?.interrupt()
77 watchdogThread = watchdog(finishOnce)
78 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
79 // Only dismiss the notification once the tool was actually answered. If
80 // there was no live connection (respond is a no-op) or the call failed,
81 // leave it up so the user can retry — otherwise it vanishes with the
82 // permission request left unanswered.
83 if (success) SessionNotifications.cancelPermission(context, sessionId)
88 val text = RemoteInput.getResultsFromIntent(intent)
89 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
90 ?.takeIf { it.isNotEmpty() }
92 // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
93 // inline field in a "sending…" state until the notification is updated or
94 // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
95 SessionNotifications.cancel(context, sessionId)
98 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
99 val pending = goAsync()
100 val finished = AtomicBoolean(false)
101 var watchdogThread: Thread? = null
103 if (finished.compareAndSet(false, true)) {
104 watchdogThread?.interrupt()
108 watchdogThread = watchdog(finishOnce)
109 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
110 // The reply is durably queued (and flushed now if online). Show a brief
111 // "Sent" confirmation by updating the completion notification's text, then
112 // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
113 // failed, post a "reply not sent" error: leaving the completion notification
114 // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
115 // that can't be resubmitted, so "leave it up to retry" doesn't actually let
118 SessionNotifications.postReplySent(context, sessionId)
120 SessionNotifications.postReplyFailed(context, sessionId, profileId)
125 ACTION_RETRY_LAST -> {
126 // "Retry last" from an error notification: re-send the most recent user prompt
127 // for this session without opening the app. Cancels the error notification on
128 // success so the user sees the retry took effect.
129 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
130 val pending = goAsync()
131 val finished = AtomicBoolean(false)
132 var watchdogThread: Thread? = null
134 if (finished.compareAndSet(false, true)) {
135 watchdogThread?.interrupt()
139 watchdogThread = watchdog(finishOnce)
140 container.retryLastFromNotification(sessionId, profileId) { success ->
141 if (success) SessionNotifications.cancel(context, sessionId)
148 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
149 * the network call hasn't resolved, so the system can't ANR the receiver. The
150 * underlying work continues on the app scope. Returns the watchdog thread so the
151 * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
152 * the sleeping thread immediately instead of leaving it for the full timeout. */
153 private fun watchdog(finishOnce: () -> Unit): Thread =
156 Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
157 } catch (_: InterruptedException) {
161 }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
164 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
165 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
166 const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
167 const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
168 const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
169 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
170 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
171 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
172 const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
173 const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
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