The WebRTC pipeline
webrtc/WebRtcClient.kt (~1,500 lines) is the entire media engine. It owns every
native WebRTC object and exposes a plain-Kotlin API to MainActivity.
Construction
WebRtcClient(
context: Context,
eglBase: EglBase, // shared GL context for capture + render
micMode: MicMode = MicMode.DEFAULT,
onIceCandidateGenerated: (IceCandidate) -> Unit,
onConnectionStateChanged: (PeerConnection.PeerConnectionState) -> Unit,
)MainActivity builds one after camera + mic permission is granted, and rebuilds
it when a call ends (webRtcClient = null then initWebRtcClient()), or when a
mic-mode change requires a hardware-stack
rebuild.
What it owns
| Field | Purpose |
|---|---|
peerConnectionFactory | Built once with the encoder/decoder factories and the ADM |
audioDeviceModule | JavaAudioDeviceModule with HW AEC/NS toggled per mic mode and an external processing chain |
peerConnection | The single PeerConnection (UNIFIED_PLAN, GATHER_CONTINUALLY) |
videoCapturer | CameraVideoCapturer - Camera2 if supported, else Camera1 |
videoSource / localVideoTrack | isScreencast = false so WebRTC adapts resolution down under load and keeps FPS smooth. Screen sharing currently reuses this source (a dedicated screencast source is a pending improvement) |
screenCapturer | ScreenCapturerAndroid feeding the same VideoSource via its capturerObserver |
audioSource / localAudioTrack | Opus source with per-mode constraints |
videoSender / audioSender | RtpSenders - hot-swap tracks with setTrack(track, false) (no renegotiation) |
remoteVideoTrack / remoteAudioTrack | Set in onTrack |
surfaceTextureHelper / screenTextureHelper | Separate helpers - sharing one leaves the MediaProjection virtual display with an invalid producer surface and zero frames delivered |
zoomController | CameraZoomController - Camera2 zoom ratio, or digital crop fallback |
| RNNoise / gain / level probes | The external audio processors, chained in CompositeAudioProcessing |
ICE / RTC configuration
val iceServers = listOf(
IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(),
)
RTCConfiguration(iceServers).apply {
sdpSemantics = UNIFIED_PLAN
continualGatheringPolicy = GATHER_CONTINUALLY
}- STUN only, Google’s public servers. There is no TURN - a call between two symmetric-NAT peers with no shared LAN will not connect. This is a known limitation (see roadmap).
GATHER_CONTINUALLYso a network change (Wi-Fi ↔ cellular) surfaces new candidates without a full ICE restart where possible.
Public API (1:1 today)
| Method | Effect |
|---|---|
createPeerConnection() | Build the PC, add local tracks, apply bitrate + codec prefs |
createOffer(cb) | createOffer → munge SDP → setLocalDescription → cb(sdp) |
handleRemoteOffer(sdp, cb) | setRemoteDescription → createAnswer → munge → setLocalDescription → cb(answer) |
handleRemoteAnswer(sdp) | setRemoteDescription |
addIceCandidate(c) | Buffered until the remote description is set, then drained |
restartIce() | createOffer with iceRestart = true |
attachRemoteRenderer(sink) / detachRemoteRenderer | Wire a Compose renderer to remoteVideoTrack |
attachLocalRenderer(sink) / detachLocalRenderer | …to localVideoTrack; also kicks ensureCapturing() |
setLocalAudioEnabled(b) / setLocalVideoEnabled(b) | Mute / camera-off (fully stops the capturer when off) |
switchCamera(cb) / setCameraFacing(front, cb) | Flip |
setVideoQuality(w, h, fps, maxKbps, lockRes) | Change capture format + encoder cap |
setPreferredCodec(name) | Re-order codec preferences (AV1 / H265 / H264) |
setMicMode(mode) | Live-swap the audio track / software APM; defer HW rebuild |
setMicGain(g) / setMicOverboost(b) | Output gain on captured PCM |
startScreenShare(projection, w, h, fps, maxKbps, onStop) / stopScreenShare() | Screencast |
startStatsPolling() / stopStatsPolling() | The debug-panel loop - off by default |
setZoom(ratio) | Pinch-zoom |
pauseCapture() / ensureCapturing() | Lifecycle-driven camera control |
close() | @Synchronized full teardown |
SDP munging
Both createOffer and createAnswer run their local description through
withRaisedLevel() before setLocalDescription:
private fun withRaisedLevel(desc: SessionDescription) =
SessionDescription(desc.type, tuneOpus(raiseH265Level(desc.description)))raiseH265Levelrewrites every H.265a=fmtpline tolevel-id=153(Level 5.1, a 4K@60 budget). The SDK advertiseslevel-id=93(Level 3.1, ~720p), and strict hardware decoders reject frames above the negotiated level.tuneOpussetsstereo=1+sprop-stereo=1,maxaveragebitrateto 256 kbps,useinbandfec=1, and disables DTX.
Full detail in media negotiation.
Stats
pollStats() runs on a coroutine only while startStatsPolling() is active
(panel visible + Activity STARTED). It calls peerConnection.getStats, and
parseStats diffs bytesSent / bytesReceived against the previous poll to
approximate bitrate, reads resolution / FPS / codec / RTT / loss from the report,
and merges in a DeviceLoadSampler reading (CPU %, battery °C, watts). Output is
a CallStats snapshot. See
stats & device load.