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 /** Derive a stable, collision-resistant notification id from a session id.
28 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
29 * bits makes accidental collisions astronomically unlikely. */
30 private fun notifIdFor(sessionId: String): Int {
31 val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
32 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
33 val hash = ((digest[0].toInt() and 0xFF) shl 24 or
34 (digest[1].toInt() and 0xFF) shl 16 or
35 (digest[2].toInt() and 0xFF) shl 8 or
36 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
37 // Offset by NOTIF_ID_PREFIX, wrapping within the positive Int range so the
38 // prefix is preserved (not masked away) and ids don't collide with the
39 // foreground service's NOTIF_ID = 1.
40 return (NOTIF_ID_PREFIX + hash).and(0x7FFFFFFF)
43 fun postCompleted(context: Context, sessionId: String, title: String) {
44 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
45 // denied it, skip the post instead of throwing. Foreground-service notifications
46 // are exempt, so the running indicator still shows during a run.
47 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
48 androidx.core.content.ContextCompat.checkSelfPermission(
49 context, android.Manifest.permission.POST_NOTIFICATIONS,
50 ) != android.content.pm.PackageManager.PERMISSION_GRANTED
54 val notifId = notifIdFor(sessionId)
55 val openIntent = Intent(context, MainActivity::class.java).apply {
56 action = Intent.ACTION_VIEW
57 data = android.net.Uri.parse("opencode://session/$sessionId")
58 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
60 val pendingIntent = PendingIntent.getActivity(
61 context, notifId, openIntent,
62 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
64 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
65 .setSmallIcon(R.drawable.ic_launcher_foreground)
66 .setContentTitle(context.getString(R.string.notif_completed_title))
67 .setContentText(context.getString(R.string.notif_completed_text, title))
69 .setContentIntent(pendingIntent)
70 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
71 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
74 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
75 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
78 /** Cancel a session's completion notification (e.g. when the user opens it). */
79 fun cancel(context: Context, sessionId: String) {
80 val notifId = notifIdFor(sessionId)
81 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