git.iko.soy/opencode-android
on 2794fda help sign in
527 lines 31 kB history raw
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
9import android.os.Build
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
20/**
21 * Builds and posts the app's session notifications:
22 *
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.
28 *
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.
33 */
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))
67 }
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?,
84 ): PendingIntent {
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) }
93 }
94 return PendingIntent.getActivity(
95 context, requestCode, openIntent,
96 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
97 )
98 }
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))
114 .build()
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) }
119 }
120 val replyPending = PendingIntent.getBroadcast(
121 context, notifId, replyIntent,
122 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
123 )
124 val replyAction = NotificationCompat.Action.Builder(
125 R.drawable.ic_action_reply,
126 context.getString(R.string.notif_action_reply),
127 replyPending,
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) }
135 }
136 val markReadPending = PendingIntent.getBroadcast(
137 context, notifId + 100, markReadIntent,
138 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
139 )
140 val markReadAction = NotificationCompat.Action.Builder(
141 R.drawable.ic_action_check,
142 context.getString(R.string.notif_action_mark_read),
143 markReadPending,
144 ).build()
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))
153 .setAutoCancel(true)
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)
163 .build()
165 runCatching {
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) }
170 }
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)
181 .setAutoCancel(true)
182 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
183 .setGroup(GROUP_COMPLETED)
184 .setGroupSummary(true)
185 .build()
186 runCatching {
187 NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
188 }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
189 }
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))
211 .setAutoCancel(true)
212 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
213 .setPriority(NotificationCompat.PRIORITY_HIGH)
214 .setGroup(GROUP_PERMISSION)
215 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
216 .setPublicVersion(
217 NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
218 .setSmallIcon(R.drawable.ic_stat_notify)
219 .setContentTitle(context.getString(R.string.notif_permission_public))
220 .build(),
221 )
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).
225 listOf(
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) }
236 }
237 val pending = PendingIntent.getBroadcast(
238 context, notifId + response.ordinal + 1, intent,
239 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
240 )
241 builder.addAction(iconRes, context.getString(labelRes), pending)
242 }
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) }
250 }
252 /** Post a notification when a permission prompt auto-rejected on timeout. A user who
253 * walked away from an in-app prompt returns to find the run stopped with no on-screen
254 * record of why; this leaves a persistent trace in the shade explaining the stop and
255 * offering to re-open the session. Uses the permission channel (heads-up) so it's seen. */
256 @SuppressLint("MissingPermission")
257 fun postPermissionAutoRejected(context: Context, sessionId: String, sessionTitle: String, profileId: String?) {
258 if (!canPost(context)) return
259 val notifId = notifId(NS_PERMISSION, sessionId) + 1 // distinct from the live prompt
260 val detail = context.getString(R.string.notif_permission_auto_rejected_text)
261 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
262 .setSmallIcon(R.drawable.ic_stat_notify)
263 .setContentTitle(context.getString(R.string.notif_permission_auto_rejected_title, sessionTitle))
264 .setContentText(detail)
265 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
266 .setAutoCancel(true)
267 .setOnlyAlertOnce(true)
268 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
269 .setCategory(NotificationCompat.CATEGORY_STATUS)
270 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
271 .setGroup(GROUP_PERMISSION)
272 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
273 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
274 .onFailure { Log.w(TAG, "Failed to post permission auto-reject notification", it) }
275 runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
276 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
277 }
279 /** Post a notification when a background run fails. */
280 @SuppressLint("MissingPermission")
281 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
282 if (!canPost(context)) return
283 val notifId = notifId(NS_ERROR, sessionId)
284 val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
285 .setSmallIcon(R.drawable.ic_stat_notify)
286 .setContentTitle(context.getString(R.string.notif_error_title))
287 .setContentText(context.getString(R.string.notif_error_text, title))
288 .setAutoCancel(true)
289 .setOnlyAlertOnce(true)
290 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
291 .setCategory(NotificationCompat.CATEGORY_ERROR)
292 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
293 // Group error notifications so a burst of failures collapses into a stackable group.
294 .setGroup(GROUP_ERROR)
295 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
296 .setPublicVersion(
297 NotificationCompat.Builder(context, NotificationChannels.ERROR)
298 .setSmallIcon(R.drawable.ic_stat_notify)
299 .setContentTitle(context.getString(R.string.notif_error_public))
300 .build(),
301 )
302 // A "Retry last" action lets the user re-run the failed prompt straight from the shade
303 // without opening the app — a failed run is exactly when a one-tap retry is most useful.
304 val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
305 action = NotificationActionReceiver.ACTION_RETRY_LAST
306 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
307 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
308 }
309 val retryPending = PendingIntent.getBroadcast(
310 context, notifId + 10, retryIntent,
311 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
312 )
313 builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
314 val notification = builder.build()
315 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
316 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
317 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
318 .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
319 }
321 /** Update the completion notification to show a brief "Sent" confirmation after a
322 * successful inline reply, replacing the completion notification's content. Auto-cancels
323 * on tap so it doesn't linger. */
324 @SuppressLint("MissingPermission")
325 fun postReplySent(context: Context, sessionId: String) {
326 if (!canPost(context)) {
327 cancel(context, sessionId)
328 return
329 }
330 val notifId = notifId(NS_COMPLETED, sessionId)
331 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
332 .setSmallIcon(R.drawable.ic_stat_notify)
333 .setContentTitle(context.getString(R.string.notif_reply_sent))
334 .setAutoCancel(true)
335 .setPriority(NotificationCompat.PRIORITY_LOW)
336 .setGroup(GROUP_COMPLETED)
337 .build()
338 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
339 .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
340 }
342 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
343 * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
344 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
345 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
346 @SuppressLint("MissingPermission")
347 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
348 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
349 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
350 if (!canPost(context)) return
351 val notifId = notifId(NS_ERROR, sessionId)
352 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
353 .setSmallIcon(R.drawable.ic_stat_notify)
354 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
355 .setContentText(context.getString(R.string.notif_reply_failed_text))
356 .setAutoCancel(true)
357 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
358 .setCategory(NotificationCompat.CATEGORY_ERROR)
359 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
360 .build()
361 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
362 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
363 }
365 /** A queued outbox message was permanently undeliverable (e.g. the target session was
366 * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
367 * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
368 * user understands the failure mode and recovery action (open the session and re-send). */
369 @SuppressLint("MissingPermission")
370 fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
371 if (!canPost(context)) return
372 val notifId = notifId(NS_ERROR, sessionId)
373 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
374 .setSmallIcon(R.drawable.ic_stat_notify)
375 .setContentTitle(context.getString(R.string.message_not_delivered))
376 .setContentText(context.getString(R.string.message_not_delivered_body, sessionTitle))
377 .setAutoCancel(true)
378 .setOnlyAlertOnce(true)
379 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
380 .setCategory(NotificationCompat.CATEGORY_ERROR)
381 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
382 .setGroup(GROUP_ERROR)
383 .build()
384 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
385 .onFailure { Log.w(TAG, "Failed to post outbox-dropped notification", it) }
386 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
387 .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
388 }
390 /** The SSE stream hit a non-retryable failure (Failed/AuthFailed) while the app was
391 * backgrounded during an active run, so the in-app banner isn't visible and the run is
392 * effectively stranded. Posts a low-priority notification so a backgrounded user is
393 * signaled that their run is stalled and can tap to reconnect. Distinct from [postError]
394 * (a run error) — this is a connection loss, not a run failure. */
395 @SuppressLint("MissingPermission")
396 fun postConnectionLost(context: Context, profileLabel: String, profileId: String?) {
397 if (!canPost(context)) return
398 val notifId = NOTIF_ID_PREFIX + 7000
399 // Tap reopens the app at the server list so the user can reconnect / fix credentials.
400 val openIntent = Intent(context, MainActivity::class.java).apply {
401 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
402 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
403 }
404 val openPending = PendingIntent.getActivity(
405 context, notifId, openIntent,
406 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
407 )
408 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
409 .setSmallIcon(R.drawable.ic_stat_notify)
410 .setContentTitle(context.getString(R.string.connection_lost, profileLabel))
411 .setContentText(context.getString(R.string.connection_lost_tap))
412 .setAutoCancel(true)
413 .setOnlyAlertOnce(true)
414 .setContentIntent(openPending)
415 .setCategory(NotificationCompat.CATEGORY_STATUS)
416 .setPriority(NotificationCompat.PRIORITY_LOW)
417 .build()
418 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
419 .onFailure { Log.w(TAG, "Failed to post connection-lost notification", it) }
420 }
422 /** Cancel a previously-posted [postConnectionLost] notification (e.g. on reconnect). */
423 fun cancelConnectionLost(context: Context) {
424 NotificationManagerCompat.from(context).cancel(NOTIF_ID_PREFIX + 7000)
425 }
427 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
428 private fun mutableFlag(): Int =
429 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
431 /** Cancel a session's completion notification (e.g. when the user opens it). */
432 fun cancel(context: Context, sessionId: String) {
433 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
434 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
435 }
437 /**
438 * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
439 * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
440 * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
441 * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
442 * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
443 */
444 @SuppressLint("MissingPermission")
445 fun updateUnreadBadge(context: Context, totalUnread: Int) {
446 val nm = NotificationManagerCompat.from(context)
447 if (totalUnread <= 0) {
448 nm.cancel(UNREAD_BADGE_ID)
449 return
450 }
451 if (!canPost(context)) return
452 val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
453 .setSmallIcon(R.drawable.ic_stat_notify)
454 .setContentTitle(context.getString(R.string.app_name))
455 .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
456 .setNumber(totalUnread.coerceAtMost(999))
457 .setOnlyAlertOnce(true)
458 .setSilent(true)
459 .setShowWhen(false)
460 .setPriority(NotificationCompat.PRIORITY_MIN)
461 .build()
462 runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
463 .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
464 }
466 /** Cancel a session's permission notification (on reply, or when the user opens it). */
467 fun cancelPermission(context: Context, sessionId: String) {
468 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
469 maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
470 }
472 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
473 * stale "run failed" notification lingers when the session is opened by any route other
474 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
475 fun cancelError(context: Context, sessionId: String) {
476 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
477 maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
478 }
480 /** Build the lock-screen (public) version of a notification: only a generic title, no
481 * detail line, so sensitive file paths / commands in the real content aren't revealed
482 * while the device is locked. */
483 /** When the last child of a group is dismissed, cancel its lingering summary so it
484 * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
485 * so reach for the platform NotificationManager. The children/summary are posted without
486 * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
487 * id) is the reliable way to tell if any child remains. */
488 private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
489 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
490 ?: return
491 val active = try {
492 nm.activeNotifications
493 } catch (e: Exception) {
494 Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
495 return
496 }
497 val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
498 if (!hasChild) {
499 runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
500 .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
501 }
502 }
504 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
505 private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
506 private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
507 private const val SUMMARY_COMPLETED_ID = -1
508 private const val SUMMARY_PERMISSION_ID = -2
509 private const val SUMMARY_ERROR_ID = -3
511 /** Post a summary notification for a group so multiple entries collapse into one stackable
512 * entry in the shade instead of filling it. */
513 @SuppressLint("MissingPermission")
514 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
515 if (!canPost(context)) return
516 val summary = NotificationCompat.Builder(context, channelId)
517 .setSmallIcon(R.drawable.ic_stat_notify)
518 .setContentTitle(context.getString(R.string.app_name))
519 .setAutoCancel(true)
520 .setGroup(group)
521 .setGroupSummary(true)
522 .build()
523 runCatching {
524 NotificationManagerCompat.from(context).notify(summaryId, summary)
525 }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
526 }