From a11d67cf70a603f3e9ef25d7e6cc7d334a0bebaf Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 6 Jun 2026 18:43:10 +0200 Subject: [PATCH] feat(android): app Kotlin/Compose sobre el binding gomobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cliente móvil nativo: embebe un peer real del bus (unibus.aar), de modo que el cifrado E2E y el transporte NATS corren en el dispositivo. - Conexión: Host (control plane) + NATS (data plane) + identidad; defaults 10.0.2.2 para el emulador, configurables (sin IPs hardcodeadas). - BusViewModel: llamadas de red del binding en Dispatchers.IO; los frames entrantes (FrameListener.onFrame, hilo NATS) se publican en un StateFlow thread-safe que Compose recolecta en el hilo principal. - Chat: crear/unir room (toggle cifrado), enviar, recibir. - El .aar es artefacto (gitignored); se regenera con gomobile bind (README). Co-Authored-By: Claude Opus 4.8 (1M context) --- android/.gitignore | 12 + android/README.md | 83 +++++ android/app/build.gradle.kts | 66 ++++ android/app/proguard-rules.pro | 4 + android/app/src/main/AndroidManifest.xml | 25 ++ .../main/java/com/unibus/app/BusViewModel.kt | 162 +++++++++ .../main/java/com/unibus/app/MainActivity.kt | 307 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 6 + android/build.gradle.kts | 8 + android/gradle.properties | 5 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 252 ++++++++++++++ android/gradlew.bat | 94 ++++++ android/settings.gradle.kts | 23 ++ 16 files changed, 1058 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/unibus/app/BusViewModel.kt create mode 100644 android/app/src/main/java/com/unibus/app/MainActivity.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..c35e177 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +captures/ +.cxx/ + +# The gomobile binding is a build artifact (~24 MB). Regenerate it from ../mobile +# with `gomobile bind` (see README.md); it is not versioned. +app/libs/*.aar +app/libs/*.jar diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..6f4341e --- /dev/null +++ b/android/README.md @@ -0,0 +1,83 @@ +# unibus · app Android + +Cliente móvil nativo de unibus. La app no habla con un gateway: embebe un **peer +real** del bus a través del binding gomobile `mobile/unibus.go`, de modo que el +cifrado extremo a extremo corre **en el dispositivo**. Cada teléfono es un peer +de primera clase del bus, igual que cualquier peer Go. + +## Arquitectura + +``` +Kotlin/Compose UI ──> BusViewModel ──> com.unibus.core.mobile.Session (.aar) + │ (NATS data plane + E2E crypto, en Go) + ▼ + membershipd (control plane HTTP :8470) + NATS (data plane :4250) +``` + +- `BusViewModel` traduce intents de UI en llamadas al binding. Las llamadas de red + (`newSession`, `createRoom`, `join`, `publish`) corren en `Dispatchers.IO`. +- Los frames entrantes llegan por `FrameListener.onFrame` en una goroutine NATS + (hilo JNI); se publican en un `StateFlow` (thread-safe) que Compose recolecta en + el hilo principal. + +## Requisitos + +- Android SDK (compileSdk 34), NDK (para regenerar el `.aar`), JDK 17. +- El binding `app/libs/unibus.aar` (no versionado: es un artefacto de ~24 MB). + +## 1. Generar el binding (.aar) + +Desde la raíz del repo de la app (`projects/message_bus/apps/unibus`): + +```bash +export ANDROID_HOME=$HOME/android-sdk +export ANDROID_NDK_HOME=$HOME/android-sdk/ndk/26.3.11579264 +mkdir -p android/app/libs +gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \ + -o android/app/libs/unibus.aar ./mobile +``` + +Esto produce `unibus.aar` con la clase estática `com.unibus.core.mobile.Mobile` +(`generateIdentity`, `newSession`) y los tipos `Session` y `FrameListener`. + +## 2. Compilar el APK + +```bash +cd android +export JAVA_HOME=$HOME/android-sdk/jdk-17/jdk-17.0.19+10 +export ANDROID_HOME=$HOME/android-sdk +./gradlew assembleDebug +# APK: app/build/outputs/apk/debug/app-debug.apk +``` + +`local.properties` apunta a `sdk.dir`; ajústalo si tu SDK está en otra ruta. + +## 3. Arrancar el bus y probar en el emulador + +```bash +# 1. En el PC: control plane + NATS embebido (HTTP :8470, NATS :4250) +cd projects/message_bus/apps/unibus && go run ./cmd/membershipd + +# 2. Emulador Pixel_API34 +$ANDROID_HOME/emulator/emulator -avd Pixel_API34 & + +# 3. Instalar + lanzar +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.unibus.app/.MainActivity +``` + +En la pantalla de conexión, desde el emulador el host del PC es `10.0.2.2`: + +- **Host (control plane):** `http://10.0.2.2:8470` +- **NATS (data plane):** `nats://10.0.2.2:4250` + +Para un teléfono físico en la misma LAN, usa la IP LAN del PC en lugar de +`10.0.2.2`. + +## Notas + +- La identidad del peer se guarda en `filesDir/peer.id` (claves privadas + Ed25519 + X25519). No se sincroniza ni se respalda. +- Una room creada en modo "cifrar (E2E)" usa la política Matrix (cifrada, + persistida, firmada); en modo normal usa NATS cleartext. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..78c52a3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.unibus.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.unibus.app" + minSdk = 21 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // The unibus gomobile binding: a real bus peer that does NATS + E2E crypto + // on the device. All protocol logic lives here, shared with every other peer. + implementation(files("libs/unibus.aar")) + + val composeBom = platform("androidx.compose:compose-bom:2024.09.03") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e3de6b1 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# gomobile generates JNI-bound classes under com.unibus.core.mobile and go.*. +# They are reached from native code, so keep them intact even when minifying. +-keep class com.unibus.core.mobile.** { *; } +-keep class go.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..55345ef --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/unibus/app/BusViewModel.kt b/android/app/src/main/java/com/unibus/app/BusViewModel.kt new file mode 100644 index 0000000..28deafb --- /dev/null +++ b/android/app/src/main/java/com/unibus/app/BusViewModel.kt @@ -0,0 +1,162 @@ +package com.unibus.app + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File + +/** One chat message shown in the UI. */ +data class ChatMessage( + val sender: String, + val text: String, + val mine: Boolean, + val ts: Long, +) + +/** The whole observable UI state of the app. */ +data class BusState( + val connecting: Boolean = false, + val connected: Boolean = false, + val endpointId: String = "", + val roomId: String = "", + val roomSubject: String = "", + val status: String = "", + val error: String? = null, + val messages: List = emptyList(), +) + +/** + * BusViewModel drives a real unibus peer on the device through the gomobile + * binding. The binding performs NATS transport and end-to-end crypto natively; + * this class only translates UI intents into binding calls and exposes the + * incoming frames as observable state. + * + * Threading: every binding call that touches the network (newSession, createRoom, + * join, publish) runs off the main thread on Dispatchers.IO to avoid + * NetworkOnMainThreadException. Incoming frames arrive on a JNI-attached NATS + * goroutine via [onFrame]; we only append to a thread-safe StateFlow there, and + * Compose collects that flow on the main thread. + */ +class BusViewModel(app: Application) : AndroidViewModel(app), FrameListener { + private val _state = MutableStateFlow(BusState()) + val state: StateFlow = _state.asStateFlow() + + private var session: Session? = null + private var myEndpoint: String = "" + + private val idPath: String + get() = File(getApplication().filesDir, "peer.id").absolutePath + + override fun onFrame(roomID: String, sender: String, msgID: String, text: String) { + _state.update { + it.copy( + messages = it.messages + ChatMessage( + sender = sender, + text = text, + mine = sender == myEndpoint, + ts = System.currentTimeMillis(), + ), + ) + } + } + + fun connect(host: String, nats: String, peerName: String) { + if (_state.value.connecting) return + _state.update { it.copy(connecting = true, error = null, status = "Conectando…") } + viewModelScope.launch(Dispatchers.IO) { + try { + val s = Mobile.newSession(idPath, nats.trim(), host.trim()) + session = s + myEndpoint = s.endpointID() + _state.update { + it.copy( + connecting = false, + connected = true, + endpointId = myEndpoint, + status = "Conectado como $peerName", + ) + } + } catch (e: Exception) { + _state.update { + it.copy(connecting = false, connected = false, error = e.message ?: "error desconocido") + } + } + } + } + + fun createRoom(subject: String, encrypted: Boolean) { + val s = session ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + val mode = if (encrypted) "matrix" else "nats" + val roomId = s.createRoom(subject.trim(), mode) + s.subscribe(roomId, this@BusViewModel) + _state.update { + it.copy( + roomId = roomId, + roomSubject = subject.trim(), + messages = emptyList(), + status = "Room creada", + ) + } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "error al crear room") } + } + } + } + + fun joinRoom(roomId: String) { + val s = session ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + val rid = roomId.trim() + s.join(rid) + s.subscribe(rid, this@BusViewModel) + _state.update { + it.copy(roomId = rid, roomSubject = "(unida)", messages = emptyList(), status = "Unido a la room") + } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "error al unirse") } + } + } + } + + fun publish(text: String) { + val s = session ?: return + val room = _state.value.roomId + if (room.isEmpty() || text.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + try { + s.publish(room, text) + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "error al publicar") } + } + } + } + + /** card returns this peer's shareable public identity (no secret). */ + fun card(): String = try { + session?.card() ?: "" + } catch (_: Exception) { + "" + } + + fun clearError() = _state.update { it.copy(error = null) } + + override fun onCleared() { + try { + session?.close() + } catch (_: Exception) { + } + session = null + } +} 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 0000000..ba45af9 --- /dev/null +++ b/android/app/src/main/java/com/unibus/app/MainActivity.kt @@ -0,0 +1,307 @@ +package com.unibus.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class MainActivity : ComponentActivity() { + private val vm: BusViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + UnibusApp(vm) + } + } + } + } +} + +@Composable +fun UnibusApp(vm: BusViewModel) { + val state by vm.state.collectAsState() + if (!state.connected) { + ConnectScreen( + connecting = state.connecting, + error = state.error, + onConnect = { host, nats, name -> vm.connect(host, nats, name) }, + ) + } else { + ChatScreen(state = state, vm = vm) + } +} + +@Composable +fun ConnectScreen( + connecting: Boolean, + error: String?, + onConnect: (String, String, String) -> Unit, +) { + var host by rememberSaveable { mutableStateOf("http://10.0.2.2:8470") } + var nats by rememberSaveable { mutableStateOf("nats://10.0.2.2:4250") } + var name by rememberSaveable { mutableStateOf("android") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text("unibus", style = MaterialTheme.typography.headlineMedium) + Text( + "chat cifrado extremo a extremo sobre NATS", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host (control plane)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = nats, + onValueChange = { nats = it }, + label = { Text("NATS (data plane)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Identidad") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Spacer(Modifier.height(12.dp)) + Text(error, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = { onConnect(host, nats, name) }, + enabled = !connecting, + modifier = Modifier.fillMaxWidth(), + ) { + if (connecting) { + CircularProgressIndicator(modifier = Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(if (connecting) "Conectando…" else "Conectar") + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(state: BusState, vm: BusViewModel) { + var subject by rememberSaveable { mutableStateOf("room.general") } + var encrypt by rememberSaveable { mutableStateOf(false) } + var joinId by rememberSaveable { mutableStateOf("") } + var draft by rememberSaveable { mutableStateOf("") } + val listState = rememberLazyListState() + + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("unibus", style = MaterialTheme.typography.titleMedium) + Text( + state.status.ifEmpty { state.endpointId.take(12) + "…" }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) + }, + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .padding(horizontal = 12.dp), + ) { + // Room controls. + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + label = { Text("subject") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button(onClick = { vm.createRoom(subject, encrypt) }) { + Icon(Icons.Filled.Add, contentDescription = "crear") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = encrypt, onCheckedChange = { encrypt = it }) + Spacer(Modifier.width(8.dp)) + Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.height(16.dp)) + Text("cifrar (E2E)", style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = joinId, + onValueChange = { joinId = it }, + label = { Text("unirse por room id") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = { if (joinId.isNotBlank()) vm.joinRoom(joinId) }) { + Text("Unir") + } + } + if (state.roomId.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + "room: ${state.roomSubject} · ${state.roomId}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + if (state.error != null) { + Text( + state.error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + } + + // Messages. + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(state.messages, key = { i, m -> "${m.ts}-$i" }) { _, m -> + MessageBubble(m) + } + } + + // Composer. + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + placeholder = { Text("Mensaje…") }, + singleLine = true, + enabled = state.roomId.isNotEmpty(), + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { + vm.publish(draft) + draft = "" + }, + enabled = state.roomId.isNotEmpty() && draft.isNotBlank(), + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "enviar") + } + } + } + } +} + +private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + +@Composable +fun MessageBubble(m: ChatMessage) { + val align = if (m.mine) Alignment.End else Alignment.Start + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) { + Card( + modifier = Modifier.fillMaxWidth(0.8f), + ) { + Column(Modifier.padding(8.dp)) { + if (!m.mine) { + Text( + m.sender.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + Text(m.text, style = MaterialTheme.typography.bodyMedium) + Text( + timeFmt.format(Date(m.ts)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} 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 0000000..968ab78 --- /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 0000000..3f5162b --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + +