Skip to Content
InternalsStats & device load

Stats & device load

The in-call debug panel (CallStatsPanel, opened with the (i) button) shows what the media stack is actually doing. Its guiding constraint: it costs nothing when it’s closed.

Zero-cost-when-closed

// CallScreen DisposableEffect(statsToggled) { onStatsVisibilityChange(statsToggled) // → webRtcClient.startStatsPolling() / stop onDispose { onStatsVisibilityChange(false) } }
  • The poll loop only exists while statsToggled is true.
  • It runs inside repeatOnLifecycle(Lifecycle.State.STARTED), so a backgrounded call with the panel left open also stops polling.
  • DeviceLoadSampler.reset() is called when polling (re)starts so the first CPU delta is over a fresh interval.

A call with the panel closed never calls getStats, never reads /proc/self/stat, and never registers the battery sticky-intent receiver.

The poll loop

pollStats() runs on a coroutine, ~1 Hz:

peerConnection.getStats { report -> val stats = parseStats(report) // on the signaling thread mainHandler.post { onStats(stats) } // → CallStats state → panel recomposes }

parseStats - byte-delta bitrate

WebRTC’s RTCStatsReport gives cumulative bytesSent / bytesReceived. parseStats diffs them against the previous poll and the wall-clock interval:

bitrateKbps ≈ (bytesNow - bytesPrev) * 8 / elapsedSeconds / 1000

The byte baselines (lastVideoBytesSent, lastVideoBytesRecv, lastAudioBytesSent, lastAudioBytesRecv, lastStatsAtMs) are @Volatile because parseStats runs on the signaling thread and resetStatsBaseline() is called from the UI loop - the volatile stops a resumed poll from diffing against a half-updated baseline.

From the same report it reads:

FieldSource stat
Send / recv resolutionoutbound-rtp / inbound-rtp frameWidth × frameHeight
Send / recv FPSframesPerSecond
RTTcandidate-pair currentRoundTripTime (ms)
Packet loss %inbound-rtp packetsLost / packetsReceived
Video codeccodec mimeType (e.g. video/H265)
Audio codec / sample rate / stereocodec mimeType, clockRate, sdpFmtpLine stereo=1
HW encode / decodeoutbound-rtp encoderImplementation / inbound-rtp decoderImplementation - “HW” unless the string looks like a software codec

Output is a CallStats data class snapshot. Bitrates are labelled approximate for exactly this reason.

Device load (DeviceLoadSampler.kt)

Three cheap signals, each of which degrades to null (and the panel omits the line) when a device doesn’t report it.

CPU %

This process’s CPU time over the interval:

utime + stime from /proc/self/stat (fields 14, 15 - parsed after the last ')')
  • /proc/self/stat is always readable for your own process - no permission, no SELinux issue.
  • Android hardcodes _SC_CLK_TCK = 100, so one tick = 10 ms of CPU time.
  • Normalised by Runtime.availableProcessors() so the result is 0–100 no matter how many cores are pinned.
  • First sample after a reset() returns null (no prior interval).

Battery temperature

ACTION_BATTERY_CHANGED sticky intent → EXTRA_TEMPERATURE (tenths of °C), sanity-bounded to -50 … 100 °C. This is the most portable proxy for “is the SoC heating up under this encode load” - nearly every device reports it, and it moves visibly during a sustained 4K call.

Instantaneous power draw

watts = |BATTERY_PROPERTY_CURRENT_NOW| (µA) × 1e-6 × EXTRA_VOLTAGE (mV) / 1000
  • CURRENT_NOW is signed and OEMs disagree on the sign convention (some negative = discharging, some the opposite), so only the magnitude is used.
  • Sanity-bounded to 0.05 … 60 W - outside that range the device is reporting garbage and the field is null.
  • Together with battery temperature this gives a rough “how hard is this call working the phone” reading without polling GPU counters (which are inconsistent and often unavailable).

Why not GPU / thermal-status APIs

PowerManager.getThermalHeadroom is API 30+, throttled, and returns NaN on many devices. GPU load has no stable public API. Battery temp + power draw is the metric that actually works across the S9+ ↔ Pixel range.

Panel layout

Grouped into Video (encode/decode HW/SW, codec, send ↑ / recv ↓ with bitrate + resolution + FPS), Audio (codec, sample rate, stereo/mono, mic mode, send/recv kbps), Network (latency, loss - only shown when non-trivial), and Device (CPU, power, battery - only the lines the device reports). Everything hugs its content; the panel is persistent (not tied to the chrome auto-hide).