feat: Android Compose client for the unibus bus

Thin Jetpack Compose UI over the real Go client (pkg/client) compiled to a
gomobile .aar, so the phone speaks NATS and runs the same end-to-end crypto as
any other peer. Connect, create room (nats/matrix), publish and live-receive.
This commit is contained in:
agent
2026-06-05 17:40:28 +02:00
commit c13d6baf60
15 changed files with 712 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
plugins {
id("com.android.application") version "8.4.0"
id("org.jetbrains.kotlin.android") version "1.9.22"
}
android {
namespace = "com.fnregistry.unibus"
compileSdk = 34
defaultConfig {
applicationId = "com.fnregistry.unibus"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// unibus client compiled from Go via gomobile: holds the NATS data-plane
// connection, the HTTP control-plane calls and the end-to-end crypto. This is
// the same pkg/client every other peer uses, so the protocol has one source
// of truth.
implementation(files("libs/unibus.aar"))
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// FnTheme + FnTokens via composite build
implementation("fn.compose:ui")
}
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Data plane (NATS TCP) and control plane (HTTP) reach the bus over the network. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,204 @@
package com.fnregistry.unibus
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTheme
import fn.compose.ui.FnBadge
import fn.compose.ui.FnBadgeColor
import fn.compose.ui.FnButton
import fn.compose.ui.FnButtonVariant
import fn.compose.ui.FnCard
import fn.compose.ui.FnGroup
import fn.compose.ui.FnStack
import fn.compose.ui.FnSwitch
import fn.compose.ui.FnText
import fn.compose.ui.FnTextInput
import fn.compose.ui.FnTextSize
import fn.compose.ui.FnTitle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mobile.FrameListener
import mobile.Mobile
import mobile.Session
import java.io.File
/**
* Single-activity chat client for the unibus message bus. The whole protocol —
* NATS data plane, HTTP control plane and end-to-end crypto — lives inside the
* gomobile-built unibus.aar (package `mobile`); this UI only orchestrates calls
* to a [Session] and renders the frames it delivers.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The long-term identity (Ed25519 + X25519 keys) is stored in the app's
// private files directory, unreadable by other apps.
val idPath = File(filesDir, "peer.id").absolutePath
setContent {
FnTheme {
Surface(Modifier.fillMaxSize()) {
ChatApp(idPath)
}
}
}
}
}
@Composable
private fun ChatApp(idPath: String) {
val scope = rememberCoroutineScope()
// onFrame arrives on a NATS delivery thread; hop to the main thread before
// touching Compose state.
val mainHandler = remember { Handler(Looper.getMainLooper()) }
// 10.0.2.2 is the host machine as seen from the Android emulator. On a real
// phone, replace it with the PC/VPS LAN or Tailscale address.
var host by remember { mutableStateOf("10.0.2.2") }
var natsPort by remember { mutableStateOf("4250") }
var ctrlPort by remember { mutableStateOf("8470") }
var subject by remember { mutableStateOf("room.general") }
var e2e by remember { mutableStateOf(false) }
var session by remember { mutableStateOf<Session?>(null) }
var roomId by remember { mutableStateOf<String?>(null) }
var status by remember { mutableStateOf("Desconectado") }
var draft by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<String>() }
val listener = remember {
object : FrameListener {
override fun onFrame(roomID: String, sender: String, msgID: String, text: String) {
mainHandler.post { messages.add("${sender.take(6)} $text") }
}
}
}
FnStack(
modifier = Modifier.fillMaxSize().padding(FnSpacing.md),
gap = FnSpacing.sm,
) {
FnTitle("Unibus", order = 2)
FnText(status, size = FnTextSize.Sm)
when {
// --- Step 1: connect to the bus ---
session == null -> {
FnTextInput(
value = host,
onValueChange = { host = it },
label = "Host",
placeholder = "10.0.2.2 / IP LAN / tailscale",
modifier = Modifier.fillMaxWidth(),
)
FnGroup(gap = FnSpacing.sm) {
FnTextInput(value = natsPort, onValueChange = { natsPort = it }, label = "NATS", modifier = Modifier.weight(1f))
FnTextInput(value = ctrlPort, onValueChange = { ctrlPort = it }, label = "Control", modifier = Modifier.weight(1f))
}
FnButton("Conectar", onClick = {
status = "Conectando…"
scope.launch {
try {
val s = withContext(Dispatchers.IO) {
Mobile.newSession(idPath, "nats://$host:$natsPort", "http://$host:$ctrlPort")
}
session = s
status = "Conectado · ${s.endpointID().take(10)}"
} catch (e: Exception) {
status = "Error al conectar: ${e.message}"
}
}
}, modifier = Modifier.fillMaxWidth())
}
// --- Step 2: open a room ---
roomId == null -> {
FnTextInput(
value = subject,
onValueChange = { subject = it },
label = "Subject de la sala",
placeholder = "room.general",
modifier = Modifier.fillMaxWidth(),
)
FnSwitch(checked = e2e, onCheckedChange = { e2e = it }, label = "Cifrado E2E (modo matrix)")
FnButton("Crear sala", onClick = {
status = "Creando sala…"
scope.launch {
try {
val mode = if (e2e) "matrix" else "nats"
val rid = withContext(Dispatchers.IO) {
val r = session!!.createRoom(subject, mode)
session!!.join(r)
session!!.subscribe(r, listener)
r
}
roomId = rid
status = "Sala activa"
} catch (e: Exception) {
status = "Error al crear sala: ${e.message}"
}
}
}, modifier = Modifier.fillMaxWidth())
}
// --- Step 3: chat ---
else -> {
FnGroup(gap = FnSpacing.sm) {
FnBadge(if (e2e) "E2E" else "PLAIN", color = if (e2e) FnBadgeColor.Green else FnBadgeColor.Gray)
FnText(subject, size = FnTextSize.Xs)
}
FnCard(modifier = Modifier.fillMaxWidth().weight(1f)) {
FnStack(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
gap = FnSpacing.xs,
) {
if (messages.isEmpty()) {
FnText("Sin mensajes todavía. Envía el primero.", size = FnTextSize.Sm)
} else {
messages.forEach { FnText(it, size = FnTextSize.Sm) }
}
}
}
FnGroup(gap = FnSpacing.sm) {
FnTextInput(
value = draft,
onValueChange = { draft = it },
label = "Mensaje",
modifier = Modifier.weight(1f),
)
FnButton("Enviar", onClick = {
val text = draft.trim()
if (text.isEmpty()) return@FnButton
draft = ""
scope.launch {
try {
withContext(Dispatchers.IO) { session!!.publish(roomId!!, text) }
} catch (e: Exception) {
status = "Error al enviar: ${e.message}"
}
}
}, variant = FnButtonVariant.Filled)
}
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Unibus</string>
</resources>