feat(mobile): rehacer binding gomobile sobre pkg/client
Reintroduce mobile/unibus.go (package mobile), borrado en la limpieza de frontends experimentales. Expone una API plana gomobile-friendly sobre pkg/client para que la app Android sea un peer del bus con el mismo cifrado de extremo a extremo que cualquier otro: - GenerateIdentity, NewSession (vía client.Connect con TLS+nkey+caPath) - EndpointID, ConnectedServer, IsConnected - CreateRoom, Join, RefreshSession (contrato de membresía issue 0006e) - Publish, Subscribe(FrameListener), ListRoomsJSON - Card, Invite, Kick, Request, Close No reimplementa criptografía: todo delega en pkg/client. FrameListener documenta el contrato de hilo (los callbacks llegan en una goroutine de NATS; Kotlin debe saltar al hilo principal). gen_aar.sh regenera el .aar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+37
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user