tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Definiciones

Consola: Foreground Service real — mantiene viva la conexión WS al daemon

El WS al shuma-gateway (y el socket al daemon Rust) ahora lo posee un
ConsolaService de primer plano (tipo dataSync), no el ViewModel. Así
Android no mata la sesión TUI al perder el foco. Notificación persistente
"[ sesiones de consola vivas ]" con acento neón y acción "detener"; la UI
se une/separa del servicio y restaura el terminal si sigue vivo al volver.

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

44e47fe5a4d5b6623904fdfabaaf0fb2c4cb8389 · por sergio · (hace 4 meses) · padre 6f37139d5 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

5 archivos · +432 −149

diff --git a/consola/src/main/AndroidManifest.xml b/consola/src/main/AndroidManifest.xml
index 7330f74..097cb35 100644
--- a/consola/src/main/AndroidManifest.xml
+++ b/consola/src/main/AndroidManifest.xml
@@ -5,6 +5,13 @@
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 
+    <!-- Servicio de primer plano: mantiene viva la conexión WS al daemon
+         aunque la app pierda el foco. Tipo dataSync (sincroniza un stream). -->
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
+    <!-- Necesario en Android 13+ para mostrar la notificación persistente. -->
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
     <application
         android:label="@string/app_name"
         android:icon="@mipmap/ic_launcher"
@@ -23,5 +30,10 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
+
+        <service
+            android:name=".service.ConsolaService"
+            android:exported="false"
+            android:foregroundServiceType="dataSync" />
     </application>
 </manifest>
diff --git a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
index 44e968f..870be52 100644
--- a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
+++ b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
@@ -1,50 +1,41 @@
 package com.rizoma.consola
 
 import android.app.Application
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
 import com.rizoma.consola.data.GatewayClient
 import com.rizoma.consola.data.GatewayConfig
-import com.rizoma.consola.data.PtyConnection
-import com.rizoma.consola.data.PtyListener
 import com.rizoma.consola.data.PtySession
 import com.rizoma.consola.data.Rpc
 import com.rizoma.consola.data.RpcResponse
 import com.rizoma.consola.data.Settings
-import com.rizoma.consola.term.TerminalEmulator
+import com.rizoma.consola.service.ConsolaService
 import com.rizoma.consola.term.TerminalFrame
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 
 enum class Screen { List, Terminal, Settings }
 enum class TermStatus { Connecting, Live, Exited, Error, Closed }
 
-private sealed interface TermEvent {
-    class Bytes(val data: ByteArray) : TermEvent
-    class Resize(val rows: Int, val cols: Int) : TermEvent
-    data object Opened : TermEvent
-    class SessionId(val id: String) : TermEvent
-    class Exit(val code: Int) : TermEvent
-    class ErrorMsg(val message: String) : TermEvent
-    class Closed(val reason: String?) : TermEvent
-}
-
 /**
- * Orquesta la consola: lista de sesiones del daemon (vía `POST /rpc`) y el
- * panel de terminal (vía WebSocket). Los bytes del PTY entran por un canal
- * y un único consumidor los alimenta al [TerminalEmulator], produce un
- * [TerminalFrame] inmutable y lo publica como estado Compose.
+ * Orquesta la consola. La **lista** de sesiones (vía `POST /rpc`) y la
+ * navegación viven aquí; el **terminal en vivo** (WebSocket) lo posee el
+ * [ConsolaService] de primer plano, para sobrevivir a que la app pierda el
+ * foco. El ViewModel se une al servicio, lee su estado y le delega las
+ * teclas/resize; el átomo de estado del terminal se expone tal cual a la UI.
  */
 class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
 
     private val settings = Settings(app)
-    private val client = GatewayClient(GatewayConfig())
+    private val listClient = GatewayClient(GatewayConfig())
 
     var config by mutableStateOf(GatewayConfig()); private set
     var screen by mutableStateOf(Screen.List); private set
@@ -54,25 +45,41 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
     var listLoading by mutableStateOf(false); private set
     var listError by mutableStateOf<String?>(null); private set
 
-    // Terminal activo.
-    var frame by mutableStateOf<TerminalFrame?>(null); private set
-    var termStatus by mutableStateOf(TermStatus.Connecting); private set
-    var termMessage by mutableStateOf<String?>(null); private set
-    var termTitle by mutableStateOf(""); private set
-    var activeSessionId by mutableStateOf<String?>(null); private set
+    // Servicio de primer plano (dueño del WS). Estado Compose para que la UI
+    // recomponga cuando el bind se completa.
+    private var service by mutableStateOf<ConsolaService?>(null)
+    private var pendingOpen: (() -> Unit)? = null
+
+    // Estado del terminal, delegado al servicio.
+    val frame: TerminalFrame? get() = service?.terminal?.frame
+    val termStatus: TermStatus get() = service?.terminal?.status ?: TermStatus.Connecting
+    val termMessage: String? get() = service?.terminal?.message
+    val termTitle: String get() = service?.terminal?.title ?: ""
+    val activeSessionId: String? get() = service?.terminal?.activeSessionId
+
+    private val connection = object : ServiceConnection {
+        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+            val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return
+            svc.updateConfig(config)
+            service = svc
+            // Si quedó un terminal vivo (app reabierta), restáuralo.
+            if (svc.terminal.live) screen = Screen.Terminal
+            pendingOpen?.let { it(); pendingOpen = null }
+        }
 
-    private var emulator = TerminalEmulator(24, 80)
-    private var connection: PtyConnection? = null
-    private var events: Channel<TermEvent>? = null
-    private var consumer: Job? = null
-    private var sizedRows = 24
-    private var sizedCols = 80
+        override fun onServiceDisconnected(name: ComponentName?) {
+            service = null
+        }
+    }
 
     init {
+        val app = getApplication<Application>()
+        app.bindService(Intent(app, ConsolaService::class.java), connection, Context.BIND_AUTO_CREATE)
         viewModelScope.launch {
             settings.config.collect {
                 config = it
-                client.config = it
+                listClient.config = it
+                service?.updateConfig(it)
             }
         }
         refresh()
@@ -84,7 +91,7 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
         if (listLoading) return
         listLoading = true
         viewModelScope.launch {
-            when (val r = client.rpc(Rpc.ptyList())) {
+            when (val r = listClient.rpc(Rpc.ptyList())) {
                 is RpcResponse.Sessions -> { sessions = r.items.sortedByDescending { it.createdUnixMs }; listError = null }
                 is RpcResponse.Failure -> listError = r.message
                 else -> listError = "respuesta inesperada del gateway"
@@ -95,7 +102,7 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
 
     fun kill(id: String) {
         viewModelScope.launch {
-            client.rpc(Rpc.ptyKill(id))
+            listClient.rpc(Rpc.ptyKill(id))
             refresh()
         }
     }
@@ -103,7 +110,7 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
     // ---- Navegación ---------------------------------------------------
 
     fun goList() {
-        teardownTerminal()
+        service?.detachTerminal()
         screen = Screen.List
         refresh()
     }
@@ -121,131 +128,57 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
     fun spawn(command: String, cwd: String, label: String) {
         val parts = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
         if (parts.isEmpty()) return
-        openTerminal(
-            session = null,
-            program = parts.first(),
-            args = parts.drop(1),
-            cwd = cwd.ifBlank { "." },
-            label = label,
-            title = label.ifBlank { command },
-        )
+        startTerminal { svc ->
+            svc.openTerminal(
+                session = null,
+                program = parts.first(),
+                args = parts.drop(1),
+                cwd = cwd.ifBlank { "." },
+                label = label,
+                title = label.ifBlank { command },
+            )
+        }
     }
 
     /** Se adjunta a una sesión existente. */
     fun attach(session: PtySession) {
-        openTerminal(
-            session = session.id,
-            program = session.program,
-            args = session.args,
-            cwd = session.cwd,
-            label = session.label,
-            title = session.label.ifBlank { session.commandLine },
-        )
+        startTerminal { svc ->
+            svc.openTerminal(
+                session = session.id,
+                program = session.program,
+                args = session.args,
+                cwd = session.cwd,
+                label = session.label,
+                title = session.label.ifBlank { session.commandLine },
+            )
+        }
     }
 
-    private fun openTerminal(
-        session: String?,
-        program: String,
-        args: List<String>,
-        cwd: String,
-        label: String,
-        title: String,
-    ) {
-        teardownTerminal()
-        emulator = TerminalEmulator(sizedRows, sizedCols)
-        frame = emulator.snapshot()
-        termStatus = TermStatus.Connecting
-        termMessage = null
-        termTitle = title
-        activeSessionId = session
+    /**
+     * Pasa el servicio a primer plano y abre el terminal. Si el bind aún no
+     * terminó, encola la apertura para cuando conecte.
+     */
+    private fun startTerminal(open: (ConsolaService) -> Unit) {
         screen = Screen.Terminal
-
-        val ch = Channel<TermEvent>(Channel.UNLIMITED)
-        events = ch
-        consumer = viewModelScope.launch(Dispatchers.Default) { consume(ch) }
-
-        connection = client.openPty(
-            session = session,
-            program = program,
-            args = args,
-            cwd = cwd,
-            label = label,
-            rows = sizedRows,
-            cols = sizedCols,
-            listener = object : PtyListener {
-                override fun onOpen() { ch.trySend(TermEvent.Opened) }
-                override fun onSessionId(id: String) { ch.trySend(TermEvent.SessionId(id)) }
-                override fun onBytes(data: ByteArray) { ch.trySend(TermEvent.Bytes(data)) }
-                override fun onExit(code: Int) { ch.trySend(TermEvent.Exit(code)) }
-                override fun onError(message: String) { ch.trySend(TermEvent.ErrorMsg(message)) }
-                override fun onClosed(reason: String?) { ch.trySend(TermEvent.Closed(reason)) }
-            },
+        val app = getApplication<Application>()
+        ContextCompat.startForegroundService(
+            app,
+            Intent(app, ConsolaService::class.java),
         )
+        val svc = service
+        if (svc != null) open(svc) else pendingOpen = { service?.let(open) }
     }
 
-    private suspend fun consume(ch: Channel<TermEvent>) {
-        for (first in ch) {
-            var ev: TermEvent? = first
-            var changed = false
-            while (ev != null) {
-                when (val e = ev) {
-                    is TermEvent.Bytes -> { emulator.feed(e.data); changed = true }
-                    is TermEvent.Resize -> {
-                        emulator.resize(e.rows, e.cols)
-                        connection?.resize(e.rows, e.cols)
-                        changed = true
-                    }
-                    is TermEvent.Opened -> setStatus(TermStatus.Live, null)
-                    is TermEvent.SessionId -> withContext(Dispatchers.Main) { activeSessionId = e.id }
-                    is TermEvent.Exit -> setStatus(TermStatus.Exited, "la sesión terminó (código ${e.code})")
-                    is TermEvent.ErrorMsg -> setStatus(TermStatus.Error, e.message)
-                    is TermEvent.Closed -> withContext(Dispatchers.Main) {
-                        if (termStatus == TermStatus.Live) {
-                            termStatus = TermStatus.Closed
-                            termMessage = "desconectado"
-                        }
-                    }
-                    null -> {}
-                }
-                ev = ch.tryReceive().getOrNull()
-            }
-            if (changed) {
-                val snap = emulator.snapshot()
-                withContext(Dispatchers.Main) { frame = snap }
-            }
-        }
-    }
-
-    private suspend fun setStatus(status: TermStatus, message: String?) {
-        withContext(Dispatchers.Main) {
-            termStatus = status
-            termMessage = message
-        }
-    }
-
-    /** El render midió la rejilla disponible: reescala emulador + PTY. */
-    fun onTerminalSized(rows: Int, cols: Int) {
-        if (rows < 1 || cols < 1) return
-        sizedRows = rows
-        sizedCols = cols
-        events?.trySend(TermEvent.Resize(rows, cols))
-    }
+    fun onTerminalSized(rows: Int, cols: Int) { service?.onSized(rows, cols) }
 
-    fun sendText(text: String) { connection?.sendInput(text) }
+    fun sendText(text: String) { service?.sendText(text) }
 
-    fun sendBytes(bytes: ByteArray) { connection?.sendInput(bytes) }
-
-    private fun teardownTerminal() {
-        connection?.detach()
-        connection = null
-        consumer?.cancel()
-        consumer = null
-        events?.close()
-        events = null
-    }
+    fun sendBytes(bytes: ByteArray) { service?.sendBytes(bytes) }
 
     override fun onCleared() {
-        teardownTerminal()
+        // No matamos el terminal: el servicio queda en primer plano si hay
+        // sesión viva. Solo soltamos el binding.
+        runCatching { getApplication<Application>().unbindService(connection) }
         super.onCleared()
     }
 }
diff --git a/consola/src/main/java/com/rizoma/consola/MainActivity.kt b/consola/src/main/java/com/rizoma/consola/MainActivity.kt
index a3138f1..c9b762a 100644
--- a/consola/src/main/java/com/rizoma/consola/MainActivity.kt
+++ b/consola/src/main/java/com/rizoma/consola/MainActivity.kt
@@ -1,20 +1,38 @@
 package com.rizoma.consola
 
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
 import android.os.Bundle
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
 import androidx.core.view.WindowCompat
 import com.rizoma.consola.ui.ConsolaApp
 import com.rizoma.consola.ui.theme.ConsolaTheme
 
 class MainActivity : ComponentActivity() {
+
+    // Permiso para mostrar la notificación persistente del servicio (Android 13+).
+    private val pedirNotificaciones =
+        registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* opcional: la sesión sigue viva sin ella */ }
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         WindowCompat.setDecorFitsSystemWindows(window, false)
+        pedirPermisoNotificaciones()
         setContent {
             ConsolaTheme {
                 ConsolaApp()
             }
         }
     }
+
+    private fun pedirPermisoNotificaciones() {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+        val concedido = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
+            PackageManager.PERMISSION_GRANTED
+        if (!concedido) pedirNotificaciones.launch(Manifest.permission.POST_NOTIFICATIONS)
+    }
 }
diff --git a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
new file mode 100644
index 0000000..693901b
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
@@ -0,0 +1,306 @@
+package com.rizoma.consola.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.Binder
+import android.os.IBinder
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import com.rizoma.consola.MainActivity
+import com.rizoma.consola.R
+import com.rizoma.consola.TermStatus
+import com.rizoma.consola.data.GatewayClient
+import com.rizoma.consola.data.GatewayConfig
+import com.rizoma.consola.data.PtyConnection
+import com.rizoma.consola.data.PtyListener
+import com.rizoma.consola.term.TerminalEmulator
+import com.rizoma.consola.term.TerminalFrame
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Estado del terminal activo, observable por Compose. Vive en el servicio
+ * (no en el ViewModel) para sobrevivir a que la app pierda el foco: la UI
+ * lee estos campos directamente (mismo proceso) y el servicio los actualiza.
+ */
+class TerminalHolder {
+    var frame by mutableStateOf<TerminalFrame?>(null); internal set
+    var status by mutableStateOf(TermStatus.Connecting); internal set
+    var message by mutableStateOf<String?>(null); internal set
+    var title by mutableStateOf(""); internal set
+    var activeSessionId by mutableStateOf<String?>(null); internal set
+
+    /** Hay una conexión WS adjunta y viva (terminal en uso). */
+    var live by mutableStateOf(false); internal set
+}
+
+private sealed interface TermEvent {
+    class Bytes(val data: ByteArray) : TermEvent
+    class Resize(val rows: Int, val cols: Int) : TermEvent
+    data object Opened : TermEvent
+    class SessionId(val id: String) : TermEvent
+    class Exit(val code: Int) : TermEvent
+    class ErrorMsg(val message: String) : TermEvent
+    class Closed(val reason: String?) : TermEvent
+}
+
+/**
+ * Servicio de primer plano que **posee la conexión WebSocket** hacia el
+ * shuma-gateway (y por ende el socket vivo hacia el daemon Rust). Mientras
+ * hay un terminal adjunto, el servicio corre en primer plano con una
+ * notificación persistente, así Android no mata el proceso —ni el WS— al
+ * perder el foco. La UI se une (bind) para leer el estado y mandar teclas,
+ * y se separa (unbind) sin matar la sesión.
+ *
+ * El daemon mantiene la sesión PTY viva aunque el WS caiga (detach ≠ kill),
+ * pero este servicio mantiene **vivo el stream en directo** mientras la app
+ * está en segundo plano.
+ */
+class ConsolaService : Service() {
+
+    inner class LocalBinder : Binder() {
+        val service: ConsolaService get() = this@ConsolaService
+    }
+
+    private val binder = LocalBinder()
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+    private val client = GatewayClient(GatewayConfig())
+
+    val terminal = TerminalHolder()
+
+    private var emulator = TerminalEmulator(24, 80)
+    private var connection: PtyConnection? = null
+    private var events: Channel<TermEvent>? = null
+    private var consumer: Job? = null
+    private var sizedRows = 24
+    private var sizedCols = 80
+
+    override fun onBind(intent: Intent?): IBinder = binder
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        if (intent?.action == ACTION_STOP) {
+            detachTerminal()
+            stopSelf()
+        }
+        // No resucitamos un servicio vacío: el daemon ya conserva la sesión.
+        return START_NOT_STICKY
+    }
+
+    /** El ViewModel propaga aquí la config del gateway (URL + token). */
+    fun updateConfig(config: GatewayConfig) {
+        client.config = config
+    }
+
+    // ---- Terminal -----------------------------------------------------
+
+    /**
+     * Abre el terminal (crea con spawn si [session] es null, o se adjunta) y
+     * pasa el servicio a primer plano. Asume que el ViewModel ya llamó a
+     * [android.content.Context.startForegroundService].
+     */
+    fun openTerminal(
+        session: String?,
+        program: String,
+        args: List<String>,
+        cwd: String,
+        label: String,
+        title: String,
+    ) {
+        teardownTerminal()
+        emulator = TerminalEmulator(sizedRows, sizedCols)
+        terminal.frame = emulator.snapshot()
+        terminal.status = TermStatus.Connecting
+        terminal.message = null
+        terminal.title = title
+        terminal.activeSessionId = session
+        terminal.live = true
+
+        enterForeground(title)
+
+        val ch = Channel<TermEvent>(Channel.UNLIMITED)
+        events = ch
+        consumer = scope.launch { consume(ch) }
+
+        connection = client.openPty(
+            session = session,
+            program = program,
+            args = args,
+            cwd = cwd,
+            label = label,
+            rows = sizedRows,
+            cols = sizedCols,
+            listener = object : PtyListener {
+                override fun onOpen() { ch.trySend(TermEvent.Opened) }
+                override fun onSessionId(id: String) { ch.trySend(TermEvent.SessionId(id)) }
+                override fun onBytes(data: ByteArray) { ch.trySend(TermEvent.Bytes(data)) }
+                override fun onExit(code: Int) { ch.trySend(TermEvent.Exit(code)) }
+                override fun onError(message: String) { ch.trySend(TermEvent.ErrorMsg(message)) }
+                override fun onClosed(reason: String?) { ch.trySend(TermEvent.Closed(reason)) }
+            },
+        )
+    }
+
+    private suspend fun consume(ch: Channel<TermEvent>) {
+        for (first in ch) {
+            var ev: TermEvent? = first
+            var changed = false
+            while (ev != null) {
+                when (val e = ev) {
+                    is TermEvent.Bytes -> { emulator.feed(e.data); changed = true }
+                    is TermEvent.Resize -> {
+                        emulator.resize(e.rows, e.cols)
+                        connection?.resize(e.rows, e.cols)
+                        changed = true
+                    }
+                    TermEvent.Opened -> setStatus(TermStatus.Live, null)
+                    is TermEvent.SessionId -> withContext(Dispatchers.Main) { terminal.activeSessionId = e.id }
+                    is TermEvent.Exit -> { setStatus(TermStatus.Exited, "la sesión terminó (código ${e.code})"); endForeground() }
+                    is TermEvent.ErrorMsg -> { setStatus(TermStatus.Error, e.message); endForeground() }
+                    is TermEvent.Closed -> withContext(Dispatchers.Main) {
+                        if (terminal.status == TermStatus.Live) {
+                            terminal.status = TermStatus.Closed
+                            terminal.message = "desconectado"
+                        }
+                        terminal.live = false
+                        leaveForeground()
+                    }
+                }
+                ev = ch.tryReceive().getOrNull()
+            }
+            if (changed) {
+                val snap = emulator.snapshot()
+                withContext(Dispatchers.Main) { terminal.frame = snap }
+            }
+        }
+    }
+
+    private suspend fun setStatus(status: TermStatus, message: String?) {
+        withContext(Dispatchers.Main) {
+            terminal.status = status
+            terminal.message = message
+        }
+    }
+
+    /** La sesión murió/cayó: ya no hace falta seguir en primer plano (hilo de fondo). */
+    private suspend fun endForeground() = withContext(Dispatchers.Main) {
+        terminal.live = false
+        leaveForeground()
+    }
+
+    /** Abandona el primer plano y quita la notificación (hilo principal). */
+    private fun leaveForeground() {
+        ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+    }
+
+    /** El render midió la rejilla disponible: reescala emulador + PTY. */
+    fun onSized(rows: Int, cols: Int) {
+        if (rows < 1 || cols < 1) return
+        sizedRows = rows
+        sizedCols = cols
+        events?.trySend(TermEvent.Resize(rows, cols))
+    }
+
+    fun sendText(text: String) { connection?.sendInput(text) }
+
+    fun sendBytes(bytes: ByteArray) { connection?.sendInput(bytes) }
+
+    /** Cierra el WS (DETACH; la sesión sigue en el daemon) y abandona el primer plano. */
+    fun detachTerminal() {
+        teardownTerminal()
+        terminal.live = false
+        leaveForeground()
+        stopSelf()
+    }
+
+    private fun teardownTerminal() {
+        connection?.detach()
+        connection = null
+        consumer?.cancel()
+        consumer = null
+        events?.close()
+        events = null
+    }
+
+    override fun onDestroy() {
+        teardownTerminal()
+        scope.cancel()
+        super.onDestroy()
+    }
+
+    // ---- Notificación de primer plano ---------------------------------
+
+    private fun enterForeground(subtitle: String) {
+        ensureChannel()
+        val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+        } else {
+            0
+        }
+        ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(subtitle), type)
+    }
+
+    private fun ensureChannel() {
+        val mgr = getSystemService(NotificationManager::class.java)
+        if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+            val ch = NotificationChannel(
+                CHANNEL_ID,
+                "sesiones vivas",
+                NotificationManager.IMPORTANCE_LOW,
+            ).apply {
+                description = "mantiene viva la conexión con el daemon mientras la app está en segundo plano"
+                setShowBadge(false)
+            }
+            mgr.createNotificationChannel(ch)
+        }
+    }
+
+    private fun buildNotification(subtitle: String): Notification {
+        val flags = PendingIntent.FLAG_IMMUTABLE
+        val open = PendingIntent.getActivity(
+            this, 0,
+            Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+            flags,
+        )
+        val stop = PendingIntent.getService(
+            this, 1,
+            Intent(this, ConsolaService::class.java).setAction(ACTION_STOP),
+            flags,
+        )
+        return NotificationCompat.Builder(this, CHANNEL_ID)
+            .setSmallIcon(R.drawable.ic_consola)
+            .setContentTitle("[ sesiones de consola vivas ]")
+            .setContentText(subtitle.ifBlank { "conexión al daemon activa" })
+            .setColor(NEON_VERDE) // acento neón sobre el fondo oscuro del sistema
+            .setColorized(false)
+            .setOngoing(true)
+            .setShowWhen(false)
+            .setSilent(true)
+            .setPriority(NotificationCompat.PRIORITY_LOW)
+            .setCategory(NotificationCompat.CATEGORY_SERVICE)
+            .setContentIntent(open)
+            .addAction(0, "detener", stop)
+            .build()
+    }
+
+    companion object {
+        private const val CHANNEL_ID = "consola_sesiones"
+        private const val NOTIF_ID = 0xC0_50 // "co so" → consola
+        private const val NEON_VERDE = 0xFF7CFF4F.toInt()
+        const val ACTION_STOP = "com.rizoma.consola.action.STOP"
+    }
+}
diff --git a/consola/src/main/res/drawable/ic_consola.xml b/consola/src/main/res/drawable/ic_consola.xml
new file mode 100644
index 0000000..5f43274
--- /dev/null
+++ b/consola/src/main/res/drawable/ic_consola.xml
@@ -0,0 +1,14 @@
+<!-- Glifo monoline para la notificación: prompt ">_" (el sistema lo tiñe). -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:strokeColor="#FFFFFFFF"
+        android:strokeWidth="2"
+        android:strokeLineCap="round"
+        android:strokeLineJoin="round"
+        android:fillColor="#00000000"
+        android:pathData="M5,8 L9,12 L5,16 M12,16 L18,16" />
+</vector>