⎇ 84d4ac3a07474bf1bcd21ee43b913b75d8b32180
consola/src/main/java/com/rizoma/consola/push/Alerts.kt
Ñawpa willay
ch'uya
consola: push en background vía UnifiedPush (ntfy)
1package com.rizoma.consola.push
2
3import android.app.NotificationChannel
4import android.app.NotificationManager
5import android.app.PendingIntent
6import android.content.Context
7import android.content.Intent
8import androidx.core.app.NotificationCompat
9import androidx.core.app.RemoteInput
10import com.rizoma.consola.MainActivity
11import com.rizoma.consola.R
12import com.rizoma.consola.service.ConsolaService
13import org.json.JSONObject
14
15/**
16 * Construye y publica las notificaciones de aviso de Claude. Lo usan los DOS
17 * caminos de entrega —el WebSocket de eventos (foreground) y el receptor
18 * UnifiedPush (background)—, así que vive fuera del servicio y **deduplica por
19 * el `id` que sella el gateway** para no mostrar el mismo aviso dos veces.
20 */
21object Alerts {
22 const val ACTION_REPLY = "com.rizoma.consola.action.REPLY"
23 const val ACTION_KEY = "com.rizoma.consola.action.KEY"
24 const val EXTRA_KEY = "key"
25 const val EXTRA_NOTIF_ID = "notif_id"
26 const val KEY_REPLY = "reply"
27
28 private const val CHANNEL_ID = "consola_alertas"
29 private const val ID_BASE = 0xA1E0
30 private const val NEON_VERDE = 0xFF7CFF4F.toInt()
31
32 private val seen = LinkedHashSet<String>()
33 private var nextId = ID_BASE
34
35 @Synchronized
36 private fun firstTime(id: String): Boolean {
37 if (id.isBlank()) return true // sin id: no podemos deduplicar, mostrar
38 if (!seen.add(id)) return false
39 if (seen.size > 100) seen.iterator().run { next(); remove() }
40 return true
41 }
42
43 @Synchronized
44 private fun allocId(): Int {
45 val n = nextId++
46 if (nextId > ID_BASE + 50) nextId = ID_BASE
47 return n
48 }
49
50 fun ensureChannel(ctx: Context) {
51 val mgr = ctx.getSystemService(NotificationManager::class.java)
52 if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
53 mgr.createNotificationChannel(
54 NotificationChannel(CHANNEL_ID, "avisos de Claude", NotificationManager.IMPORTANCE_HIGH)
55 .apply { description = "cuando un Claude pide permiso o termina una tarea" },
56 )
57 }
58 }
59
60 /** Parsea el evento JSON, deduplica por `id` y publica la notificación. */
61 fun show(ctx: Context, eventJson: String) {
62 val o = runCatching { JSONObject(eventJson) }.getOrNull() ?: return
63 if (!firstTime(o.optString("id"))) return
64
65 val kind = o.optString("kind")
66 val cwd = o.optString("cwd")
67 val msg = o.optString("message")
68 val session = o.optString("shuma_session").ifBlank { null }
69 val dir = cwd.substringAfterLast('/').ifBlank { cwd }
70 val (title, text) = when (kind) {
71 "Notification" ->
72 "Un Claude te necesita" to (msg.ifBlank { "esperando tu input" } + if (dir.isNotBlank()) " · $dir" else "")
73 "Stop" ->
74 "Un Claude terminó" to (if (dir.isNotBlank()) "en $dir" else "tarea completada")
75 else ->
76 kind.ifBlank { "Claude" } to msg.ifBlank { dir }
77 }
78
79 ensureChannel(ctx)
80 val nid = allocId()
81
82 // Al tocar: abre la app y se adjunta a ESA sesión (si la sabemos).
83 val open = Intent(ctx, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
84 if (session != null) open.putExtra(MainActivity.EXTRA_SESSION, session)
85 val contentPending = PendingIntent.getActivity(
86 ctx, nid * 8, open,
87 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
88 )
89
90 val b = NotificationCompat.Builder(ctx, CHANNEL_ID)
91 .setSmallIcon(R.drawable.ic_consola)
92 .setContentTitle(title)
93 .setContentText(text)
94 .setColor(NEON_VERDE)
95 .setPriority(NotificationCompat.PRIORITY_HIGH)
96 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
97 .setAutoCancel(true)
98 .setContentIntent(contentPending)
99
100 // Acciones rápidas: solo si sabemos a qué sesión inyectar.
101 if (session != null) {
102 val reply = RemoteInput.Builder(KEY_REPLY).setLabel("responder a Claude…").build()
103 val replyPending = PendingIntent.getService(
104 ctx, nid * 8 + 1, serviceAction(ctx, ACTION_REPLY, session, nid),
105 PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
106 )
107 b.addAction(
108 NotificationCompat.Action.Builder(0, "responder", replyPending)
109 .addRemoteInput(reply).build(),
110 )
111 b.addAction(0, "⏎ continuar", keyPending(ctx, session, "enter", nid, nid * 8 + 2))
112 b.addAction(0, "esc", keyPending(ctx, session, "esc", nid, nid * 8 + 3))
113 }
114
115 ctx.getSystemService(NotificationManager::class.java).notify(nid, b.build())
116 }
117
118 private fun serviceAction(ctx: Context, action: String, sessionId: String, nid: Int): Intent =
119 Intent(ctx, ConsolaService::class.java)
120 .setAction(action)
121 .putExtra(MainActivity.EXTRA_SESSION, sessionId)
122 .putExtra(EXTRA_NOTIF_ID, nid)
123
124 private fun keyPending(ctx: Context, sessionId: String, key: String, nid: Int, req: Int): PendingIntent =
125 PendingIntent.getService(
126 ctx, req,
127 serviceAction(ctx, ACTION_KEY, sessionId, nid).putExtra(EXTRA_KEY, key),
128 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
129 )
130}