git.iko.soy/opencode-android
on 8bc2cce help sign in
95 lines 4.9 kB history raw
1package soy.iko.opencode.notification
3import android.app.PendingIntent
4import android.content.Context
5import android.content.Intent
6import android.os.Build
7import android.util.Log
8import androidx.core.app.NotificationCompat
9import androidx.core.app.NotificationManagerCompat
10import soy.iko.opencode.MainActivity
11import soy.iko.opencode.R
12import java.security.MessageDigest
14/**
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.
18 *
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.
21 */
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))
47 }
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
57 ) {
58 return
59 }
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
68 }
69 val pendingIntent = PendingIntent.getActivity(
70 context, notifId, openIntent,
71 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
72 )
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))
80 .setAutoCancel(true)
81 .setContentIntent(pendingIntent)
82 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
83 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
84 .build()
86 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
87 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
88 }
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)
94 }