gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Definitions

consola: push en background vía UnifiedPush (ntfy)

Conector UnifiedPush 2.5.0 + ConsolaPushReceiver: registra con el
distribuidor (app ntfy), manda el endpoint al gateway (/register) y al
recibir un push muestra la notif. Notificaciones extraídas a Alerts
(compartido WS+push) con dedup por id. Sin Google ni Play Services.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

84d4ac3a07474bf1bcd21ee43b913b75d8b32180 · by Sergio · (4 months ago) · parent e2e8a7b60 · browse the tree at this commit

Semantic delta

No definition changed: this change is text, documentation or data.

8 files · +242 −110

diff --git a/consola/build.gradle.kts b/consola/build.gradle.kts
index 7384d6d..65a5fcc 100644
--- a/consola/build.gradle.kts
+++ b/consola/build.gradle.kts
@@ -50,4 +50,6 @@ dependencies {
     implementation(libs.androidx.datastore.preferences)
     implementation(libs.kotlinx.coroutines.android)
     implementation(libs.okhttp)
+    // UnifiedPush: push en background sin Google, vía un distribuidor (ntfy).
+    implementation("org.unifiedpush.android:connector:2.5.0")
 }
diff --git a/consola/src/main/AndroidManifest.xml b/consola/src/main/AndroidManifest.xml
index 097cb35..bb69676 100644
--- a/consola/src/main/AndroidManifest.xml
+++ b/consola/src/main/AndroidManifest.xml
@@ -35,5 +35,18 @@
             android:name=".service.ConsolaService"
             android:exported="false"
             android:foregroundServiceType="dataSync" />
+
+        <!-- Receptor UnifiedPush: el distribuidor (ntfy) entrega los push aquí
+             aunque la app esté cerrada. -->
+        <receiver
+            android:name=".push.ConsolaPushReceiver"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="org.unifiedpush.android.connector.MESSAGE" />
+                <action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
+                <action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
+                <action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
+            </intent-filter>
+        </receiver>
     </application>
 </manifest>
diff --git a/consola/src/main/java/com/rizoma/consola/MainActivity.kt b/consola/src/main/java/com/rizoma/consola/MainActivity.kt
index 3404b04..02283db 100644
--- a/consola/src/main/java/com/rizoma/consola/MainActivity.kt
+++ b/consola/src/main/java/com/rizoma/consola/MainActivity.kt
@@ -28,6 +28,9 @@ class MainActivity : ComponentActivity() {
         super.onCreate(savedInstanceState)
         WindowCompat.setDecorFitsSystemWindows(window, false)
         pedirPermisoNotificaciones()
+        // Registra UnifiedPush (si hay distribuidor, p.ej. la app ntfy) para
+        // recibir avisos en background; si no, cae al WS de eventos.
+        com.rizoma.consola.push.Push.ensureRegistered(this)
         abrirSesionDeNotificacion(intent)
         setContent {
             ConsolaTheme {
diff --git a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
index 15c0898..08f2762 100644
--- a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
+++ b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
@@ -34,6 +34,24 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
         .pingInterval(20, TimeUnit.SECONDS)
         .build()
 
+    /** Registra el endpoint UnifiedPush en el gateway (`POST /register`). */
+    suspend fun registerUp(endpoint: String): Boolean =
+        postJson("/register", JSONObject().put("endpoint", endpoint).toString())
+
+    /** Da de baja el endpoint (`POST /unregister`). */
+    suspend fun unregisterUp(endpoint: String): Boolean =
+        postJson("/unregister", JSONObject().put("endpoint", endpoint).toString())
+
+    private suspend fun postJson(path: String, body: String): Boolean = withContext(Dispatchers.IO) {
+        val url = config.baseUrl.trimEnd('/') + path
+        val req = Request.Builder()
+            .url(url)
+            .post(body.toRequestBody(json))
+            .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
+            .build()
+        runCatching { client.newCall(req).execute().use { it.isSuccessful } }.getOrDefault(false)
+    }
+
     /** Round-trip de una Request JSON (cuerpos de [Rpc]) → [RpcResponse]. */
     suspend fun rpc(body: String): RpcResponse = withContext(Dispatchers.IO) {
         val rpcUrl = config.baseUrl.trimEnd('/') + "/rpc"
diff --git a/consola/src/main/java/com/rizoma/consola/push/Alerts.kt b/consola/src/main/java/com/rizoma/consola/push/Alerts.kt
new file mode 100644
index 0000000..6fd3bb1
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/push/Alerts.kt
@@ -0,0 +1,130 @@
+package com.rizoma.consola.push
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.RemoteInput
+import com.rizoma.consola.MainActivity
+import com.rizoma.consola.R
+import com.rizoma.consola.service.ConsolaService
+import org.json.JSONObject
+
+/**
+ * Construye y publica las notificaciones de aviso de Claude. Lo usan los DOS
+ * caminos de entrega —el WebSocket de eventos (foreground) y el receptor
+ * UnifiedPush (background)—, así que vive fuera del servicio y **deduplica por
+ * el `id` que sella el gateway** para no mostrar el mismo aviso dos veces.
+ */
+object Alerts {
+    const val ACTION_REPLY = "com.rizoma.consola.action.REPLY"
+    const val ACTION_KEY = "com.rizoma.consola.action.KEY"
+    const val EXTRA_KEY = "key"
+    const val EXTRA_NOTIF_ID = "notif_id"
+    const val KEY_REPLY = "reply"
+
+    private const val CHANNEL_ID = "consola_alertas"
+    private const val ID_BASE = 0xA1E0
+    private const val NEON_VERDE = 0xFF7CFF4F.toInt()
+
+    private val seen = LinkedHashSet<String>()
+    private var nextId = ID_BASE
+
+    @Synchronized
+    private fun firstTime(id: String): Boolean {
+        if (id.isBlank()) return true // sin id: no podemos deduplicar, mostrar
+        if (!seen.add(id)) return false
+        if (seen.size > 100) seen.iterator().run { next(); remove() }
+        return true
+    }
+
+    @Synchronized
+    private fun allocId(): Int {
+        val n = nextId++
+        if (nextId > ID_BASE + 50) nextId = ID_BASE
+        return n
+    }
+
+    fun ensureChannel(ctx: Context) {
+        val mgr = ctx.getSystemService(NotificationManager::class.java)
+        if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+            mgr.createNotificationChannel(
+                NotificationChannel(CHANNEL_ID, "avisos de Claude", NotificationManager.IMPORTANCE_HIGH)
+                    .apply { description = "cuando un Claude pide permiso o termina una tarea" },
+            )
+        }
+    }
+
+    /** Parsea el evento JSON, deduplica por `id` y publica la notificación. */
+    fun show(ctx: Context, eventJson: String) {
+        val o = runCatching { JSONObject(eventJson) }.getOrNull() ?: return
+        if (!firstTime(o.optString("id"))) return
+
+        val kind = o.optString("kind")
+        val cwd = o.optString("cwd")
+        val msg = o.optString("message")
+        val session = o.optString("shuma_session").ifBlank { null }
+        val dir = cwd.substringAfterLast('/').ifBlank { cwd }
+        val (title, text) = when (kind) {
+            "Notification" ->
+                "Un Claude te necesita" to (msg.ifBlank { "esperando tu input" } + if (dir.isNotBlank()) "  ·  $dir" else "")
+            "Stop" ->
+                "Un Claude terminó" to (if (dir.isNotBlank()) "en $dir" else "tarea completada")
+            else ->
+                kind.ifBlank { "Claude" } to msg.ifBlank { dir }
+        }
+
+        ensureChannel(ctx)
+        val nid = allocId()
+
+        // Al tocar: abre la app y se adjunta a ESA sesión (si la sabemos).
+        val open = Intent(ctx, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+        if (session != null) open.putExtra(MainActivity.EXTRA_SESSION, session)
+        val contentPending = PendingIntent.getActivity(
+            ctx, nid * 8, open,
+            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+        )
+
+        val b = NotificationCompat.Builder(ctx, CHANNEL_ID)
+            .setSmallIcon(R.drawable.ic_consola)
+            .setContentTitle(title)
+            .setContentText(text)
+            .setColor(NEON_VERDE)
+            .setPriority(NotificationCompat.PRIORITY_HIGH)
+            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+            .setAutoCancel(true)
+            .setContentIntent(contentPending)
+
+        // Acciones rápidas: solo si sabemos a qué sesión inyectar.
+        if (session != null) {
+            val reply = RemoteInput.Builder(KEY_REPLY).setLabel("responder a Claude…").build()
+            val replyPending = PendingIntent.getService(
+                ctx, nid * 8 + 1, serviceAction(ctx, ACTION_REPLY, session, nid),
+                PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+            )
+            b.addAction(
+                NotificationCompat.Action.Builder(0, "responder", replyPending)
+                    .addRemoteInput(reply).build(),
+            )
+            b.addAction(0, "⏎ continuar", keyPending(ctx, session, "enter", nid, nid * 8 + 2))
+            b.addAction(0, "esc", keyPending(ctx, session, "esc", nid, nid * 8 + 3))
+        }
+
+        ctx.getSystemService(NotificationManager::class.java).notify(nid, b.build())
+    }
+
+    private fun serviceAction(ctx: Context, action: String, sessionId: String, nid: Int): Intent =
+        Intent(ctx, ConsolaService::class.java)
+            .setAction(action)
+            .putExtra(MainActivity.EXTRA_SESSION, sessionId)
+            .putExtra(EXTRA_NOTIF_ID, nid)
+
+    private fun keyPending(ctx: Context, sessionId: String, key: String, nid: Int, req: Int): PendingIntent =
+        PendingIntent.getService(
+            ctx, req,
+            serviceAction(ctx, ACTION_KEY, sessionId, nid).putExtra(EXTRA_KEY, key),
+            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+        )
+}
diff --git a/consola/src/main/java/com/rizoma/consola/push/ConsolaPushReceiver.kt b/consola/src/main/java/com/rizoma/consola/push/ConsolaPushReceiver.kt
new file mode 100644
index 0000000..f302342
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/push/ConsolaPushReceiver.kt
@@ -0,0 +1,35 @@
+package com.rizoma.consola.push
+
+import android.content.Context
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.unifiedpush.android.connector.MessagingReceiver
+
+/**
+ * Receptor UnifiedPush: el distribuidor (ntfy) entrega aquí los push aunque
+ * consola esté cerrada. El gateway POSTea el evento JSON tal cual al endpoint,
+ * así que reusamos [Alerts] (que deduplica por `id` contra el camino WS).
+ */
+class ConsolaPushReceiver : MessagingReceiver() {
+
+    override fun onMessage(context: Context, message: ByteArray, instance: String) {
+        Alerts.show(context.applicationContext, String(message, Charsets.UTF_8))
+    }
+
+    override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
+        val ctx = context.applicationContext
+        val pending = goAsync()
+        CoroutineScope(Dispatchers.IO).launch {
+            try {
+                Push.sendEndpoint(ctx, endpoint)
+            } finally {
+                pending.finish()
+            }
+        }
+    }
+
+    override fun onRegistrationFailed(context: Context, instance: String) {}
+
+    override fun onUnregistered(context: Context, instance: String) {}
+}
diff --git a/consola/src/main/java/com/rizoma/consola/push/Push.kt b/consola/src/main/java/com/rizoma/consola/push/Push.kt
new file mode 100644
index 0000000..031be2a
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/push/Push.kt
@@ -0,0 +1,31 @@
+package com.rizoma.consola.push
+
+import android.content.Context
+import com.rizoma.consola.data.GatewayClient
+import com.rizoma.consola.data.Settings
+import kotlinx.coroutines.flow.first
+import org.unifiedpush.android.connector.UnifiedPush
+
+/**
+ * Pegamento UnifiedPush: registra la app con el distribuidor (la app ntfy) y
+ * sincroniza el endpoint resultante con el gateway, que es quien empuja los
+ * eventos por ese endpoint cuando consola está cerrada.
+ */
+object Push {
+    /**
+     * Registra con un distribuidor UnifiedPush si hay alguno instalado. Si no
+     * (no hay app ntfy), no hace nada y consola cae al WS de eventos como
+     * respaldo. Idempotente: re-registrar re-dispara `onNewEndpoint`.
+     */
+    fun ensureRegistered(ctx: Context) {
+        val dists = runCatching { UnifiedPush.getDistributors(ctx) }.getOrDefault(emptyList())
+        if (dists.isEmpty()) return
+        UnifiedPush.saveDistributor(ctx, dists.first())
+        UnifiedPush.registerApp(ctx)
+    }
+
+    suspend fun sendEndpoint(ctx: Context, endpoint: String) {
+        val cfg = Settings(ctx).config.first()
+        GatewayClient(cfg).registerUp(endpoint)
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
index cab6dce..954492b 100644
--- a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
+++ b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
@@ -27,6 +27,7 @@ import com.rizoma.consola.data.PtyConnection
 import com.rizoma.consola.data.PtyListener
 import com.rizoma.consola.data.Rpc
 import com.rizoma.consola.data.Settings
+import com.rizoma.consola.push.Alerts
 import com.rizoma.consola.term.TerminalEmulator
 import com.rizoma.consola.term.TerminalFrame
 import kotlinx.coroutines.CoroutineScope
@@ -102,25 +103,24 @@ class ConsolaService : Service() {
     private var eventsConn: EventsConnection? = null
     private var supervising = false
     private var reconnectJob: Job? = null
-    private var alertId = ALERT_ID_BASE
 
     override fun onBind(intent: Intent?): IBinder = binder
 
     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
         when (intent?.action) {
-            ACTION_REPLY -> {
-                val session = intent.getStringExtra(EXTRA_SESSION)
+            Alerts.ACTION_REPLY -> {
+                val session = intent.getStringExtra(MainActivity.EXTRA_SESSION)
                 val reply = RemoteInput.getResultsFromIntent(intent)
-                    ?.getCharSequence(KEY_REPLY)?.toString()
+                    ?.getCharSequence(Alerts.KEY_REPLY)?.toString()
                 if (!session.isNullOrBlank() && !reply.isNullOrEmpty()) {
                     // El texto + Enter: responde a Claude sin abrir el terminal.
                     injectInput(session, (reply + "\r").toByteArray(Charsets.UTF_8))
                 }
                 cancelAlert(intent)
             }
-            ACTION_KEY -> {
-                val session = intent.getStringExtra(EXTRA_SESSION)
-                val bytes = when (intent.getStringExtra(EXTRA_KEY)) {
+            Alerts.ACTION_KEY -> {
+                val session = intent.getStringExtra(MainActivity.EXTRA_SESSION)
+                val bytes = when (intent.getStringExtra(Alerts.EXTRA_KEY)) {
                     "enter" -> byteArrayOf(0x0d)
                     "esc" -> byteArrayOf(0x1b)
                     "yes" -> "y\r".toByteArray()
@@ -160,7 +160,7 @@ class ConsolaService : Service() {
         if (supervising) return
         supervising = true
         ensureChannel()
-        ensureAlertChannel()
+        Alerts.ensureChannel(this)
         enterForeground(SUPERVISOR_SUBTITLE)
         connectEvents()
     }
@@ -178,7 +178,7 @@ class ConsolaService : Service() {
     private fun connectEvents() {
         eventsConn?.close()
         eventsConn = client.openEvents(object : EventsListener {
-            override fun onEvent(json: String) { handleEvent(json) }
+            override fun onEvent(json: String) { Alerts.show(this@ConsolaService, json) }
             override fun onClosed(reason: String?) {
                 // Reintento mientras sigamos vigilando (NAT/proxy/doze cortan).
                 if (!supervising) return
@@ -191,84 +191,6 @@ class ConsolaService : Service() {
         })
     }
 
-    private fun handleEvent(jsonText: String) {
-        val o = runCatching { JSONObject(jsonText) }.getOrNull() ?: return
-        val kind = o.optString("kind")
-        val cwd = o.optString("cwd")
-        val msg = o.optString("message")
-        val session = o.optString("shuma_session").ifBlank { null }
-        val dir = cwd.substringAfterLast('/').ifBlank { cwd }
-        val (title, text) = when (kind) {
-            "Notification" ->
-                "Un Claude te necesita" to (msg.ifBlank { "esperando tu input" } + if (dir.isNotBlank()) "  ·  $dir" else "")
-            "Stop" ->
-                "Un Claude terminó" to (if (dir.isNotBlank()) "en $dir" else "tarea completada")
-            else ->
-                kind.ifBlank { "Claude" } to msg.ifBlank { dir }
-        }
-        notifyAlert(title, text, session)
-    }
-
-    private fun notifyAlert(title: String, text: String, sessionId: String?) {
-        val mgr = getSystemService(NotificationManager::class.java)
-        // id rotativo para que no se pisen avisos consecutivos.
-        val nid = alertId++
-        if (alertId > ALERT_ID_BASE + 50) alertId = ALERT_ID_BASE
-
-        // Al tocar: abre la app y, si el evento trae la sesión exacta, se
-        // adjunta a ESE terminal (SHUMA_SESSION sembrado por el daemon).
-        val intent = Intent(this, MainActivity::class.java)
-            .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
-        if (sessionId != null) intent.putExtra(MainActivity.EXTRA_SESSION, sessionId)
-        val open = PendingIntent.getActivity(
-            this, nid * 8,
-            intent,
-            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
-        )
-
-        val b = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
-            .setSmallIcon(R.drawable.ic_consola)
-            .setContentTitle(title)
-            .setContentText(text)
-            .setColor(NEON_VERDE)
-            .setPriority(NotificationCompat.PRIORITY_HIGH)
-            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
-            .setAutoCancel(true)
-            .setContentIntent(open)
-
-        // Acciones rápidas: solo si sabemos a qué sesión inyectar.
-        if (sessionId != null) {
-            // Responder con texto libre (se manda + Enter) desde la notif.
-            val reply = RemoteInput.Builder(KEY_REPLY).setLabel("responder a Claude…").build()
-            val replyPending = PendingIntent.getService(
-                this, nid * 8 + 1,
-                serviceAction(ACTION_REPLY, sessionId, nid),
-                PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
-            )
-            b.addAction(
-                NotificationCompat.Action.Builder(0, "responder", replyPending)
-                    .addRemoteInput(reply).build(),
-            )
-            b.addAction(0, "⏎ continuar", keyPending(sessionId, "enter", nid, nid * 8 + 2))
-            b.addAction(0, "esc", keyPending(sessionId, "esc", nid, nid * 8 + 3))
-        }
-
-        mgr.notify(nid, b.build())
-    }
-
-    private fun serviceAction(action: String, sessionId: String, nid: Int): Intent =
-        Intent(this, ConsolaService::class.java)
-            .setAction(action)
-            .putExtra(EXTRA_SESSION, sessionId)
-            .putExtra(EXTRA_NOTIF_ID, nid)
-
-    private fun keyPending(sessionId: String, key: String, nid: Int, req: Int): PendingIntent =
-        PendingIntent.getService(
-            this, req,
-            serviceAction(ACTION_KEY, sessionId, nid).putExtra(EXTRA_KEY, key),
-            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
-        )
-
     /** Inyecta teclas a la sesión exacta por `/rpc` (PtySendInput), sin attach. */
     private fun injectInput(session: String, bytes: ByteArray) {
         scope.launch {
@@ -280,24 +202,10 @@ class ConsolaService : Service() {
     }
 
     private fun cancelAlert(intent: Intent) {
-        val nid = intent.getIntExtra(EXTRA_NOTIF_ID, -1)
+        val nid = intent.getIntExtra(Alerts.EXTRA_NOTIF_ID, -1)
         if (nid >= 0) getSystemService(NotificationManager::class.java).cancel(nid)
     }
 
-    private fun ensureAlertChannel() {
-        val mgr = getSystemService(NotificationManager::class.java)
-        if (mgr.getNotificationChannel(ALERT_CHANNEL_ID) == null) {
-            val ch = NotificationChannel(
-                ALERT_CHANNEL_ID,
-                "avisos de Claude",
-                NotificationManager.IMPORTANCE_HIGH,
-            ).apply {
-                description = "cuando un Claude pide permiso o termina una tarea"
-            }
-            mgr.createNotificationChannel(ch)
-        }
-    }
-
     // ---- Terminal -----------------------------------------------------
 
     /**
@@ -505,17 +413,9 @@ class ConsolaService : Service() {
 
     companion object {
         private const val CHANNEL_ID = "consola_sesiones"
-        private const val ALERT_CHANNEL_ID = "consola_alertas"
         private const val NOTIF_ID = 0xC0_50 // "co so" → consola
-        private const val ALERT_ID_BASE = 0xA1_E0 // base de ids para avisos
         private const val NEON_VERDE = 0xFF7CFF4F.toInt()
         private const val SUPERVISOR_SUBTITLE = "vigilando tus Claudes"
         const val ACTION_STOP = "com.rizoma.consola.action.STOP"
-        const val ACTION_REPLY = "com.rizoma.consola.action.REPLY"
-        const val ACTION_KEY = "com.rizoma.consola.action.KEY"
-        const val EXTRA_SESSION = MainActivity.EXTRA_SESSION
-        const val EXTRA_KEY = "key"
-        const val EXTRA_NOTIF_ID = "notif_id"
-        const val KEY_REPLY = "reply"
     }
 }