# Mobile SDKs (Android Kotlin, iOS Swift)

Native ports of the browser SDK (`sdk/js/calling-client.js`). Both speak exactly the same wire
protocol as the JS client, so a Kotlin peer, a Swift peer and a browser peer can share a room.

| | Android | iOS |
|--|--|--|
| Location | `sdk/android` (Gradle library module `:callingclient`, package `com.callingserver.sdk`) | `sdk/ios` (Swift Package `CallingClient`) |
| Minimum OS | API 24 | iOS 14 / macOS 11 |
| HTTP + WebSocket | OkHttp 4.12.0 | `URLSession` / `URLSessionWebSocketTask` |
| WebRTC | `io.getstream:stream-webrtc-android:1.3.8` (`org.webrtc`) | `stasel/WebRTC` ≥ 130.0.0 (SPM xcframework) |
| JSON | `org.json` (platform) | `JSONSerialization` |
| Async model | Kotlin coroutines (`suspend`) + `Listener` interface on the main thread | `async/await` + delegate protocol, plus a Combine `PassthroughSubject` |
| Verification | **Not compiled** (no Android toolchain on the build host) | `swiftc -parse` on every file; `-typecheck` of the whole package against a stub WebRTC module; no device run |

## Feature matrix vs the JS SDK

| Feature | JS | Android | iOS | Notes |
|---------|----|---------|-----|-------|
| `GET /v1/ice` with peer token, `ice_servers[].urls/username/credential` → RTC config | yes | yes | yes | `iceServers` may be supplied up front to skip the request |
| `connect()` → waits for `welcome`, populates `peerId/roomId/callId/peers` | yes | `suspend` | `async` | An `error` frame before `welcome` rejects `connect()` |
| `call(peerId, media)` → offer | yes | `suspend`, `List<MediaStreamTrack>` | `async`, `[RTCMediaStreamTrack]` | Tracks are added with one synthetic stream id `local-<peerId>` |
| `incoming` → `accept(media)` / `reject()` | yes | `IncomingCall` (suspend + callback flavour) | `IncomingCall` (`async`) | `reject` sends `data {kind:"reject"}` like JS |
| ICE candidate queue until remote description is set | yes | yes (per-peer `synchronized` list) | yes (per-peer list on the state queue) | |
| `hangup(peerId, notify)` → `data {kind:"hangup"}` | yes | yes | yes | Also triggered by `peer-left`, remote hangup/reject, PC `failed/closed/disconnected` |
| `sendData(payload, to?)` | yes | `JSONObject` | `[String: Any]` | |
| Events: `connected, peer-joined, peer-left, incoming, track, connection-state, hangup, rejected, data, meter, consultation-ended, stats, error, disconnected` | yes | `Listener.onXxx` | delegate methods / `CallingEvent` | Same semantics; `track` carries the track plus its streams |
| Stats every 5 s (`rtt_ms, jitter_ms, packet_loss, bitrate_kbps`) emitted and sent as `stats` | yes | yes (`ScheduledExecutorService`) | yes (`DispatchSourceTimer`) | Same report fields: candidate-pair RTT, inbound audio jitter/loss, outbound video bitrate delta |
| Consultation meter (`meter` → `onMeter`, `state == "ended"` → `onConsultationEnded`) | yes | `Meter` data class | `Meter` struct | Typed fields plus the raw payload |
| Chat: `sendChat` (ack + 10 s timeout, `client_id` retry), `setTyping` (3 s auto-off), `markRead`, `fetchMessages` (REST, room id from JWT), `lastSeq`, events `chat/typing/read` | yes | yes | yes | `fetchMessages` hits `GET /v1/rooms/{id}/messages?since_seq=&limit=` with the peer token |
| `ping` (app-level keep-alive) | implicit | `ping()` | `ping()` | OkHttp also sends protocol pings every 30 s; URLSession answers server pings |
| `PresenceClient`: `connect/accept/decline/setStatus/disconnect`; events `connected, request, accepted, ended, closed, status, error, disconnected` | yes | yes | yes | Payloads are passed through as JSON objects, like JS |
| Automatic reconnect | no | no | no | Documented pattern: reconnect with a fresh token on `disconnected` |
| Media capture helpers (camera/mic) | n/a (getUserMedia) | no – app creates tracks via `PeerConnectionFactory` | no – app creates tracks via `RTCPeerConnectionFactory` | Deliberate: capture, audio routing and rendering are app concerns |
| Data channels | no | no | no | Not in the JS SDK either |

## Protocol notes

- Frames are `{type, from?, to?, payload?, error?}`; both SDKs build them with the same keys and
  omit `to`/`payload` when absent, matching `JSON.stringify` of the JS SDK.
- ICE candidate payload is `{candidate, sdpMid, sdpMLineIndex}` (the fields of
  `RTCIceCandidate.toJSON()` that libwebrtc needs; `usernameFragment` is omitted). Inbound candidates
  are parsed from the same keys, so browser ↔ mobile interop is symmetric.
- SDP payloads are `{sdp, type}`; mobile builds `SessionDescription`/`RTCSessionDescription` from
  `sdp` and ignores `type` (the frame type already says offer/answer), exactly like the JS SDK.
- Unified Plan is set explicitly on both platforms (`sdpSemantics = UNIFIED_PLAN` / `.unifiedPlan`)
  so `onAddTrack` / `didAdd rtpReceiver` fire per track, as `ontrack` does in browsers.
- The `stats` sample uses the same keys and units as the JS SDK (`rtt_ms`, `jitter_ms` in ms,
  `packet_loss` as a 0–1 ratio, `bitrate_kbps`), so the analytics service needs no changes.
- Room id for `fetchMessages` before `connect()` is read from the peer JWT's `room_id` claim
  (base64url-decoded second segment), as in `_roomIdFromToken()`.
- Chat acks arrive as `chat-ack` with `payload.client_id`; a frame-level `error` on the ack rejects
  the pending `sendChat` and also emits the `error` event.
- Local `disconnect()` closes peer connections immediately and lets the socket close callback emit
  `disconnected`, mirroring the browser `onclose` ordering.

## Threading model

- **Android**: OkHttp delivers frames into a `Channel` drained by one coroutine, so offers,
  answers and candidates are applied in arrival order. Listener callbacks are posted to the main
  `Looper`. PeerConnection teardown is never done inside an `org.webrtc` observer callback (posted to
  the main thread) to avoid the known `dispose()`-from-callback deadlock.
- **iOS**: one private serial queue owns all state; public sync methods run inline when already on
  that queue (`DispatchSpecificKey`) and `sync` otherwise. WebRTC delegate callbacks always `async`
  onto the queue so `close()` cannot deadlock. Delegate/Combine events are delivered on
  `callbackQueue` (main by default).

## Known gaps / open decisions

1. **Kotlin is unverified by compilation.** No JDK/Gradle/Android SDK was available. Expect small
   fixes on first sync (e.g. nullability of `optString(name, null)`, `PeerConnection.Observer`
   signatures across libwebrtc versions). The code avoids experimental APIs.
2. **Swift is typechecked against a stub, not the real `WebRTC` module.** The stub mirrors the
   public signatures of stasel/WebRTC M1xx for the symbols used (`peerConnection(with:constraints:delegate:)`
   returning an optional, `add(_:completionHandler:)` for candidates, `statistics(_:)`). If the pinned
   release differs (e.g. non-optional `peerConnection(with:)` in very old builds), adjust `linkFor`.
3. **Dependency pins are best-effort**: `stream-webrtc-android:1.3.8`, `stasel/WebRTC` from 130.0.0,
   OkHttp 4.12.0, coroutines 1.9.0, AGP 8.7.3, Kotlin 2.0.21. Check Maven Central / GitHub releases
   and bump to the versions you build against.
4. No automatic reconnect or token refresh; no CallKit/ConnectionService integration; no audio
   session / `AudioManager` management; no capture or rendering helpers. These are intentionally
   left to the app (see each README's background/permissions sections).
5. The mobile SDKs do not expose the raw `connections` map; use `connection(peerId)` /
   `connection(for:)` to get the `PeerConnection` for advanced use (renegotiation, data channels).
6. The Android `Listener` and Swift delegate are wide interfaces with default no-op methods; if the
   JS SDK gains events (as it did with chat during this work), both must be extended in lock-step.
7. Sample code in the READMEs is illustrative and not compiled either.
