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:
@@ -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.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Unibus</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user