Skip to main content

ADR 0017 -- Mobile SDK Architecture

Status: Accepted Context date: 2026-07

Context

Following the adoption of the JavaScript/TypeScript SDK architecture (ADR 0014), the platform needs first-party mobile SDKs for iOS and Android so that tenant developers building native applications can integrate EthioConnect without reimplementing HTTP clients, WebSocket signaling, session resume, or token lifecycle management. The mobile SDKs must feel familiar to developers who have already used the JS SDK while respecting the idioms, concurrency models, and dependency ecosystems of each native platform.

Key tensions driving the design:

  1. Structural consistency vs. platform idiom. Mirroring the JS SDK layer-by-layer (typed resource facades, signaling connection, event emitter) reduces the learning curve for teams that work across web and mobile. However, blindly copying JavaScript patterns into Swift or Kotlin produces unidiomatic code that fights the platform. The mobile SDKs must preserve the same conceptual layers while expressing each layer in the native platform's preferred style.
  2. LiveKit coupling. On the web, LiveKit is an optional peer dependency because signaling-only use cases (roster tracking, moderation dashboards) are common in browsers. On mobile, there is no practical signaling-only use case: native apps that integrate a communication SDK always need media. Making LiveKit optional on mobile adds configuration complexity without a real benefit.
  3. Concurrency safety. Mobile SDKs must handle WebSocket callbacks, timer-driven heartbeats, and user-initiated calls from arbitrary threads. Swift and Kotlin solve thread safety differently. Choosing the wrong concurrency primitive leads to either data races or excessive boilerplate.
  4. Event consumption patterns. Tenant developers on iOS may prefer Combine pipelines, for await loops over AsyncStream, or traditional delegate callbacks. Android developers may prefer Kotlin SharedFlow collection in a CoroutineScope or Java-friendly listener interfaces. Supporting a single pattern forces awkward adapters on part of the audience.
  5. HTTP and WebSocket library choice. Kotlin has multiple networking stacks (OkHttp, Ktor, platform HttpURLConnection). The SDK must choose one that minimizes dependency conflicts in typical Android projects.
  6. Token refresh. Both platforms need an async-capable token provider that the SDK calls when a token expires or a reconnection requires a fresh credential. The provider signature must integrate naturally with each platform's concurrency model.

Decision

1. Layer-by-layer mirror of the JS SDK

Both mobile SDKs replicate the three conceptual layers from the JS @comm-baas/client-sdk:

  • Typed models -- Platform-native data classes (struct in Swift, data class in Kotlin) matching the @comm-baas/types package. These are hand-authored to use platform conventions (Swift CodingKeys with Codable, Kotlin @Serializable with kotlinx.serialization).
  • Resource facades -- Grouped method namespaces (client.rooms.create(), client.auth.mintToken()) with identical naming to the JS SDK. Method signatures differ only where platform idiom requires it (throwing functions in Swift, suspending functions in Kotlin).
  • Signaling connection -- WebSocket state machine with the same disconnected -> connecting -> connected -> resuming lifecycle and session_id / last_seq resume protocol defined in ADR 0014.

This mirroring means that platform documentation, tutorials, and API references translate directly across all three SDKs. A developer who has read the JS SDK guide can predict the Swift or Kotlin API shape without separate mobile documentation.

2. LiveKit as a regular dependency on mobile

Unlike the JS SDK where LiveKit is an optional peer dependency, the mobile SDKs declare LiveKit as a regular (non-optional) dependency:

  • Swift: livekit-swift is listed as a standard dependency in Package.swift.
  • Kotlin: livekit-android is declared as an api dependency in the Gradle module.

The rationale:

  • There is no mobile signaling-only use case. Native apps integrating EthioConnect always render video/audio. Removing LiveKit from the dependency graph saves no real-world bundle size because every consumer would install it anyway.
  • Making LiveKit required eliminates a class of runtime errors where the SDK attempts to join a room but the LiveKit dependency is missing.
  • The SDK still does not wrap or re-export LiveKit media APIs. It provides the LiveKit token from the join response and lets the tenant create a LiveKit Room directly, preserving the same "escape hatch" pattern from ADR 0014.

3. Swift actor for SignalingConnection

The iOS SignalingConnection is implemented as a Swift actor:

actor SignalingConnection {
private var state: ConnectionState = .disconnected
private var sessionId: String?
private var lastSeq: UInt64 = 0
private var webSocketTask: URLSessionWebSocketTask?

func connect(url: URL, token: String) async throws { ... }
func disconnect() async { ... }
func send(_ message: SignalingMessage) async throws { ... }
}

The actor keyword provides data race safety for all mutable state (state, sessionId, lastSeq, webSocketTask) without manual locking. Every property access and method call is automatically serialized by the actor's executor. This eliminates an entire category of concurrency bugs that would otherwise require NSLock, DispatchQueue, or os_unfair_lock with careful discipline.

The actor model integrates naturally with Swift structured concurrency: callers await actor methods, and the compiler enforces isolation boundaries at compile time. Internal reconnection logic uses Task to schedule retry attempts within the actor's isolation context.

4. Dual event surface on each platform

Both SDKs expose events through two complementary mechanisms to support different consumption patterns.

Swift:

  • Combine -- A set of AnyPublisher properties for developers using Combine pipelines (common in UIKit and mixed codebases):
    client.events.roomEvents // AnyPublisher<RoomEvent, Never>
    client.events.participantEvents // AnyPublisher<ParticipantEvent, Never>
  • AsyncStream -- AsyncStream properties for developers using structured concurrency and for await loops (common in SwiftUI):
    for await event in client.events.roomEventStream {
    // handle event
    }

Both surfaces emit from the same underlying source. Combine publishers are backed by PassthroughSubject; AsyncStream continuations yield from the same subject subscription.

Kotlin:

  • SharedFlow -- Kotlin SharedFlow properties for coroutine-based collection:
    client.events.roomEvents // SharedFlow<RoomEvent>
    client.events.participantEvents // SharedFlow<ParticipantEvent>
  • Listener interface -- A callback-based EventListener interface for Java interoperability and developers who prefer the observer pattern:
    client.events.addListener(object : EventListener {
    override fun onRoomEvent(event: RoomEvent) { ... }
    override fun onParticipantEvent(event: ParticipantEvent) { ... }
    })

SharedFlow is the primary mechanism. The listener adapter is a thin wrapper that collects from the flow on Dispatchers.Main and dispatches to the registered listener, ensuring callbacks arrive on the main thread.

5. OkHttp for Kotlin networking

The Kotlin SDK uses OkHttp for both HTTP requests and WebSocket connections rather than Ktor or platform APIs. The reasons:

  • Smaller footprint than Ktor. Ktor Client pulls in multiple engine modules, serialization plugins, and coroutine wrappers. OkHttp is a single dependency that most Android projects already include transitively through Retrofit, Coil, or other common libraries.
  • Mature WebSocket support. OkHttp's WebSocket and WebSocketListener API is stable, well-documented, and handles ping/pong, close frames, and backpressure correctly. Ktor's WebSocket client, while functional, has had historical issues with reconnection and frame ordering.
  • Dependency conflict avoidance. Since OkHttp is already present in the vast majority of Android dependency graphs, adding it does not introduce a new transitive dependency tree. Ktor would add Ktor Core, Ktor Client, an engine (CIO or OkHttp-based), and kotlinx.serialization integration modules.
  • HTTP/2 and connection pooling. OkHttp provides HTTP/2 multiplexing and connection pooling out of the box, which benefits the resource facade layer when making multiple sequential API calls.

The SDK declares OkHttp with an api scope so that consumers can share the OkHttpClient instance (connection pool, thread pool) with their own networking code via a builder parameter.

6. Platform-native concurrency models

Each SDK adopts its platform's preferred concurrency model throughout the entire stack, not just at the public API boundary.

Swift -- Structured concurrency:

  • Public methods are async functions. Callers use await and benefit from structured cancellation via Task and TaskGroup.
  • Internal reconnection and heartbeat logic runs in detached Task instances tied to the SignalingConnection actor's lifetime.
  • Cancellation propagates automatically: cancelling the parent Task that owns the SDK connection cancels in-flight HTTP requests and closes the WebSocket.

Kotlin -- Coroutines:

  • Public methods are suspend functions. Callers launch them in their own CoroutineScope (typically viewModelScope or lifecycleScope).
  • The SDK accepts a CoroutineScope at construction time for internal work (heartbeat timers, reconnection retries). When the scope is cancelled, all internal coroutines are cancelled and the WebSocket is closed.
  • WebSocket message dispatch uses Dispatchers.IO for network reads and Dispatchers.Main for listener callbacks. SharedFlow emission happens on Dispatchers.Default.

This design means the SDK does not create unscoped background work. All concurrent operations are tied to a lifecycle that the caller controls, preventing resource leaks when an Activity is destroyed or a SwiftUI view disappears.

7. Token provider pattern

Both SDKs accept a token provider closure that the SDK calls whenever it needs a fresh JWT -- on initial connection and on each reconnection attempt.

Swift:

let client = CommBaaSClient(
baseURL: url,
tokenProvider: { @Sendable in
try await myAuthService.fetchToken()
}
)

The provider signature is @Sendable () async throws -> String. The @Sendable annotation is required because the closure is called from the SignalingConnection actor's isolation context, which may differ from the caller's context. The async throws capability lets the provider perform network requests to refresh tokens and propagate failures to the SDK's reconnection logic.

Kotlin:

val client = CommBaaSClient(
baseURL = url,
tokenProvider = suspend { myAuthService.fetchToken() }
)

The provider signature is suspend () -> String. The suspend keyword integrates with the SDK's coroutine-based reconnection loop: the SDK calls the provider within a withTimeout block to prevent indefinite hangs. Errors thrown from the provider are caught by the reconnection retry logic and count toward the maximum retry budget.

In both cases, the SDK never caches or stores the token beyond immediate use. The provider is the single source of truth for credentials, and the tenant controls token expiry, refresh logic, and error handling.

Consequences

  • Positive: Layer-by-layer mirroring of the JS SDK means a single set of conceptual documentation covers all three platforms. Developers moving between web and mobile projects encounter the same resource namespaces, method names, and signaling lifecycle.
  • Positive: LiveKit as a regular dependency eliminates optional-dependency configuration errors on mobile and simplifies the getting-started experience for native developers.
  • Positive: The Swift actor model provides compile-time enforcement of thread safety for the signaling connection, eliminating data race bugs without runtime overhead from manual locking.
  • Positive: Dual event surfaces on each platform meet developers where they are: Combine/AsyncStream for Swift, SharedFlow/Listener for Kotlin. No single consumption pattern is forced on the entire audience.
  • Positive: OkHttp reuse avoids adding a new dependency subtree to Android projects, reducing version conflict risk and total APK size.
  • Positive: Scoped concurrency on both platforms (structured concurrency in Swift, caller-provided CoroutineScope in Kotlin) prevents resource leaks and orphaned background work.
  • Negative: Maintaining three SDKs (JS, Swift, Kotlin) in parallel increases the surface area for API drift. Mitigated by the shared type definitions and integration tests that validate all three SDKs against the same API contract test suite.
  • Negative: OkHttp locks the Kotlin SDK to the JVM/Android target. If Kotlin Multiplatform (KMP) is adopted in the future, the networking layer would need to be abstracted behind an interface. Mitigated by keeping OkHttp usage confined to a single internal HttpEngine class that can be swapped.
  • Negative: The dual event surface on each platform doubles the event delivery code paths that must be tested. Mitigated by deriving both surfaces from a single internal source (Swift PassthroughSubject, Kotlin MutableSharedFlow) and testing at that source level.