git.iko.soy/opencode-android
on 0bc1eda help sign in
79 lines 3.7 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 return (NOTIF_ID_PREFIX + ((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 }
39 fun postCompleted(context: Context, sessionId: String, title: String) {
40 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
41 // denied it, skip the post instead of throwing. Foreground-service notifications
42 // are exempt, so the running indicator still shows during a run.
43 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
44 androidx.core.content.ContextCompat.checkSelfPermission(
45 context, android.Manifest.permission.POST_NOTIFICATIONS,
46 ) != android.content.pm.PackageManager.PERMISSION_GRANTED
47 ) {
48 return
49 }
50 val notifId = notifIdFor(sessionId)
51 val openIntent = Intent(context, MainActivity::class.java).apply {
52 action = Intent.ACTION_VIEW
53 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
54 putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
55 }
56 val pendingIntent = PendingIntent.getActivity(
57 context, notifId, openIntent,
58 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
59 )
60 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
61 .setSmallIcon(R.drawable.ic_launcher_foreground)
62 .setContentTitle(context.getString(R.string.notif_completed_title))
63 .setContentText(context.getString(R.string.notif_completed_text, title))
64 .setAutoCancel(true)
65 .setContentIntent(pendingIntent)
66 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
67 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
68 .build()
70 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
71 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
72 }
74 /** Cancel a session's completion notification (e.g. when the user opens it). */
75 fun cancel(context: Context, sessionId: String) {
76 val notifId = notifIdFor(sessionId)
77 NotificationManagerCompat.from(context).cancel(notifId)
78 }