feat(call): media health watchdog — warn user when no audio arrives

When a P2P direct call establishes successfully but the underlying
network path dies (phone switched from WiFi to LTE mid-call, or
cross-relay media forwarding isn't working), the call stays up
silently with recv_fr frozen at 0. No feedback to the user.

New watchdog in the Android recv task: tracks consecutive
heartbeat ticks (2s each) where recv_fr hasn't advanced. After 3
ticks (6s) with no new packets, emits:

- call-event { kind: "media-degraded" } — user-facing warning
  banner: "No audio — connection may be lost. Try hanging up and
  reconnecting, or switch to a different relay."
- call-debug media:no_recv_timeout for the debug log

If packets resume (recv_fr advances), clears the banner via:
- call-event { kind: "media-recovered" }

JS listener creates/removes a red-tinted banner dynamically at
the top of the call screen. Banner is also cleaned up on
showConnectScreen (call end).

This covers:
- Direct P2P that established on WiFi but died when the phone
  switched to LTE (stale NAT mapping, unreachable peer)
- Cross-relay calls where federation media isn't forwarding
  (relay not upgraded, not federated, etc.)
- Any other "connected but silent" scenario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 09:18:38 +04:00
parent 847699bf66
commit 0ccf4ed6b5
2 changed files with 103 additions and 0 deletions

View File

@@ -653,6 +653,17 @@ impl CallEngine {
let mut last_written: usize = 0;
let mut decode_errs: u64 = 0;
let mut first_packet_logged = false;
// Phase 5.6: media health watchdog — track consecutive
// heartbeat ticks where recv_fr hasn't advanced. If
// media doesn't arrive for 3 consecutive heartbeats
// (6s), emit a user-facing "media-degraded" call-event
// so the UI can show a warning like "No audio — try
// reconnecting?". Covers the case where P2P direct
// established but the underlying network path died
// (e.g., phone switched from WiFi to LTE mid-call).
let mut last_recv_fr_for_watchdog: u64 = 0;
let mut no_recv_ticks: u32 = 0;
let mut media_degraded_emitted = false;
loop {
if !recv_r.load(Ordering::Relaxed) {
@@ -905,6 +916,59 @@ impl CallEngine {
"codec": format!("{:?}", current_codec),
}),
);
// Media health watchdog: if recv_fr hasn't
// advanced in 3 consecutive heartbeats (6s) and
// we've been "connected" for at least 4s (give
// the first few frames time to arrive), emit a
// user-facing "media-degraded" event so the UI
// can show "No audio — connection may be lost".
if fr == last_recv_fr_for_watchdog {
no_recv_ticks += 1;
} else {
no_recv_ticks = 0;
if media_degraded_emitted {
// Was degraded but recovered — clear
// the banner.
media_degraded_emitted = false;
let _ = recv_app.emit(
"call-event",
serde_json::json!({
"kind": "media-recovered",
}),
);
crate::emit_call_debug(
&recv_app,
"media:recovered",
serde_json::json!({}),
);
}
}
last_recv_fr_for_watchdog = fr;
if no_recv_ticks >= 3 && !media_degraded_emitted {
media_degraded_emitted = true;
tracing::warn!(
recv_fr = fr,
no_recv_ticks,
"media watchdog: no inbound packets for 6s"
);
let _ = recv_app.emit(
"call-event",
serde_json::json!({
"kind": "media-degraded",
}),
);
crate::emit_call_debug(
&recv_app,
"media:no_recv_timeout",
serde_json::json!({
"recv_fr": fr,
"no_recv_ticks": no_recv_ticks,
}),
);
}
heartbeat = std::time::Instant::now();
}
}