Kotlin Multiplatform WebRTC SDK — highly customizable, HTTP-based signaling with flexible media direction control. Supports any combination of sending/receiving video and audio through a single unified session, with zero WebRTC boilerplate.
- Architecture Overview
- Supported Platforms
- Features
- Installation
- Quick Start (New Unified API)
- Customization & Limitations
- API Reference
- Application Patterns
- Platform Guides
- Lifecycle & Connection Management
- Architecture Notes
- Migration Guide
- FAQ
- Build & Publish
The SDK is organized into three layers. Application code interacts with Layer 2 (Session) as the primary API, and optionally Layer 3 (Composables) for Compose UI integration.
┌──────────────────────────────────────────────────────────────┐
│ Layer 3: Composables (UI) │
│ VideoRenderer(session) — display remote video │
│ CameraPreview(session) — display local camera │
│ AudioPushPlayer(session) — microphone send controls │
│ AudioPlayer(session) — remote audio playback controls │
├──────────────────────────────────────────────────────────────┤
│ Layer 2: Session API (Core Public API) │
│ WebRTCSession(signaling, mediaConfig) │
│ → connect / close / mute / switchCamera / DataChannel │
│ → state: StateFlow<SessionState> │
│ → stats: StateFlow<WebRTCStats?> │
├──────────────────────────────────────────────────────────────┤
│ Layer 1: Signaling Adapter │
│ HttpSignalingAdapter(url, auth, httpClient?) │
│ Custom: implement SignalingAdapter interface │
├──────────────────────────────────────────────────────────────┤
│ Internal: WebRTCClient, PeerConnectionFactory, ICE, SDP │
│ (Not exposed — managed by Session) │
└──────────────────────────────────────────────────────────────┘
Key concept: WebRTCSession is the single entry point. Media directions (send/receive video/audio) are configured via MediaConfig — the same session class handles all scenarios from receive-only to full video calls.
| Layer | Role | When to Use |
|---|---|---|
| SignalingAdapter | Handles SDP offer/answer exchange over HTTP | Always needed — use HttpSignalingAdapter or implement your own |
| WebRTCSession | Manages full PeerConnection lifecycle, media capture, DataChannel, auto-reconnect, stats | Primary entry point for all connection logic |
| Composables | Renders video / camera preview / audio controls within Compose UI | When using Compose for UI (Android/iOS/Desktop) |
| Platform | Status | WebRTC Implementation |
|---|---|---|
| Android | ✅ | webrtc-android SDK |
| iOS (Physical Device) | ✅ | GoogleWebRTC CocoaPod 1.1.31999 |
| iOS Simulator | ❌ | Not supported — GoogleWebRTC has no simulator binaries |
| JVM/Desktop | ✅ | webrtc-java 0.14.0 |
| JavaScript (Browser) | Native RTCPeerConnection (stubs) |
|
| WebAssembly (WasmJS) | Native RTCPeerConnection (stubs) |
| Type | Codecs | Notes |
|---|---|---|
| Video | H.264, VP8, VP9 | AV1 on JS/WasmJS (browser dependent) |
| Audio | Opus (primary), G.711 | Opus 48kHz stereo default |
| Feature | Description |
|---|---|
| Unified WebRTCSession | Single session for any media direction — receive, send, or bidirectional |
| Flexible MediaConfig | Configure receiveVideo, receiveAudio, sendVideo, sendAudio independently |
| Camera Capture | Cross-platform camera support (Android Camera2, iOS AVFoundation, JVM webrtc-java) |
| Video Receive | Receive streams via any HTTP-based signaling (WHEP, custom) |
| Audio Send/Receive | Microphone capture + remote audio playback in same or separate sessions |
| DataChannel | Bidirectional text/JSON and binary (images, files) messaging |
| Custom Signaling | Pluggable SignalingAdapter — implement any signaling protocol (HTTP, WebSocket, Firebase, MQTT, gRPC) |
| ICE Mode Control | FULL_ICE (all candidates in offer) or TRICKLE_ICE (incremental via PATCH) — configurable per session |
| STUN/TURN Support | Built-in ICE server configuration for NAT traversal and relay, including custom TURN credentials |
| Built-in Auth | JWT Bearer, Cookie (app-managed), API key, custom headers |
| Auto Reconnect | Configurable exponential backoff with jitter |
| Real-time Stats | RTT, packet loss, bitrate, codec via StateFlow<WebRTCStats?> |
| Compose UI | VideoRenderer, CameraPreview, AudioPushPlayer, AudioPlayer composables |
| Low-level Access | onRemoteVideoFrame / onLocalVideoTrack callbacks for fully custom rendering |
The built-in HttpSignalingAdapter works with any server that implements the standard WHEP / WHIP protocol (they share the same HTTP flow). For non-standard signaling, implement the SignalingAdapter interface (see Custom Signaling Adapter).
| Media Server | WHEP (Receive) | WHIP (Send) | Notes |
|---|---|---|---|
| MediaMTX | ✅ | ✅ | Works out of the box with built-in adapters |
| Cloudflare Stream | ✅ | ✅ | Works out of the box with built-in adapters |
| GStreamer (whipsink/whepsrc) | ✅ | ✅ | Works out of the box with built-in adapters |
| Dolby.io / Millicast | ✅ | ✅ | Works out of the box with built-in adapters |
| OBS Studio (v30+) | — | ✅ | OBS WHIP output only |
| Janus Gateway | ❌ | ❌ | Custom SignalingAdapter required (proprietary API) |
| LiveKit | ❌ | ❌ | Custom SignalingAdapter required (proprietary signaling) |
| Ant Media Server | ❌ | ❌ | Custom SignalingAdapter required (WebSocket-based) |
| Custom Backend | — | — | Implement SignalingAdapter for any protocol (WebSocket, gRPC, Firebase, MQTT, etc.) |
The library supports both ICE gathering modes, configurable per session via WebRTCConfig.iceMode:
| Mode | Behavior | Server Requirement |
|---|---|---|
IceMode.FULL_ICE (default) |
Gathers all ICE candidates first, includes them in the SDP offer sent via POST | Any WHEP/WHIP server |
IceMode.TRICKLE_ICE |
Sends SDP offer immediately, then sends candidates individually via HTTP PATCH | Server must support trickle ICE (RFC 8838) |
WebRTCConfig(iceMode = IceMode.TRICKLE_ICE) // faster connection setup
WebRTCConfig(iceMode = IceMode.FULL_ICE) // simpler, wider compatibilityBuilt-in support for STUN and TURN servers via WebRTCConfig.iceServers. Default includes Google's public STUN server.
WebRTCConfig(
iceServers = listOf(
IceServer(urls = listOf("stun:stun.l.google.com:19302")), // NAT discovery
IceServer( // Relay fallback
urls = listOf("turn:your-turn.example.com:3478"),
username = "user",
credential = "password"
)
),
iceTransportPolicy = "all" // "all" = try direct first, fallback to TURN
// "relay" = force all traffic through TURN
)Note: STUN/TURN is a client-side configuration. The STUN/TURN server itself is separate infrastructure (e.g. coturn). The signaling server (WHEP/WHIP endpoint) is unrelated to STUN/TURN.
The library's signaling layer is protocol-agnostic. While HttpSignalingAdapter handles HTTP-based WHEP/WHIP out of the box, WebSocket signaling is supported by implementing the SignalingAdapter interface:
// Custom WebSocket signaling — full example in Quick Start section
class MyWebSocketSignaling(wsUrl: String) : SignalingAdapter {
override suspend fun sendOffer(sdpOffer: String): SignalingResult { /* WS send/receive */ }
override suspend fun sendIceCandidate(...) { /* WS send */ }
override suspend fun terminate(...) { /* WS close */ }
}
// Same WebRTCSession API, different transport
val session = WebRTCSession(
signaling = MyWebSocketSignaling("wss://my-server/signaling"),
mediaConfig = MediaConfig.VIDEO_CALL
)This enables integration with any signaling backend: WebSocket, Firebase Firestore, MQTT, gRPC, or proprietary protocols — all using the same WebRTCSession API.
In settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/Syncrobotic/SyncAI-Lib-KmpWebRTC")
credentials {
val localProps = java.util.Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) }
}
username = localProps.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")
password = localProps.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")
}
}
}
}In gradle/libs.versions.toml:
[versions]
kmp-webrtc = "2.0.0"
[libraries]
kmp-webrtc = { module = "com.syncrobotic:syncai-lib-kmpwebrtc", version.ref = "kmp-webrtc" }In your module build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kmp.webrtc)
}
}
}Note: GitHub Packages requires authentication. Generate a PAT with
read:packagesscope.
git clone https://github.com/Syncrobotic/SyncAI-Lib-KmpWebRTC.git
cd SyncAI-Lib-KmpWebRTC
./gradlew publishToMavenLocalThen add mavenLocal() to your repositories.
Display a WebRTC video stream from any WHEP-compatible server:
import com.syncrobotic.webrtc.config.*
import com.syncrobotic.webrtc.session.*
import com.syncrobotic.webrtc.signaling.*
import com.syncrobotic.webrtc.ui.*
@Composable
fun VideoScreen() {
val session = remember {
WebRTCSession(
signaling = HttpSignalingAdapter("https://your-server/stream/whep"),
mediaConfig = MediaConfig.RECEIVE_VIDEO // receiveVideo + receiveAudio
)
}
DisposableEffect(session) { onDispose { session.close() } }
// VideoRenderer auto-connects the session
VideoRenderer(
session = session,
modifier = Modifier.fillMaxSize(),
onStateChange = { state ->
when (state) {
is PlayerState.Playing -> println("Video playing")
is PlayerState.Reconnecting -> println("Reconnecting ${state.attempt}/${state.maxAttempts}")
is PlayerState.Error -> println("Error: ${state.message}")
else -> {}
}
}
)
}Capture camera and microphone, push to a WHIP endpoint:
@Composable
fun CameraStreamScreen() {
val session = remember {
WebRTCSession(
signaling = HttpSignalingAdapter("https://your-server/stream/whip"),
mediaConfig = MediaConfig.SEND_VIDEO // sendVideo + sendAudio
)
}
DisposableEffect(session) { onDispose { session.close() } }
// CameraPreview displays local camera and auto-connects
CameraPreview(
session = session,
modifier = Modifier.fillMaxSize(),
mirror = true // mirror for front camera
)
// Optional: mic controls
val audioController = AudioPushPlayer(session = session, autoStart = true)
Button(onClick = { audioController.toggleMute() }) {
Text(if (audioController.isMuted) "Unmute" else "Mute")
}
}Send and receive audio in a single session:
@Composable
fun IntercomScreen() {
val session = remember {
WebRTCSession(
signaling = HttpSignalingAdapter("https://your-server/intercom/whep"),
mediaConfig = MediaConfig.BIDIRECTIONAL_AUDIO // sendAudio + receiveAudio
)
}
DisposableEffect(session) { onDispose { session.close() } }
// Receive remote audio
val playerController = AudioPlayer(session = session)
// Send local microphone
val pushController = AudioPushPlayer(session = session, autoStart = true)
Column {
Text("Intercom Active")
Button(onClick = { pushController.toggleMute() }) {
Text(if (pushController.isMuted) "Unmute" else "Mute")
}
Button(onClick = { playerController.setSpeakerphoneEnabled(true) }) {
Text("Speaker")
}
}
}Send and receive both video and audio:
@Composable
fun VideoCallScreen() {
val session = remember {
WebRTCSession(
signaling = HttpSignalingAdapter("https://your-server/room/webrtc"),
mediaConfig = MediaConfig.VIDEO_CALL // send + receive video + audio
)
}
DisposableEffect(session) { onDispose { session.close() } }
Box(Modifier.fillMaxSize()) {
// Remote video (full screen)
VideoRenderer(session = session, modifier = Modifier.fillMaxSize())
// Local camera preview (small overlay)
CameraPreview(
session = session,
modifier = Modifier.size(120.dp).align(Alignment.TopEnd).padding(8.dp),
mirror = true
)
}
// Camera/mic controls
Row {
Button(onClick = { session.switchCamera() }) { Text("Flip") }
Button(onClick = { session.setVideoEnabled(false) }) { Text("Camera Off") }
Button(onClick = { session.setMuted(true) }) { Text("Mute") }
}
}Send and receive text/binary data through a WebRTC DataChannel:
import com.syncrobotic.webrtc.config.*
import com.syncrobotic.webrtc.session.*
import com.syncrobotic.webrtc.signaling.*
import com.syncrobotic.webrtc.datachannel.*
// DataChannel works with any MediaConfig
val session = WebRTCSession(
signaling = HttpSignalingAdapter("https://your-server/stream/whep"),
mediaConfig = MediaConfig.RECEIVE_VIDEO
)
session.connect()
// Create a reliable DataChannel
val channel = session.createDataChannel(DataChannelConfig.reliable("commands"))
// Listen for incoming messages
channel?.setListener(object : DataChannelListener {
override fun onStateChanged(state: DataChannelState) {
println("DataChannel state: $state")
}
override fun onMessage(message: String) {
println("Received: $message")
}
override fun onBinaryMessage(data: ByteArray) {
println("Received binary: ${data.size} bytes")
}
override fun onError(error: Throwable) {
println("Error: ${error.message}")
}
})
// Send text messages
if (channel?.state == DataChannelState.OPEN) {
channel.send("""{"action":"move","direction":"forward","speed":0.5}""")
}
// Send binary data (images, files)
if (channel?.state == DataChannelState.OPEN) {
val imageBytes: ByteArray = loadImage()
channel.sendBinary(imageBytes)
}
// Cleanup
channel?.close()
session.close()The library provides type-safe SignalingAuth for common authentication patterns — including native cookie support.
Design principle: The library does NOT perform login. Your app is responsible for authentication; the library only attaches auth credentials to signaling HTTP requests.
val signaling = HttpSignalingAdapter(
url = "https://api.example.com/streams/camera-1/whep",
auth = SignalingAuth.Bearer(token = "eyJhbGciOiJIUzI1NiIs...")
)Your app handles login and passes the obtained cookies to the library:
// Step 1: App performs login (your own code)
val loginResponse = httpClient.post("https://api.example.com/auth/login") {
contentType(ContentType.Application.Json)
setBody(mapOf("username" to "admin", "password" to "secret"))
}
val cookies = loginResponse.setCookie().associate { it.name to it.value }
// e.g. {"session" to "abc123", "csrf_token" to "xyz789"}
// Step 2: Pass cookies to signaling adapter
val signaling = HttpSignalingAdapter(
url = "https://api.example.com/streams/camera-1/whep",
auth = SignalingAuth.Cookies(cookies)
)
// Step 3: Library attaches Cookie header to all signaling requests
val session = WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO)
session.connect()// Works with any login method — OAuth, SSO, form login, etc.
val oauthCookies = myOAuthFlow.getSessionCookies()
val signaling = HttpSignalingAdapter(
url = "https://api.example.com/streams/audio/whip",
auth = SignalingAuth.Cookies(oauthCookies)
)Cookie Auth Flow:
App Library
───── ───────
1. POST /auth/login + credentials
→ 200 OK + Set-Cookie: session=abc
2. Extract cookies from response
3. Create SignalingAuth.Cookies(cookies)
Pass to HttpSignalingAdapter
4. POST /whep + Cookie: session=abc + SDP
→ 201 + SDP answer
5. PATCH /resource + Cookie: session=abc
(ICE candidates)
6. DELETE /resource + Cookie: session=abc
(terminate)
Cookie expired (401):
7. SessionState.Error(isRetryable = true)
8. App re-logins, gets new cookies
9. App creates new adapter + session
When a signaling request receives HTTP 401, the session reports SessionState.Error(isRetryable = true). Your app re-logins and creates a new session:
class StreamViewModel : ViewModel() {
private val authRepo: AuthRepository
var session: WebRTCSession? = null
fun connect() {
viewModelScope.launch {
val cookies = authRepo.getSessionCookies()
val signaling = HttpSignalingAdapter(
url = "https://api.example.com/streams/cam/whep",
auth = SignalingAuth.Cookies(cookies)
)
session = WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO).also { s ->
s.connect()
s.state.collect { state ->
if (state is SessionState.Error && state.isRetryable) {
// Possibly 401 — re-login and reconnect
session?.close()
authRepo.refreshSession()
connect()
}
}
}
}
}
}val signaling = HttpSignalingAdapter(
url = "https://api.example.com/streams/main/whep",
auth = SignalingAuth.Custom(
headers = mapOf(
"X-API-Key" to "your-api-key",
"X-Device-Id" to "robot-001",
"X-Tenant" to "factory-a"
)
)
)When your app and the library need to share the same cookie jar (e.g. SSO session with automatic Set-Cookie handling):
import io.ktor.client.plugins.cookies.*
// App-managed CookiesStorage — shared between your app's HttpClient and the library
val sharedStorage = AcceptAllCookiesStorage() // or your custom implementation
val signaling = HttpSignalingAdapter(
url = "https://sso-server.example.com/stream/whep",
auth = SignalingAuth.CookieStorage(storage = sharedStorage)
)
// Library installs HttpCookies plugin using sharedStorage.
// Automatic Set-Cookie handling, CSRF token tracking, etc.When to use which?
Cookies(map)— Simple, stateless, no plugin. Best for CLI tools, tests, or when cookies don't change mid-session.CookieStorage(storage)— Stateful, plugin-based. Best when app and library share cookies, or server issuesSet-Cookieduring signaling.
// Default — no auth headers, no plugins installed
val signaling = HttpSignalingAdapter(
url = "https://open-server/stream/whep"
// auth defaults to SignalingAuth.None
)For advanced control over HTTP behavior (custom timeout, SSL pinning, interceptors), inject your own Ktor HttpClient. Auth is still handled by the library.
When
httpClientis provided, the library uses it as-is — no extra plugins are installed (unless you explicitly useSignalingAuth.CookieStorage). If your httpClient already has its ownHttpCookiesplugin configured, useSignalingAuth.NoneorBearerto avoid conflicts.
val customHttpClient = HttpClient(OkHttp) {
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
engine {
config {
// custom OkHttp settings, SSL pinning, etc.
}
}
}
val signaling = HttpSignalingAdapter(
url = "https://api.example.com/streams/main/whep",
auth = SignalingAuth.Bearer(token = jwt),
httpClient = customHttpClient // inject your own HttpClient
)Implement SignalingAdapter for any signaling protocol (WebSocket, Firebase, MQTT, gRPC, etc.).
WebSocket signaling: v2.0 focuses on standard WHEP/WHIP over HTTP. The existing
WebSocketSignalingclass is deprecated but not removed. A built-inWebSocketSignalingAdapteris planned for v2.1. In the meantime, you can wrap the existing class or implement your own:
import com.syncrobotic.webrtc.signaling.SignalingAdapter
import com.syncrobotic.webrtc.signaling.SignalingResult
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
class WebSocketSignalingAdapter(
private val wsUrl: String,
private val streamName: String = "raw",
private val httpClient: HttpClient = HttpClient { install(WebSockets) }
) : SignalingAdapter {
private var session: WebSocketSession? = null
override suspend fun sendOffer(sdpOffer: String): SignalingResult {
val ws = httpClient.webSocketSession(wsUrl)
session = ws
// Send offer
ws.send(Frame.Text(Json.encodeToString(
mapOf("type" to "offer", "sdp" to sdpOffer, "stream" to streamName)
)))
// Wait for answer
val frame = ws.incoming.receive() as Frame.Text
val msg = Json.decodeFromString<Map<String, String>>(frame.readText())
return SignalingResult(
sdpAnswer = msg["sdp"] ?: error("No SDP in answer"),
resourceUrl = msg["resourceUrl"]
)
}
override suspend fun sendIceCandidate(
resourceUrl: String, candidate: String,
sdpMid: String?, sdpMLineIndex: Int,
iceUfrag: String?, icePwd: String?
) {
session?.send(Frame.Text(Json.encodeToString(
mapOf("type" to "ice", "candidate" to candidate,
"sdpMid" to (sdpMid ?: ""), "sdpMLineIndex" to "$sdpMLineIndex")
)))
}
override suspend fun terminate(resourceUrl: String) {
session?.close()
session = null
}
}
// Usage — same Session API, different transport
val signaling = WebSocketSignalingAdapter(wsUrl = "wss://server/signaling")
val session = WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO)
session.connect()When to use which?
HttpSignalingAdapter— Standard WHEP/WHIP servers (Cloudflare, MediaMTX, GStreamer, OBS WHIP)WebSocketSignalingAdapter(custom) — Custom backends with WebSocket-based signalingFirebaseSignalingAdapter(custom) — Serverless / P2P scenarios with Firestore
import com.syncrobotic.webrtc.signaling.SignalingAdapter
import com.syncrobotic.webrtc.signaling.SignalingResult
class FirebaseSignalingAdapter(
private val roomId: String,
private val firestore: FirebaseFirestore
) : SignalingAdapter {
override suspend fun sendOffer(sdpOffer: String): SignalingResult {
// 1. Write offer to Firestore
val docRef = firestore.collection("rooms").document(roomId)
docRef.set(mapOf("offer" to sdpOffer)).await()
// 2. Wait for answer
val answer = docRef.addSnapshotListener { snapshot, _ ->
snapshot?.getString("answer")
}.awaitFirst()
return SignalingResult(
sdpAnswer = answer,
resourceUrl = "rooms/$roomId", // used for terminate
etag = null,
iceServers = emptyList()
)
}
override suspend fun sendIceCandidate(
resourceUrl: String,
candidate: String,
sdpMid: String?,
sdpMLineIndex: Int,
iceUfrag: String?,
icePwd: String?
) {
firestore.collection("rooms").document(roomId)
.collection("candidates")
.add(mapOf(
"candidate" to candidate,
"sdpMid" to sdpMid,
"sdpMLineIndex" to sdpMLineIndex
))
}
override suspend fun terminate(resourceUrl: String) {
firestore.collection("rooms").document(roomId).delete().await()
}
}
// Usage — same Session API, different signaling
val signaling = FirebaseSignalingAdapter(roomId = "room-123", firestore)
val session = WebRTCSession(signaling, MediaConfig.VIDEO_CALL)
session.connect()| Setting | How | Example |
|---|---|---|
| Signaling endpoint (IP, port, path) | Full URL in adapter constructor | HttpSignalingAdapter(url = "https://192.168.1.100:9090/custom/path/whip") |
| STUN/TURN servers | WebRTCConfig.iceServers |
IceServer(urls = listOf("turn:10.0.0.5:3478"), username, credential) |
| ICE mode | WebRTCConfig.iceMode |
IceMode.FULL_ICE or IceMode.TRICKLE_ICE |
| ICE gathering timeout | WebRTCConfig.iceGatheringTimeoutMs |
10_000L (default 10s) |
| ICE transport policy | WebRTCConfig.iceTransportPolicy |
"all" or "relay" (force TURN) |
| Bundle / RTCP mux policy | WebRTCConfig.bundlePolicy, rtcpMuxPolicy |
"max-bundle", "require" |
| Authentication | SignalingAuth sealed interface |
Bearer, Cookies, CookieStorage, Custom headers |
| HTTP behavior | Inject custom HttpClient |
Timeout, SSL pinning, interceptors |
| Signaling protocol | Implement SignalingAdapter |
WebSocket, Firebase, MQTT, gRPC, etc. |
| Audio processing | AudioPushConfig |
Echo cancellation, noise suppression, auto gain |
| Retry strategy | RetryConfig |
Max retries, delays, backoff factor, jitter |
| DataChannel reliability | DataChannelConfig presets |
reliable(), unreliable(), maxLifetime() |
| Limitation | Details | Workaround |
|---|---|---|
| No dynamic URL change | url is immutable after adapter creation |
session.close() → create new adapter + session |
| No video codec preference | Cannot specify H.264 vs VP8 priority | Uses platform default negotiation |
| No audio sample rate control | Uses platform default (typically Opus 48kHz) | N/A — Opus auto-negotiates |
| No SDP manipulation | SDP munging not exposed | Implement custom SignalingAdapter to modify SDP in sendOffer() |
| No dynamic auth refresh | Auth tokens are set at adapter creation | Close session → create new adapter with fresh token |
| iOS Simulator | GoogleWebRTC has no simulator binaries | Use physical device |
Design rationale: Adapters and sessions are intentionally immutable after creation. This prevents mid-connection state corruption and ensures thread safety across coroutine scopes. To change any connection parameter, close the current session and create a new one.
The unified session class for all WebRTC scenarios. Media directions are configured via MediaConfig.
expect class WebRTCSession(
signaling: SignalingAdapter,
mediaConfig: MediaConfig,
webrtcConfig: WebRTCConfig = WebRTCConfig.DEFAULT,
retryConfig: RetryConfig = RetryConfig.DEFAULT
) {
val state: StateFlow<SessionState>
val stats: StateFlow<WebRTCStats?>
suspend fun connect()
fun createDataChannel(config: DataChannelConfig): DataChannel?
fun setAudioEnabled(enabled: Boolean) // incoming audio playback
fun setSpeakerphoneEnabled(enabled: Boolean) // speaker vs earpiece
fun setMuted(muted: Boolean) // microphone mute
fun toggleMute()
fun setVideoEnabled(enabled: Boolean) // camera on/off
fun switchCamera() // front/rear toggle
fun close()
}Controls which media types are sent and/or received:
data class MediaConfig(
val receiveVideo: Boolean = false,
val receiveAudio: Boolean = false,
val sendVideo: Boolean = false,
val sendAudio: Boolean = false,
val audioConfig: AudioPushConfig = AudioPushConfig(),
val videoConfig: VideoCaptureConfig = VideoCaptureConfig.HD
)| Preset | Description | Use Case |
|---|---|---|
MediaConfig.RECEIVE_VIDEO |
receiveVideo + receiveAudio | Watching a stream |
MediaConfig.SEND_AUDIO |
sendAudio only | Push microphone to server |
MediaConfig.SEND_VIDEO |
sendVideo + sendAudio | Camera live streaming |
MediaConfig.BIDIRECTIONAL_AUDIO |
sendAudio + receiveAudio | Voice intercom |
MediaConfig.VIDEO_CALL |
send + receive all | Full video call |
Or create custom combinations:
// Receive video + send audio (intercom with video monitoring)
MediaConfig(receiveVideo = true, receiveAudio = true, sendAudio = true)Unified HTTP signaling adapter (replaces the removed WhepSignalingAdapter and WhipSignalingAdapter from v1.x):
class HttpSignalingAdapter(
url: String,
auth: SignalingAuth = SignalingAuth.None,
httpClient: HttpClient? = null
) : SignalingAdapterWHEP and WHIP use the exact same HTTP flow (POST offer → 201 answer → PATCH ICE → DELETE teardown). The only difference is the URL endpoint.
The pluggable interface for SDP offer/answer exchange. Built-in implementation: HttpSignalingAdapter.
interface SignalingAdapter {
/** Exchange SDP offer → answer with the signaling server. */
suspend fun sendOffer(sdpOffer: String): SignalingResult
/** Send a trickle ICE candidate (for Trickle ICE mode). */
suspend fun sendIceCandidate(
resourceUrl: String,
candidate: String,
sdpMid: String?,
sdpMLineIndex: Int,
iceUfrag: String?,
icePwd: String?
)
/** Terminate the signaling session (e.g. HTTP DELETE). */
suspend fun terminate(resourceUrl: String)
}
data class SignalingResult(
val sdpAnswer: String,
val resourceUrl: String?,
val etag: String?,
val iceServers: List<IceServer> = emptyList()
)Type-safe authentication configuration for built-in signaling adapters.
The library does not perform login — your app handles authentication and provides the credentials (tokens, cookies) to the library.
sealed interface SignalingAuth {
/** JWT or OAuth Bearer token → Authorization: Bearer <token> */
data class Bearer(val token: String) : SignalingAuth
/**
* Pre-obtained cookies from your app's login flow.
* Library attaches these as a Cookie header string — no Ktor plugin installed.
* Suitable for standalone / CLI / test scenarios that don't share cookie storage.
*
* On HTTP 401: Session reports SessionState.Error(isRetryable = true).
* App should re-login and create a new adapter + session.
*
* @param cookies Cookie name → value pairs obtained from your app's login
*/
data class Cookies(val cookies: Map<String, String>) : SignalingAuth
/**
* Shared cookie storage — installs Ktor HttpCookies plugin with the given storage.
* Use this when app and library need to share the same cookie jar (e.g. SSO session,
* automatic Set-Cookie handling, CSRF tokens).
*
* Requires dependency: io.ktor:ktor-client-plugins (HttpCookies)
*
* @param storage Ktor CookiesStorage instance managed by your app
*/
data class CookieStorage(val storage: CookiesStorage) : SignalingAuth
/** Arbitrary static HTTP headers (API keys, device IDs, etc.) */
data class Custom(val headers: Map<String, String>) : SignalingAuth
/** No authentication — no extra headers, no plugins installed. */
data object None : SignalingAuth
}All composables accept WebRTCSession and auto-connect if the session is idle.
Important: Composables auto-connect the session internally. Do not call
session.connect()manually when using composables — useDisposableEffectfor cleanup only.
@Composable
fun VideoRenderer(
session: WebRTCSession,
modifier: Modifier = Modifier,
onStateChange: ((PlayerState) -> Unit)? = null,
onEvent: ((PlayerEvent) -> Unit)? = null,
): VideoPlayerControllerBuilt-in connection status overlay — automatically displays connecting/reconnecting/error states.
@Composable
fun CameraPreview(
session: WebRTCSession,
modifier: Modifier = Modifier,
mirror: Boolean = true, // mirror for front camera
onStateChange: ((PlayerState) -> Unit)? = null,
)Requires mediaConfig.sendVideo = true.
@Composable
fun AudioPushPlayer(
session: WebRTCSession,
autoStart: Boolean = true,
onStateChange: ((AudioPushState) -> Unit)? = null,
): AudioPushControllerReturns AudioPushController with start(), stop(), setMuted(), toggleMute(), stats.
@Composable
fun AudioPlayer(
session: WebRTCSession,
autoStart: Boolean = true,
onStateChange: ((AudioPlaybackState) -> Unit)? = null,
): AudioPlayerControllerReturns AudioPlayerController with setAudioEnabled(), setSpeakerphoneEnabled(), stop().
Compose Multiplatform composable for sending microphone audio via a WebRTCSession.
@Composable
expect fun AudioPushPlayer(
session: WebRTCSession,
autoStart: Boolean = false,
onStateChange: OnAudioPushStateChange = {}
): AudioPushControllerReturns an AudioPushController:
interface AudioPushController {
val state: AudioPushState
val isStreaming: Boolean
val isMuted: Boolean
val isConnected: Boolean
val stats: WebRTCStats?
fun start()
fun stop()
fun setMuted(muted: Boolean)
fun toggleMute()
suspend fun refreshStats()
}Bidirectional messaging channel for text and binary data. Created from a connected session.
expect class DataChannel {
val label: String
val id: Int
val state: DataChannelState
val bufferedAmount: Long
fun setListener(listener: DataChannelListener?)
fun send(message: String): Boolean
fun sendBinary(data: ByteArray): Boolean
fun close()
}data class DataChannelConfig(
val label: String,
val ordered: Boolean = true,
val maxRetransmits: Int? = null,
val maxPacketLifeTimeMs: Int? = null,
val protocol: String = "",
val negotiated: Boolean = false,
val id: Int? = null
) {
companion object {
/** Reliable, ordered delivery (like TCP). */
fun reliable(label: String): DataChannelConfig
/** Unreliable delivery for lowest latency (like UDP). */
fun unreliable(label: String, maxRetransmits: Int = 0): DataChannelConfig
/** Time-limited delivery — packets dropped after timeout. */
fun maxLifetime(label: String, maxPacketLifeTimeMs: Int): DataChannelConfig
}
}interface DataChannelListener {
fun onStateChanged(state: DataChannelState)
fun onMessage(message: String)
fun onBinaryMessage(data: ByteArray) {}
fun onBufferedAmountChange(bufferedAmount: Long) {}
fun onError(error: Throwable) {}
}enum class DataChannelState {
CONNECTING, OPEN, CLOSING, CLOSED
}PeerConnection-level configuration. Normally use the presets.
data class WebRTCConfig(
val iceServers: List<IceServer> = IceServer.DEFAULT_ICE_SERVERS,
val iceMode: IceMode = IceMode.FULL_ICE,
val iceGatheringTimeoutMs: Long = 10_000L,
val iceTransportPolicy: String = "all",
val bundlePolicy: String = "max-bundle",
val rtcpMuxPolicy: String = "require"
) {
companion object {
val DEFAULT: WebRTCConfig // Standard receiver config
val SENDER: WebRTCConfig // Optimized for sending
}
}data class IceServer(
val urls: List<String>,
val username: String? = null,
val credential: String? = null
) {
companion object {
val GOOGLE_STUN: IceServer // stun:stun.l.google.com:19302
val DEFAULT_ICE_SERVERS: List<IceServer> // [GOOGLE_STUN]
}
}
// TURN server example
val turnServer = IceServer(
urls = listOf("turn:turn.example.com:3478"),
username = "user",
credential = "password"
)Audio-specific settings for WebRTCSession (send audio).
data class AudioPushConfig(
val enableEchoCancellation: Boolean = true,
val enableNoiseSuppression: Boolean = true,
val enableAutoGainControl: Boolean = true,
val webrtcConfig: WebRTCConfig = WebRTCConfig.SENDER
) {
/** Disable all audio processing (raw audio). */
fun withoutAudioProcessing(): AudioPushConfig
}Exponential backoff retry configuration, shared by all session types.
data class RetryConfig(
val maxRetries: Int = 5,
val initialDelayMs: Long = 1000L,
val maxDelayMs: Long = 45000L,
val backoffFactor: Double = 2.0,
val retryOnDisconnect: Boolean = true,
val retryOnError: Boolean = true,
val jitterFactor: Double = 0.1
) {
companion object {
val DEFAULT: RetryConfig // 5 retries, 1s → 45s
val AGGRESSIVE: RetryConfig // 10 retries, 500ms → 60s
val PERSISTENT: RetryConfig // Unlimited retries, 1s → 45s (recommended for IoT/unattended)
val DISABLED: RetryConfig // No retries
}
}Reconnect behavior: Both
DISCONNECTED(temporary loss) andFAILED(ICE failure) trigger auto-reconnect. After all retries are exhausted, the session entersSessionState.Error. UsePERSISTENTfor unattended scenarios (IoT, robotics) where the app should keep retrying indefinitely untilsession.close()is called.
Connection state of WebRTCSession, exposed as StateFlow<SessionState>.
sealed class SessionState {
/** Session created, not yet connected. */
data object Idle : SessionState()
/** Establishing WebRTC connection (SDP/ICE negotiation). */
data object Connecting : SessionState()
/** WebRTC connected, media flowing. */
data object Connected : SessionState()
/** Connection lost, attempting reconnection. */
data class Reconnecting(
val attempt: Int,
val maxAttempts: Int
) : SessionState()
/** Connection error. Check isRetryable for recovery possibility. */
data class Error(
val message: String,
val cause: Throwable? = null,
val isRetryable: Boolean = true
) : SessionState()
/** Session closed. Terminal state — create a new session to reconnect. */
data object Closed : SessionState()
}Video rendering state, reported by VideoRenderer via onStateChange.
| State | Description |
|---|---|
Idle |
Initial state |
Connecting |
Establishing connection |
Loading |
Connection established, waiting for first frame |
Playing |
Video frames rendering |
Paused |
Playback paused |
Buffering(percent) |
Temporary interruption |
Reconnecting(attempt, maxAttempts, reason, nextRetryMs) |
Auto-reconnecting |
Error(message, cause) |
Error occurred |
Stopped |
Playback stopped |
Audio sending state, reported by AudioPushPlayer via onStateChange.
| State | Description |
|---|---|
Idle |
Initial state |
Connecting |
Establishing connection |
Streaming |
Audio is being sent |
Muted |
Connected but microphone muted |
Reconnecting(attempt, maxAttempts) |
Auto-reconnecting |
Error(message, cause, isRetryable) |
Error occurred |
Disconnected |
Disconnected |
Video events reported by VideoRenderer via onEvent.
| Event | Description |
|---|---|
FirstFrameRendered(timestampMs) |
First video frame displayed |
StreamInfoReceived(info) |
Stream metadata (resolution, FPS, codec, bitrate) |
BitrateChanged(bitrate) |
Bitrate changed |
FrameReceived(timestampMs) |
Frame received |
Connection statistics, available as StateFlow<WebRTCStats?> on sessions.
data class WebRTCStats(
val audioBitrate: Long = 0,
val roundTripTimeMs: Double = 0.0,
val jitterMs: Double = 0.0,
val packetsSent: Long = 0,
val packetsLost: Long = 0,
val codec: String = "unknown",
val timestampMs: Long = 0
) {
val packetLossPercent: Double // Calculated: packetsLost / packetsSent * 100
val bitrateDisplay: String // e.g. "1.2 Mbps"
val latencyDisplay: String // e.g. "12 ms"
}Session lifecycle tied to the composable. Simplest approach.
@Composable
fun CameraView(streamUrl: String) {
val signaling = remember { HttpSignalingAdapter(url = streamUrl) }
val session = remember { WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO) }
// VideoRenderer auto-connects; do NOT call session.connect() manually
DisposableEffect(session) { onDispose { session.close() } }
// Observe state for UI feedback
val sessionState by session.state.collectAsState()
Column {
// Video — auto-connects on first composition
VideoRenderer(
session = session,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f)
)
// Status
Text(text = when (sessionState) {
is SessionState.Connected -> "Live"
is SessionState.Connecting -> "Connecting..."
is SessionState.Reconnecting -> "Reconnecting..."
is SessionState.Error -> "Error: ${(sessionState as SessionState.Error).message}"
else -> "Idle"
})
}
}Recommended for production. Session outlives Composable recompositions; cleanup on ViewModel destruction.
class RobotControlViewModel : ViewModel() {
// Signaling with JWT auth
private val signaling = HttpSignalingAdapter(
url = "https://api.syncrobotic.com/robots/arm-01/camera/whep",
auth = SignalingAuth.Bearer(token = jwtToken)
)
// Session — lives as long as ViewModel
val session = WebRTCSession(
signaling = signaling,
mediaConfig = MediaConfig.RECEIVE_VIDEO,
webrtcConfig = WebRTCConfig(
iceServers = listOf(
IceServer.GOOGLE_STUN,
IceServer(
urls = listOf("turn:turn.syncrobotic.com:3478"),
username = "robot",
credential = "secret"
)
)
),
retryConfig = RetryConfig.AGGRESSIVE
)
// DataChannel for robot commands
var commandChannel: DataChannel? = null
private set
// Expose states
val connectionState = session.state
val networkStats = session.stats
init {
viewModelScope.launch {
session.connect()
// Create DataChannel after connection
commandChannel = session.createDataChannel(
DataChannelConfig.reliable("robot-commands")
)
commandChannel?.setListener(object : DataChannelListener {
override fun onMessage(message: String) {
// Handle robot responses
println("Robot response: $message")
}
override fun onStateChanged(state: DataChannelState) {}
})
}
}
fun sendCommand(action: String, params: Map<String, Any> = emptyMap()) {
val json = buildJsonObject {
put("action", action)
params.forEach { (k, v) ->
when (v) {
is Number -> put(k, v.toDouble())
is String -> put(k, v)
is Boolean -> put(k, v)
}
}
}.toString()
commandChannel?.send(json)
}
fun moveForward() = sendCommand("move", mapOf("direction" to "forward", "speed" to 0.5))
fun stop() = sendCommand("stop")
fun rotateCamera(pan: Double, tilt: Double) =
sendCommand("camera", mapOf("pan" to pan, "tilt" to tilt))
override fun onCleared() {
commandChannel?.close()
session.close()
}
}
// Composable — only handles UI
@Composable
fun RobotControlScreen(viewModel: RobotControlViewModel = viewModel()) {
val state by viewModel.connectionState.collectAsState()
val stats by viewModel.networkStats.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// Video feed
VideoRenderer(
session = viewModel.session,
modifier = Modifier.fillMaxWidth().weight(1f),
onEvent = { event ->
if (event is PlayerEvent.StreamInfoReceived) {
println("${event.info.width}x${event.info.height} @ ${event.info.fps}fps")
}
}
)
// Stats bar
stats?.let { s ->
Text("${s.latencyDisplay} | ${s.bitrateDisplay} | Loss: ${"%.1f".format(s.packetLossPercent)}%")
}
// Controls
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
Button(onClick = { viewModel.moveForward() }) { Text("Forward") }
Button(onClick = { viewModel.stop() }) { Text("Stop") }
Button(onClick = { viewModel.rotateCamera(10.0, 0.0) }) { Text("Pan Right") }
}
}
}For scenarios that need audio streaming without UI (e.g., Android Service, iOS Background Task):
// Android Service example
class AudioStreamingService : Service() {
private lateinit var session: WebRTCSession
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onCreate() {
super.onCreate()
val signaling = HttpSignalingAdapter(
url = "https://api.example.com/audio/whip",
auth = SignalingAuth.Bearer(token = token)
)
session = WebRTCSession(
signaling = signaling,
mediaConfig = MediaConfig.SEND_AUDIO,
retryConfig = RetryConfig.AGGRESSIVE
)
serviceScope.launch {
session.connect()
// Monitor state
session.state.collect { state ->
when (state) {
is SessionState.Connected -> updateNotification("Streaming audio")
is SessionState.Reconnecting -> updateNotification("Reconnecting...")
is SessionState.Error -> updateNotification("Error: ${state.message}")
else -> {}
}
}
}
}
override fun onDestroy() {
session.close()
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?) = null
private fun updateNotification(text: String) { /* ... */ }
}Receive video and send audio simultaneously — use a single WebRTCSession with bidirectional config, or separate sessions:
@Composable
fun BidirectionalScreen(
serverUrl: String,
auth: SignalingAuth
) {
// Single session with bidirectional media
val session = remember {
WebRTCSession(
signaling = HttpSignalingAdapter(url = serverUrl, auth = auth),
mediaConfig = MediaConfig(receiveVideo = true, receiveAudio = true, sendAudio = true)
)
}
// Cleanup
DisposableEffect(Unit) {
onDispose { session.close() }
}
// Observe state
val sessionState by session.state.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// Video
VideoRenderer(
session = session,
modifier = Modifier.fillMaxWidth().weight(1f)
)
// Audio controls
val audioController = AudioPushPlayer(
session = session,
autoStart = false
)
Text("State: ${sessionState::class.simpleName}")
// Push-to-talk
Button(
onClick = { },
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
audioController.start()
tryAwaitRelease()
audioController.stop()
}
)
}
) {
Text("Push to Talk")
}
}
}Display multiple camera feeds simultaneously. Each WebRTCSession manages its own PeerConnection independently.
data class CameraFeed(val id: String, val name: String, val whepUrl: String)
@Composable
fun MultiCameraScreen(cameras: List<CameraFeed>, auth: SignalingAuth) {
val sessions = remember(cameras) {
cameras.map { cam ->
cam.id to WebRTCSession(
signaling = HttpSignalingAdapter(url = cam.whepUrl, auth = auth),
mediaConfig = MediaConfig.RECEIVE_VIDEO
)
}.toMap()
}
// Connect all
LaunchedEffect(sessions) {
sessions.values.forEach { session ->
launch { session.connect() }
}
}
// Cleanup all
DisposableEffect(sessions) {
onDispose { sessions.values.forEach { it.close() } }
}
// Grid layout
val columns = if (cameras.size <= 2) 1 else 2
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxSize()
) {
items(cameras) { camera ->
val session = sessions[camera.id] ?: return@items
val state by session.state.collectAsState()
Column(modifier = Modifier.padding(4.dp)) {
Text(camera.name, style = MaterialTheme.typography.labelSmall)
VideoRenderer(
session = session,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f)
)
Text(
text = when (state) {
is SessionState.Connected -> "Live"
is SessionState.Connecting -> "Connecting..."
else -> state::class.simpleName ?: ""
},
color = if (state is SessionState.Connected) Color.Green else Color.Gray
)
}
}
}
}
// Usage
MultiCameraScreen(
cameras = listOf(
CameraFeed("cam1", "Front Camera", "https://server/cam1/whep"),
CameraFeed("cam2", "Arm Camera", "https://server/cam2/whep"),
CameraFeed("cam3", "Rear Camera", "https://server/cam3/whep"),
CameraFeed("cam4", "Overview", "https://server/cam4/whep")
),
auth = SignalingAuth.Bearer(token = jwt)
)@Composable
fun StatsOverlay(session: WebRTCSession) {
val stats by session.stats.collectAsState()
val state by session.state.collectAsState()
Column(
modifier = Modifier
.background(Color.Black.copy(alpha = 0.6f))
.padding(8.dp)
) {
Text("State: ${state::class.simpleName}", color = Color.White)
stats?.let { s ->
Text("RTT: ${s.latencyDisplay}", color = Color.White)
Text("Bitrate: ${s.bitrateDisplay}", color = Color.White)
Text("Packet Loss: ${"%.2f".format(s.packetLossPercent)}%", color = Color.White)
Text("Jitter: ${"%.1f".format(s.jitterMs)} ms", color = Color.White)
Text("Codec: ${s.codec}", color = Color.White)
Text("Packets Sent: ${s.packetsSent}", color = Color.White)
} ?: Text("No stats", color = Color.Gray)
}
}
// Compose over video
Box {
VideoRenderer(session = session, modifier = Modifier.fillMaxSize())
StatsOverlay(session = session)
}Add to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Required for AudioPushPlayer / WebRTCSession (send audio) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application android:usesCleartextTraffic="true"> <!-- Only for HTTP endpoints -->For production, use HTTPS and remove
usesCleartextTraffic. RequestRECORD_AUDIOat runtime.
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<!-- Required for WebRTCSession (send audio) / AudioPushPlayer -->
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access required for audio streaming.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Local network access for streaming server.</string>In your iOS project's Podfile:
platform :ios, '15.0'
target 'YourApp' do
use_frameworks!
pod 'GoogleWebRTC', '1.1.31999'
pod 'shared', :path => '../shared'
endGoogleWebRTC does not support iOS Simulator. Use a physical device.
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "WebRTC Demo") {
val signaling = remember { HttpSignalingAdapter(url = "https://server/stream/whep") }
val session = remember { WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO) }
// VideoRenderer auto-connects; do NOT call session.connect() manually
DisposableEffect(session) { onDispose { session.close() } }
VideoRenderer(session = session, modifier = Modifier.fillMaxSize())
}
}fun main() {
if (!WebRTCClient.isSupported()) {
console.log("Browser does not support WebRTC")
return
}
val signaling = HttpSignalingAdapter(url = "https://server/stream/whep")
val session = WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO)
MainScope().launch {
session.connect()
session.state.collect { state ->
document.getElementById("status")?.textContent = state::class.simpleName
}
}
}All connection logic is encapsulated inside WebRTCSession. Application code never handles SDP, ICE, or signaling directly.
SessionState transitions:
Idle ──connect()──► Connecting ──success──► Connected
│ │
failure DISCONNECTED/FAILED
(retryable) │
│ ▼ (5s debounce)
▼ Reconnecting(1, max)
Error(retryable) │
│ retry ◄─┘
retry ◄──────────────────┘
│
retries exhausted
│
▼
Error(terminal) ──► close() ──► Closed
| Phase | Managed by | Details |
|---|---|---|
| PeerConnection creation | Session internal | PeerConnectionFactory + platform init |
| SDP offer/answer | Session internal | WebRTCClient.createOffer() → SignalingAdapter.sendOffer() → setRemoteAnswer() |
| ICE gathering | Session internal | Full ICE (wait for all) or Trickle ICE (incremental) |
| Auto-reconnect | Session internal | StreamRetryHandler with exponential backoff |
| Stats collection | Session internal | Background job, updates StateFlow<WebRTCStats?> every ~1s |
| Resource cleanup | Session close() |
PeerConnection close → signaling terminate → scope cancel |
Sessions own their own CoroutineScope (with SupervisorJob), independent of Compose lifecycle. This means:
- Reconnect jobs survive recomposition
close()can be safely called fromDisposableEffect.onDispose- Multiple sessions can run concurrently with no shared state
| Responsibility | Session | Composable | Application |
|---|---|---|---|
| PeerConnection lifecycle | ✅ | ||
| SDP/ICE negotiation | ✅ | ||
| Auto-reconnect | ✅ | ||
| Stats collection | ✅ | ||
| Video rendering | ✅ VideoRenderer | ||
| Audio capture/encoding | ✅ (WebRTCSession) | ||
| Create session | ✅ | ||
| Decide when to connect | Can auto | ✅ | |
| Decide when to close | DisposableEffect | ✅ (recommended) | |
| Create DataChannel | ✅ | ||
| Handle DataChannel messages | ✅ |
| Platform | Strategy | Reason |
|---|---|---|
| Android | Separate Factory per connection | EglContext bound at Factory creation; sharing causes video decode failures |
| iOS | Separate Factory per connection | Consistency; ARC handles memory |
| JVM | Shared Factory (reference counted) | webrtc-java has global state; multiple dispose causes crashes |
WebRTCSession
└── sessionScope: CoroutineScope(SupervisorJob() + Dispatchers.Main)
├── connectionJob: handles connect + ICE gathering
├── reconnectJob: handles auto-reconnect loop
└── statsJob: periodic stats collection
VideoRenderer (Composable)
└── Uses session's state; only manages rendering lifecycle
AudioPushPlayer (Composable)
└── Uses session's state; delegates all logic to WebRTCSession
close() sequence:
- Cancel
sessionScope(stops all internal jobs) WebRTCClient.close()— synchronous, releases PeerConnection + media resourcesSignalingAdapter.terminate()— fire-and-forget in separate scope- State →
Closed
Important: All v1.x classes (
WhepSignaling,WhipSignaling,WhepSession,WhipSession,WhepSignalingAdapter,WhipSignalingAdapter,StreamConfig,BidirectionalConfig,BidirectionalPlayer,AudioRetryConfig) were completely removed in v2.0. You must migrate to the new unified API.
| v1.x (Removed) | v2.0 (Current) |
|---|---|
WhepSignaling(httpClient) |
HttpSignalingAdapter(url, auth, httpClient?) |
WhipSignaling(httpClient) |
HttpSignalingAdapter(url, auth, httpClient?) |
Manual headers = mapOf("Cookie" to ...) |
SignalingAuth.Cookies(cookies) — type-safe, app-managed cookie auth |
Manual headers = mapOf("Authorization" to ...) |
SignalingAuth.Bearer(token) — type-safe JWT |
WebSocketSignaling(...) |
Custom SignalingAdapter impl (built-in WebSocketSignalingAdapter planned v2.1) |
StreamConfig(endpoints, protocol, ...) |
WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO) |
AudioPushConfig(whipUrl, ...) |
WebRTCSession(signaling, MediaConfig.SEND_AUDIO) |
BidirectionalConfig(...) |
WebRTCSession(signaling, MediaConfig.BIDIRECTIONAL_AUDIO) or MediaConfig.VIDEO_CALL |
BidirectionalPlayer(config) |
VideoRenderer(session) + AudioPushPlayer(session) |
VideoRenderer(config) returns Unit |
VideoRenderer(session) returns VideoPlayerController |
AudioRetryConfig |
RetryConfig (unified) |
WebRTCClient direct use |
WebRTCSession |
getStats() (manual, suspend) |
session.stats (reactive StateFlow) |
Before (v1.x):
// Complex: must know SDP, ICE, signaling details
val client = WebRTCClient()
val httpClient = HttpClient(OkHttp)
val whep = WhepSignaling(httpClient)
client.initialize(WebRTCConfig.DEFAULT, listener)
val offer = client.createOffer()
val result = whep.sendOffer("https://server/whep", offer)
client.setRemoteAnswer(result.sdpAnswer)After (v2.0):
// Simple: all internals managed by Session
val session = WebRTCSession(
signaling = HttpSignalingAdapter(
url = "https://server/whep",
auth = SignalingAuth.Bearer(token = jwt)
),
mediaConfig = MediaConfig.RECEIVE_VIDEO
)
session.connect() // That's it — SDP, ICE, signaling all handled- Replace signaling classes →
HttpSignalingAdapter - Replace
StreamConfig+VideoRenderer(config)→WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO)+VideoRenderer(session) - Replace
AudioPushConfig(whipUrl)+AudioPushPlayer(config)→WebRTCSession(signaling, MediaConfig.SEND_AUDIO)+AudioPushPlayer(session) - Replace
BidirectionalPlayer→WebRTCSession(signaling, MediaConfig.VIDEO_CALL)+ separateVideoRenderer+AudioPushPlayer - Replace direct
WebRTCClientusage →WebRTCSession
The legacy WhepSession / WhipSession and WhepSignalingAdapter / WhipSignalingAdapter were completely removed in v2.0. Migration is straightforward:
| Removed API | v2.0 Replacement |
|---|---|
WhepSignalingAdapter(url) |
HttpSignalingAdapter(url) |
WhipSignalingAdapter(url) |
HttpSignalingAdapter(url) |
WhepSession(signaling) |
WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO) |
WhipSession(signaling) |
WebRTCSession(signaling, MediaConfig.SEND_AUDIO) |
VideoRenderer(session: WhepSession) |
VideoRenderer(session: WebRTCSession) |
AudioPushPlayer(session: WhipSession) |
AudioPushPlayer(session: WebRTCSession) |
Before (removed in v2.0):
// These classes no longer exist — shown for migration reference only
val signaling = WhepSignalingAdapter(url = "https://server/stream/whep")
val session = WhepSession(signaling)
VideoRenderer(session = session, modifier = Modifier.fillMaxSize())After (v2.0):
val session = WebRTCSession(
signaling = HttpSignalingAdapter("https://server/stream/whep"),
mediaConfig = MediaConfig.RECEIVE_VIDEO
)
VideoRenderer(session = session, modifier = Modifier.fillMaxSize())The new API additionally unlocks:
- Camera capture via
MediaConfig.SEND_VIDEO - Bidirectional audio via
MediaConfig.BIDIRECTIONAL_AUDIO - Full video calls via
MediaConfig.VIDEO_CALL - Any custom combination of
sendVideo/sendAudio/receiveVideo/receiveAudio
GoogleWebRTC does not support iOS Simulator. Use a physical device.
Ensure network permissions are set and usesCleartextTraffic="true" for HTTP endpoints.
val config = WebRTCConfig(
iceServers = listOf(
IceServer.GOOGLE_STUN,
IceServer(
urls = listOf("turn:turn.example.com:3478"),
username = "user",
credential = "password"
)
)
)
val session = WebRTCSession(signaling, MediaConfig.RECEIVE_VIDEO, webrtcConfig = config)Use DataChannelConfig.unreliable() to reduce latency at the cost of reliability.
Yes. Create a WebRTCSession with any MediaConfig, call connect(), then createDataChannel(). The DataChannel works independently of media tracks.
Implement the SignalingAdapter interface. See Custom Signaling Adapter for a Firebase example.
WHEP received audio plays through the speaker by default. Use WebRTCSession controls:
session.setAudioEnabled(false) // Mute incoming audio
session.setSpeakerphoneEnabled(false) // Switch to earpiece/headphones (Android/iOS)| Platform | Speaker | Earpiece | Headphones/Bluetooth |
|---|---|---|---|
| Android | setSpeakerphoneEnabled(true) |
setSpeakerphoneEnabled(false) |
Auto-detected by system |
| iOS | setSpeakerphoneEnabled(true) |
setSpeakerphoneEnabled(false) |
Auto-detected via AVAudioSession |
| JVM/Desktop | System default (no-op) | N/A | System controlled |
| JS/WasmJS | Browser default (no-op) | N/A | Browser controlled |
For advanced audio device selection (e.g. choosing a specific Bluetooth device), use platform-native APIs (AudioManager on Android, AVAudioSession on iOS) directly in your app.
Yes. Each session has its own PeerConnection, coroutine scope, and signaling. No shared state.
# Build all platforms
./gradlew build
# Build specific targets
./gradlew jvmMainClasses # JVM (fast check)
./gradlew bundleReleaseAar # Android AAR
./gradlew jsJar # JavaScript
./gradlew wasmJsJar # WebAssembly
./gradlew linkPodReleaseFrameworkIosArm64 # iOS
# Run tests
./gradlew jvmTest
# Publish
./gradlew publishToMavenLocal # Local Maven
./gradlew publish # GitHub Packages| Dependency | Version | Purpose |
|---|---|---|
| Kotlin | 2.3.0 | Language |
| Compose Multiplatform | 1.10.0 | UI framework |
| Ktor | 3.0.3 | HTTP/WebSocket (signaling) |
| WebRTC Android SDK | 125.6422.05 | Android WebRTC |
| GoogleWebRTC CocoaPod | 1.1.31999 | iOS WebRTC |
| webrtc-java | 0.14.0 | JVM WebRTC |
| kotlinx-coroutines | 1.10.2 | Async/Concurrency |
MIT License
Issues and Pull Requests are welcome!
After cloning the repository, build the project once to automatically install Git hooks:
./gradlew buildThis runs the installGitHooks task, which configures git core.hooksPath to .githooks/. A pre-push hook will then run ./gradlew jvmTest before every git push — if tests fail, the push is blocked.
If you skip the initial build, you can install hooks manually:
./gradlew installGitHooks
This project uses Conventional Commits (required by release-please):
| Type | Example |
|---|---|
feat: |
feat: add DataChannelSession |
fix: |
fix: resolve reconnect on ICE FAILED |
docs: |
docs: update README examples |
refactor: |
refactor: extract signaling interface |
test: |
test: add RetryConfig unit tests |
perf: |
perf: reduce JVM video memory usage |