git.iko.soy/opencode-android
on 0a26f8d help sign in
opencode-android/app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt
124 lines 6.7 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.data.model.PermissionResponse
9import soy.iko.opencode.data.network.NetworkConfig
10import java.util.concurrent.atomic.AtomicBoolean
12/**
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:
15 *
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.
20 *
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.
24 *
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).
29 */
30class NotificationActionReceiver : BroadcastReceiver() {
32 override fun onReceive(context: Context, intent: Intent) {
33 val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34 val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
35 when (intent.action) {
36 ACTION_PERMISSION -> {
37 val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
38 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
39 val response = PermissionResponse.entries.firstOrNull {
40 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
41 } ?: return
42 val pending = goAsync()
43 val finished = AtomicBoolean(false)
44 var watchdogThread: Thread? = null
45 val finishOnce = {
46 if (finished.compareAndSet(false, true)) {
47 watchdogThread?.interrupt()
48 pending.finish()
49 }
50 }
51 watchdogThread = watchdog(finishOnce)
52 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
53 // Only dismiss the notification once the tool was actually answered. If
54 // there was no live connection (respond is a no-op) or the call failed,
55 // leave it up so the user can retry — otherwise it vanishes with the
56 // permission request left unanswered.
57 if (success) SessionNotifications.cancelPermission(context, sessionId)
58 finishOnce()
59 }
60 }
61 ACTION_REPLY -> {
62 val text = RemoteInput.getResultsFromIntent(intent)
63 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
64 ?.takeIf { it.isNotEmpty() }
65 if (text == null) {
66 // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
67 // inline field in a "sending…" state until the notification is updated or
68 // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
69 SessionNotifications.cancel(context, sessionId)
70 return
71 }
72 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
73 val pending = goAsync()
74 val finished = AtomicBoolean(false)
75 var watchdogThread: Thread? = null
76 val finishOnce = {
77 if (finished.compareAndSet(false, true)) {
78 watchdogThread?.interrupt()
79 pending.finish()
80 }
81 }
82 watchdogThread = watchdog(finishOnce)
83 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
84 // The reply is durably queued (and flushed now if online); clear the
85 // "session ready" notification. If the enqueue failed, cancel it anyway and
86 // post a "reply not sent" error: leaving the completion notification up strands
87 // SystemUI's RemoteInput field in a permanent "sending…" spinner that can't be
88 // resubmitted, so "leave it up to retry" doesn't actually let the user retry.
89 if (enqueued) {
90 SessionNotifications.cancel(context, sessionId)
91 } else {
92 SessionNotifications.postReplyFailed(context, sessionId, profileId)
93 }
94 finishOnce()
95 }
96 }
97 }
98 }
100 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
101 * the network call hasn't resolved, so the system can't ANR the receiver. The
102 * underlying work continues on the app scope. Returns the watchdog thread so the
103 * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
104 * the sleeping thread immediately instead of leaving it for the full timeout. */
105 private fun watchdog(finishOnce: () -> Unit): Thread =
106 Thread {
107 try {
108 Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
109 } catch (_: InterruptedException) {
110 return@Thread
111 }
112 finishOnce()
113 }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
115 companion object {
116 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
117 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
118 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
119 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
120 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
121 const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
122 const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
123 }