feat(android): app Kotlin/Compose sobre el binding gomobile
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Unibus">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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<ChatMessage> = 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<BusState> = _state.asStateFlow()
|
||||
|
||||
private var session: Session? = null
|
||||
private var myEndpoint: String = ""
|
||||
|
||||
private val idPath: String
|
||||
get() = File(getApplication<Application>().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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">unibus</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- A minimal Material3 base theme; the real UI styling is driven by Compose
|
||||
Material3 (MaterialTheme) at runtime. -->
|
||||
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user