1package soy.iko.opencode.notification
3import android.annotation.SuppressLint
4import android.app.PendingIntent
5import android.content.Context
6import android.content.Intent
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
19 * Builds and posts the app's session notifications:
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.
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.
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))
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?,
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) }
92 return PendingIntent.getActivity(
93 context, requestCode, openIntent,
94 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
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))
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) }
118 val replyPending = PendingIntent.getBroadcast(
119 context, notifId, replyIntent,
120 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
122 val replyAction = NotificationCompat.Action.Builder(
123 R.drawable.ic_launcher_foreground,
124 context.getString(R.string.notif_action_reply),
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))
136 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
137 .addAction(replyAction)
138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
142 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
143 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
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))
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).
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) }
185 val pending = PendingIntent.getBroadcast(
186 context, notifId + response.ordinal + 1, intent,
187 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
189 builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
192 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
193 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
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))
206 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
207 .setCategory(NotificationCompat.CATEGORY_ERROR)
208 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
210 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
211 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
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))
228 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
229 .setCategory(NotificationCompat.CATEGORY_ERROR)
230 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
232 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
233 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
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))
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))
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))
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