Skip to content

Latest commit

 

History

History
289 lines (258 loc) · 17.4 KB

File metadata and controls

289 lines (258 loc) · 17.4 KB

The phone client

The phone side of CodeDeck+ is a native Android app (apps/android, Kotlin + Jetpack Compose) on top of a Rust core. The UI renders and collects input; everything else — the wire protocol, encryption, the relay connection, transcript sync, pairing, notification decisions, persistence — lives in Rust and is shared by any future client.

Layers

Layer What it holds Rule
crates/protocol the phone wire: messages, total codec, kinds, ranges, chunking, NIP-44/NIP-42 depends on nothing internal; shared with the bridge
crates/nostr-transport relay WebSocket + SOCKS5 driver, NIP-42 AUTH, publish verdicts shared with the bridge
crates/client-core reducers and stores as pure state machines: connection FSM, machines/sessions merge, outbox, pairing, transcript sync, settings, UI state, notification decisions, presentation (display_entries) no tokio, no sockets, no I/O; clock and entropy injected; deterministic tests
crates/client-runtime the async host: one tokio event loop composing the core's stores with the transport, the subscription client, timers and the platform ports — the Core handle bindings attach here, never to client-core
crates/client-ffi the UniFFI surface over Core, compiled to the .so the app loads the only crate that knows UniFFI
apps/android Compose UI, the foreground service, platform ports (SQLite, notifications, HTTP) no protocol knowledge

The Core surface

client_runtime::Core is a cheap, cloneable handle; every call is a message to its event loop.

  • dispatch(Intent) — one closed enum, one variant per user action (send input, answer a card, create a session, pair, change a setting, …).
  • *_view() — plain-data read projections: connection, machines, outbox, pairing, settings, pending sessions, quick prompts, UI state, transcript rows. A consumer reads only the slices it paints.
  • CoreEvent (via CoreObserver::on_event) — a small semantic stream: StateChanged { slice } (re-read that view), TranscriptAppended, OutboxSettled, PairingSettled, ActionFailed, FolderAck, Ping. No UI strings: the app writes the copy.
  • Ports the host supplies: Kv and TranscriptStore (SQLite on Android), Notifier, HttpFetch (Blossom uploads of attachments), a clock and an entropy source.

client-ffi re-exposes this to Kotlin. CoreEvent and the view types cross as the real Rust types; Intent crosses as a hand-mapped UniffiIntent (a UniFFI enum derive is all-or-nothing, and several Intent payloads are wire types). The Kotlin bindings under apps/android/app/src/main/java/uniffi/ are generated — never hand-edit them; ./codedeck gen-android-bindings regenerates them and CI fails on drift.

The Android app

  • core/CoreHost.kt owns the generated Core, turns its callbacks into StateFlows, and re-reads a view when its slice changes.
  • platform/StayConnectedService.kt is the foreground service that keeps the core (and the relay connection) alive while the app is in the background; its notification summarizes machines, sessions and relays.
  • platform/ also holds the SQLite-backed ports, the notifier (one channel per attention class; a tap opens the session, and a session's notifications clear once it is in view — opened, or the app brought back onto it — or deleted for good) and the Blossom HTTP client.
  • ui/ is the Compose UI: the welcome (login) screen, the sessions list (home), the session screen and its transcript rows, pairing (QR scan), new session, settings (a hub with a page per machine and per phone setting), and the log viewer. ui/components/ holds the shared kit the screens are built from (page frame, grouped rows, buttons, fields, the app's own icons).

Pairing is a QR scan of the bridge's codedeck://pair?… URL (see PROTOCOL.md), or, by hand, the bridge's npub, token and relays.

Per-machine settings. The phone has no relays of its own. Each paired machine keeps the relays it is reached over — the ones its pairing link and pair-ack named, editable on the machine's settings page — and the transport dials the union of them (plus a pairing candidate's). A command is published only to its machine's relays. A phone with nothing paired dials nothing. The defaults a new session starts with (the agent, and per agent its mode, effort and model) are kept per machine too; everything else in Settings (appearance, notifications, stay connected, Orbot, quick prompts, uploads) is the phone's own, with the log viewer and the account last.

Login. A fresh install opens on a welcome screen, and the core does not start until the user picks how the identity is held: a NIP-55 signer app (the app asks it for the public key and, up front, permission to sign kinds 4515, 22242 and 24242 and to NIP-44 encrypt/decrypt), a key created on the phone, or an imported nsec. A key on the phone is stored encrypted with an Android Keystore-backed key (KeyVault), and so are the session keys; neither is ever in the app's database. With a signer app, each request first goes to its content provider, which answers in the background once the user let it remember the permission; otherwise the signer's own activity asks the user (while the app is in the background, a notification says a request is waiting).

Keys. The core reaches the identity only through a signer port (IdentitySigner; over the FFI, UniffiIdentitySigner, which Kotlin can implement for an external NIP-55 signer). The identity signs every event the phone publishes — commands, grants, relay NIP-42 AUTH, Blossom upload auth — so a relay or image server allowlist only ever needs that one pubkey. Each install also holds a local session key, granted to every bridge that advertises session-keys (with the pair-request, or once its heartbeat shows the capability; at most every 10 minutes while unconfirmed). A key lives 89 days; a month before it lapses a fresh one replaces it and is granted to every bridge, and the previous key is kept only until each bridge confirms the new one. Once a bridge confirms a key (a message it encrypted to it), payloads both ways use that key; until then, and whenever the bridge speaks to the identity again, the signer encrypts and decrypts. The core keeps the keys through a SessionKeyStore port (the Keystore on Android). See PROTOCOL.md.

Several clients. One identity may run on any number of devices; a bridge knows each by its session key and sends each its own copy, tagged for it. The core subscribes only to its own copies (the recipient tags of its keys and of its identity) and drops any other before decrypting, so another device's traffic never reaches the signer. A bridge sends the shared updates only to clients present: any command keeps this one present, the core sends presence after ten quiet minutes, and presence {away} when it stops (waiting at most 2 s for it to go out). See PROTOCOL.md.

Unprompted by the user, the core asks the identity to sign only: one refresh-sessions per machine on each (re)connect; the sync-requests for sessions with gaps and their sync-acks (held 500 ms, so a sync's chunks cost one ack per window rather than one each); one NIP-42 AUTH per relay connection that challenges; a presence per machine after ten quiet minutes, and an away when it stops; a close-session again, at most every five minutes, for a session the user deleted that its bridge still lists; and a grant, rarely. Everything else it signs — commands, Blossom upload auth — follows a user action.

Deleting a session. A delete hides the session at once and can be undone for 4 s; then (or as the app goes to the background) the close-session goes out. The session stays hidden while its bridge still lists it, and the close is sent again while it does, until the bridge acknowledges it: one sent while offline, or one the bridge did not fetch within the relays' hour, is not lost. It stays hidden for two hours after the bridge stops listing it, counted in that bridge's heartbeats as well as by the clock, so a bridge offline for a day does not bring it back. A session gone — deleted here, on another device (a tombstone in the heartbeat), or with its machine — takes with it its unread dot, its answered cards, its unsent messages, its stored transcript and any notification it posted.

Config backup. Opt-in, in Settings → Backup, and offered once right after logging in with an existing key. It holds what the identity's devices share to reach their machines — each paired machine's relays, label, direct endpoints and new-session defaults — and the quick prompts, as one NIP-78 event (kind 30078) on a relay the user picks. A bridge trusts an identity, not a device, so these are all another device needs. Nothing about one device goes in: not its settings, not its session keys (a restored or added device makes its own and is its own client of each bridge). The content is NIP-44 encrypted to the identity itself, through the signer, so a NIP-55 login works too; the d tag is a hash of the identity and an app-private context, so it names neither the app nor what it holds. What a bridge sends again in its heartbeat (sessions, agents, models, credentials) is left out.

Every device of the identity that backs up to the same relay keeps in step (client_core::stores::backup): while the core runs it follows the backup on the relay, merges each version another device saves at once, and saves when it holds anything newer. Each machine carries when its shared fields last changed and a removed machine leaves a tombstone (kept 30 days); per machine the newest wins, a tie going to the backup's side, so a machine paired, edited or removed on one device is on all of them, and pairing it again later brings it back. The quick prompts go as one list, the newest wins. What a device had before it first joined counts as older than what the backup holds. Stamps come from each device's clock, but an edit is stamped after the newest stamp the device has seen, so an edit made after seeing another device's change wins over it whatever the clocks say. A save follows 30 s after the last change worth backing up (a merge that leaves this device newer included), and only when the content changed; "Back up now" forces one. Nothing is saved before the relay said what it holds, while a version is being opened, or over a version that could not be opened. A relay that does not answer within 20 s, or ends the subscription, shows as a failure; the subscription is made again, waiting longer each time. Turning backup off on a device leaves the others going; deleting it sends a NIP-09 deletion and then an empty version at the same address, which replaces the backup even on a relay that ignores NIP-09: a device that took part before it turns backup off when it sees it, and one joining later finds no backup. The backup relay gets the backup's subscription and nothing else, and the bridges' relays never get it (each subscription goes to its own relays only); the Tor and AUTH rules of every relay apply to it. A login kept on the phone can show and copy its nsec (Settings → Account), the one thing a backup cannot hold.

Orbot. A settings toggle routes the relay connections and Blossom traffic through Orbot's SOCKS5 proxy (127.0.0.1:9050); DNS resolves at the proxy, so .onion relays and Blossom servers work. The app does not launch or manage Orbot, and the toggle is fully applied on the next app start (while running it affects new connections only). Cleartext ws:// and http:// are refused except to .onion hosts (and ws:// to loopback, for tests).

The release APK is signed and built for aarch64; minSdk is 26 (the JNA runtime the UniFFI bindings use needs it).

Transport behaviour

These are the rules the connection code keeps; the subscription filters themselves are in PROTOCOL.md.

  • The transport is hand-rolled on tokio-tungstenite + tokio-socks, not a relay-pool library: a pool brings its own idle timeouts and reconnects, and collapses the publish outcomes below.
  • Each relay is redialled on its own when its socket fails: 2 s doubling to 5 min, jittered, restarting from 2 s after a connection that lasted a minute. A subscription reports one close only when it is dead on every relay; the connection FSM then owns the backoff — 2 s → 30 s with up to 25% jitter, 8 s → 60 s over Tor — and dials every relay at once. Going back online reconnects at once. A relay that comes back gets every open subscription's REQ again.
  • A publish waits, within its budget, for one of its relays to come up when none is yet (a relay a pairing just added is still connecting when the pair-request goes out); it is unreachable only when none does.
  • A REQ or event a relay refuses with auth-required: is re-sent once that relay has accepted the NIP-42 AUTH; if it refuses the AUTH, or asks again after accepting it, the subscription is dead there and the publish rejected there.
  • Each event is verified once per subscription: the copies other relays send are dropped before their signature is checked.
  • Liveness: every relay is pinged together, every 30 s in the foreground and 150 s in the background; a socket silent for two missed pings (75 s / 315 s) is dropped. "Stay connected" holds no permanent wake lock: the device sleeps, incoming relay traffic wakes it, and an inexact alarm every 60 s (stretched by deep Doze) runs Core::keepalive under a wake lock of at most 15 s — it pings every relay, drops those silent for 10 s, and brings a stalled reconnect forward (the core's timers count awake time only). If every paired machine's heartbeat is older than 150 s (240 s over Tor) while "connected", the subscriptions are torn down and reopened.
  • The machines store and the stored-event cursor are written at most every 2 s (heartbeats, usage and every stored event change them) and at once when the app is backgrounded or stopped; the other stores are written as they change.
  • A publish settles as accepted, unconfirmed (written, no OK in time — not a failure), rejected or unreachable; only unreachable retries, and it retries the same signed event.
  • An epoch guard drops callbacks from a superseded connection, so a deliberate teardown never surfaces as a close.
  • WsTransport is single-threaded (!Send callbacks): the core runs on a current-thread runtime inside a LocalSet on its own thread.
  • wss:// uses rustls (webpki roots), so the core cross-compiles for Android without OpenSSL.

Direct link

A bridge can also be reached without the relays (the wire is in PROTOCOL.md, the bridge's side in BRIDGE.md). Pairing always goes over Nostr; the link is an extra path beside the relays, never a replacement.

  • Where to dial comes from the bridge's heartbeat (direct: endpoints and the SHA-256 of its self-signed certificate), followed by any endpoints the user added for that machine in Settings (a VPN or MagicDNS name the bridge cannot know). Both live in the machines store.
  • The core keeps one link per machine that has endpoints, while it runs. It tries them in order with the relay transport's backoff, and restarts the link when the endpoints, the pin or the Orbot proxy change.
  • wss:// endpoints are pinned to the advertised certificate (no CA, so private addresses and VPN names work); without a pin they are skipped. Cleartext ws:// goes only to an onion service. Orbot does not apply to the direct link: it protects the phone from public relays, while a direct endpoint is the user's own bridge on their LAN or VPN, so it is dialled directly with Orbot on or off. .onion endpoints are the exception: only Tor reaches them, so they are dialled through Orbot and skipped without it. Falling back to the relays, the phone uses Orbot as usual.
  • The HELLO is signed by the identity, like a relay's AUTH, and resumes from the newest event the link has seen (two minutes back on the first connection). Events that arrive go through the same dedup and ingest as a relay's, so a copy the relays also deliver is dropped.
  • A command goes over the link while it is up and the bridge answers with an OK within 5 s; otherwise it is published to the relays as usual.
  • The machines view reports the endpoint each link is up on; Settings shows it per machine.

Conventions

  • A UniFFI error variant must not have a field named message (it collides with Kotlin's Throwable.message); use detail or reason.
  • A type must not share its name with the enum variant that carries it (e.g. ActionFailedKind, not ActionFailed): the Kotlin codegen would resolve the field to the variant's own subclass.
  • The wire's absent / null / value cases use Tristate<T> (Keep / Clear / Set).
  • The nostr crate is used with minimal features (NIP-44, NIP-59, signing); its only heavy dependency is secp256k1.

Build and test

./codedeck apk                           # debug APK into dist/ (Docker)
apps/android/scripts/build-apk-local.sh  # the same on Linux, no Docker
./codedeck gen-android-bindings          # after changing client-ffi's surface
cargo test -p client-core -p client-runtime -p client-ffi

In apps/android, ./gradlew testDebugUnitTest verifyPaparazziDebug runs the unit and screenshot tests (CI runs them, with the bindings drift check).