# Report 0002 — Cliente humano de unibus: SPA web + app Android nativa

- **Fecha:** 06/06/2026
- **Autor:** agente (Claude) — frente frontend
- **Ámbito:** `mobile/unibus.go`, `playground/server.go` (gateway), `web/` (SPA nueva), `android/` (app nueva)
- **Estado:** done (con gaps documentados)

## Resumen

Se construyó el cliente humano de unibus en dos frentes, ambos verificados end-to-end:

1. **Web**: SPA de chat (React + Vite + TypeScript + Mantine v9) contra el gateway Go
   (`playground/server.go`), que monta un peer real por identidad y emite los mensajes
   recibidos por SSE. Dos identidades crean una room cifrada E2E, una invita a la otra y
   ambas chatean en vivo.
2. **Móvil**: app Android nativa (Kotlin + Jetpack Compose) sobre el binding gomobile
   `mobile/unibus.go`. El cifrado y el transporte NATS corren **en el dispositivo** (cada
   teléfono es un peer real del bus). La app genera identidad, conecta al `membershipd`,
   crea/se une a una room y envía/recibe mensajes por el bus.

No se reimplementó protocolo ni criptografía en JS ni en Kotlin: todo delega en `pkg/client`
a través del binding y del gateway.

## Cambios

| Archivo / dir | Qué | Por qué |
|---|---|---|
| `mobile/unibus.go` | Añadidos `Card()` (exporta la identidad pública del peer como JSON portable), `Invite(roomID, peerCard)` y `Kick(roomID, endpointID)` al binding. | La UI móvil necesita invitar/expulsar; `Card()` permite el intercambio de identidad peer-a-peer (paste/QR) sin un gateway. |
| `playground/server.go` | Añadidos `GET /api/rooms?peer=` (rooms del peer), `GET /api/members?room_id=` (proxy al control plane) y middleware `withCORS` (preflight + headers) para el dev server de Vite. | La SPA necesita listar rooms/miembros y llamar al gateway desde otro origen. |
| `web/` | SPA nueva: conexión (gateway URL + identidad), navbar (crear/unir/listar rooms), panel central (mensajes en vivo por SSE + composer), panel lateral (miembros, invitar por peer, expulsar si owner). Mantine v9, `@tabler/icons-react`, sin Tailwind ni CSS manual. | Cliente web de chat. |
| `android/` | App Kotlin + Compose: pantalla de conexión (Host + NATS + identidad), `BusViewModel` que dirige el binding (llamadas de red en `Dispatchers.IO`, frames entrantes vía `StateFlow`), pantalla de chat (crear/unir room, enviar, recibir). `.aar` generado con `gomobile bind`. | Cliente móvil con E2E en el dispositivo. |

## Verificación (evidencia ejecutable)

### Builds

```
# Binding + gateway (Go, sin CGO)
$ CGO_ENABLED=0 go build ./mobile/ ./playground/   → OK
$ CGO_ENABLED=0 go vet  ./mobile/ ./playground/    → OK

# .aar gomobile
$ gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \
    -o android/app/libs/unibus.aar ./mobile        → unibus.aar (24 MB)

# SPA
$ cd web && pnpm build
  ✓ tsc -b && vite build  →  6949 modules transformed, dist/ generado  (exit 0)

# APK
$ cd android && ./gradlew assembleDebug
  BUILD SUCCESSFUL in 1m13s
  → app/build/outputs/apk/debug/app-debug.apk  (41 MB, incluye libgojni.so)
```

### Web — flujo cifrado E2E por curl (determinista)

Gateway (`go run ./playground`, web :7700) + dos peers `ana`/`leo`:

```
1. POST /api/peer ana → endpoint cc9f8Gm_RQZ76lX2I6C0ZepmiC82AG19M_ajiaDW4P8
   POST /api/peer leo → endpoint yT7GWz97tRcCx3i4st43d4LcGC85Tl4PDZenycUaQ_I
2. POST /api/room {peer:ana, encrypt:true} → room_id 01KTEVZEE7B7AQAD72WSDHH3Y3
3. POST /api/invite {peer:ana, target:leo} → {"status":"invited"}
4. POST /api/join  {peer:leo}              → {"encrypt":true,"subject":"room.general"}
5. SSE leo + POST /api/publish ana "hola leo, mensaje cifrado E2E":
   leo recibe → data: {"sender":"cc9f8...","text":"hola leo, mensaje cifrado E2E","encrypted":true}
6. GET /api/rooms?peer=ana   → [{"encrypt":true,"room_id":"01KTEVZEE...","subject":"room.general"}]
7. GET /api/members?room_id  → [{ana,role:owner}, {leo,role:member}]
```

### Web — UI con dos pestañas (SPA real, `pnpm preview` :4173)

- Pestaña ana y pestaña leo conectadas como identidades distintas a la room cifrada.
- leo → ana: "hola ana, soy leo desde la SPA" (18:31:05) llegó **en vivo por SSE** a ana.
- ana → leo: "¡recibido leo! ana responde cifrado" (18:32:28), bucle bidireccional.
- Panel de miembros: ana **OWNER** + leo; botón expulsar visible solo para el owner;
  selector de invitación con los peers conectados.

### Móvil — AVD Pixel_API34 (emulador headless)

```
$ adb install -r app-debug.apk          → Success
$ adb shell am start -n com.unibus.app/.MainActivity
  → proceso vivo, sin FATAL/AndroidRuntime, libgojni.so cargada (lib/x86_64)
  → pantalla "Conectado como android"   (newSession contra membershipd 10.0.2.2:8470/4250)
  → "Room creada · 01KTEWTZ8TB1NYEGNNR7F3MY5G"
  → enviado "peer movil real en el bus" → burbuja recibida con hora 18:39:35
```

La burbuja con timestamp prueba el camino completo **en el dispositivo**: el código no añade
el mensaje localmente al publicar; solo lo pinta `FrameListener.onFrame`, así que su aparición
demuestra que el frame viajó por NATS y volvió al peer. Captura en
`reports/assets/unibus_movil.png`.

## Gaps / pendientes

- **Media (PublishMedia/FetchMedia)**: el `pkg/client` ya la soporta, pero NO está expuesta en
  el binding móvil ni en la SPA. Requiere que `FrameListener.onFrame` señale frames con `Blob`
  (hoy entrega solo texto). Pendiente para v2.
- **ListMembers en el binding móvil**: necesita un método **público** en `pkg/client`
  (`ListMembers(roomID)`); hoy es privado (`signerPub` lo usa internamente). **Dependencia del
  CORE**, no implementada aquí por contrato (no tocar `pkg/`). La SPA sí lista miembros porque
  el gateway hace proxy al control plane.
- **Threading/reply/reaction**: el otro agente añadió `ThreadID`/`ReplyTo`/tipo `REACT` al
  `Frame` (ya en master). Ni la SPA ni la app móvil los usan todavía — gap de UI, no de backend.
- **Invite/Kick en la UI móvil**: el binding ya expone `Card()`/`Invite()`/`Kick()`, pero la
  app Kotlin v1 no tiene aún la pantalla de intercambio de "card" (paste/QR) ni el botón de
  expulsar. El chat cleartext y E2E self-echo funcionan; el flujo de dos teléfonos invitándose
  queda para v2.
- **Auth del gateway web**: el gateway identifica peers por nombre (modelo del playground), sin
  autenticación de sesión. Suficiente para uso local/LAN; endurecer es fase posterior (igual que
  el control plane, que en v1 no tiene TLS ni auth en GETs).
- **`.aar` no versionado**: es un artefacto de ~24 MB; se regenera con `gomobile bind` (ver
  `android/README.md`). Gitignored.

## Notas

- Web: `cd web && pnpm install && pnpm dev` (o `pnpm preview` tras `pnpm build`); conectar a
  `http://localhost:7700` (gateway: `go run ./playground`).
- Móvil: ver `android/README.md` para generar el `.aar`, compilar el APK y probar en el AVD.
  Desde el emulador, el host del PC es `10.0.2.2`; desde un teléfono físico, la IP LAN del PC.
- Mantine v9 exige **React 19** (peerDependency `^19.2.0`); con React 18 la SPA compila pero
  no monta en runtime (`s is not a function`). Fijado React 19 en `web/package.json`.
- pnpm 10 bloquea los build scripts: `web/pnpm-workspace.yaml` con `allowBuilds: { esbuild: true }`.
