1package soy.iko.opencode.notification
3import android.app.PendingIntent
4import android.content.Context
5import android.content.Intent
8import androidx.core.app.NotificationCompat
9import androidx.core.app.NotificationManagerCompat
10import soy.iko.opencode.MainActivity
11import soy.iko.opencode.R
12import java.security.MessageDigest
15 * Builds and posts the "session completed" notification when a background session
16 * finishes a run. Tapping it opens the session via [MainActivity] with an
17 * [MainActivity.EXTRA_SESSION_ID] extra.
19 * Notification posting is guarded for Android 13+ runtime permission: if the user
20 * hasn't granted POST_NOTIFICATIONS, the post is silently skipped rather than throwing.
22object SessionNotifications {
24 private const val NOTIF_ID_PREFIX = 4000
25 private const val TAG = "SessionNotifications"
27 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
29 /** Derive a stable, collision-resistant notification id from a session id.
30 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
31 * bits makes accidental collisions astronomically unlikely. */
32 private fun notifIdFor(sessionId: String): Int {
33 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
34 .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
35 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
36 // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
37 // left-to-right, so the shifts MUST be parenthesized individually — otherwise
38 // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
39 // byte out of the Int entirely and collapsing the id's entropy.
40 val hash = (((digest[0].toInt() and 0xFF) shl 24) or
41 ((digest[1].toInt() and 0xFF) shl 16) or
42 ((digest[2].toInt() and 0xFF) shl 8) or
43 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
44 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
45 // Using modulo ensures the prefix is preserved without Int overflow.
46 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
49 fun postCompleted(context: Context, sessionId: String, title: String) {
50 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
51 // denied it, skip the post instead of throwing. Foreground-service notifications
52 // are exempt, so the running indicator still shows during a run.
53 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
54 androidx.core.content.ContextCompat.checkSelfPermission(
55 context, android.Manifest.permission.POST_NOTIFICATIONS,
56 ) != android.content.pm.PackageManager.PERMISSION_GRANTED
60 val notifId = notifIdFor(sessionId)
61 // Sanitize the session id before embedding it in the URI so characters like
62 // /, ?, # can't inject path segments or query parameters.
63 val safeSessionId = notifIdRegex.replace(sessionId, "")
64 val openIntent = Intent(context, MainActivity::class.java).apply {
65 action = Intent.ACTION_VIEW
66 data = android.net.Uri.parse("opencode://session/$safeSessionId")
67 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
69 val pendingIntent = PendingIntent.getActivity(
70 context, notifId, openIntent,
71 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
73 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
74 .setSmallIcon(R.drawable.ic_launcher_foreground)
75 .setContentTitle(context.getString(R.string.notif_completed_title))
76 // No % escaping here: getString(id, arg) inserts the argument verbatim and
77 // never re-scans it for format specifiers, so escaping the title would show
78 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
79 .setContentText(context.getString(R.string.notif_completed_text, title))
81 .setContentIntent(pendingIntent)
82 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
83 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
86 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
87 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
90 /** Cancel a session's completion notification (e.g. when the user opens it). */
91 fun cancel(context: Context, sessionId: String) {
92 val notifId = notifIdFor(sessionId)
93 NotificationManagerCompat.from(context).cancel(notifId)
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