git.iko.soy/opencode-android
on d29e57b help sign in
90 lines 4.5 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 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))
43 }
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
53 ) {
54 return
55 }
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
64 }
65 val pendingIntent = PendingIntent.getActivity(
66 context, notifId, openIntent,
67 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
68 )
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("%", "%%")))
75 .setAutoCancel(true)
76 .setContentIntent(pendingIntent)
77 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
78 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
79 .build()
81 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
82 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
83 }
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)
89 }