// 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. Its methods are invoked from a NATS delivery // goroutine, so implementations must hop back to the UI thread (for example via // a coroutine on the main dispatcher) before touching Android views. 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. 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 http://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 } // 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. 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) } // 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. 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 } // 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() }