1package soy.iko.opencode.notification
3import android.annotation.SuppressLint
4import android.app.Notification
5import android.app.NotificationManager
6import android.app.PendingIntent
7import android.content.Context
8import android.content.Intent
10import android.util.Log
11import androidx.core.app.NotificationCompat
12import androidx.core.app.NotificationManagerCompat
13import androidx.core.app.RemoteInput
14import soy.iko.opencode.MainActivity
15import soy.iko.opencode.R
16import soy.iko.opencode.data.model.Permission
17import soy.iko.opencode.data.model.PermissionResponse
18import java.security.MessageDigest
21 * Builds and posts the app's session notifications:
23 * - [postCompleted]: a "session ready" notification when a background run finishes, with an
24 * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
25 * - [postPermission]: a heads-up notification when a paused tool needs approval, with
26 * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
27 * - [postError]: a notification when a background run fails.
29 * Tapping a notification body opens the session via [MainActivity] with an
30 * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
31 * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
32 * skipped rather than throwing.
34object SessionNotifications {
36 private const val NOTIF_ID_PREFIX = 4000
37 private const val TAG = "SessionNotifications"
38 private const val UNREAD_BADGE_ID = NOTIF_ID_PREFIX + 9999
40 // Distinct namespaces so a session can have a completion, a permission, and an error
41 // notification outstanding at once without their ids colliding.
42 private const val NS_COMPLETED = "done"
43 private const val NS_PERMISSION = "perm"
44 private const val NS_ERROR = "err"
46 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
48 /** Derive a stable, collision-resistant notification id from a namespace + session id.
49 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
50 * bits makes accidental collisions astronomically unlikely. The namespace keeps the
51 * three notification kinds for one session in separate id spaces. */
52 private fun notifId(namespace: String, sessionId: String): Int {
53 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
54 .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
55 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
56 // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
57 // left-to-right, so the shifts MUST be parenthesized individually — otherwise
58 // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
59 // byte out of the Int entirely and collapsing the id's entropy.
60 val hash = (((digest[0].toInt() and 0xFF) shl 24) or
61 ((digest[1].toInt() and 0xFF) shl 16) or
62 ((digest[2].toInt() and 0xFF) shl 8) or
63 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
64 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
65 // Using modulo ensures the prefix is preserved without Int overflow.
66 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
69 /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
70 * it, callers should skip posting instead of throwing. Foreground-service notifications
71 * are exempt, so the running indicator still shows during a run. */
72 private fun canPost(context: Context): Boolean =
73 Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
74 androidx.core.content.ContextCompat.checkSelfPermission(
75 context, android.Manifest.permission.POST_NOTIFICATIONS,
76 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
78 /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
79 * [profileId] (the originating server) is embedded so a tap after the user has switched
80 * servers routes back to the server that ran the session, not whichever is active —
81 * mirroring the Reply and permission action intents. */
82 private fun openSessionIntent(
83 context: Context, sessionId: String, requestCode: Int, profileId: String?,
85 // Sanitize the session id before embedding it in the deep-link URI so characters like
86 // /, ?, # can't inject path segments or query parameters.
87 val safeId = notifIdRegex.replace(sessionId, "")
88 val openIntent = Intent(context, MainActivity::class.java).apply {
89 action = Intent.ACTION_VIEW
90 data = android.net.Uri.parse("opencode://session/$safeId")
91 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
92 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
94 return PendingIntent.getActivity(
95 context, requestCode, openIntent,
96 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
100 // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
101 // check through the helper, so suppress its MissingPermission flag on these posts.
102 @SuppressLint("MissingPermission")
103 fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
104 if (!canPost(context)) return
105 val notifId = notifId(NS_COMPLETED, sessionId)
107 // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
108 // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
109 // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
110 // receiver enqueues the reply against the server that ran the session, not whichever
111 // server is active when the user taps (mirrors postPermission).
112 val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
113 .setLabel(context.getString(R.string.notif_reply_hint))
115 val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
116 action = NotificationActionReceiver.ACTION_REPLY
117 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
118 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
120 val replyPending = PendingIntent.getBroadcast(
121 context, notifId, replyIntent,
122 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
124 val replyAction = NotificationCompat.Action.Builder(
125 R.drawable.ic_action_reply,
126 context.getString(R.string.notif_action_reply),
128 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
130 // "Mark read" action so the user can triage a completion without opening the session.
131 val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
132 action = NotificationActionReceiver.ACTION_MARK_READ
133 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
134 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
136 val markReadPending = PendingIntent.getBroadcast(
137 context, notifId + 100, markReadIntent,
138 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
140 val markReadAction = NotificationCompat.Action.Builder(
141 R.drawable.ic_action_check,
142 context.getString(R.string.notif_action_mark_read),
146 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
147 .setSmallIcon(R.drawable.ic_stat_notify)
148 .setContentTitle(context.getString(R.string.notif_completed_title))
149 // No % escaping here: getString(id, arg) inserts the argument verbatim and
150 // never re-scans it for format specifiers, so escaping the title would show
151 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
152 .setContentText(context.getString(R.string.notif_completed_text, title))
154 .setOnlyAlertOnce(true)
155 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
156 .addAction(replyAction)
157 .addAction(markReadAction)
158 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
159 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
160 // Group notifications from the same server so multiple completions collapse into
161 // a stackable group on Android 7+ instead of filling the shade with individual entries.
162 .setGroup(GROUP_COMPLETED)
166 NotificationManagerCompat.from(context).notify(notifId, notification)
167 // Post a summary notification so the group has a single top-level entry in the shade.
168 postCompletedSummary(context)
169 }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
172 /** Post (or update) a summary notification for the completed-sessions group so multiple
173 * completions collapse into one stackable entry instead of filling the shade. */
174 @SuppressLint("MissingPermission")
175 private fun postCompletedSummary(context: Context) {
176 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
177 .setSmallIcon(R.drawable.ic_stat_notify)
178 .setContentTitle(context.getString(R.string.notif_completed_title))
179 .setContentText(context.getString(R.string.app_name))
180 .setSmallIcon(R.drawable.ic_stat_notify)
182 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
183 .setGroup(GROUP_COMPLETED)
184 .setGroupSummary(true)
187 NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
188 }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
191 /** Post a heads-up notification for a permission request with Allow once / Always /
192 * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
193 * pattern/type supplies the detail line. [profileId] is embedded in the action intents
194 * so the receiver routes the response back to the server that posted the request, not
195 * whichever server happens to be active when the user taps. */
196 @SuppressLint("MissingPermission")
197 fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
198 if (!canPost(context)) return
199 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
200 val notifId = notifId(NS_PERMISSION, sessionId)
201 val detail = permission.title?.takeIf { it.isNotBlank() }
202 ?: permission.patternText?.takeIf { it.isNotBlank() }
203 ?: permission.type?.takeIf { it.isNotBlank() }
204 ?: context.getString(R.string.notif_permission_fallback)
206 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
207 .setSmallIcon(R.drawable.ic_stat_notify)
208 .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
209 .setContentText(detail)
210 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
212 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
213 .setPriority(NotificationCompat.PRIORITY_HIGH)
214 .setGroup(GROUP_PERMISSION)
215 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
217 NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
218 .setSmallIcon(R.drawable.ic_stat_notify)
219 .setContentTitle(context.getString(R.string.notif_permission_public))
223 // One action per response. Each PendingIntent needs a distinct request code or
224 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
226 Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
227 Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
228 Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
229 ).forEach { (response, labelRes, iconRes) ->
230 val intent = Intent(context, NotificationActionReceiver::class.java).apply {
231 action = NotificationActionReceiver.ACTION_PERMISSION
232 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
233 putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
234 putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
235 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
237 val pending = PendingIntent.getBroadcast(
238 context, notifId + response.ordinal + 1, intent,
239 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
241 builder.addAction(iconRes, context.getString(labelRes), pending)
244 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
245 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
246 // Group permission notifications so a multi-session burst collapses into a stackable
247 // group instead of filling the shade — mirroring the completed-sessions grouping.
248 runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
249 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
252 /** Post a notification when a background run fails. */
253 @SuppressLint("MissingPermission")
254 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
255 if (!canPost(context)) return
256 val notifId = notifId(NS_ERROR, sessionId)
257 val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
258 .setSmallIcon(R.drawable.ic_stat_notify)
259 .setContentTitle(context.getString(R.string.notif_error_title))
260 .setContentText(context.getString(R.string.notif_error_text, title))
262 .setOnlyAlertOnce(true)
263 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
264 .setCategory(NotificationCompat.CATEGORY_ERROR)
265 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
266 // Group error notifications so a burst of failures collapses into a stackable group.
267 .setGroup(GROUP_ERROR)
268 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
270 NotificationCompat.Builder(context, NotificationChannels.ERROR)
271 .setSmallIcon(R.drawable.ic_stat_notify)
272 .setContentTitle(context.getString(R.string.notif_error_public))
275 // A "Retry last" action lets the user re-run the failed prompt straight from the shade
276 // without opening the app — a failed run is exactly when a one-tap retry is most useful.
277 val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
278 action = NotificationActionReceiver.ACTION_RETRY_LAST
279 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
280 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
282 val retryPending = PendingIntent.getBroadcast(
283 context, notifId + 10, retryIntent,
284 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
286 builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
287 val notification = builder.build()
288 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
289 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
290 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
291 .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
294 /** Update the completion notification to show a brief "Sent" confirmation after a
295 * successful inline reply, replacing the completion notification's content. Auto-cancels
296 * on tap so it doesn't linger. */
297 @SuppressLint("MissingPermission")
298 fun postReplySent(context: Context, sessionId: String) {
299 if (!canPost(context)) {
300 cancel(context, sessionId)
303 val notifId = notifId(NS_COMPLETED, sessionId)
304 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
305 .setSmallIcon(R.drawable.ic_stat_notify)
306 .setContentTitle(context.getString(R.string.notif_reply_sent))
308 .setPriority(NotificationCompat.PRIORITY_LOW)
309 .setGroup(GROUP_COMPLETED)
311 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
312 .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
315 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
316 * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
317 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
318 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
319 @SuppressLint("MissingPermission")
320 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
321 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
322 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
323 if (!canPost(context)) return
324 val notifId = notifId(NS_ERROR, sessionId)
325 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
326 .setSmallIcon(R.drawable.ic_stat_notify)
327 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
328 .setContentText(context.getString(R.string.notif_reply_failed_text))
330 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
331 .setCategory(NotificationCompat.CATEGORY_ERROR)
332 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
334 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
335 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
338 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
339 private fun mutableFlag(): Int =
340 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
342 /** Cancel a session's completion notification (e.g. when the user opens it). */
343 fun cancel(context: Context, sessionId: String) {
344 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
345 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
349 * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
350 * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
351 * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
352 * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
353 * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
355 @SuppressLint("MissingPermission")
356 fun updateUnreadBadge(context: Context, totalUnread: Int) {
357 val nm = NotificationManagerCompat.from(context)
358 if (totalUnread <= 0) {
359 nm.cancel(UNREAD_BADGE_ID)
362 if (!canPost(context)) return
363 val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
364 .setSmallIcon(R.drawable.ic_stat_notify)
365 .setContentTitle(context.getString(R.string.app_name))
366 .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
367 .setNumber(totalUnread.coerceAtMost(999))
368 .setOnlyAlertOnce(true)
371 .setPriority(NotificationCompat.PRIORITY_MIN)
373 runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
374 .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
377 /** Cancel a session's permission notification (on reply, or when the user opens it). */
378 fun cancelPermission(context: Context, sessionId: String) {
379 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
380 maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
383 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
384 * stale "run failed" notification lingers when the session is opened by any route other
385 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
386 fun cancelError(context: Context, sessionId: String) {
387 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
388 maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
391 /** Build the lock-screen (public) version of a notification: only a generic title, no
392 * detail line, so sensitive file paths / commands in the real content aren't revealed
393 * while the device is locked. */
394 /** When the last child of a group is dismissed, cancel its lingering summary so it
395 * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
396 * so reach for the platform NotificationManager. The children/summary are posted without
397 * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
398 * id) is the reliable way to tell if any child remains. */
399 private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
400 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
403 nm.activeNotifications
404 } catch (e: Exception) {
405 Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
408 val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
410 runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
411 .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
415 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
416 private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
417 private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
418 private const val SUMMARY_COMPLETED_ID = -1
419 private const val SUMMARY_PERMISSION_ID = -2
420 private const val SUMMARY_ERROR_ID = -3
422 /** Post a summary notification for a group so multiple entries collapse into one stackable
423 * entry in the shade instead of filling it. */
424 @SuppressLint("MissingPermission")
425 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
426 if (!canPost(context)) return
427 val summary = NotificationCompat.Builder(context, channelId)
428 .setSmallIcon(R.drawable.ic_stat_notify)
429 .setContentTitle(context.getString(R.string.app_name))
432 .setGroupSummary(true)
435 NotificationManagerCompat.from(context).notify(summaryId, summary)
436 }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
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