Skip to main content

ADR 0022 — Native Dart SDK (commbaas)

Status: Accepted Context date: 2026-07-26

Context

ADR 0019 §3 shipped a Flutter (Dart) integration guide as a deliberate stopgap: six documentation pages teaching developers to hand-roll clients against the raw REST and WebSocket contracts using http, web_socket_channel, and livekit_client. ADR 0019 §4 promised a native Dart SDK following the mobile SDK architecture (ADR 0017) and a follow-up ADR when work began. This is that ADR: the SDK now exists at sdks/dart/commbaas/, and this record documents the decisions embodied in it.

Forces at play:

  1. Cross-SDK consistency. The JS (@comm-baas/client-sdk), Swift (CommBaaS), and Kotlin (com.commbaas:sdk) SDKs share three conceptual layers — typed models, resource facades, signaling connection with session resume (ADR 0014, ADR 0017 §1). A fourth SDK that deviates structurally would break the "one set of conceptual docs covers all platforms" property.
  2. Dart idiom. ADR 0017 resolved "structural consistency vs. platform idiom" per platform: actor in Swift, coroutines + OkHttp in Kotlin. Dart brings its own idioms — Stream as the single canonical async-sequence primitive, Dart 3 sealed classes with exhaustive switch pattern matching, Future-based async — and its own constraint: livekit_client is a Flutter plugin, not a pure Dart package.
  3. Docs-as-contract drift. ADR 0019 accepted that the integration guide documents raw endpoints and wire messages, so every contract change had to be propagated to it manually. That burden was explicitly temporary, ending when the native SDK abstracts the contracts.
  4. Trust boundary. The tenant API key (cb_<prefix>.<secret>) must never ship inside a client application. The SDK's public surface has to make the wrong thing impossible, not merely discouraged.
  5. Testability. The join flow spans HTTP, a WebSocket, and a media plane backed by native platform channels. Unit and widget tests must be able to drive all of it without a network or a device.

Decision

1. Ship the native Dart SDK per ADR 0017's layer mirror

The package implements the same three layers as the JS, Swift, and Kotlin SDKs:

  • Typed models — hand-written Dart classes with fromJson/toJson, taken verbatim from contracts/openapi/*.yaml and contracts/ws-protocol/signaling.json (lib/src/models/, lib/src/signaling/messages.dart). No code generation (see §3).
  • Resource facadesCommBaasClient exposes auth, rooms, presence, analytics, billing, and turn, with the same resource and method naming as the JS/server SDKs (client.rooms.create(), client.presence.room(), client.billing.usage()).
  • Signaling connectionSignalingConnection implements the disconnected -> connecting -> connected -> resuming state machine from ADR 0014: it stores the server-assigned session_id from hello, tracks last_seq from forwarded room events, sends heartbeat ping frames at the server-specified interval (default 15 s, echoing the last server_time), and on unexpected close reconnects in resume mode with exponential backoff — 1 s, 2 s, 4 s, at most 3 attempts — sending {"type": "resume", "session_id", "last_seq"} on the new socket. A graceful close (client bye / code 1000) never triggers reconnection; exhausted retries land in disconnected and surface to the app.

On top of the layers, CommBaasClient.joinRoom(roomId) orchestrates the full join flow (join grant → signaling hello → LiveKit media connect → QoS telemetry) and returns a RoomHandle — the same high-level room object the JS SDK provides — with setMicEnabled/setCameraEnabled, moderation methods, a roster, and a unified event stream.

2. A Flutter package named commbaas at sdks/dart/commbaas/

The package name is commbaas, continuing the ADR 0019 §1 rule: EthioConnect in prose, commbaas namespaces in identifiers. It lives at sdks/dart/commbaas/ alongside the other SDKs.

It is a Flutter package, not a pure Dart package, because livekit_client is a Flutter plugin and is a regular, non-optional dependency — the same stance ADR 0017 §2 took for Swift and Kotlin. There is no mobile signaling-only use case, and making media optional would only reintroduce a class of missing-dependency runtime errors. The cost — the package cannot be consumed by non-Flutter Dart (server/CLI) programs — is acceptable: server-side integration is the job of @comm-baas/server-sdk, not a client SDK.

As on the other mobile platforms, the SDK does not wrap or re-export the LiveKit API. LiveKit types cross the SDK boundary as Object (the Dart analogue of the JS SDK typing them unknown), and RoomHandle.livekitRoom is the escape hatch for rendering, screen share, and custom tracks.

3. Three runtime dependencies; no code-gen; no storage packages

The dependency list is exactly http, web_socket_channel, and livekit_client — the same packages the ADR 0019 integration guide already told developers to use, now wrapped instead of hand-rolled.

  • No code generation. Models are hand-authored, mirroring the hand-authored Codable/@Serializable models of ADR 0017 §1. This avoids build_runner/json_serializable as transitive dev tooling in every consumer, keeps generated-file churn out of review, and keeps the wire mapping (snake_case fields, urls string-or-array normalization, enum wire values) explicit and reviewable against the contracts.
  • No storage packages. Token supply is an async closure, typedef TokenProvider = Future<String> Function(), called whenever the SDK needs a fresh JWT and never cached beyond immediate use (ADR 0017 §7). The app owns storage, refresh, and expiry — flutter_secure_storage (or anything else) stays in the app, never in the SDK, so the SDK never persists a credential. A static token: constructor parameter exists as a convenience for short-lived scripts and tests; the constructor asserts that exactly one of tokenProvider/token is supplied.

4. Media isolated behind a MediaSession interface with injectable factories

The SDK talks to the media plane exclusively through the MediaSession interface (connect, setMicEnabled, setCameraEnabled, collectQoSSamples, disconnect, an events stream, and the livekitRoom escape hatch). The production implementation, RoomSession, is the only file that imports livekit_client.

CommBaasClient accepts injectable seams — an http.Client, a SignalingSocketFactory, and a MediaSessionFactory — so the entire join flow, including signaling resume and media reconnect behavior, can be driven in unit and widget tests with fakes: no network, no devices, no platform channels.

5. A single sealed-class broadcast Stream per emitter

Each emitter exposes exactly one event surface: a broadcast Stream of a sealed event hierarchy — RoomHandle.events (Stream<RoomEvent>), SignalingConnection.events (Stream<SignalingEvent>), MediaSession.events (Stream<MediaSessionEvent>). Consumers pattern-match exhaustively:

room.events.listen((event) {
switch (event) {
case ParticipantJoined(:final participant): ...
case ActiveSpeakersChanged(:final speakers): ...
case Disconnected(:final reason): ...
default: break;
}
});

This is a deliberate deviation from ADR 0017 §4, which mandated a dual event surface per platform (Combine + AsyncStream on iOS, SharedFlow + listener interface on Android). The dual surface existed because each mobile platform has two established consumer audiences that do not share a primitive. Dart has no such split: Stream is the platform's single canonical async-sequence type, and it already serves every consumption style — listen callbacks, await for loops, and StreamBuilder in the widget tree. A second surface would be a thin wrapper over the same stream, doubling the delivery code paths that must be tested (a cost ADR 0017 itself recorded as a negative) while serving no audience the stream does not. Dart 3 sealed classes additionally replace ADR 0017's per-event-type publishers (roomEvents, participantEvents) with one typed stream and compiler-checked exhaustive handling.

6. No mintToken in the client auth facade

The server-to-server mint endpoint (POST /v1/auth/token) requires the tenant API key, which must never ship inside a client application. Unlike the server SDK, the client AuthResource therefore deliberately omits mintToken; it exposes only end-user flows — login, register, refresh, logout, me. Backend-minted tokens reach the SDK exclusively through the tokenProvider closure. The trust boundary is enforced by the API surface, not by documentation.

7. pub.dev publishing is a follow-up

The package is consumed from the repository (git or path dependency) for now. Publishing commbaas to pub.dev — with API-stability commitments, semver policy, and score/lint gating — is deliberately deferred until the surface has soaked against real consumers.

Consequences

  • Positive: The four SDKs (JS, Swift, Kotlin, Dart) now share the same conceptual layers, resource naming, and signaling lifecycle; the cross-platform documentation property of ADR 0017 §1 extends to Flutter.
  • Positive: Per ADR 0019 §4, the Flutter integration guide pages (docs-site/docs/sdks/flutter/*) are converted from the primary path to "under the hood" material: the SDK is now the documented path, and the raw-contract sections remain as wire-protocol documentation of what the SDK implements.
  • Positive: The manual docs-contract sync burden accepted in ADR 0019 ends — contract changes are absorbed in one place (the SDK's typed models) instead of being hand-propagated through guide prose.
  • Positive: The MediaSession interface plus injectable factories make the full join/resume/reconnect flow testable without devices, and confine the livekit_client import to a single file.
  • Positive: Omitting mintToken and all storage makes the two most dangerous client-side mistakes (shipping the tenant API key, persisting tokens in an SDK cache) structurally impossible.
  • Security review: The SDK passed Zero-Trust review of its JWT lifecycle before ship: tokens are transmitted only in the Authorization header, never logged, and never persisted. SDK-level hardening applied during the review: signaling tickets are redacted from error output, https/wss schemes are enforced with an explicit allowInsecure opt-in for development, the server-supplied heartbeat interval is clamped, caller-supplied IDs are path-segment encoded, and resume guards against sequence-number regression. Presenting the one-time signaling ticket as a WebSocket query parameter matches the current contract and is acceptable only because tickets are single-use and short-lived; header-based ticket presentation for native clients is suggested as a future contract revision.
  • Negative: A fourth SDK widens the surface for API drift. Mitigated as in ADR 0017: shared contract sources and validating all SDKs against the same contract test suite.
  • Negative: Being a Flutter package excludes non-Flutter Dart programs. Accepted — server-side Dart is out of scope for a client SDK.
  • Negative: The single event surface is a documented deviation from ADR 0017 §4; cross-SDK documentation must note that Dart consumers get one stream where Swift/Kotlin get two surfaces.
  • Follow-up (ws-protocol contract, affects all SDKs): The security review found that the signaling resume channel is effectively unauthenticated: per contracts/ws-protocol/signaling.json, a resume connect uses only ?resume=1 plus {session_id, last_seq} in the resume frame — no ticket, JWT, or rotating resume token — so anyone who learns a session_id can hijack the signaling session and receive replayed buffered frames. This is a contract-level defect mirrored faithfully by all client SDKs (JS, Swift, Kotlin, Dart), not a Dart-specific bug. Amend the contract to issue a single-use resume token in hello, rotate it on every resumed, require it in the resume frame, and bind resume to the authenticated principal server-side. Until then, SDK consumers must treat session_id as a bearer secret.
  • Follow-up: Publish commbaas to pub.dev (§7) with a versioning and deprecation policy.
  • Follow-up: The device-token push registration gap noted in ADR 0019 remains open; revisit alongside pub.dev publishing.