Skip to Content
InternalsResilience & recovery

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. isOfferer is set during the call lifecycle.
  • iceRestartInFlight latches so a flurry of DISCONNECTED/FAILED transitions produces one restart, not ten.
  • Cleared on the next CONNECTED and on user-left.
  • GATHER_CONTINUALLY means many transient blips recover without a full restart at all - new candidates just appear.

Lifecycle-correct camera pausing

See video capture → recovery. The summary:

EventCamera
onStop (backgrounded, locked)postDelayed(pause, 250ms)
onStart (foregrounded)cancel the pending pause; ensureCapturing()
PiP close (onStoponStart in ~90 ms)pause never fires - the delay outlives the bounce
onUserLeaveHint during a callenterPipMode() - capture keeps running in PiP
Camera error / disconnectonCaptureLost() → retry in 900 ms, fallback format after 2 failures

Leak fixes

LeakFix
Per-call OkHttpClient.Builder().build() leaked its dispatcher + connection pool on every hang-upOne process-wide SignalingClient.sharedClient (by lazy)
Signaling collector kept running after the Activity stoppedrepeatOnLifecycle(STARTED)
getStats / device sampling ran for the whole callGated 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 stateremoveCallbacks at every transition; removeCallbacksAndMessages(null) in close()

Malformed-payload hardening

Every inbound frame is parsed defensively:

  • SignalingClient.onMessage wraps gson.fromJson in try/catch - a bad frame is logged and dropped, the socket stays up.
  • dispatchSignalingMessage null-checks every payload field (sdpPayload?.sdp != null, cand?.candidate != null, …) before touching WebRtcClient.
  • The Rust server’s handle_text logs and returns on a serde_json parse 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:

  1. ensureCapturing()startCapture(3840, 2160, 30) → the HAL delivers one frame then tears the session down (Function not implemented (-38)).
  2. onFirstFrameAvailable → reset consecutiveCaptureFailures = 0.
  3. onCameraDisconnectedonCaptureLost()failures++ (back to 1), retry in 900 ms.
  4. Retry at 4K again - failures never reaches the >= 2 fallback threshold.
  5. 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.