git.iko.soy/opencode-android
on d518ef7 help sign in
556 lines 33 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 // Fixed notification IDs use NEGATIVE space (mirroring the SUMMARY_*_ID constants below)
39 // so they can never collide with the hash-derived per-session IDs returned by notifId(),
40 // which are always positive (NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX)),
41 // range [4000, 2147483646]). A collision would let a session-specific notification
42 // overwrite or be canceled by the fixed one (~1 in 2 billion per session, but a real
43 // ID-space flaw). Negative IDs are valid for NotificationManager.cancel/notify.
44 private const val UNREAD_BADGE_ID = -10
45 private const val CONNECTION_LOST_ID = -11
47 // Distinct namespaces so a session can have a completion, a permission, and an error
48 // notification outstanding at once without their ids colliding.
49 private const val NS_COMPLETED = "done"
50 private const val NS_PERMISSION = "perm"
51 private const val NS_ERROR = "err"
53 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
55 /** Derive a stable, collision-resistant notification id from a namespace + session id.
56 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
57 * bits makes accidental collisions astronomically unlikely. The namespace keeps the
58 * three notification kinds for one session in separate id spaces. */
59 private fun notifId(namespace: String, sessionId: String): Int {
60 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
61 .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
62 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
63 // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
64 // left-to-right, so the shifts MUST be parenthesized individually — otherwise
65 // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
66 // byte out of the Int entirely and collapsing the id's entropy.
67 val hash = (((digest[0].toInt() and 0xFF) shl 24) or
68 ((digest[1].toInt() and 0xFF) shl 16) or
69 ((digest[2].toInt() and 0xFF) shl 8) or
70 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
71 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
72 // Using modulo ensures the prefix is preserved without Int overflow.
73 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
74 }
76 /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
77 * it, callers should skip posting instead of throwing. Foreground-service notifications
78 * are exempt, so the running indicator still shows during a run. */
79 private fun canPost(context: Context): Boolean =
80 Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
81 androidx.core.content.ContextCompat.checkSelfPermission(
82 context, android.Manifest.permission.POST_NOTIFICATIONS,
83 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
85 /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
86 * [profileId] (the originating server) is embedded so a tap after the user has switched
87 * servers routes back to the server that ran the session, not whichever is active —
88 * mirroring the Reply and permission action intents. */
89 private fun openSessionIntent(
90 context: Context, sessionId: String, requestCode: Int, profileId: String?,
91 ): PendingIntent {
92 // Sanitize the session id before embedding it in the deep-link URI so characters like
93 // /, ?, # can't inject path segments or query parameters.
94 val safeId = notifIdRegex.replace(sessionId, "")
95 val openIntent = Intent(context, MainActivity::class.java).apply {
96 action = Intent.ACTION_VIEW
97 data = android.net.Uri.parse("opencode://session/$safeId")
98 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
99 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
100 }
101 return PendingIntent.getActivity(
102 context, requestCode, openIntent,
103 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
104 )
105 }
107 // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
108 // check through the helper, so suppress its MissingPermission flag on these posts.
109 @SuppressLint("MissingPermission")
110 fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
111 if (!canPost(context)) return
112 val notifId = notifId(NS_COMPLETED, sessionId)
114 // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
115 // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
116 // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
117 // receiver enqueues the reply against the server that ran the session, not whichever
118 // server is active when the user taps (mirrors postPermission).
119 val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
120 .setLabel(context.getString(R.string.notif_reply_hint))
121 .build()
122 val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
123 action = NotificationActionReceiver.ACTION_REPLY
124 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
125 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
126 }
127 val replyPending = PendingIntent.getBroadcast(
128 context, notifId, replyIntent,
129 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
130 )
131 val replyAction = NotificationCompat.Action.Builder(
132 R.drawable.ic_action_reply,
133 context.getString(R.string.notif_action_reply),
134 replyPending,
135 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
137 // "Mark read" action so the user can triage a completion without opening the session.
138 val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
139 action = NotificationActionReceiver.ACTION_MARK_READ
140 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
141 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
142 }
143 val markReadPending = PendingIntent.getBroadcast(
144 context, notifId + 100, markReadIntent,
145 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
146 )
147 val markReadAction = NotificationCompat.Action.Builder(
148 R.drawable.ic_action_check,
149 context.getString(R.string.notif_action_mark_read),
150 markReadPending,
151 ).build()
153 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
154 .setSmallIcon(R.drawable.ic_stat_notify)
155 .setContentTitle(context.getString(R.string.notif_completed_title))
156 // No % escaping here: getString(id, arg) inserts the argument verbatim and
157 // never re-scans it for format specifiers, so escaping the title would show
158 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
159 .setContentText(context.getString(R.string.notif_completed_text, title))
160 .setAutoCancel(true)
161 .setOnlyAlertOnce(true)
162 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
163 .addAction(replyAction)
164 .addAction(markReadAction)
165 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
166 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
167 // Group notifications from the same server so multiple completions collapse into
168 // a stackable group on Android 7+ instead of filling the shade with individual entries.
169 .setGroup(GROUP_COMPLETED)
170 .build()
172 runCatching {
173 NotificationManagerCompat.from(context).notify(notifId, notification)
174 // Post a summary notification so the group has a single top-level entry in the shade.
175 postCompletedSummary(context)
176 }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
177 }
179 /** Post (or update) a summary notification for the completed-sessions group so multiple
180 * completions collapse into one stackable entry instead of filling the shade. */
181 @SuppressLint("MissingPermission")
182 private fun postCompletedSummary(context: Context) {
183 // The summary needs a content intent so tapping it opens the app (and so
184 // setAutoCancel can fire on tap — without a PendingIntent the tap does nothing
185 // and the summary can only be dismissed by swiping). A plain open-app intent is
186 // right for a group summary: there's no single session to route to.
187 val openIntent = Intent(context, MainActivity::class.java).apply {
188 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
189 }
190 val contentIntent = PendingIntent.getActivity(
191 context, 0, openIntent,
192 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
193 )
194 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
195 .setSmallIcon(R.drawable.ic_stat_notify)
196 .setContentTitle(context.getString(R.string.notif_completed_title))
197 .setContentText(context.getString(R.string.app_name))
198 .setContentIntent(contentIntent)
199 .setAutoCancel(true)
200 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
201 .setGroup(GROUP_COMPLETED)
202 .setGroupSummary(true)
203 .build()
204 runCatching {
205 NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
206 }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
207 }
209 /** Post a heads-up notification for a permission request with Allow once / Always /
210 * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
211 * pattern/type supplies the detail line. [profileId] is embedded in the action intents
212 * so the receiver routes the response back to the server that posted the request, not
213 * whichever server happens to be active when the user taps. */
214 @SuppressLint("MissingPermission")
215 fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
216 if (!canPost(context)) return
217 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
218 val notifId = notifId(NS_PERMISSION, sessionId)
219 val detail = permission.title?.takeIf { it.isNotBlank() }
220 ?: permission.patternText?.takeIf { it.isNotBlank() }
221 ?: permission.type?.takeIf { it.isNotBlank() }
222 ?: context.getString(R.string.notif_permission_fallback)
224 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
225 .setSmallIcon(R.drawable.ic_stat_notify)
226 .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
227 .setContentText(detail)
228 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
229 .setAutoCancel(true)
230 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
231 .setPriority(NotificationCompat.PRIORITY_HIGH)
232 .setGroup(GROUP_PERMISSION)
233 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
234 .setPublicVersion(
235 NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
236 .setSmallIcon(R.drawable.ic_stat_notify)
237 .setContentTitle(context.getString(R.string.notif_permission_public))
238 .build(),
239 )
241 // One action per response. Each PendingIntent needs a distinct request code or
242 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
243 listOf(
244 Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
245 Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
246 Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
247 ).forEach { (response, labelRes, iconRes) ->
248 val intent = Intent(context, NotificationActionReceiver::class.java).apply {
249 action = NotificationActionReceiver.ACTION_PERMISSION
250 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
251 putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
252 putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
253 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
254 }
255 val pending = PendingIntent.getBroadcast(
256 context, notifId + response.ordinal + 1, intent,
257 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
258 )
259 builder.addAction(iconRes, context.getString(labelRes), pending)
260 }
262 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
263 .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
264 // Group permission notifications so a multi-session burst collapses into a stackable
265 // group instead of filling the shade — mirroring the completed-sessions grouping.
266 runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
267 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
268 }
270 /** Post a notification when a permission prompt auto-rejected on timeout. A user who
271 * walked away from an in-app prompt returns to find the run stopped with no on-screen
272 * record of why; this leaves a persistent trace in the shade explaining the stop and
273 * offering to re-open the session. Uses the permission channel (heads-up) so it's seen. */
274 @SuppressLint("MissingPermission")
275 fun postPermissionAutoRejected(context: Context, sessionId: String, sessionTitle: String, profileId: String?) {
276 if (!canPost(context)) return
277 val notifId = notifId(NS_PERMISSION, sessionId) + 1 // distinct from the live prompt
278 val detail = context.getString(R.string.notif_permission_auto_rejected_text)
279 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
280 .setSmallIcon(R.drawable.ic_stat_notify)
281 .setContentTitle(context.getString(R.string.notif_permission_auto_rejected_title, sessionTitle))
282 .setContentText(detail)
283 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
284 .setAutoCancel(true)
285 .setOnlyAlertOnce(true)
286 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
287 .setCategory(NotificationCompat.CATEGORY_STATUS)
288 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
289 .setGroup(GROUP_PERMISSION)
290 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
291 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
292 .onFailure { Log.w(TAG, "Failed to post permission auto-reject notification", it) }
293 runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
294 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
295 }
297 /** Post a notification when a background run fails. */
298 @SuppressLint("MissingPermission")
299 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
300 if (!canPost(context)) return
301 val notifId = notifId(NS_ERROR, sessionId)
302 val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
303 .setSmallIcon(R.drawable.ic_stat_notify)
304 .setContentTitle(context.getString(R.string.notif_error_title))
305 .setContentText(context.getString(R.string.notif_error_text, title))
306 .setAutoCancel(true)
307 .setOnlyAlertOnce(true)
308 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
309 .setCategory(NotificationCompat.CATEGORY_ERROR)
310 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
311 // Group error notifications so a burst of failures collapses into a stackable group.
312 .setGroup(GROUP_ERROR)
313 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
314 .setPublicVersion(
315 NotificationCompat.Builder(context, NotificationChannels.ERROR)
316 .setSmallIcon(R.drawable.ic_stat_notify)
317 .setContentTitle(context.getString(R.string.notif_error_public))
318 .build(),
319 )
320 // A "Retry last" action lets the user re-run the failed prompt straight from the shade
321 // without opening the app — a failed run is exactly when a one-tap retry is most useful.
322 val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
323 action = NotificationActionReceiver.ACTION_RETRY_LAST
324 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
325 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
326 }
327 val retryPending = PendingIntent.getBroadcast(
328 context, notifId + 10, retryIntent,
329 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
330 )
331 builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
332 val notification = builder.build()
333 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
334 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
335 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
336 .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
337 }
339 /** Update the completion notification to show a brief "Sent" confirmation after a
340 * successful inline reply, replacing the completion notification's content. Auto-cancels
341 * on tap (via a [setContentIntent]) so it doesn't linger. */
342 @SuppressLint("MissingPermission")
343 fun postReplySent(context: Context, sessionId: String, profileId: String? = null) {
344 if (!canPost(context)) {
345 cancel(context, sessionId)
346 return
347 }
348 val notifId = notifId(NS_COMPLETED, sessionId)
349 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
350 .setSmallIcon(R.drawable.ic_stat_notify)
351 .setContentTitle(context.getString(R.string.notif_reply_sent))
352 // setAutoCancel(true) only fires when the user taps a contentIntent; without one
353 // the "Sent" notification lingers indefinitely (the prior behavior). Wire the
354 // open-session intent so a tap dismisses the notification and opens the session.
355 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
356 .setAutoCancel(true)
357 .setPriority(NotificationCompat.PRIORITY_LOW)
358 .setGroup(GROUP_COMPLETED)
359 .build()
360 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
361 .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
362 }
364 /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
365 * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
366 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
367 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
368 @SuppressLint("MissingPermission")
369 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
370 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
371 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
372 if (!canPost(context)) return
373 val notifId = notifId(NS_ERROR, sessionId)
374 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
375 .setSmallIcon(R.drawable.ic_stat_notify)
376 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
377 .setContentText(context.getString(R.string.notif_reply_failed_text))
378 .setAutoCancel(true)
379 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
380 .setCategory(NotificationCompat.CATEGORY_ERROR)
381 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
382 .build()
383 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
384 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
385 }
387 /** A queued outbox message was permanently undeliverable (e.g. the target session was
388 * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
389 * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
390 * user understands the failure mode and recovery action (open the session and re-send). */
391 @SuppressLint("MissingPermission")
392 fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
393 if (!canPost(context)) return
394 val notifId = notifId(NS_ERROR, sessionId)
395 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
396 .setSmallIcon(R.drawable.ic_stat_notify)
397 .setContentTitle(context.getString(R.string.message_not_delivered))
398 .setContentText(context.getString(R.string.message_not_delivered_body, sessionTitle))
399 .setAutoCancel(true)
400 .setOnlyAlertOnce(true)
401 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
402 .setCategory(NotificationCompat.CATEGORY_ERROR)
403 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
404 .setGroup(GROUP_ERROR)
405 .build()
406 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
407 .onFailure { Log.w(TAG, "Failed to post outbox-dropped notification", it) }
408 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
409 .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
410 }
412 /** The SSE stream hit a non-retryable failure (Failed/AuthFailed) while the app was
413 * backgrounded during an active run, so the in-app banner isn't visible and the run is
414 * effectively stranded. Posts a low-priority notification so a backgrounded user is
415 * signaled that their run is stalled and can tap to reconnect. Distinct from [postError]
416 * (a run error) — this is a connection loss, not a run failure. */
417 @SuppressLint("MissingPermission")
418 fun postConnectionLost(context: Context, profileLabel: String, profileId: String?) {
419 if (!canPost(context)) return
420 val notifId = CONNECTION_LOST_ID
421 // Tap reopens the app at the server list so the user can reconnect / fix credentials.
422 val openIntent = Intent(context, MainActivity::class.java).apply {
423 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
424 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
425 }
426 val openPending = PendingIntent.getActivity(
427 context, notifId, openIntent,
428 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
429 )
430 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
431 .setSmallIcon(R.drawable.ic_stat_notify)
432 .setContentTitle(context.getString(R.string.connection_lost, profileLabel))
433 .setContentText(context.getString(R.string.connection_lost_tap))
434 .setAutoCancel(true)
435 .setOnlyAlertOnce(true)
436 .setContentIntent(openPending)
437 .setCategory(NotificationCompat.CATEGORY_STATUS)
438 .setPriority(NotificationCompat.PRIORITY_LOW)
439 .build()
440 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
441 .onFailure { Log.w(TAG, "Failed to post connection-lost notification", it) }
442 }
444 /** Cancel a previously-posted [postConnectionLost] notification (e.g. on reconnect). */
445 fun cancelConnectionLost(context: Context) {
446 NotificationManagerCompat.from(context).cancel(CONNECTION_LOST_ID)
447 }
449 /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
450 private fun mutableFlag(): Int =
451 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
453 /** Cancel a session's completion notification (e.g. when the user opens it). */
454 fun cancel(context: Context, sessionId: String) {
455 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
456 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
457 }
459 /**
460 * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
461 * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
462 * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
463 * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
464 * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
465 */
466 @SuppressLint("MissingPermission")
467 fun updateUnreadBadge(context: Context, totalUnread: Int) {
468 val nm = NotificationManagerCompat.from(context)
469 if (totalUnread <= 0) {
470 nm.cancel(UNREAD_BADGE_ID)
471 return
472 }
473 if (!canPost(context)) return
474 val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
475 .setSmallIcon(R.drawable.ic_stat_notify)
476 .setContentTitle(context.getString(R.string.app_name))
477 .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
478 .setNumber(totalUnread.coerceAtMost(999))
479 .setOnlyAlertOnce(true)
480 .setSilent(true)
481 .setShowWhen(false)
482 .setPriority(NotificationCompat.PRIORITY_MIN)
483 .build()
484 runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
485 .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
486 }
488 /** Cancel a session's permission notification (on reply, or when the user opens it). Also
489 * cancels the auto-rejected-permission notification (posted under notifId + 1) so opening
490 * the session by any route other than tapping that notification clears it too — without
491 * this a stale "auto-rejected" heads-up lingers in the shade (setAutoCancel only dismisses
492 * on a direct tap). */
493 fun cancelPermission(context: Context, sessionId: String) {
494 val nm = NotificationManagerCompat.from(context)
495 val baseId = notifId(NS_PERMISSION, sessionId)
496 nm.cancel(baseId)
497 nm.cancel(baseId + 1) // postPermissionAutoRejected uses baseId + 1
498 maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
499 }
501 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
502 * stale "run failed" notification lingers when the session is opened by any route other
503 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
504 fun cancelError(context: Context, sessionId: String) {
505 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
506 maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
507 }
509 /** Build the lock-screen (public) version of a notification: only a generic title, no
510 * detail line, so sensitive file paths / commands in the real content aren't revealed
511 * while the device is locked. */
512 /** When the last child of a group is dismissed, cancel its lingering summary so it
513 * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
514 * so reach for the platform NotificationManager. The children/summary are posted without
515 * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
516 * id) is the reliable way to tell if any child remains. */
517 private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
518 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
519 ?: return
520 val active = try {
521 nm.activeNotifications
522 } catch (e: Exception) {
523 Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
524 return
525 }
526 val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
527 if (!hasChild) {
528 runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
529 .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
530 }
531 }
533 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
534 private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
535 private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
536 private const val SUMMARY_COMPLETED_ID = -1
537 private const val SUMMARY_PERMISSION_ID = -2
538 private const val SUMMARY_ERROR_ID = -3
540 /** Post a summary notification for a group so multiple entries collapse into one stackable
541 * entry in the shade instead of filling it. */
542 @SuppressLint("MissingPermission")
543 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
544 if (!canPost(context)) return
545 val summary = NotificationCompat.Builder(context, channelId)
546 .setSmallIcon(R.drawable.ic_stat_notify)
547 .setContentTitle(context.getString(R.string.app_name))
548 .setAutoCancel(true)
549 .setGroup(group)
550 .setGroupSummary(true)
551 .build()
552 runCatching {
553 NotificationManagerCompat.from(context).notify(summaryId, summary)
554 }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
555 }