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 val hash = ((digest[0].toInt() and 0xFF) shl 24 or
37 (digest[1].toInt() and 0xFF) shl 16 or
38 (digest[2].toInt() and 0xFF) shl 8 or
39 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
40 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
41 // Using modulo ensures the prefix is preserved without Int overflow.
42 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
45 fun postCompleted(context: Context, sessionId: String, title: String) {
46 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
47 // denied it, skip the post instead of throwing. Foreground-service notifications
48 // are exempt, so the running indicator still shows during a run.
49 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
50 androidx.core.content.ContextCompat.checkSelfPermission(
51 context, android.Manifest.permission.POST_NOTIFICATIONS,
52 ) != android.content.pm.PackageManager.PERMISSION_GRANTED
56 val notifId = notifIdFor(sessionId)
57 // Sanitize the session id before embedding it in the URI so characters like
58 // /, ?, # can't inject path segments or query parameters.
59 val safeSessionId = notifIdRegex.replace(sessionId, "")
60 val openIntent = Intent(context, MainActivity::class.java).apply {
61 action = Intent.ACTION_VIEW
62 data = android.net.Uri.parse("opencode://session/$safeSessionId")
63 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
65 val pendingIntent = PendingIntent.getActivity(
66 context, notifId, openIntent,
67 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
69 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
70 .setSmallIcon(R.drawable.ic_launcher_foreground)
71 .setContentTitle(context.getString(R.string.notif_completed_title))
72 // Escape % characters in the title so getString formatting doesn't break
73 // when a server-controlled session title contains format-specifier-like text.
74 .setContentText(context.getString(R.string.notif_completed_text, title.replace("%", "%%")))
76 .setContentIntent(pendingIntent)
77 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
78 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
81 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
82 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
85 /** Cancel a session's completion notification (e.g. when the user opens it). */
86 fun cancel(context: Context, sessionId: String) {
87 val notifId = notifIdFor(sessionId)
88 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