diff --git a/mobile/gen_aar.sh b/mobile/gen_aar.sh new file mode 100755 index 00000000..88d86f62 --- /dev/null +++ b/mobile/gen_aar.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Regenera el binding gomobile (unibus.aar) a partir de ./mobile sobre pkg/client. +# +# El .aar (~38 MB, con libgojni.so para 4 ABIs) NO se versiona: es un artefacto +# de build reproducible. Este script lo regenera. Requisitos: +# - Go con gomobile/gobind instalados: +# go install golang.org/x/mobile/cmd/gomobile@latest +# go install golang.org/x/mobile/cmd/gobind@latest +# gomobile init +# - Android NDK (este repo usó 26.3.11579264 dentro del Android SDK). +# +# En un worktree fuera del árbol del registry, pkg/client importa +# "fn-registry/functions/cybersecurity" vía el `replace` del go.mod. Si ese +# replace relativo no resuelve (p. ej. worktree en /tmp), crea un go.work local +# (gitignored) con: replace fn-registry => /ruta/absoluta/a/fn_registry +set -euo pipefail + +cd "$(dirname "$0")/.." + +: "${ANDROID_HOME:=$HOME/android-sdk}" +: "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/26.3.11579264}" +export ANDROID_HOME ANDROID_NDK_HOME +export PATH="$HOME/go/bin:$PATH" + +OUT="android/app/libs/unibus.aar" +mkdir -p "$(dirname "$OUT")" + +echo "==> gomobile bind -> $OUT" +gomobile bind \ + -target=android \ + -androidapi 21 \ + -javapkg com.unibus.core \ + -o "$OUT" \ + ./mobile + +echo "==> OK: $OUT" +ls -lh "$OUT" diff --git a/mobile/unibus.go b/mobile/unibus.go new file mode 100644 index 00000000..574ead3d --- /dev/null +++ b/mobile/unibus.go @@ -0,0 +1,236 @@ +// Package mobile exposes a flat, gomobile-friendly API over the unibus client +// so an Android app can join rooms, publish, and receive messages with the same +// end-to-end encryption as any native Go peer. +// +// gomobile only supports a limited set of types across the binding boundary +// (string, []byte, int, bool, error, named structs, and interfaces). This layer +// translates the richer client API into those primitives and delivers incoming +// frames through a Java/Kotlin-implemented FrameListener callback. No protocol +// or cryptography is reimplemented here: every call delegates to pkg/client, +// which is the single source of truth shared with every other peer on the bus. +package mobile + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/room" +) + +// FrameListener receives decrypted messages for a subscribed room. The Android +// side implements this interface. +// +// IMPORTANT (threading): OnFrame is invoked from a NATS delivery goroutine, NOT +// the Android main thread. A Kotlin implementation MUST hop back to the UI +// thread before touching any Compose state or Android view — for example with +// `withContext(Dispatchers.Main)` from a coroutine, or by posting to a +// MutableStateFlow that the UI collects. Touching views directly from here +// crashes with CalledFromWrongThreadException. +type FrameListener interface { + OnFrame(roomID string, sender string, msgID string, text string) +} + +// Session is a connected unibus peer. Create it with NewSession and close it +// with Close when the app stops. +type Session struct { + c *client.Client +} + +// GenerateIdentity creates (or loads) the long-term keypair stored at path. +// Call it once on first launch. The resulting file holds the peer's private +// Ed25519 and X25519 keys and must be kept private to the app sandbox +// (use Context.getFilesDir() on Android). +func GenerateIdentity(path string) error { + _, err := client.LoadOrCreateIdentity(path) + return err +} + +// NewSession loads the identity at idPath and connects to the bus. natsURL is +// the data plane (for example tls://host:4250) and ctrlURL is the control plane +// HTTP endpoint (for example https://host:8470). caPath is the path to the bus +// CA certificate (ca.crt) bundled with the app: when set, the session connects +// securely (TLS pinned to that CA + nkey authentication on the data plane), +// matching a bus running with auth + TLS. Pass an empty caPath to connect in +// plaintext to an unsecured (dev) bus. +func NewSession(idPath, natsURL, ctrlURL, caPath string) (*Session, error) { + id, err := client.LoadOrCreateIdentity(idPath) + if err != nil { + return nil, err + } + c, err := client.Connect(natsURL, ctrlURL, id, caPath) + if err != nil { + return nil, err + } + return &Session{c: c}, nil +} + +// EndpointID returns this peer's stable endpoint identifier, derived from its +// signing public key. It is the value that appears as the sender of frames. +func (s *Session) EndpointID() string { + return s.c.Endpoint().ID +} + +// ConnectedServer returns the NATS URL the session is currently connected to, +// useful for surfacing a "connected to" hint in the UI. +func (s *Session) ConnectedServer() string { + return s.c.ConnectedServer() +} + +// IsConnected reports whether the underlying NATS connection is live. +func (s *Session) IsConnected() bool { + return s.c.IsConnected() +} + +// CreateRoom opens a room on the given subject. mode is "matrix" for the +// encrypted, persisted and signed policy, or "nats" for plain cleartext. It +// returns the room id used by Join, Publish and Subscribe. +// +// On a secured bus, call RefreshSession after CreateRoom and before +// Subscribe/Publish so the bus re-derives this peer's per-subject permissions +// from its new membership (issue 0006e). +func (s *Session) CreateRoom(subject, mode string) (string, error) { + p := room.ModeNATS + if mode == "matrix" { + p = room.ModeMatrix + } + return s.c.CreateRoom(subject, p) +} + +// Join fetches the room key when the room is encrypted and prepares the session +// to publish to and receive from the room. +func (s *Session) Join(roomID string) error { + return s.c.Join(roomID) +} + +// RefreshSession reconnects the data plane so the bus re-derives this peer's +// per-subject permissions from its current room membership. +// +// Membership-change contract (issue 0006e): a secured bus (--bus-auth enforce) +// freezes a connection's permissions at connect time. After ANY membership change +// — a room you just created, were invited to, or joined — call RefreshSession +// BEFORE Publish/Subscribe on that room, or the bus denies the new room's subject. +// It also drops active subscriptions, so re-Subscribe afterwards. On an unsecured +// bus it is a harmless reconnect. A mobile/gateway caller wires this exactly like +// cmd/chat and cmd/worker do: CreateRoom -> RefreshSession -> Subscribe/Publish. +func (s *Session) RefreshSession() error { + return s.c.RefreshSession() +} + +// Publish sends a UTF-8 text message to the room. +func (s *Session) Publish(roomID, text string) error { + return s.c.Publish(roomID, []byte(text)) +} + +// Subscribe streams decrypted messages of the room to the listener until the +// session is closed. See FrameListener for the threading contract. +func (s *Session) Subscribe(roomID string, l FrameListener) error { + _, err := s.c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) { + l.OnFrame(roomID, f.Sender, f.MsgID, string(plaintext)) + }) + return err +} + +// roomJSON is the flat shape returned by ListRoomsJSON for each room the peer +// belongs to. It mirrors the fields the UI needs to render a room list item. +type roomJSON struct { + RoomID string `json:"room_id"` + Subject string `json:"subject"` + Epoch int `json:"epoch"` + Encrypted bool `json:"encrypted"` + Role string `json:"role"` +} + +// ListRoomsJSON returns the peer's rooms as a JSON array string. gomobile does +// not bind slices of structs cleanly across the boundary, so the list is +// marshalled to JSON and the Kotlin side decodes it (kotlinx.serialization). +// Each element is a roomJSON object. +func (s *Session) ListRoomsJSON() (string, error) { + refs, err := s.c.ListMyRooms() + if err != nil { + return "", err + } + out := make([]roomJSON, 0, len(refs)) + for _, r := range refs { + out = append(out, roomJSON{ + RoomID: r.RoomID, + Subject: r.Subject, + Epoch: r.Epoch, + Encrypted: r.Policy.Encrypt, + Role: r.Role, + }) + } + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// cardJSON is the portable, copy-pasteable public identity a peer shares so a +// room owner can invite it to an encrypted room. It carries no secret: only the +// endpoint id and the two public keys (signing + key-exchange), base64-encoded +// for transport over text or a QR code. +type cardJSON struct { + ID string `json:"id"` + SignPub string `json:"sign_pub"` // base64 std of the Ed25519 public key + KexPub string `json:"kex_pub"` // base64 std of the X25519 public key +} + +// Card returns this peer's public identity as a portable JSON string. Share it +// (paste, QR) with a room owner so they can Invite you to an encrypted room. It +// contains no private key and is safe to transmit in the clear. +func (s *Session) Card() string { + ep := s.c.Endpoint() + b, _ := json.Marshal(cardJSON{ + ID: ep.ID, + SignPub: base64.StdEncoding.EncodeToString(ep.SignPub), + KexPub: base64.StdEncoding.EncodeToString(ep.KexPub), + }) + return string(b) +} + +// Invite adds the holder of peerCard to roomID. peerCard is the JSON string the +// invitee produced with Card(). For encrypted rooms this seals the current room +// key to the invitee's X25519 public key and signs the request; the caller must +// be the room owner. +func (s *Session) Invite(roomID, peerCard string) error { + var card cardJSON + if err := json.Unmarshal([]byte(peerCard), &card); err != nil { + return fmt.Errorf("mobile: bad peer card: %w", err) + } + signPub, err := base64.StdEncoding.DecodeString(card.SignPub) + if err != nil { + return fmt.Errorf("mobile: bad sign_pub in card: %w", err) + } + kexPub, err := base64.StdEncoding.DecodeString(card.KexPub) + if err != nil { + return fmt.Errorf("mobile: bad kex_pub in card: %w", err) + } + return s.c.Invite(roomID, client.Endpoint{ID: card.ID, SignPub: signPub, KexPub: kexPub}) +} + +// Kick removes endpointID from roomID and, for encrypted rooms, rotates the room +// key to a new epoch so the removed peer cannot decrypt messages published after +// the kick (forward secrecy). The caller must be the room owner. +func (s *Session) Kick(roomID, endpointID string) error { + return s.c.Kick(roomID, endpointID) +} + +// Request performs an RPC request/reply against subject and returns the reply +// payload as text. timeoutMs bounds the wait in milliseconds. +func (s *Session) Request(subject, text string, timeoutMs int) (string, error) { + out, err := s.c.Request(subject, []byte(text), time.Duration(timeoutMs)*time.Millisecond) + if err != nil { + return "", err + } + return string(out), nil +} + +// Close disconnects the peer from the bus. +func (s *Session) Close() error { + return s.c.Close() +}