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 / executor | Owned by | Work |
|---|---|---|
| Main / UI | Android | Compose recomposition, MainActivity callbacks, mainHandler posts |
| WebRTC signaling thread | libwebrtc | SDP callbacks, getStats, PeerConnection mutations |
| WebRTC worker / network threads | libwebrtc | ICE, RTP, pacing |
CaptureThread | SurfaceTextureHelper | Camera frame delivery |
| Screen-capture helper thread | separate SurfaceTextureHelper | MediaProjection frames |
| OkHttp dispatcher + reader | shared OkHttpClient | WebSocket send/receive |
| Audio record / playback threads | JavaAudioDeviceModule | PCM in/out; the external processors run here |
| Coroutine dispatchers | kotlinx.coroutines | repeatOnLifecycle 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
MainActivityholds the call session state - who’s on the call, what they’re doing, how long it’s been.WebRtcClientholds the media engine state - native handles, current capture format, mic-mode bookkeeping, stats baselines.AppSettingsholds user preferences - read when a call starts and inonResumeso 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.