Resilience & recovery
A hand-built WebRTC client fails in a hundred small ways. This page is the catalogue of what Viora does to survive them.
ICE restart
When a route dies (Wi-Fi drop, NAT rebinding, cell handoff), the
PeerConnection goes DISCONNECTED then FAILED.
// MainActivity - on connection state change
DISCONNECTED, FAILED -> if (isOfferer && !iceRestartInFlight) {
iceRestartInFlight = true
cameraGate.postDelayed(iceRecoveryRunnable, delay) // debounced
}
// iceRecoveryRunnable:
webRtcClient?.restartIce() // createOffer { iceRestart = true } → new offer → peer answers- Only the offerer restarts. Both sides restarting is glare.
isOffereris set during the call lifecycle. iceRestartInFlightlatches so a flurry ofDISCONNECTED/FAILEDtransitions produces one restart, not ten.- Cleared on the next
CONNECTEDand onuser-left. GATHER_CONTINUALLYmeans many transient blips recover without a full restart at all - new candidates just appear.
Lifecycle-correct camera pausing
See video capture → recovery. The summary:
| Event | Camera |
|---|---|
onStop (backgrounded, locked) | postDelayed(pause, 250ms) |
onStart (foregrounded) | cancel the pending pause; ensureCapturing() |
PiP close (onStop→onStart in ~90 ms) | pause never fires - the delay outlives the bounce |
onUserLeaveHint during a call | enterPipMode() - capture keeps running in PiP |
| Camera error / disconnect | onCaptureLost() → retry in 900 ms, fallback format after 2 failures |
Leak fixes
| Leak | Fix |
|---|---|
Per-call OkHttpClient.Builder().build() leaked its dispatcher + connection pool on every hang-up | One process-wide SignalingClient.sharedClient (by lazy) |
| Signaling collector kept running after the Activity stopped | repeatOnLifecycle(STARTED) |
getStats / device sampling ran for the whole call | Gated on panel visibility + STARTED |
RNNoise native handle could be freed mid-process() | @Synchronized on process() and release() |
WebRtcClient.close() racing a renderer attach from the next call | @Synchronized on close() and every attach* / setMicMode |
| Handler runnables outliving their state | removeCallbacks at every transition; removeCallbacksAndMessages(null) in close() |
Malformed-payload hardening
Every inbound frame is parsed defensively:
SignalingClient.onMessagewrapsgson.fromJsonin try/catch - a bad frame is logged and dropped, the socket stays up.dispatchSignalingMessagenull-checks every payload field (sdpPayload?.sdp != null,cand?.candidate != null, …) before touchingWebRtcClient.- The Rust server’s
handle_textlogs and returns on aserde_jsonparse error rather than closing the connection. - SDP munging (
raiseH265Level,tuneOpus) catches its own exceptions and falls back to the unmodified SDP.
Foreground service
CallForegroundService (foregroundServiceType="camera|microphone|mediaProjection")
is started when a call connects and carries a CallStyle notification with a
“Hang Up” action. It keeps the process alive when the call is backgrounded
(including while screen sharing without PiP).
onStartCommand returns START_NOT_STICKY - if the system kills the process,
the call is over; it should not silently resurrect a dead call.
Config changes
The manifest declares
configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|density|fontScale"
so a rotation, a dark-mode toggle, or a font-scale change doesn’t recreate the
Activity mid-call and tear down the PeerConnection. Compose handles the
re-layout.
The stall that shipped, and its fix
A real freeze on the reference Pixel: AppSettings.videoQuality = 4K, but the
Pixel’s front camera can’t capture 3840×2160. The old recovery logic:
ensureCapturing()→startCapture(3840, 2160, 30)→ the HAL delivers one frame then tears the session down (Function not implemented (-38)).onFirstFrameAvailable→ resetconsecutiveCaptureFailures = 0.onCameraDisconnected→onCaptureLost()→failures++(back to 1), retry in 900 ms.- Retry at 4K again -
failuresnever reaches the>= 2fallback threshold. - Loop forever, ~1 Hz, pegging a thread, flooding a renderer with a 0-height frame.
The fix: onFirstFrameAvailable no longer resets the counter directly. It arms
captureHealthyRunnable, which clears the counter only if the capture is still
alive 4 seconds later. A session that dies right after its first frame now
counts as a failure, so ensureCapturing() escalates to the 640×480 fallback
within two cycles and stabilises.