Skip to Content
ArchitectureThe WebRTC pipeline

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

FieldPurpose
peerConnectionFactoryBuilt once with the encoder/decoder factories and the ADM
audioDeviceModuleJavaAudioDeviceModule with HW AEC/NS toggled per mic mode and an external processing chain
peerConnectionThe single PeerConnection (UNIFIED_PLAN, GATHER_CONTINUALLY)
videoCapturerCameraVideoCapturer - Camera2 if supported, else Camera1
videoSource / localVideoTrackisScreencast = 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)
screenCapturerScreenCapturerAndroid feeding the same VideoSource via its capturerObserver
audioSource / localAudioTrackOpus source with per-mode constraints
videoSender / audioSenderRtpSenders - hot-swap tracks with setTrack(track, false) (no renegotiation)
remoteVideoTrack / remoteAudioTrackSet in onTrack
surfaceTextureHelper / screenTextureHelperSeparate helpers - sharing one leaves the MediaProjection virtual display with an invalid producer surface and zero frames delivered
zoomControllerCameraZoomController - Camera2 zoom ratio, or digital crop fallback
RNNoise / gain / level probesThe 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_CONTINUALLY so a network change (Wi-Fi ↔ cellular) surfaces new candidates without a full ICE restart where possible.

Public API (1:1 today)

MethodEffect
createPeerConnection()Build the PC, add local tracks, apply bitrate + codec prefs
createOffer(cb)createOffer → munge SDP → setLocalDescriptioncb(sdp)
handleRemoteOffer(sdp, cb)setRemoteDescriptioncreateAnswer → munge → setLocalDescriptioncb(answer)
handleRemoteAnswer(sdp)setRemoteDescription
addIceCandidate(c)Buffered until the remote description is set, then drained
restartIce()createOffer with iceRestart = true
attachRemoteRenderer(sink) / detachRemoteRendererWire 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)))
  • raiseH265Level rewrites every H.265 a=fmtp line to level-id=153 (Level 5.1, a 4K@60 budget). The SDK advertises level-id=93 (Level 3.1, ~720p), and strict hardware decoders reject frames above the negotiated level.
  • tuneOpus sets stereo=1 + sprop-stereo=1, maxaveragebitrate to 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.