git.iko.soy/opencode-android
on 66d239c help sign in
83 lines 4.0 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 /** 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)
41 }
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
51 ) {
52 return
53 }
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
59 }
60 val pendingIntent = PendingIntent.getActivity(
61 context, notifId, openIntent,
62 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
63 )
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))
68 .setAutoCancel(true)
69 .setContentIntent(pendingIntent)
70 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
71 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
72 .build()
74 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
75 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
76 }
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)
82 }