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>
Semantic delta
No definition changed: this change is text, documentation or data.
5 files · +432 −149
- consola/src/main/AndroidManifest.xml +12 −0
- consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt +82 −149
- consola/src/main/java/com/rizoma/consola/MainActivity.kt +18 −0
- consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt +306 −0
- consola/src/main/res/drawable/ic_consola.xml +14 −0
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>