git.iko.soy/opencode-android
on 066cded help sign in
256 lines 15 kB history raw
1package soy.iko.opencode.notification
3import android.annotation.SuppressLint
4import android.app.PendingIntent
5import android.content.Context
6import android.content.Intent
7import android.os.Build
8import android.util.Log
9import androidx.core.app.NotificationCompat
10import androidx.core.app.NotificationManagerCompat
11import androidx.core.app.RemoteInput
12import soy.iko.opencode.MainActivity
13import soy.iko.opencode.R
14import soy.iko.opencode.data.model.Permission
15import soy.iko.opencode.data.model.PermissionResponse
16import java.security.MessageDigest
18/**
19 * Builds and posts the app's session notifications:
20 *
21 * - [postCompleted]: a "session ready" notification when a background run finishes, with an
22 * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
23 * - [postPermission]: a heads-up notification when a paused tool needs approval, with
24 * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
25 * - [postError]: a notification when a background run fails.
26 *
27 * Tapping a notification body opens the session via [MainActivity] with an
28 * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
29 * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
30 * skipped rather than throwing.
31 */
32object SessionNotifications {
34 private const val NOTIF_ID_PREFIX = 4000
35 private const val TAG = "SessionNotifications"
37 // Distinct namespaces so a session can have a completion, a permission, and an error
38 // notification outstanding at once without their ids colliding.
39 private const val NS_COMPLETED = "done"
40 private const val NS_PERMISSION = "perm"
41 private const val NS_ERROR = "err"
43 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
45 /** Derive a stable, collision-resistant notification id from a namespace + session id.
46 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
47 * bits makes accidental collisions astronomically unlikely. The namespace keeps the
48 * three notification kinds for one session in separate id spaces. */
49 private fun notifId(namespace: String, sessionId: String): Int {
50 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
51 .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
52 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
53 // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
54 // left-to-right, so the shifts MUST be parenthesized individually — otherwise
55 // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
56 // byte out of the Int entirely and collapsing the id's entropy.
57 val hash = (((digest[0].toInt() and 0xFF) shl 24) or
58 ((digest[1].toInt() and 0xFF) shl 16) or
59 ((digest[2].toInt() and 0xFF) shl 8) or
60 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
61 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
62 // Using modulo ensures the prefix is preserved without Int overflow.
63 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
64 }
66 /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
67 * it, callers should skip posting instead of throwing. Foreground-service notifications
68 * are exempt, so the running indicator still shows during a run. */
69 private fun canPost(context: Context): Boolean =
70 Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
71 androidx.core.content.ContextCompat.checkSelfPermission(
72 context, android.Manifest.permission.POST_NOTIFICATIONS,
73 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
75 /** Sanitize a session id before embedding it in the deep-link URI so characters like
76 * /, ?, # can't inject path segments or query parameters. */
77 private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
79 /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
80 * [profileId] (the originating server) is embedded so a tap after the user has switched
81 * servers routes back to the server that ran the session, not whichever is active —
82 * mirroring the Reply and permission action intents. */
83 private fun openSessionIntent(
84 context: Context, sessionId: String, requestCode: Int, profileId: String?,
85 ): PendingIntent {
86 val openIntent = Intent(context, MainActivity::class.java).apply {
87 action = Intent.ACTION_VIEW
88 data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
89 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
90 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
91 }
92 return PendingIntent.getActivity(
93 context, requestCode, openIntent,
94 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
95 )
96 }
98 // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
99 // check through the helper, so suppress its MissingPermission flag on these posts.
100 @SuppressLint("MissingPermission")
101 fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
102 if (!canPost(context)) return
103 val notifId = notifId(NS_COMPLETED, sessionId)
105 // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
106 // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
107 // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
108 // receiver enqueues the reply against the server that ran the session, not whichever
109 // server is active when the user taps (mirrors postPermission).
110 val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
111 .setLabel(context.getString(R.string.notif_reply_hint))
112 .build()
113 val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
114 action = NotificationActionReceiver.ACTION_REPLY
115 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
116 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
117 }
118 val replyPending = PendingIntent.getBroadcast(
119 context, notifId, replyIntent,
120 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
121 )
122 val replyAction = NotificationCompat.Action.Builder(
123 R.drawable.ic_launcher_foreground,
124 context.getString(R.string.notif_action_reply),
125 replyPending,
126 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
128 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
129 .setSmallIcon(R.drawable.ic_launcher_foreground)
130 .setContentTitle(context.getString(R.string.notif_completed_title))
131 // No % escaping here: getString(id, arg) inserts the argument verbatim and
132 // never re-scans it for format specifiers, so escaping the title would show
133 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
134 .setContentText(context.getString(R.string.notif_completed_text, title))
135 .setAutoCancel(true)
136 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
137 .addAction(replyAction)
138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
140 .build()
142 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
143 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
144 }
146 /** Post a heads-up notification for a permission request with Allow once / Always /
147 * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
148 * pattern/type supplies the detail line. [profileId] is embedded in the action intents
149 * so the receiver routes the response back to the server that posted the request, not
150 * whichever server happens to be active when the user taps. */
151 @SuppressLint("MissingPermission")
152 fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
153 if (!canPost(context)) return
154 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
155 val notifId = notifId(NS_PERMISSION, sessionId)
156 val detail = permission.title?.takeIf { it.isNotBlank() }
157 ?: permission.patternText?.takeIf { it.isNotBlank() }
158 ?: permission.type?.takeIf { it.isNotBlank() }
159 ?: context.getString(R.string.notif_permission_fallback)
161 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
162 .setSmallIcon(R.drawable.ic_launcher_foreground)
163 .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
164 .setContentText(detail)
165 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
166 .setAutoCancel(true)
167 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
168 .setCategory(NotificationCompat.CATEGORY_CALL)
169 .setPriority(NotificationCompat.PRIORITY_HIGH)
171 // One action per response. Each PendingIntent needs a distinct request code or
172 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
173 listOf(
174 PermissionResponse.ONCE to R.string.notif_action_allow_once,
175 PermissionResponse.ALWAYS to R.string.notif_action_always,
176 PermissionResponse.REJECT to R.string.notif_action_reject,
177 ).forEach { (response, labelRes) ->
178 val intent = Intent(context, NotificationActionReceiver::class.java).apply {
179 action = NotificationActionReceiver.ACTION_PERMISSION
180 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
181 putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
182 putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
183 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
184 }
185 val pending = PendingIntent.getBroadcast(
186 context, notifId + response.ordinal + 1, intent,
187 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
188 )
189 builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
190 }
192 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
193 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
194 }
196 /** Post a notification when a background run fails. */
197 @SuppressLint("MissingPermission")
198 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
199 if (!canPost(context)) return
200 val notifId = notifId(NS_ERROR, sessionId)
201 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
202 .setSmallIcon(R.drawable.ic_launcher_foreground)
203 .setContentTitle(context.getString(R.string.notif_error_title))
204 .setContentText(context.getString(R.string.notif_error_text, title))
205 .setAutoCancel(true)
206 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
207 .setCategory(NotificationCompat.CATEGORY_ERROR)
208 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
209 .build()
210 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
211 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
212 }
214 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
215 * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
216 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
217 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
218 @SuppressLint("MissingPermission")
219 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
220 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
221 if (!canPost(context)) return
222 val notifId = notifId(NS_ERROR, sessionId)
223 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
224 .setSmallIcon(R.drawable.ic_launcher_foreground)
225 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
226 .setContentText(context.getString(R.string.notif_reply_failed_text))
227 .setAutoCancel(true)
228 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
229 .setCategory(NotificationCompat.CATEGORY_ERROR)
230 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
231 .build()
232 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
233 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
234 }
236 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
237 private fun mutableFlag(): Int =
238 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
240 /** Cancel a session's completion notification (e.g. when the user opens it). */
241 fun cancel(context: Context, sessionId: String) {
242 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
243 }
245 /** Cancel a session's permission notification (on reply, or when the user opens it). */
246 fun cancelPermission(context: Context, sessionId: String) {
247 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
248 }
250 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
251 * stale "run failed" notification lingers when the session is opened by any route other
252 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
253 fun cancelError(context: Context, sessionId: String) {
254 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
255 }