diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 00000000..8a246d84
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,15 @@
+# Android / Gradle build artifacts
+*.iml
+.gradle/
+/local.properties
+/.idea
+.DS_Store
+/build
+/app/build
+/captures
+.externalNativeBuild
+.cxx
+
+# binding gomobile regenerable (38MB): ver mobile/gen_aar.sh
+/app/libs/*.aar
+/app/libs/*-sources.jar
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 00000000..659b859f
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,75 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "com.unibus.app"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.unibus.app"
+ minSdk = 21
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0"
+ // The unibus.aar ships native libgojni.so for these ABIs. Limit the APK
+ // to the desktop/emulator + phone ABIs we actually target.
+ ndk {
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ // Compose compiler matching Kotlin 1.9.24.
+ kotlinCompilerExtensionVersion = "1.5.14"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ // gomobile binding over pkg/client (real end-to-end crypto on device).
+ implementation(files("libs/unibus.aar"))
+
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.activity:activity-compose:1.9.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
+
+ val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+}
diff --git a/android/app/libs/README.md b/android/app/libs/README.md
new file mode 100644
index 00000000..d9cdd47a
--- /dev/null
+++ b/android/app/libs/README.md
@@ -0,0 +1,12 @@
+# libs/
+
+`unibus.aar` (binding gomobile sobre `pkg/client`, ~38 MB con `libgojni.so` para
+4 ABIs) vive aquí pero **no se versiona** — es un artefacto de build reproducible.
+
+Regenéralo con:
+
+```bash
+../../mobile/gen_aar.sh
+```
+
+(desde la raíz del repo: `./mobile/gen_aar.sh`). Requiere Go + gomobile + Android NDK.
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 00000000..517b09ed
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,4 @@
+# gomobile binding: keep the generated Go<->Java bridge classes intact so the
+# JNI layer can find them by name at runtime.
+-keep class go.** { *; }
+-keep class com.unibus.core.mobile.** { *; }
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..858f63e9
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/unibus/app/AppViewModel.kt b/android/app/src/main/java/com/unibus/app/AppViewModel.kt
new file mode 100644
index 00000000..ca58ffa5
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/AppViewModel.kt
@@ -0,0 +1,88 @@
+package com.unibus.app
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.unibus.app.data.Message
+import com.unibus.app.data.MockUnibusRepository
+import com.unibus.app.data.Room
+import com.unibus.app.data.UnibusRepository
+import com.unibus.app.data.User
+import kotlinx.coroutines.launch
+
+/**
+ * Estado de la app. Orquesta el [UnibusRepository] (mock por defecto) y expone
+ * estado observable a Compose. Cambiar el repo por [com.unibus.app.data.BindingUnibusRepository]
+ * conecta la UI al bus real sin tocar las pantallas.
+ */
+class AppViewModel(
+ private val repo: UnibusRepository,
+) : ViewModel() {
+
+ // Constructor no-arg para que androidx `viewModel()` lo instancie por
+ // reflexión. Por defecto usa el repositorio mock (iteración de diseño).
+ constructor() : this(MockUnibusRepository())
+
+ var user by mutableStateOf(null)
+ private set
+ var rooms by mutableStateOf>(emptyList())
+ private set
+ var activeRoomId by mutableStateOf(null)
+ private set
+ var messages by mutableStateOf>(emptyList())
+ private set
+ var connecting by mutableStateOf(false)
+ private set
+ var error by mutableStateOf(null)
+ private set
+
+ val activeRoom: Room?
+ get() = rooms.firstOrNull { it.id == activeRoomId }
+
+ fun connect(handle: String, password: String) {
+ if (connecting) return
+ connecting = true
+ error = null
+ viewModelScope.launch {
+ repo.connect(handle, password)
+ .onSuccess {
+ user = it
+ rooms = repo.listRooms()
+ }
+ .onFailure { error = it.message ?: "No se pudo conectar" }
+ connecting = false
+ }
+ }
+
+ fun openRoom(id: String) {
+ activeRoomId = id
+ messages = repo.messagesOf(id)
+ repo.subscribe(id) { incoming ->
+ if (activeRoomId == id) messages = messages + incoming
+ }
+ }
+
+ fun closeRoom() {
+ activeRoomId = null
+ messages = emptyList()
+ }
+
+ fun send(text: String) {
+ val rid = activeRoomId ?: return
+ val body = text.trim()
+ if (body.isEmpty()) return
+ viewModelScope.launch {
+ repo.send(rid, body).onSuccess { messages = messages + it }
+ }
+ }
+
+ fun logout() {
+ repo.close()
+ user = null
+ rooms = emptyList()
+ activeRoomId = null
+ messages = emptyList()
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/MainActivity.kt b/android/app/src/main/java/com/unibus/app/MainActivity.kt
new file mode 100644
index 00000000..ab0ab75a
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/MainActivity.kt
@@ -0,0 +1,63 @@
+package com.unibus.app
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.unibus.app.ui.ChatScreen
+import com.unibus.app.ui.LoginScreen
+import com.unibus.app.ui.RoomListScreen
+import com.unibus.app.ui.theme.LocalUnibusColors
+import com.unibus.app.ui.theme.UnibusColors
+import com.unibus.app.ui.theme.UnibusTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ UnibusTheme {
+ CompositionLocalProvider(LocalUnibusColors provides UnibusColors()) {
+ UnibusApp()
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Navegación por estado (sin librería de routing — KISS): el usuario fluye
+ * Login → lista de rooms → chat, igual que la web pero en una sola columna.
+ */
+@Composable
+private fun UnibusApp(vm: AppViewModel = viewModel()) {
+ val user = vm.user
+ val activeRoom = vm.activeRoom
+
+ when {
+ user == null -> LoginScreen(
+ connecting = vm.connecting,
+ error = vm.error,
+ onLogin = { handle, password -> vm.connect(handle, password) },
+ )
+
+ activeRoom == null -> RoomListScreen(
+ user = user,
+ rooms = vm.rooms,
+ onSelect = { vm.openRoom(it) },
+ onLogout = { vm.logout() },
+ )
+
+ else -> {
+ BackHandler { vm.closeRoom() }
+ ChatScreen(
+ room = activeRoom,
+ messages = vm.messages,
+ onSend = { vm.send(it) },
+ onBack = { vm.closeRoom() },
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/data/BindingRepository.kt b/android/app/src/main/java/com/unibus/app/data/BindingRepository.kt
new file mode 100644
index 00000000..90d8a929
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/data/BindingRepository.kt
@@ -0,0 +1,157 @@
+package com.unibus.app.data
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import com.unibus.core.mobile.FrameListener
+import com.unibus.core.mobile.Mobile
+import com.unibus.core.mobile.Session
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.io.File
+
+/**
+ * Implementación real sobre el binding gomobile (pkg/client): cifrado de extremo
+ * a extremo EN el dispositivo, igual que cualquier otro peer del bus. Comparte
+ * interfaz con [MockUnibusRepository], así que la UI no cambia al enchufarla.
+ *
+ * Estado: cableado completo y compilable contra unibus.aar. La iteración 1 de la
+ * app corre sobre el mock para iterar el diseño; para activar el bus real basta
+ * con instanciar este repo en [com.unibus.app.MainActivity] pasando las URLs del
+ * bus y (si el bus exige TLS+auth) el ca.crt en assets.
+ *
+ * Contrato de membresía (issue 0006e): tras CreateRoom / Join / Invite hay que
+ * llamar [refresh] ANTES de subscribe/publish en esa room, o un bus seguro
+ * deniega el subject. refresh() además tira las suscripciones: re-suscribir luego.
+ */
+class BindingUnibusRepository(
+ context: Context,
+ private val natsURL: String,
+ private val ctrlURL: String,
+) : UnibusRepository {
+
+ private val appContext = context.applicationContext
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private var session: Session? = null
+ private var user: User? = null
+
+ @Serializable
+ private data class RoomDTO(
+ val room_id: String,
+ val subject: String,
+ val epoch: Int = 0,
+ val encrypted: Boolean = false,
+ val role: String = "",
+ )
+
+ /** Ruta sandbox de la identidad de larga duración (claves privadas). */
+ private fun identityPath(): String =
+ File(appContext.filesDir, "identity.key").absolutePath
+
+ /**
+ * Copia ca.crt de assets a un fichero local y devuelve su ruta; "" si no hay
+ * (bus de desarrollo en texto plano). El binding pinea TLS a este CA cuando
+ * la ruta no está vacía.
+ */
+ private fun caPathOrEmpty(): String {
+ return try {
+ val out = File(appContext.filesDir, "ca.crt")
+ appContext.assets.open("ca.crt").use { input ->
+ out.outputStream().use { input.copyTo(it) }
+ }
+ out.absolutePath
+ } catch (_: Exception) {
+ ""
+ }
+ }
+
+ override suspend fun connect(handle: String, password: String): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ // La identidad se persiste cifrada en el sandbox; password la
+ // desbloquea en una iteración futura (hoy LoadOrCreateIdentity la
+ // crea/lee directamente). handle es la etiqueta visible local.
+ Mobile.generateIdentity(identityPath())
+ val s = Mobile.newSession(identityPath(), natsURL, ctrlURL, caPathOrEmpty())
+ session = s
+ val u = User(id = s.endpointID(), handle = handle)
+ user = u
+ Result.success(u)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun listRooms(): List = withContext(Dispatchers.IO) {
+ val s = session ?: return@withContext emptyList()
+ val raw = runCatching { s.listRoomsJSON() }.getOrDefault("[]")
+ val dtos = runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList())
+ dtos.map {
+ Room(
+ id = it.room_id,
+ name = it.subject,
+ encrypted = it.encrypted,
+ lastMessage = "",
+ lastTs = System.currentTimeMillis(),
+ unread = 0,
+ messages = emptyList(),
+ )
+ }
+ }
+
+ override fun messagesOf(roomId: String): List = emptyList()
+
+ override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
+ val s = session ?: return
+ val myId = user?.id
+ // FrameListener.onFrame llega en una goroutine de NATS: saltamos al hilo
+ // principal antes de tocar estado de Compose.
+ val listener = object : FrameListener {
+ override fun onFrame(rid: String, sender: String, msgID: String, text: String) {
+ val msg = Message(
+ id = msgID,
+ sender = sender,
+ body = text,
+ ts = System.currentTimeMillis(),
+ mine = sender == myId,
+ )
+ mainHandler.post { onMessage(msg) }
+ }
+ }
+ runCatching { s.subscribe(roomId, listener) }
+ }
+
+ override suspend fun send(roomId: String, text: String): Result =
+ withContext(Dispatchers.IO) {
+ val s = session ?: return@withContext Result.failure(IllegalStateException("sin sesión"))
+ try {
+ s.publish(roomId, text)
+ Result.success(
+ Message(
+ id = "local-${System.currentTimeMillis()}",
+ sender = user?.id ?: "yo",
+ body = text,
+ ts = System.currentTimeMillis(),
+ mine = true,
+ ),
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /** Reaplica permisos tras un cambio de membresía. Re-suscribir después. */
+ suspend fun refresh(): Result = withContext(Dispatchers.IO) {
+ runCatching { session?.refreshSession(); Unit }
+ }
+
+ override fun close() {
+ runCatching { session?.close() }
+ session = null
+ user = null
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/data/MockData.kt b/android/app/src/main/java/com/unibus/app/data/MockData.kt
new file mode 100644
index 00000000..72313a8e
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/data/MockData.kt
@@ -0,0 +1,59 @@
+package com.unibus.app.data
+
+// Datos de muestra para iterar el diseño sin el bus conectado (espejo de mock.ts).
+private const val NOW = 1749300000000L
+private fun m(n: Int): Long = NOW - n * 60_000L
+
+val MOCK_ROOMS: List = listOf(
+ Room(
+ id = "general",
+ name = "general",
+ encrypted = true,
+ lastMessage = "¿Lo desplegamos hoy?",
+ lastTs = m(2),
+ unread = 3,
+ messages = listOf(
+ Message("1", "ana", "Buenas, ¿cómo va el cluster?", m(40)),
+ Message("2", "lucas", "Los 3 nodos en R3, quorum verde", m(38), mine = true),
+ Message("3", "ana", "Brutal. ¿Y el frontend?", m(30)),
+ Message("4", "leo", "Primera iteración lista, estilo Element", m(6)),
+ Message("5", "ana", "¿Lo desplegamos hoy?", m(2)),
+ ),
+ ),
+ Room(
+ id = "board",
+ name = "board · privado",
+ encrypted = true,
+ lastMessage = "Os paso el acta cifrada",
+ lastTs = m(95),
+ unread = 0,
+ messages = listOf(
+ Message("1", "ceo", "Reunión a las 18:00", m(120)),
+ Message("2", "lucas", "Anotado", m(96), mine = true),
+ Message("3", "ceo", "Os paso el acta cifrada", m(95)),
+ ),
+ ),
+ Room(
+ id = "bots",
+ name = "bots",
+ encrypted = false,
+ lastMessage = "echo: ping",
+ lastTs = m(210),
+ unread = 0,
+ messages = listOf(
+ Message("1", "lucas", "!ping", m(212), mine = true),
+ Message("2", "echobot", "echo: ping", m(210)),
+ ),
+ ),
+ Room(
+ id = "infra",
+ name = "infra",
+ encrypted = true,
+ lastMessage = "magnus + homer + datardos OK",
+ lastTs = m(330),
+ unread = 1,
+ messages = listOf(
+ Message("1", "leo", "magnus + homer + datardos OK", m(330)),
+ ),
+ ),
+)
diff --git a/android/app/src/main/java/com/unibus/app/data/Models.kt b/android/app/src/main/java/com/unibus/app/data/Models.kt
new file mode 100644
index 00000000..bcce05bd
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/data/Models.kt
@@ -0,0 +1,30 @@
+package com.unibus.app.data
+
+/**
+ * Modelos de dominio de la UI. En la iteración 1 se llenan con datos mock; más
+ * adelante vendrán del binding gomobile (pkg/client) a través de
+ * [UnibusRepository]. Reflejan los tipos de la app web (types.ts).
+ */
+
+data class User(
+ val id: String,
+ val handle: String,
+)
+
+data class Message(
+ val id: String,
+ val sender: String, // handle
+ val body: String,
+ val ts: Long, // epoch ms
+ val mine: Boolean = false,
+)
+
+data class Room(
+ val id: String,
+ val name: String,
+ val encrypted: Boolean,
+ val lastMessage: String,
+ val lastTs: Long,
+ val unread: Int,
+ val messages: List,
+)
diff --git a/android/app/src/main/java/com/unibus/app/data/Repository.kt b/android/app/src/main/java/com/unibus/app/data/Repository.kt
new file mode 100644
index 00000000..0a27b1e8
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/data/Repository.kt
@@ -0,0 +1,74 @@
+package com.unibus.app.data
+
+/**
+ * Capa de repositorio que aísla la UI de la fuente de datos. La iteración 1 usa
+ * [MockUnibusRepository] (en memoria) para iterar el diseño. Cuando se enchufe
+ * el bus real, [BindingUnibusRepository] (en BindingRepository.kt) implementa
+ * esta misma interfaz sobre el binding gomobile (pkg/client), sin tocar la UI.
+ */
+interface UnibusRepository {
+ /** Desbloquea/crea la identidad y conecta al bus. Devuelve el usuario logueado. */
+ suspend fun connect(handle: String, password: String): Result
+
+ /** Rooms a las que pertenece el peer. */
+ suspend fun listRooms(): List
+
+ /** Mensajes históricos conocidos de una room (mock: los del propio Room). */
+ fun messagesOf(roomId: String): List
+
+ /**
+ * Suscribe a una room. [onMessage] se invoca por cada mensaje entrante.
+ * Las implementaciones que vienen del bus DEBEN entregar [onMessage] en el
+ * hilo principal (el binding lo recibe en una goroutine de NATS).
+ */
+ fun subscribe(roomId: String, onMessage: (Message) -> Unit)
+
+ /** Publica texto en la room. */
+ suspend fun send(roomId: String, text: String): Result
+
+ /** Cierra la sesión. */
+ fun close()
+}
+
+/**
+ * Implementación en memoria: arranca con [MOCK_ROOMS] y acumula los mensajes que
+ * el usuario envía. No toca red ni binding — sirve para construir y revisar la UI.
+ */
+class MockUnibusRepository : UnibusRepository {
+ private var user: User? = null
+ private val sent = mutableMapOf>()
+
+ override suspend fun connect(handle: String, password: String): Result {
+ val u = User(id = handle, handle = handle)
+ user = u
+ return Result.success(u)
+ }
+
+ override suspend fun listRooms(): List = MOCK_ROOMS
+
+ override fun messagesOf(roomId: String): List {
+ val base = MOCK_ROOMS.firstOrNull { it.id == roomId }?.messages.orEmpty()
+ return base + (sent[roomId].orEmpty())
+ }
+
+ override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
+ // El mock no recibe tráfico entrante; el eco lo gestiona la UI al enviar.
+ }
+
+ override suspend fun send(roomId: String, text: String): Result {
+ val handle = user?.handle ?: "yo"
+ val msg = Message(
+ id = "local-${System.currentTimeMillis()}",
+ sender = handle,
+ body = text,
+ ts = System.currentTimeMillis(),
+ mine = true,
+ )
+ sent.getOrPut(roomId) { mutableListOf() }.add(msg)
+ return Result.success(msg)
+ }
+
+ override fun close() {
+ user = null
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/ui/ChatScreen.kt b/android/app/src/main/java/com/unibus/app/ui/ChatScreen.kt
new file mode 100644
index 00000000..1bf664ac
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/ui/ChatScreen.kt
@@ -0,0 +1,203 @@
+package com.unibus.app.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unibus.app.data.Message
+import com.unibus.app.data.Room
+import com.unibus.app.ui.theme.Brand3
+import com.unibus.app.ui.theme.LocalUnibusColors
+
+@Composable
+fun ChatScreen(
+ room: Room,
+ messages: List,
+ onSend: (String) -> Unit,
+ onBack: () -> Unit,
+) {
+ val colors = LocalUnibusColors.current
+ var draft by remember { mutableStateOf("") }
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(messages.size, room.id) {
+ if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colors.chatBg),
+ ) {
+ // Header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 6.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Atrás", tint = Color.White)
+ }
+ InitialsAvatar(room.name, size = 38.dp, rounded = true, accent = true)
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 10.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ room.name,
+ fontWeight = FontWeight(650),
+ fontSize = 16.sp,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Icon(
+ if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
+ contentDescription = null,
+ tint = colors.dimmed,
+ modifier = Modifier
+ .padding(start = 6.dp)
+ .size(14.dp),
+ )
+ }
+ Text(
+ if (room.encrypted) "cifrada · E2E" else "abierta · cleartext",
+ color = colors.dimmed,
+ fontSize = 11.sp,
+ )
+ }
+ IconButton(onClick = { /* opciones de room (futuro) */ }) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "Opciones", tint = colors.dimmed)
+ }
+ }
+ HorizontalDivider(color = colors.divider)
+
+ // Mensajes
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ items(messages, key = { it.id }) { msg -> MessageRow(msg) }
+ }
+
+ HorizontalDivider(color = colors.divider)
+
+ // Composer
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = { /* adjuntar (futuro) */ }) {
+ Icon(Icons.Filled.AttachFile, contentDescription = "Adjuntar", tint = colors.dimmed)
+ }
+ OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ placeholder = { Text("Mensaje a ${room.name}") },
+ singleLine = true,
+ shape = CircleShape,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = colors.field,
+ unfocusedContainerColor = colors.field,
+ ),
+ modifier = Modifier.weight(1f),
+ keyboardOptions = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = {
+ if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" }
+ }),
+ )
+ Box(
+ modifier = Modifier
+ .padding(start = 6.dp)
+ .size(46.dp)
+ .clip(CircleShape)
+ .background(if (draft.trim().isEmpty()) colors.field else colors.brand),
+ contentAlignment = Alignment.Center,
+ ) {
+ IconButton(
+ onClick = { if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" } },
+ enabled = draft.trim().isNotEmpty(),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Enviar", tint = Color.White)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageRow(msg: Message) {
+ val colors = LocalUnibusColors.current
+ Row(verticalAlignment = Alignment.Top) {
+ InitialsAvatar(msg.sender, size = 36.dp, rounded = false, accent = msg.mine)
+ Column(modifier = Modifier.padding(start = 10.dp)) {
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ msg.sender,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ color = if (msg.mine) Brand3 else Color.White,
+ )
+ Text(
+ timeShort(msg.ts),
+ color = colors.dimmed,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ Text(
+ msg.body,
+ fontSize = 14.sp,
+ color = com.unibus.app.ui.theme.OnSurface,
+ modifier = Modifier.padding(top = 1.dp),
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/ui/Components.kt b/android/app/src/main/java/com/unibus/app/ui/Components.kt
new file mode 100644
index 00000000..54338401
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/ui/Components.kt
@@ -0,0 +1,48 @@
+package com.unibus.app.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unibus.app.ui.theme.Brand5
+
+/**
+ * Avatar con iniciales, equivalente al de la web. [rounded] = esquinas
+ * (rooms/chat header) vs círculo (usuarios). [accent] colorea el de marca.
+ */
+@Composable
+fun InitialsAvatar(
+ text: String,
+ size: Dp = 42.dp,
+ rounded: Boolean = true,
+ accent: Boolean = false,
+ modifier: Modifier = Modifier,
+) {
+ val shape = if (rounded) RoundedCornerShape((size.value * 0.28f).dp) else CircleShape
+ val bg = if (accent) Brand5 else Color(0xFF3A3D44) // gris neutro tipo Avatar color="gray"
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(shape)
+ .background(bg),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = initials(text),
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = (size.value * 0.36f).sp,
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/ui/LoginScreen.kt b/android/app/src/main/java/com/unibus/app/ui/LoginScreen.kt
new file mode 100644
index 00000000..0ed53c15
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/ui/LoginScreen.kt
@@ -0,0 +1,154 @@
+package com.unibus.app.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.VpnKey
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unibus.app.ui.theme.Brand4
+import com.unibus.app.ui.theme.Dark7
+import com.unibus.app.ui.theme.Dark9
+import com.unibus.app.ui.theme.LocalUnibusColors
+
+@Composable
+fun LoginScreen(
+ connecting: Boolean,
+ error: String?,
+ onLogin: (handle: String, password: String) -> Unit,
+) {
+ val colors = LocalUnibusColors.current
+ var handle by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ val ready = handle.trim().isNotEmpty() && password.isNotEmpty() && !connecting
+
+ fun submit() {
+ if (ready) onLogin(handle.trim(), password)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Dark9),
+ contentAlignment = Alignment.Center,
+ ) {
+ Card(
+ modifier = Modifier
+ .padding(24.dp)
+ .fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Dark7),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(18.dp),
+ ) {
+ // ThemeIcon "light brand" — círculo translúcido con candado.
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .clip(CircleShape)
+ .background(Brand4.copy(alpha = 0.18f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Lock,
+ contentDescription = null,
+ tint = Brand4,
+ modifier = Modifier.size(30.dp),
+ )
+ }
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("unibus", fontSize = 26.sp, color = Brand4)
+ Text(
+ "Mensajería cifrada de extremo a extremo",
+ color = colors.dimmed,
+ fontSize = 13.sp,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ OutlinedTextField(
+ value = handle,
+ onValueChange = { handle = it },
+ label = { Text("Identidad") },
+ placeholder = { Text("tu-handle") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ )
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Contraseña") },
+ placeholder = { Text("••••••••") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ leadingIcon = { Icon(Icons.Filled.VpnKey, contentDescription = null) },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
+ keyboardActions = KeyboardActions(onGo = { submit() }),
+ )
+ Text(
+ "Desbloquea tu identidad cifrada en este dispositivo",
+ color = colors.dimmed,
+ fontSize = 12.sp,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ if (error != null) {
+ Text(error, color = androidx.compose.ui.graphics.Color(0xFFFF6B6B), fontSize = 13.sp)
+ }
+
+ Button(
+ onClick = { submit() },
+ enabled = ready,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (connecting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = androidx.compose.ui.graphics.Color.White,
+ )
+ } else {
+ Text("Conectar")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/ui/RoomListScreen.kt b/android/app/src/main/java/com/unibus/app/ui/RoomListScreen.kt
new file mode 100644
index 00000000..72a67d55
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/ui/RoomListScreen.kt
@@ -0,0 +1,199 @@
+package com.unibus.app.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material3.Badge
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.unibus.app.data.Room
+import com.unibus.app.data.User
+import com.unibus.app.ui.theme.LocalUnibusColors
+
+@Composable
+fun RoomListScreen(
+ user: User,
+ rooms: List,
+ onSelect: (String) -> Unit,
+ onLogout: () -> Unit,
+) {
+ val colors = LocalUnibusColors.current
+ var query by remember { mutableStateOf("") }
+ val q = query.trim().lowercase()
+ val filtered = if (q.isEmpty()) rooms else rooms.filter {
+ it.name.lowercase().contains(q) || it.messages.any { m -> m.body.lowercase().contains(q) }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colors.sidebarBg),
+ ) {
+ // Header: avatar + handle + menú
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ InitialsAvatar(user.handle, size = 36.dp, rounded = false, accent = true)
+ Text(
+ user.handle,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 15.sp,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 10.dp),
+ )
+ var menuOpen by remember { mutableStateOf(false) }
+ Box {
+ IconButton(onClick = { menuOpen = true }) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "Menú", tint = colors.dimmed)
+ }
+ DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
+ DropdownMenuItem(
+ text = { Text("Desconectar") },
+ onClick = { menuOpen = false; onLogout() },
+ leadingIcon = {
+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, modifier = Modifier.size(18.dp))
+ },
+ )
+ }
+ }
+ }
+
+ // Buscador
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ placeholder = { Text("Buscar rooms, usuarios, mensajes…") },
+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ )
+
+ HorizontalDivider(color = colors.divider)
+
+ if (filtered.isEmpty()) {
+ Text(
+ "Sin resultados",
+ color = colors.dimmed,
+ fontSize = 14.sp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 24.dp),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(6.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ items(filtered, key = { it.id }) { room ->
+ RoomItem(room = room, onClick = { onSelect(room.id) })
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RoomItem(room: Room, onClick: () -> Unit) {
+ val colors = LocalUnibusColors.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ .clickable(onClick = onClick)
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ InitialsAvatar(room.name, size = 46.dp, rounded = true)
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 10.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
+ contentDescription = if (room.encrypted) "cifrada" else "abierta",
+ tint = colors.dimmed,
+ modifier = Modifier.size(13.dp),
+ )
+ Text(
+ room.name,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 4.dp),
+ )
+ Text(timeShort(room.lastTs), color = colors.dimmed, fontSize = 11.sp)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 2.dp),
+ ) {
+ Text(
+ room.lastMessage,
+ color = colors.dimmed,
+ fontSize = 12.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ if (room.unread > 0) {
+ Badge(
+ containerColor = colors.brand,
+ contentColor = Color.White,
+ ) { Text(room.unread.toString()) }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/unibus/app/ui/Util.kt b/android/app/src/main/java/com/unibus/app/ui/Util.kt
new file mode 100644
index 00000000..c1b0cee1
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/ui/Util.kt
@@ -0,0 +1,17 @@
+package com.unibus.app.ui
+
+import java.util.Calendar
+
+/** Iniciales (hasta 2 letras/dígitos) para los avatares, igual que la web. */
+fun initials(s: String): String {
+ val cleaned = s.filter { it.isLetterOrDigit() }
+ return if (cleaned.isEmpty()) "?" else cleaned.take(2).uppercase()
+}
+
+/** Hora corta HH:mm a partir de epoch ms. */
+fun timeShort(ts: Long): String {
+ val c = Calendar.getInstance().apply { timeInMillis = ts }
+ val h = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
+ val min = c.get(Calendar.MINUTE).toString().padStart(2, '0')
+ return "$h:$min"
+}
diff --git a/android/app/src/main/java/com/unibus/app/ui/theme/Theme.kt b/android/app/src/main/java/com/unibus/app/ui/theme/Theme.kt
new file mode 100644
index 00000000..95d29245
--- /dev/null
+++ b/android/app/src/main/java/com/unibus/app/ui/theme/Theme.kt
@@ -0,0 +1,80 @@
+package com.unibus.app.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// --- Brand: índigo/violeta de unibus (mismos tonos que el tema Mantine de la web) ---
+val Brand2 = Color(0xFFB5A3F5) // brand.2
+val Brand3 = Color(0xFF8D70ED) // brand.3 — legible sobre fondo oscuro
+val Brand4 = Color(0xFF6C47E6) // brand.4 — acento principal
+val Brand5 = Color(0xFF5A2FE2) // brand.5 — filled
+
+// --- Grises oscuros equivalentes a la escala dark.* de Mantine ---
+val Dark9 = Color(0xFF101113) // fondo de la app (login)
+val Dark8 = Color(0xFF141517) // sidebar / lista de rooms
+val Dark7 = Color(0xFF1A1B1E) // panel de chat / superficie
+val Dark6 = Color(0xFF25262B) // item activo / elevado
+val Dark5 = Color(0xFF2C2E33) // campos de entrada
+val Dark4 = Color(0xFF373A40) // bordes / divisores
+val Dimmed = Color(0xFF909296) // texto secundario
+val OnSurface = Color(0xFFE3E3E6) // texto principal
+
+/**
+ * Tokens de color que Material 3 no expresa directamente y que la UI replica de
+ * la web (matices dark.6/7/8/9, color "dimmed", borde). Se exponen vía un
+ * CompositionLocal para que cualquier composable los lea sin prop-drilling.
+ */
+data class UnibusColors(
+ val appBg: Color = Dark9,
+ val sidebarBg: Color = Dark8,
+ val chatBg: Color = Dark7,
+ val activeItem: Color = Dark6,
+ val field: Color = Dark5,
+ val divider: Color = Dark4,
+ val dimmed: Color = Dimmed,
+ val brand: Color = Brand4,
+)
+
+val LocalUnibusColors = staticCompositionLocalOf { UnibusColors() }
+
+private val UnibusDarkScheme = darkColorScheme(
+ primary = Brand4,
+ onPrimary = Color.White,
+ primaryContainer = Brand5,
+ onPrimaryContainer = Color.White,
+ secondary = Brand3,
+ background = Dark9,
+ onBackground = OnSurface,
+ surface = Dark7,
+ onSurface = OnSurface,
+ surfaceVariant = Dark6,
+ onSurfaceVariant = Dimmed,
+ outline = Dark4,
+ error = Color(0xFFFF6B6B),
+)
+
+private val UnibusTypography = Typography(
+ titleLarge = Typography().titleLarge.copy(fontWeight = FontWeight(650)),
+ titleMedium = Typography().titleMedium.copy(fontWeight = FontWeight(650)),
+ bodyMedium = Typography().bodyMedium.copy(fontSize = 14.sp),
+ labelLarge = Typography().labelLarge.copy(fontWeight = FontWeight.SemiBold),
+)
+
+@Composable
+fun UnibusTheme(content: @Composable () -> Unit) {
+ // unibus es dark-first; ignoramos el modo del sistema a propósito.
+ @Suppress("UNUSED_EXPRESSION")
+ isSystemInDarkTheme()
+ MaterialTheme(
+ colorScheme = UnibusDarkScheme,
+ typography = UnibusTypography,
+ content = content,
+ )
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..ce3c58f8
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..c92512ba
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..c92512ba
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..0d343467
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+
+ #101113
+
+ #5A2FE2
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..968ab786
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ unibus
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..530b8063
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 00000000..dedf0a48
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 00000000..ee0507b6
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.caching=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e6441136
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..b82aa23a
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 00000000..1aa94a42
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 00000000..7101f8e4
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 00000000..afc02922
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "unibus"
+include(":app")