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>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
8 archivos · +242 −110
- consola/build.gradle.kts +2 −0
- consola/src/main/AndroidManifest.xml +13 −0
- consola/src/main/java/com/rizoma/consola/MainActivity.kt +3 −0
- consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt +18 −0
- consola/src/main/java/com/rizoma/consola/push/Alerts.kt +130 −0
- consola/src/main/java/com/rizoma/consola/push/ConsolaPushReceiver.kt +35 −0
- consola/src/main/java/com/rizoma/consola/push/Push.kt +31 −0
- consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt +10 −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"
}
}