Skip to Content

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 / carrier

Two 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 lifetimeGatewayForegroundService and BootReceiver
  • Backend connection — WebSocket client, protocol codec, heartbeat, reconnect, REST fallback, credential store, pairing
  • Transports — the Transport interface, with SmsTransport as 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/acked

The 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) → ACKED

Status 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.