Skip to Content
ArchitectureThreading & state

Threading & state

WebRTC on Android is a multi-threaded native library wrapped in a thin JNI layer, driven by a single-threaded Compose UI. Getting the boundaries right is most of what keeps calls stable.

The threads in play

Thread / executorOwned byWork
Main / UIAndroidCompose recomposition, MainActivity callbacks, mainHandler posts
WebRTC signaling threadlibwebrtcSDP callbacks, getStats, PeerConnection mutations
WebRTC worker / network threadslibwebrtcICE, RTP, pacing
CaptureThreadSurfaceTextureHelperCamera frame delivery
Screen-capture helper threadseparate SurfaceTextureHelperMediaProjection frames
OkHttp dispatcher + readershared OkHttpClientWebSocket send/receive
Audio record / playback threadsJavaAudioDeviceModulePCM in/out; the external processors run here
Coroutine dispatcherskotlinx.coroutinesrepeatOnLifecycle signaling collector, stats loop, UI animations

Rules the code follows

1. WebRtcClient mutation is serialised

Every method that touches a native object or shared field is @Synchronized on the WebRtcClient instance: close(), setMicMode(), pauseCapture(), ensureCapturing(), attach*Renderer(), rebuildAudioStackIfDirty(), the capture-failure callbacks. This is what makes a fast hang-up → redial safe - close() can’t interleave with a renderer attach from the next call’s UI.

2. Callbacks bounce to the main thread before touching Compose state

onConnectionStateChanged, onIceCandidateGenerated, onRemoteOrientation, and the mic/remote level probes all mainHandler.post { … } (or the consumer does runOnUiThread) before updating any mutableStateOf. Reading localAudioLevel / remoteAudioLevel from the UI thread is explicitly safe - they’re plain Float getters over a smoothed envelope.

3. Stats do zero work when the panel is closed

startStatsPolling() / stopStatsPolling() are called from a DisposableEffect(statsToggled) in CallScreen, and the poll loop is inside a repeatOnLifecycle(STARTED). A call with the debug panel closed never calls getStats, never samples /proc/self/stat, and never reads the battery sticky intent.

4. Signaling is ordered and lossless at the edges

SignalingClient publishes inbound frames with MutableSharedFlow(replay = 0, extraBufferCapacity = 64, DROP_OLDEST) and tryEmit straight from the OkHttp callback thread. An earlier scope.launch { emit() } per message could reorder offer / answer / candidate. The connection-state flow uses replay = 1 so a collector that subscribes just after onOpen still sees connected and sends its join.

The collector runs under repeatOnLifecycle(STARTED) so it detaches when the Activity stops and re-attaches (re-joining if needed) on resume.

5. One process-wide OkHttpClient

SignalingClient.sharedClient is a by lazy singleton. A per-call OkHttpClient.Builder().build() leaked its dispatcher thread pool and connection pool on every hang-up because nothing shut them down.

6. Handlers are cancelled on every state transition

mainHandler / cameraGate postDelayed runnables (recoverCaptureRunnable, captureHealthyRunnable, iceRecoveryRunnable, pauseCameraRunnable, shareOrientationSync) are always paired with a removeCallbacks at the start of the next transition and in close() (removeCallbacksAndMessages(null)).

State ownership

  • MainActivity holds the call session state - who’s on the call, what they’re doing, how long it’s been.
  • WebRtcClient holds the media engine state - native handles, current capture format, mic-mode bookkeeping, stats baselines.
  • AppSettings holds user preferences - read when a call starts and in onResume so a change in Settings applies to the next (or current) call.

The @Volatile on the stats byte baselines (lastVideoBytesSent, etc.) matters because parseStats runs on the signaling thread while resetStatsBaseline() is called from the UI loop - the volatile keeps a resumed poll from diffing against a half-updated baseline.