Architecture
This is the “how it fits together” page: who owns what, how a message travels end to end, and which boundaries are load-bearing.
The mental model
┌──────────────────────────────────────────────────────────────────────┐
│ CONTROL PLANE (any Luno-protocol backend, out of scope) │
│ device registry · auth/pairing · command dispatch · event ingestion │
│ rate-limit policy · dashboards for many nodes │
└───────────────▲──────────────────────────────────────────▲───────────┘
│ WSS (primary) │ HTTPS (fallback)
│ versioned wire protocol │
┌───────────────┴──────────────────────────────────────────────────────┐
│ ANDROID NODE │
│ │
│ ┌───────────── Native Kotlin agent (headless-capable) ───────────┐ │
│ │ GatewayForegroundService ── owns the process lifetime │ │
│ │ AgentController ── orchestrates everything below │ │
│ │ backend/ WebSocketClient · ProtocolCodec · Heartbeat │ │
│ │ transport/ Transport interface → SmsTransport │ │
│ │ telephony/ Sim · Signal · Battery · Network monitors │ │
│ │ data/ Room outbox/inbox/reports + repositories (durable) │ │
│ │ work/ BootReceiver · WorkManager retry (safety net) │ │
│ └────────────────────────────▲───────────────────────────────────┘ │
│ │ Pigeon HostApi + EventChannel │
│ ┌────────────────────────────┴───────────────────────────────────┐ │
│ │ Flutter UI (renders native state, issues commands) │ │
│ │ pairing · dashboard · logs · settings │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│ Android telephony APIs
▼
SIM / radio / carrierTwo hard boundaries define the whole system:
- Native ↔ Flutter is Pigeon plus
EventChannel. Crossing it: commands and rendered state. Not crossing it: sockets, queues, telephony, retries. - Node ↔ backend is the versioned wire protocol. This is the real API and the real extension seam.
Responsibility split
Flutter — UI and only UI
Owns pairing screens, the live dashboard, the log viewer, settings, the connection indicator, and ephemeral UI state. It issues commands to native and renders what native streams back.
It explicitly does not own the WebSocket, the message queue, retry and backoff, delivery correlation, telephony, credentials, or any state that must outlive the Activity.
If the Dart process is killed, the gateway keeps running unaffected. That is the acceptance test for this boundary.
Native Android — the entire agent
Owns everything load-bearing:
- Process lifetime —
GatewayForegroundServiceandBootReceiver - Backend connection — WebSocket client, protocol codec, heartbeat, reconnect, REST fallback, credential store, pairing
- Transports — the
Transportinterface, withSmsTransportas the v1 implementation; multi-SIM aware - Telemetry — SIM, signal, battery and network monitors
- Durability — Room outbox/inbox/report tables, repositories, message state machines, idempotency and dedupe
- Resilience — WorkManager retry, offline queueing, resync on reconnect
- Security — Keystore, encryption at rest, redaction, rate-limit enforcement
Backend — the fleet brain
Owns the device registry and identity, pairing and credential issuance, command origination, event ingestion and persistence, authoritative rate-limit and recipient policy, and multi-node dashboards.
It is the source of truth for what the fleet should do. The node is the source of truth for what physically happened on the radio.
The node names no backend technology anywhere in its code, config or branching logic, and endpoints are runtime configuration set at pairing — never compiled in. Point a node at any conformant server and it works.
Communication flows
Outbound — the backend wants an SMS sent:
Backend ──command:send_sms(id=C1)──▶ WebSocketClient
→ ProtocolCodec decodes → AgentController
→ OutboxRepository.enqueue(C1) [PERSIST FIRST] — idempotent on C1
→ ack(C1 accepted) ─────────────────▶ Backend
→ SmsTransport.send(part…) via SmsManager (per subId)
→ sentIntent fires → outbox: QUEUED→SENT/FAILED → event:sms_sent ▶ Backend
→ deliveryIntent fires → outbox: SENT→DELIVERED/UNDELIVERED
→ event:delivery_report ▶ Backend
→ on backend ack of each event → mark acked (stop resending)Inbound — a message arrives at the SIM:
SMS_RECEIVED broadcast → SmsReceiver.goAsync()
→ hand PDUs to service → MultipartAssembler
→ InboxRepository.insert(RECEIVED) [PERSIST FIRST]
→ event:sms_received(id=E7) ────────▶ Backend
→ Backend ack(E7) → inbox: mark reported/ackedThe invariant in both directions: the durable store is written before the network is trusted and before any ack is sent. Everything else is a state machine draining that store.
Message state machines
Outbox:
QUEUED ─▶ SENDING ─▶ SENT ─────────▶ DELIVERED (terminal, success)
│ │ └─▶ UNDELIVERED (terminal, delivery failed)
│ └─▶ FAILED_RETRYABLE ─▶ (backoff) ─▶ QUEUED
│ └─▶ FAILED_TERMINAL (terminal, e.g. bad number)
└─▶ CANCELLED (backend cancel before send)A multipart message fans out to N parts. It reaches SENT only when all parts
report sent, DELIVERED only when all report delivered, and rolls to the worst
part outcome otherwise.
Inbox:
RECEIVED ─▶ REASSEMBLED ─▶ REPORTED ─▶ ACKED
└─▶ REASSEMBLY_TIMEOUT → REPORTED (as partial) → ACKEDStatus transitions are the only place message state changes, they are all persisted, and they are all idempotent on the message or part id.
Connection state machine
┌────────────► OFFLINE_NO_NETWORK ◄─────────┐
│ │ network available │
│ ▼ │
DISCONNECTED ──────▶ CONNECTING ──────▶ CONNECTED ─auth ok─▶ AUTHENTICATED ─▶ READY
▲ │ fail │ socket drop │
│ ▼ │ │ heartbeat miss×N
└──── BACKING_OFF ◀── RECONNECTING ◀──┴───────────────────┘
(exp backoff + jitter, capped; reset on stable READY)- Transport liveness (WebSocket ping/pong) detects dead sockets fast.
- Application heartbeat detects a useless connection — socket up but peer not processing — and drives backend-side online/offline.
- Network changes come from
ConnectivityManager.NetworkCallback, never polling. - Backoff resets only after a stably READY connection, so a flapping network does not hammer the backend.
Logging
Structured LunoLogger, with three sinks: logcat in debug, a bounded Room ring
buffer for the on-device viewer and post-mortems, and throttled log events to
the backend.
Redaction is mandatory and central. Phone numbers and message bodies are
redacted at info level and above by a single function, so no call site can
accidentally leak PII. Full bodies exist only in the encrypted inbox and outbox
tables. Every log line carries the relevant messageId, commandId or seq,
so a message can be traced end to end across database, radio and wire.
Extensibility — two axes
Axis A — more transports on Android, behind one interface:
interface Transport {
val id: TransportId // SMS, MMS, USSD, VOICE…
val capabilities: Set<TransportCapability> // SEND, RECEIVE, DELIVERY_REPORT…
suspend fun send(request: OutboundMessage): SendHandle
fun incoming(): Flow<InboundMessage>
fun state(): Flow<TransportState>
}The AgentController and the wire protocol only ever speak in these neutral
terms, so adding MMS means writing one MmsTransport and registering it — no
change to the queue, the protocol, or the UI’s data model.
Axis B — more node platforms, behind the wire protocol. A Linux or Windows GSM-modem node is a separate program implementing the same envelope, commands and events. The backend cannot tell what kind of node it is talking to. This is why the protocol, not a class hierarchy, is the primary extension point.