57 Commits

Author SHA1 Message Date
Siavash Sameni
d249b32ee5 test+docs: add tests for QualityDirective, ParticipantQuality; update docs
- QualityDirective signal roundtrip tests (with/without reason)
- ParticipantQuality unit tests (initial tier, degradation, weakest-link)
- Updated PROGRESS.md with desktop adaptive quality, relay coordinated
  switching, Oboe state polling entries
- Updated ARCHITECTURE.md SFU fan-out rules with QualityDirective
- Updated PRD-coordinated-codec.md with implementation status
- 312 tests passing across all modified crates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:56:46 +04:00
Siavash Sameni
22045bc5e6 feat: adaptive quality in desktop, relay quality directive, Oboe state polling
- Wire AdaptiveQualityController into desktop engine send/recv tasks
  (mirrors Android pattern: AtomicU8 pending_profile, auto-mode check)
- Wire same into Android engine send task (was only in recv before)
- QualityDirective SignalMessage variant for relay-initiated codec switch
- ParticipantQuality tracking in relay RoomManager (per-participant
  AdaptiveQualityController, weakest-link tier computation)
- Relay broadcasts QualityDirective to all participants when room-wide
  tier degrades (coordinated codec switching)
- Oboe stream state polling: poll getState() for up to 2s after
  requestStart() to ensure both streams reach Started before proceeding
  (fixes intermittent silent calls on cold start, Nothing Phone A059)

Tasks: #7, #25, #26, #31, #35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:54:04 +04:00
Siavash Sameni
766c9df442 feat(dred): continuous DRED tuning, PMTUD, extended Opus6k window
- DredTuner: maps live network metrics (loss/RTT/jitter) to continuous
  DRED duration every ~500ms instead of discrete tier-locked values.
  Includes jitter-spike detection for pre-emptive Starlink-style boost.
- Opus6k DRED extended from 500ms to 1040ms (max libopus 1.5 supports)
- PMTUD: quinn MtuDiscoveryConfig with upper_bound=1452, 300s interval
- TrunkedForwarder respects discovered MTU (was hard-coded 1200)
- QuinnPathSnapshot exposes quinn internal stats + discovered MTU
- AudioEncoder trait: set_expected_loss() + set_dred_duration() methods
- PathMonitor: sliding-window jitter variance for spike detection
- Integrated into both Android and desktop send tasks in engine.rs
- 14 new tests (10 tuner unit + 4 encoder integration)
- Updated ARCHITECTURE.md, PROGRESS.md, PRD-dred-integration, PRD-mtu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:38:37 +04:00
Siavash Sameni
24cc74d93c fix(audio): clear BT SCO communication device on call end
Without clearCommunicationDevice(), the BT headset stays locked in SCO
mode after the call. Media playback (video, music) can't route to BT
A2DP, requiring a device reboot to restore normal audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:40:44 +04:00
Siavash Sameni
300ea66d13 docs: update DESIGN, ARCHITECTURE, PRDs, PROGRESS for BT + network + build changes
Reflects the current reality: setCommunicationDevice API 31+, deferred
MODE_IN_COMMUNICATION, BT-mode Oboe (bt_active flag), per-arch builds,
Hangup call_id fix, and network monitoring integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:39:59 +04:00
Siavash Sameni
114d69e488 fix: use tracing::warn! instead of bare warn! in engine.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:31:12 +04:00
Siavash Sameni
15c237ceea fix(audio): defer MODE_IN_COMMUNICATION to call start, restore on end
Root cause: MainActivity set MODE_IN_COMMUNICATION at app launch,
hijacking system audio routing immediately — BT A2DP music dropped to
earpiece, and the pre-existing communication mode confused subsequent
setCommunicationDevice calls for BT SCO.

Fix: MainActivity now only sets volumes. MODE_IN_COMMUNICATION is set
via JNI right before Oboe audio_start() in CallEngine, and MODE_NORMAL
is restored after audio_stop() when the call ends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:29:59 +04:00
Siavash Sameni
a37c8b30fe fix(native): add missing bt_active field to stall detector config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:25:11 +04:00
Siavash Sameni
137fe5f084 fix(bluetooth): BT SCO mode skips 48kHz + VoiceCommunication on capture
Root cause: Oboe capture at 48kHz with InputPreset::VoiceCommunication
cannot open against a BT SCO device (only supports 8/16kHz). The stream
silently falls back to builtin mic, delivering zeros.

Fix: add bt_active flag to WzpOboeConfig. When set, capture skips
setSampleRate and setInputPreset, letting the system route to BT SCO
at its native rate. Oboe's SampleRateConversionQuality::Best resamples
to 48kHz for our ring buffers. Playout uses Usage::Media in BT mode.

New API: wzp_native_audio_start_bt() for BT mode, called from
set_bluetooth_sco(on=true). Normal audio_start() restores the
standard config when switching back to earpiece/speaker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:23:19 +04:00
Siavash Sameni
5dfb5b3581 fix(bluetooth): use Shared mode for Oboe + delay restart for BT route
Two fixes for BT audio silence:

1. Switch Oboe streams from Exclusive to Shared sharing mode. Exclusive
   mode bypasses Oboe's internal resampler, so opening a 48kHz stream
   against a BT SCO device (8/16kHz only) fails at the AudioPolicy
   level. Shared mode lets Oboe's resampler bridge the gap.

2. Add 500ms post-SCO delay before Oboe restart. The audio policy needs
   time to apply the bt-sco route after setCommunicationDevice returns.
   Without the delay, Oboe opens against the old device (handset).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:14:06 +04:00
Siavash Sameni
fd0ccf8e99 fix(bluetooth): enable Oboe sample rate conversion for BT SCO (8/16kHz)
BT SCO devices only support 8kHz or 16kHz but our Oboe streams request
48kHz. Without resampling, AudioPolicyManager rejects the input stream
("getInputProfile could not find profile for... sampling rate 48000").

Fix: add setSampleRateConversionQuality(Best) to both capture and
playout stream builders. Oboe resamples internally so our ring buffers
stay at 48kHz regardless of the hardware sample rate.

Also removes the broken setBluetoothScoOn/isBluetoothScoOn calls from
stop_bluetooth_sco — just call stopBluetoothSco() unconditionally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:08:48 +04:00
Siavash Sameni
2d4948a7b3 fix(bluetooth): add missing &[] arg to getAvailableCommunicationDevices JNI call
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:02:57 +04:00
Siavash Sameni
19703ff66c fix(bluetooth): use setCommunicationDevice API on Android 12+
Root cause: setBluetoothScoOn(true) is silently rejected on Android 12+
for non-system apps ("is greater than FIRST_APPLICATION_UID exiting").
Audio policy routed to handset instead of BT despite SCO link being up.

Fix: use the modern setCommunicationDevice(AudioDeviceInfo) API on
API 31+ which properly routes voice audio to the BT device. Falls back
to deprecated startBluetoothSco() on older APIs.

Also uses getCommunicationDevice() for is_bluetooth_sco_on() and
clearCommunicationDevice() for stop, matching the modern API surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:01:33 +04:00
Siavash Sameni
7e8dc400dc fix(bluetooth): wait for SCO link before Oboe restart + detect A2DP devices
Three fixes for Bluetooth audio not working:

1. is_bluetooth_available() now checks for TYPE_BLUETOOTH_A2DP (8) in
   addition to TYPE_BLUETOOTH_SCO (7) — many headsets only register as
   A2DP until SCO is explicitly started.

2. set_bluetooth_sco(on=true) polls isBluetoothScoOn() for up to 3s
   before restarting Oboe. startBluetoothSco() is async — the SCO link
   takes 500ms-2s to establish. Without waiting, Oboe opens against
   earpiece and audio goes nowhere.

3. Frontend skips redundant set_speakerphone(false) when transitioning
   to BT — start_bluetooth_sco() handles speaker-off internally,
   avoiding a double Oboe restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:46:56 +04:00
Siavash Sameni
a798634b3d fix(signal): add call_id to Hangup — prevents stale hangup killing new calls
Root cause: Hangup had no call_id field. The relay forwarded hangups to
ALL active calls for a user. When user A hung up call 1 and user B
immediately placed call 2, the relay's processing of A's hangup would
also kill call 2 (race window ~1-2s).

Fix: add optional call_id to Hangup (backwards-compatible via serde
skip_serializing_if). When present, the relay only ends the named call.
Old clients send call_id=None and get the legacy broadcast behavior.

Also: clear pending_path_report in Hangup recv handler and
internal_deregister to prevent stale oneshot channels from blocking
subsequent call setups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:39:21 +04:00
Siavash Sameni
d89376016a fix(build): sign release APKs with project keystore (wzp-release.jks)
Release builds from cargo-tauri are unsigned. After Gradle produces the
APK, zipalign + apksigner now sign it with the release keystore
(android/keystore/wzp-release.jks). Falls back to debug keystore if
release is missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:21:38 +04:00
Siavash Sameni
678695776e fix(build): correct APK output path — target/ is mounted from cache dir
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:10:03 +04:00
Siavash Sameni
4c1ad841e1 feat(android): Bluetooth audio routing + network change detection + per-arch APK builds
Bluetooth: wire existing AudioRouteManager SCO support through both app
variants. Replace binary speaker toggle with 3-way route cycling
(Earpiece → Speaker → Bluetooth). Tauri side adds JNI bridge functions
(start/stop/query SCO, device availability) and Oboe stream restart.

Network awareness: integrate Android ConnectivityManager to detect
WiFi/cellular transitions and feed them to AdaptiveQualityController
via lock-free AtomicU8 signaling. Enables proactive quality downgrade
and FEC boost on network handoffs.

Build: add --arch flag to build-tauri-android.sh supporting arm64,
armv7, or all (separate per-arch APKs for smaller tester binaries).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:07:41 +04:00
Siavash Sameni
29cd23fe39 fix(p2p): connection cleanup — 4 fixes for stale/dead connections
PRD 4: Disable IPv6 direct dial/accept temporarily. IPv6 QUIC
handshakes succeed but connections die immediately on datagram
send ("connection lost"). IPv4 candidates work reliably. IPv6
candidates still gathered but filtered at dial time.

PRD 1: Close losing transport after Phase 6 negotiation. The
non-selected transport now gets an explicit QUIC close frame
instead of silently dropping after 30s idle timeout. Prevents
phantom connections from polluting future accept() calls.

PRD 2: Harden accept loop with max 3 stale retries. Stale
connections are explicitly closed (conn.close) and counted.
After 3 stale connections, the accept loop aborts instead of
spinning until the race timeout.

PRD 3: Resource cleanup — close old IPv6 endpoint before
creating a new one in place_call/answer_call. Add Drop impl
to CallEngine so tasks are signalled to stop on ungraceful
shutdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:11:50 +04:00
Siavash Sameni
4d66d3769d fix(relay): set peer_relay_fp on originating relay when answer arrives
The originating relay (where the caller is) never set peer_relay_fp
because the call was created locally. When the callee's answer
arrived via federation, the cross-relay dispatcher handled it but
didn't mark the call as cross-relay. This meant the caller's
MediaPathReport was delivered via local hub.send_to() to a peer
fingerprint that isn't connected locally — silently dropped.

Fix: in the cross-relay answer dispatcher, call
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp)) so the
originating relay knows to forward MediaPathReport via federation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:49:34 +04:00
Siavash Sameni
002df15c5e fix(cli): add .. rest pattern for RegisterPresenceAck error arm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:32:57 +04:00
Siavash Sameni
1eb82d77b8 feat(relay+client): relay reports build version in Ack
Add relay_build field to RegisterPresenceAck so the client logs
which relay version it connected to. Shows in the debug log as
register_signal:ack_received {"relay_build":"f843a93"}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:27:58 +04:00
Siavash Sameni
f843a934fe fix(relay): forward MediaPathReport across federation
MediaPathReport was only delivered via local signal_hub, so calls
between peers on different relays always hit peer_report_timeout
and fell back to relay — even when direct P2P worked perfectly.

Fix: check peer_relay_fp in call_registry (same pattern as
DirectCallAnswer). If the peer is on a remote relay, wrap in
FederatedSignalForward and send via federation link. Also fix
the cross-relay dispatcher to deliver to BOTH caller and callee
(not just caller), since the report can come from either side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:14:30 +04:00
Siavash Sameni
b79073c649 Revert "fix(connect): trust direct path on peer report timeout"
This reverts commit 82b439595c.
2026-04-12 14:10:44 +04:00
Siavash Sameni
82b439595c fix(connect): trust direct path on peer report timeout
When peers are on different relays, MediaPathReport can't be
forwarded — causing a 3s timeout and false relay fallback even
though direct P2P works perfectly.

Fix: on timeout, if local_direct_ok is true AND the direct
transport's connection is still alive (no close_reason), trust
the direct path instead of falling back to relay. The timeout
indicates a relay forwarding issue, not a direct path failure.

Also fix ALT build paste URL (paste.tbs.manko.yoga not amn.gg).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:07:44 +04:00
Siavash Sameni
1904b19d05 fix(direct): validate A-role accepted connection, skip stale ones
The Acceptor's accept() on the shared signal endpoint can dequeue
a stale QUIC connection from a previous call that the Dialer has
already dropped. This results in "connection lost" errors when
media datagrams are sent — 100% drops on both sides.

Fix: after accepting a connection, check close_reason(). If the
connection is already closed, log a warning and re-accept. Also
verify max_datagram_size() is available before returning.

Additionally: emit transport details (remote addr, max_datagram,
close_reason) in the call_engine_starting debug event so stale
connection issues are visible in the user-facing debug log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:50:21 +04:00
Siavash Sameni
40955bd11c debug(media): add connection diagnostics for direct P2P drops
When direct P2P calls show 100% datagram drops, we need to know
WHY send_media() fails. This commit adds:

- Remote address + stable_id logging on A-role accept and D-role
  dial success (dual_path.rs) — tells us which candidate won
- Remote address + max_datagram_size on engine transport init —
  verifies datagrams are negotiated
- last_send_err in send heartbeat — captures the actual error
  from send_datagram() failures
- QuinnTransport::remote_address() helper

Also fixes UI badge: was looking for wrong event name
("dual_path_race_won" → "path_negotiated").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:29:58 +04:00
Siavash Sameni
7554959baa fix(ui): show correct P2P Direct / Via Relay badge
The UI looked for event "connect:dual_path_race_won" which doesn't
exist — the actual event is "connect:path_negotiated" with a
use_direct boolean. Badge always showed "Via Relay" even when the
call was direct P2P.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:22:00 +04:00
Siavash Sameni
0b62d3e22f fix(cli): add missing build_version fields to Offer/Answer
CLI binary was missing the new caller_build_version and
callee_build_version fields, causing E0063 compile errors on
Linux relay/client builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:09:26 +04:00
Siavash Sameni
4cfcd5117f fix(connect): install MediaPathReport oneshot BEFORE race starts
The peer's MediaPathReport can arrive while our dual_path::race is
still running. Previously, the oneshot was created AFTER the race
completed, so the recv loop had nowhere to deliver the report —
it was silently dropped, causing a 3s timeout and false relay
fallback on ~50% of calls.

Fix: create the oneshot and install it in SignalState BEFORE
starting the race. The oneshot::Receiver buffers the value so the
connect command can read it immediately after the race finishes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:06:13 +04:00
Siavash Sameni
bd6733b2e5 feat(signal): advertise build version in Offer/Answer
Add caller_build_version / callee_build_version (git short hash)
to DirectCallOffer and DirectCallAnswer so peers can identify each
other's build in debug logs. Also log own build at register time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:43:55 +04:00
Siavash Sameni
7d1b8f1fdc fix(android): add missing CallSetup pattern fields (.. rest)
The CallSetup enum gained peer_direct_addr and peer_local_addrs
in Phase 5.5 but the wzp-android signal recv match arm was never
updated, breaking cargo ndk builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:09:44 +04:00
Siavash Sameni
c2d298beb5 feat(net): Phase 7 — dual-socket IPv4+IPv6 ICE
Adds a dedicated IPv6 QUIC endpoint (IPV6_V6ONLY=1 via socket2)
alongside the existing IPv4 signal endpoint for proper dual-stack
P2P connectivity. Previous [::]:0 dual-stack attempt broke IPv4
on Android; this uses separate sockets per address family like
WebRTC/libwebrtc.

- create_ipv6_endpoint(): socket2-based IPv6-only UDP socket,
  tries same port as IPv4 signal EP, falls back to ephemeral
- local_host_candidates(v4_port, v6_port): now gathers IPv6
  global-unicast (2000::/3) and unique-local (fc00::/7) addrs
- dual_path::race(): A-role accepts on both v4+v6 via select!,
  D-role routes each candidate to matching-AF endpoint
- Graceful fallback: if IPv6 unavailable, .ok() → None → pure
  IPv4 behavior identical to pre-Phase-7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:54:13 +04:00
Siavash Sameni
aee41a638d fix(audio+net): revert dual-stack [::]:0, add Oboe playout stall auto-restart
Two fixes:

## Revert [::]:0 dual-stack sockets → back to 0.0.0.0:0

Android's IPV6_V6ONLY=1 default on some kernels (confirmed on
Nothing Phone) makes [::]:0 IPv6-only, silently killing ALL
IPv4 traffic. This broke P2P direct calls: IPv4 LAN candidates
(172.16.81.x) couldn't complete QUIC handshakes through the
IPv6-only socket, causing local_direct_ok=false and relay
fallback on every call after the first.

Reverted all bind sites to 0.0.0.0:0 (reliable IPv4). IPv6 host
candidates are disabled in local_host_candidates() until a
proper dual-socket approach (one IPv4 + one IPv6 endpoint,
Phase 7) is implemented.

## Fix A (task #35): Oboe playout callback stall auto-restart

The Nothing Phone's Oboe playout callback fires once (cb#0) and
then stops draining the ring on ~50% of cold-launch calls. Fix
D+C (stop+prime from previous commit) didn't help because
audio_stop is a no-op on cold launch.

New approach: self-healing watchdog in audio_write_playout.
Tracks the playout ring's read_idx across writes. If read_idx
hasn't advanced in 50 consecutive writes (~1 second), the Oboe
playout callback has stopped:

1. Log "playout STALL detected"
2. Call wzp_oboe_stop() to tear down the stuck streams
3. Clear both ring buffers (prevent stale data reads)
4. Call wzp_oboe_start() to rebuild fresh streams
5. Log success/failure
6. Return 0 (caller retries on next frame)

This is the same teardown+rebuild that "rejoin" does — but
triggered automatically from the first stalled call instead of
requiring the user to hang up and redial. The watchdog runs
on every write so it fires within 1s of the stall starting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:24:16 +04:00
Siavash Sameni
9fb92967eb fix(net): bind all endpoints to [::]:0 for dual-stack IPv4+IPv6
Every QUIC endpoint was bound to 0.0.0.0:0 (IPv4-only). This
silently killed ALL IPv6 host candidates: the Dialer couldn't
send packets to [2a0d:...] addresses (wrong address family on
the socket), and the Acceptor couldn't receive incoming IPv6
QUIC handshakes. The IPv6 candidates were gathered and advertised
in DirectCallOffer/Answer but were completely non-functional.

On same-LAN with dual-stack (which both test phones have), this
meant:
- JoinSet fanned out 3+ candidates (2× IPv6 + 1× IPv4)
- IPv6 dials failed silently or timed out
- IPv4 dial worked but competed with failed IPv6 for JoinSet
  attention
- Sometimes the JoinSet returned an IPv6 failure before the
  IPv4 success, causing unnecessary fallback to relay

Fix: bind to [::]:0 (IPv6 any) instead of 0.0.0.0:0. On
dual-stack systems (Linux/Android default), [::]:0 creates a
socket that handles BOTH:
- IPv6 natively (global unicast, ULA)
- IPv4 via v4-mapped addresses (::ffff:172.16.81.x)

One socket, both protocols. All 7 bind sites updated:
- register_signal (signal endpoint)
- do_register_signal
- ping_relay
- probe_reflect_addr (fresh endpoint fallback)
- dual_path::race (A-role fresh, D-role fresh, relay fresh)

With this fix, same-LAN P2P should prefer the IPv6 path (no
NAT, direct routing, lower latency) and fall through to IPv4
if IPv6 fails — relay is the last resort after ALL candidates
are exhausted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:09:06 +04:00
Siavash Sameni
9f2ff6a6ec fix(android-audio): Fix D+C — stop+prime cycle on every call start
Addresses the first-join no-audio regression (tasks #35-37) where
the Oboe playout callback fires once (cb#0) and then stops
draining the ring on the Nothing Phone, causing written_samples
to freeze at 7679 (ring capacity minus one burst). Second call
(rejoin) always works because audio_stop tears down the streams
and audio_start rebuilds them fresh.

Two combined fixes:

**Fix D (task #37)**: always call audio_stop() before audio_start()
at the top of CallEngine::start. On a cold launch this is a no-op
(streams not yet started). On subsequent calls it guarantees a
clean teardown before rebuild — the same thing rejoin does. Added
a 50ms pause between stop and start to let the Android HAL release
the audio session.

**Fix C (task #36)**: after audio_start(), immediately write 960
samples (20ms) of silence into the playout ring. This ensures the
Oboe playout callback has data to drain on its first invocation.
On devices where an empty-ring first callback causes the stream
to self-pause (Nothing Phone's Qualcomm HAL), the priming data
keeps the callback loop alive until real decoded audio arrives
from the recv task.

Together these cover the two most likely root causes:
1. Stale Oboe state from a previous audio_start that didn't
   clean up properly → Fix D forces a clean rebuild
2. Playout callback self-pausing on an empty ring → Fix C
   ensures the ring is non-empty at callback time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:50:58 +04:00
Siavash Sameni
134ee3a77f fix(engine): pass is_direct_p2p explicitly instead of deriving from is_some
Critical Phase 6 bug: when the negotiation agreed on relay path
but delivered the relay transport via pre_connected_transport,
CallEngine saw is_some() = true → is_direct_p2p = true → skipped
perform_handshake. The relay couldn't authenticate the participant
→ room join silently failed → recv_fr: 0, both sides sending
into the void.

Fix: add explicit is_direct_p2p: bool parameter to CallEngine::
start (both android and desktop branches). The connect command
sets it from the Phase 6 negotiation result (use_direct), not
from whether pre_connected_transport is Some.

Now relay-negotiated calls correctly run perform_handshake,
and direct P2P calls correctly skip it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:34:21 +04:00
Siavash Sameni
e61397ca85 fix(connect): remove pre-Phase-6 same-IP heuristic
The commit de007ec added a heuristic that forced relay-only when
peers had different public IPs. That was a stopgap for the race
condition where one side picked Direct and the other picked Relay.
Phase 6 (f5542ef) solved this properly via MediaPathReport
negotiation, but the heuristic wasn't cleaned up and was still
running BEFORE the Phase 6 code — suppressing the race entirely
for cross-network calls.

Removed. Phase 6 negotiation now handles ALL cases: both sides
race, exchange reports, and agree on the same path before
committing media. Cross-network calls that can't go P2P will
have both sides report direct_ok=false and agree on relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:23:36 +04:00
Siavash Sameni
f5542ef822 feat(p2p): Phase 6 — ICE-style path negotiation
Before Phase 6, each side's dual-path race ran independently and
committed to whichever transport completed first. When one side
picked Direct and the other picked Relay, they sent media to
different places — TX > 0 RX: 0 on both, completely silent call.

Phase 6 adds a negotiation step: after the local race completes,
each side sends a MediaPathReport { call_id, direct_ok, winner }
to the peer through the relay. Both wait for the other's report
before committing a transport to the CallEngine. The decision
rule is simple: if BOTH report direct_ok = true, use direct; if
EITHER reports false, BOTH use relay.

## Wire protocol

New `SignalMessage::MediaPathReport { call_id, direct_ok,
race_winner }`. The relay forwards it to the call peer via the
same signal_hub routing used for DirectCallOffer/Answer. The
cross-relay dispatcher also forwards it.

## dual_path::race restructured

Returns `RaceResult` instead of `(Arc<QuinnTransport>, WinningPath)`:
- `direct_transport: Option<Arc<QuinnTransport>>`
- `relay_transport: Option<Arc<QuinnTransport>>`
- `local_winner: WinningPath`

Both paths are run as spawned tasks. After the first completes,
a 1s grace period lets the loser also finish. The connect
command gets BOTH transports (when available) and picks the
right one based on the negotiation outcome. The unused transport
is dropped.

## connect command flow (revised)

1. Run race() → RaceResult with both transports
2. Send MediaPathReport to relay with our direct_ok
3. Install oneshot; wait for peer's report (3s timeout)
4. Decision: both direct_ok → use direct; else → use relay
5. Start CallEngine with the agreed transport

If the peer never responds (old build, timeout), falls back to
relay — backward compatible.

## Relay forwarding

MediaPathReport is forwarded like DirectCallOffer/Answer: via
signal_hub.send_to(peer_fp) for same-relay calls, and via
cross-relay dispatcher for federated calls.

## Debug log events

- `connect:dual_path_race_done` — local race result
- `connect:path_report_sent` — our report to the peer
- `connect:peer_report_received` — peer's report
- `connect:peer_report_timeout` — peer didn't respond (3s)
- `connect:path_negotiated` — final agreed path with reasons

Full workspace test: 423 passing (no regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:03:42 +04:00
Siavash Sameni
de007ec2fd fix(p2p): skip direct P2P when peers are on different public IPs
Race condition: when two phones are on different networks (WiFi
vs LTE, home vs office, etc.), each side's dual-path race runs
independently. One side may pick Direct while the other picks
Relay, causing both to send media to different places — TX > 0,
RX: 0 on both sides, completely silent call.

Root cause: the dual-path race doesn't have a negotiation step.
Each side picks the first transport that completes a QUIC
handshake, which may be a different path than the other side
picked. On same-LAN this doesn't matter because direct always
wins on both (the 500ms relay delay guarantees it). On cross-
network, the asymmetry bites.

Heuristic fix: compare own_reflex_addr IP to peer_reflex_addr
IP. If they're different → different networks → force relay-only
(set role = None, which skips the dual-path race entirely).

Same public IP means same LAN / same NAT:
  → LAN host candidates work, direct always wins on both sides
  → Safe for P2P

Different public IPs means cross-network:
  → Direct may work on one side but not the other
  → Relay is the safe choice for both

This preserves the proven same-LAN P2P and eliminates the broken
cross-network case. The full fix is ICE-style path negotiation
(Phase 6) where both sides exchange connectivity check results
through the signal plane and agree on a winner before committing
media — but that's a 500+ line protocol change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:50:56 +04:00
Siavash Sameni
0a973b234b fix(engine): import tauri::Emitter for AppHandle::emit on Android target 2026-04-12 09:29:56 +04:00
Siavash Sameni
026940d492 fix(federation): diagnostic logging for cross-relay media routing
Added warn-level log in handle_datagram when a federation
datagram arrives but no matching local room is found. Prints:
- room_hash (8-byte tag from the datagram)
- active_rooms (all rooms the relay currently has)
- seq + peer label

This diagnoses the cross-relay recv_fr=0 issue: if media IS
arriving from the peer relay but the room hash doesn't match any
active room, the log tells us exactly what hash is expected vs
what rooms exist locally. If no datagram log fires at all, the
issue is upstream (peer relay not forwarding, federation link
down, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:27:34 +04:00
Siavash Sameni
0ccf4ed6b5 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>
2026-04-12 09:18:38 +04:00
Siavash Sameni
847699bf66 fix(ui): pre-flight ping + cancel button for register
Two UX issues when the selected relay is unreachable (e.g. user
switched from WiFi to LTE and the LAN relay is gone):

1. Pressing Register blocked the UI for ~30s while the QUIC
   connect timed out against a dead host. No way to abort.
2. No feedback that the relay was unreachable — just a long
   wait followed by a cryptic error.

Fix:

**Pre-flight ping**: before attempting the full register flow,
run `ping_relay` (existing Tauri command, 3s QUIC handshake
timeout). If it fails, immediately show "Server unavailable:
<error>" and re-enable the Register button. No blocking, no
wasted time. If it succeeds, proceed to register_signal.

**Cancel button**: during the register_signal await, the
Register button becomes "Cancel". Tapping it calls `deregister`
which closes the in-flight transport and makes the connect
fail immediately, breaking the await. The button goes back to
"Register on Relay" with a "Registration cancelled" message.

Flow:
  [Register] → "Checking..." (disabled, 3s ping) →
    ping fails → "Server unavailable" (re-enabled)
    ping ok → "Cancel" (enabled, register in flight) →
      user taps Cancel → "Registration cancelled" (re-enabled)
      register succeeds → registered panel shown
      register fails → error shown (re-enabled)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:13:35 +04:00
Siavash Sameni
6cd61fc63b feat(federation): Phase 4.1 — call-* rooms are implicitly global
All rooms with names starting with 'call-' are now treated as
global rooms by the federation pipeline. This enables relay-
mediated media fallback for cross-relay direct calls: when Alice
on Relay A and Bob on Relay B both join the same call-<id> room,
the federation media forwarding pipeline (GlobalRoomActive
announcements + datagram forwarding + presence replication)
kicks in automatically without any runtime registration step.

Previously, cross-relay direct calls that couldn't go P2P
(symmetric NAT on either side) failed with "no media path"
because the call-<id> room wasn't in the configured global_rooms
set and media datagrams weren't forwarded across the federation
link.

The relay's existing ACL for call-* rooms (only the two
authorized fingerprints from the call registry can join)
prevents random clients from creating or eavesdropping on
call rooms.

## Changes

### `is_global_room` (federation.rs)
Added `room.starts_with("call-")` check before the static
global_rooms set lookup. Returns true immediately for any
call-prefixed room.

### `resolve_global_room` (federation.rs)
Return type changed from `Option<&str>` to `Option<String>`
(owned) because call-* room names aren't stored on `self` —
they come from the caller and resolve to themselves as the
canonical name. The 13 callers continue to work via String/&str
auto-deref; 4 HashMap lookups needed explicit `.as_str()` or
`&` borrows.

Full workspace test: 423 passing (no regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:55:01 +04:00
Siavash Sameni
50e6a50de4 feat(ui): phone-style layout for direct calls
The call screen now shows two different layouts depending on
whether the call is a 1:1 direct call or a room/group call:

**Direct call (directCallPeer set):**
- Large centered identicon (96px circular with glow)
- Peer name (22px bold) + fingerprint (11px mono)
- Connection badge: "P2P Direct" (green), "Via Relay" (blue),
  or "Connecting..." (yellow) — auto-detected from the
  call-debug buffer's dual_path_race_won event
- Room name header shows the peer's alias/fp instead of "general"
- Group participant list is hidden

**Room/group call (directCallPeer null):**
- Existing group participant list layout — unchanged

The badge updates live from pollStatus by scanning the debug
buffer for the connect:dual_path_race_won event. If the path
was "Direct" → green P2P badge; if "Relay" → blue relay badge.
Before the race resolves, shows yellow "Connecting...".

directCallView is cleared on showConnectScreen (call end).

CSS in style.css: .direct-call-view, .dc-identicon, .dc-name,
.dc-fp, .dc-badge with .relay and .connecting modifiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:47:13 +04:00
Siavash Sameni
0cb8d34b21 fix(ui): show peer identity on direct P2P calls instead of "Waiting for participants"
On relay-mediated calls, the relay broadcasts RoomUpdate with the
participant list and pollStatus renders it. On direct P2P calls
neither peer joins the relay's media room, so RoomUpdate never
fires and the UI showed "Waiting for participants..." even though
audio was flowing bidirectionally.

Fix: track the peer's identity (fingerprint + alias) from the
signal plane in a `directCallPeer` variable:

- Set on incoming call from the DirectCallOffer (caller_fp +
  caller_alias)
- Set on outgoing call from the Call button click (target_fp)
- Cleared on showConnectScreen (call ended)

pollStatus now checks: if the engine's participant list is empty
AND directCallPeer is set, inject a synthetic participant entry
with relay_label = "P2P Direct". The participant row renders with
identicon + fingerprint + alias as normal, but grouped under a
"P2P Direct" header instead of "This Relay".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:26:17 +04:00
Siavash Sameni
2427630472 fix(connect): make peerLocalAddrs optional + skip handshake on direct P2P
Two regressions from Phase 5.5/5.6:

1. Room connect broken: the connect Tauri command required
   peerLocalAddrs as a Vec<String>, but the room-join JS path
   doesn't pass it (only the direct-call setup handler does).
   Error: "invalid args 'peerLocalAddrs' for command 'connect':
   command connect missing required key peerLocalAddrs".

   Fix: change to Option<Vec<String>>, unwrap_or_default() at
   usage sites. Room connect works again with zero peer addrs.

2. Direct P2P call connects but then CallEngine fails with
   "expected CallAnswer, got Discriminant(0)". Root cause: after
   the dual-path race picked a direct P2P transport, CallEngine
   still ran perform_handshake() on it. That handshake is a
   relay-specific protocol — sends a CallOffer signal and waits
   for CallAnswer back. On a direct QUIC connection to a phone,
   there's nobody running accept_handshake, so the handshake
   reads garbage from the peer's first media packet and errors.

   Fix: track is_direct_p2p = pre_connected_transport.is_some()
   and skip perform_handshake when true. The direct connection
   is already TLS-encrypted by QUIC, and both peers' identities
   were verified through the signal channel (DirectCallOffer/
   Answer carry identity_pub + ephemeral_pub + signature). Both
   android and desktop branches updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:09:32 +04:00
Siavash Sameni
16793be36f fix(p2p): Phase 5.6 — direct-path head start + hangup propagation + media debug events
Three fixes from a field-test log where same-LAN calls were
still losing the dual-path race to the relay path, peers were
getting stuck on an empty call screen when the other side
hung up, and 1-way audio was hard to diagnose because the
GUI debug log had no media-level events.

## 1. Direct-path 500ms head start (dual_path.rs)

The race was resolving in ~105ms with Relay winning even when
both phones were on the same MikroTik LAN with valid IPv6 host
candidates. Root cause: the relay dial is a plain outbound QUIC
connect that completes in whatever the client→relay RTT is
(~100ms), while the direct path needs the PEER to also process
its CallSetup, spin up its own race, and complete at least one
LAN dial back to us. That cross-client sequence reliably takes
longer than 100ms, so relay always won.

Fix: delay the relay_fut with `tokio::time::sleep(500ms)` before
starting its connect. Same-LAN direct dials complete in 30-50ms
typically, so the head start gives direct plenty of time to win
cleanly. Users on setups where direct genuinely can't work
(LTE-to-LTE cross-carrier) pay 500ms extra on the relay fallback,
which is invisible for a call setup.

## 2. Hangup propagation via a new hangup_call command (lib.rs + main.ts)

The hangup button was calling `disconnect` which stopped the
local media engine but never sent a SignalMessage::Hangup to
the relay. The peer never got notified and was stuck on the
call screen with silent audio. My earlier fix (commit e75b045)
only handled the RECEIVE side — auto-dismiss call screen on
recv:Hangup — but the SEND side was still missing.

New Tauri command `hangup_call`:
  1. Acquire state.signal.lock(), send SignalMessage::Hangup
     over the signal transport (best-effort; log + continue if
     signal is down)
  2. Acquire state.engine.lock(), stop the CallEngine

JS hangupBtn click handler now calls hangup_call with a fallback
to raw disconnect if the command is missing (older builds).

## 3. Media debug events (engine.rs + lib.rs)

Threaded tauri::AppHandle into CallEngine::start so the send/
recv tasks can emit call-debug events when the user has debug
logs enabled. Added on the Android branch (desktop branch
accepts the arg for API symmetry but doesn't emit yet):

  - media:first_send — emitted when the first encoded frame is
    handed to the transport. Useful for 1-way audio diagnosis:
    if this fires on side A but side B never sees media:first_recv,
    A's outbound is broken.
  - media:first_recv — emitted when the first packet from the
    peer arrives. Mirror of first_send.
  - media:send_heartbeat — every 2s with frames_sent, last_rms,
    last_pkt_bytes, short_reads, drops. A stalled last_rms
    (== 0) tells you the mic isn't producing samples; a frozen
    frames_sent tells you the encode pipeline hung.
  - media:recv_heartbeat — every 2s with recv_fr, decoded_frames,
    last_written, written_samples, decode_errs, codec. Mirror
    invariants for the inbound direction.

All four are gated by `call_debug_logs_enabled()` via
`emit_call_debug`, so they only show up in the GUI log when the
user has the Call Flow Debug Logs checkbox on. Tracing::info!
still runs unconditionally so logcat (adb) keeps its copy
regardless.

The `emit_call_debug` fn in lib.rs is now `pub(crate)` so
engine.rs can call it via `crate::emit_call_debug`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 07:55:41 +04:00
Siavash Sameni
fa038df057 feat(p2p): Phase 5.5 — ICE LAN host candidates (IPv4 + IPv6)
Same-LAN P2P was failing because MikroTik masquerade (like most
consumer NATs) doesn't support NAT hairpinning — the advertised
WAN reflex addr is unreachable from a peer on the same LAN as
the advertiser. Phase 5 got us Cone NAT classification and fixed
the measurement artifact, but same-LAN direct dials still had
nowhere to land.

Phase 5.5 adds ICE-style host candidates: each client enumerates
its LAN-local network interface addresses, includes them in the
DirectCallOffer/Answer alongside the reflex addr, and the
dual-path race fans out to ALL peer candidates in parallel.
Same-LAN peers find each other via their RFC1918 IPv4 + ULA /
global-unicast IPv6 addresses without touching the NAT at all.

Dual-stack IPv6 is in scope from the start — on modern ISPs
(including Starlink) the v6 path often works even when v4
hairpinning doesn't, because there's no NAT on the v6 side.

## Changes

### `wzp_client::reflect::local_host_candidates(port)` (new)

Enumerates network interfaces via `if-addrs` and returns
SocketAddrs paired with the caller's port. Filters:

- IPv4: RFC1918 (10/8, 172.16/12, 192.168/16) + CGNAT (100.64/10)
- IPv6: global unicast (2000::/3) + ULA (fc00::/7)
- Skipped: loopback, link-local (169.254, fe80::), public v4
  (already covered by reflex-addr), unspecified

Safe from any thread, one `getifaddrs(3)` syscall.

### Wire protocol (wzp-proto/packet.rs)

Three new `#[serde(default, skip_serializing_if = "Vec::is_empty")]`
fields, backward-compat with pre-5.5 clients/relays by
construction:

- `DirectCallOffer.caller_local_addrs: Vec<String>`
- `DirectCallAnswer.callee_local_addrs: Vec<String>`
- `CallSetup.peer_local_addrs: Vec<String>`

### Call registry (wzp-relay/call_registry.rs)

`DirectCall` gains `caller_local_addrs` + `callee_local_addrs`
Vec<String> fields. New `set_caller_local_addrs` /
`set_callee_local_addrs` setters. Follow the same pattern as
the reflex addr fields.

### Relay cross-wiring (wzp-relay/main.rs)

Both the local-call and cross-relay-federation paths now track
the local_addrs through the registry and inject them into the
CallSetup's peer_local_addrs. Cross-wiring is identical to the
existing peer_direct_addr logic — each party's CallSetup
carries the OTHER party's LAN candidates.

### Client side (desktop/src-tauri/lib.rs)

- `place_call`: gathers local host candidates via
  `local_host_candidates(signal_endpoint.local_addr().port())`
  and includes them in `DirectCallOffer.caller_local_addrs`.
  The port match is critical — it's the Phase 5 shared signal
  socket, so incoming dials to these addrs land on the same
  endpoint that's already listening.
- `answer_call`: same, AcceptTrusted only (privacy mode keeps
  LAN addrs hidden too, for consistency with the reflex addr).
- `connect` Tauri command: new `peer_local_addrs: Vec<String>`
  arg. Builds a `PeerCandidates` bundle and passes it to the
  dual-path race.
- Recv loop's CallSetup handler: destructures + forwards the
  new field to JS via the signal-event payload.

### `dual_path::race` (wzp-client/dual_path.rs)

Signature change: takes `PeerCandidates` (reflex + local Vec)
instead of a single SocketAddr. The D-role branch now fans out
N parallel dials via `tokio::task::JoinSet` — one per candidate
— and the first successful dial wins (losers are aborted
immediately via `set.abort_all()`). Only when ALL candidates
have failed do we return Err; individual candidate failures are
just traced at debug level and the race waits for the others.

LAN host candidates are tried BEFORE the reflex addr in
`PeerCandidates::dial_order()` — they're faster when they work,
and the reflex addr is the fallback for the not-on-same-LAN
case.

### JS side (desktop/main.ts)

`connect` invoke now passes `peerLocalAddrs: data.peer_local_addrs ?? []`
alongside the existing `peerDirectAddr`.

### Tests

All existing test callsites updated for the new Vec<String>
fields (defaults to Vec::new() in tests — they don't exercise
the multi-candidate path). `dual_path.rs` integration tests
wrap the single `dead_peer` / `acceptor_listen_addr` in a
`PeerCandidates { reflexive: Some(_), local: Vec::new() }`.

Full workspace test: 423 passing (same as before 5.5).

## Expected behavior on the reporter's setup

Two phones behind MikroTik, both on the same LAN:

  place_call:host_candidates {"local_addrs": ["192.168.88.21:XXX", "2001:...:YY:XXX"]}
  recv:DirectCallAnswer {"callee_local_addrs": ["192.168.88.22:ZZZ", "2001:...:WW:ZZZ"]}
  recv:CallSetup {"peer_direct_addr":"150.228.49.65:NN",
                  "peer_local_addrs":["192.168.88.22:ZZZ","2001:...:WW:ZZZ"]}
  connect:dual_path_race_start {"peer_reflex":"...","peer_local":[...]}
  dual_path: direct dial succeeded on candidate 0   ← LAN v4 wins
  connect:dual_path_race_won {"path":"Direct"}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 07:34:49 +04:00
Siavash Sameni
8990514417 fix(call): default Accept to AcceptTrusted + add log Copy/Share buttons
## Accept button regression — diagnosed from a user log

Field report: incoming call → callee taps Accept → debug log
shows the dual-path race being skipped with
`connect:dual_path_skipped {"has_own":false,"has_peer":true,
"role":"None"}` and the call falling to relay-only on the
callee side.

Root cause: the Accept button was calling `answer_call` with
`mode: 2` which falls through to `AcceptGeneric` (privacy
mode). By design, privacy mode SKIPS the reflex query on the
callee so the callee's IP stays hidden from the caller — but
the side effect is that `own_reflex_addr` never gets cached in
`SignalState`. When `connect` runs a moment later, it sees
`own_reflex_addr = None`, can't compute the deterministic role
for the dual-path race, and falls back to relay.

For a normal VoIP app where P2P is the desired default, the
right behavior is `AcceptTrusted` — which queries reflect,
advertises the callee's addr in the answer, and enables direct
P2P. Privacy mode can come back as a dedicated second button
if anyone actually needs it.

Changed `acceptCallBtn` click handler from `mode: 2` to
`mode: 1`. The next call from a Phase-5 APK should show
`connect:dual_path_race_start` + `connect:dual_path_race_won
{"path":"Direct"}` on a cone-NAT-to-cone-NAT pair.

## Debug log export — new Copy / Share buttons

Field-testing the GUI debug log required me to keep asking the
user to type out what they saw. Added two new buttons next to
Clear:

- **Copy log** — serialises the rolling buffer as plain text
  (same HH:MM:SS.mmm format the on-screen panel uses) and
  writes to `navigator.clipboard`. Falls back to the old
  selection-based `execCommand("copy")` for WebViews that
  refuse the new API without a permission prompt.

- **Share** — tries the Web Share API (`navigator.share(...)`)
  first. On Android WebView this opens the system share sheet
  so the user can send the text straight to a messaging app.
  Falls back to clipboard copy on WebViews that don't expose
  navigator.share (most desktop ones). Also falls back if the
  user cancels the share sheet.

Flash status line below the buttons shows a 2.5s confirmation
("✓ Copied 47 entries") or an error hint. The log is plain
text so anyone can paste a log fragment into a message and
send it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 07:04:46 +04:00
Siavash Sameni
1618ff6c9d feat(p2p): Phase 5 — single-socket architecture (Nebula-style)
Before Phase 5 WarzonePhone used THREE separate UDP sockets per
client:

  1. Signal endpoint         (register_signal, client-only)
  2. Reflect probe endpoints (one fresh socket per relay probe)
  3. Dual-path race endpoint (fresh per call setup)

This broke two things in production on port-preserving NATs
(MikroTik masquerade, most consumer routers):

  a. Phase 2 NAT detection was WRONG. Each probe used a fresh
     internal port, so MikroTik mapped each one to a different
     external port, and the classifier saw "different port per
     relay" and labeled it SymmetricPort. The real NAT was
     cone-like but measurement via fresh sockets hid that.

  b. Phase 3.5 dual-path P2P race was BROKEN. The reflex addr
     we advertised in DirectCallOffer was observed by the signal
     endpoint's socket. The actual dual-path race listened on a
     DIFFERENT fresh socket, on a different internal (and
     therefore external) port. Peers dialed the advertised addr
     and hit MikroTik's mapping for the signal socket, which
     forwarded to the signal endpoint — a client-only endpoint
     that doesn't accept incoming connections. Direct path
     silently failed, relay always won the race.

Nebula-style fix: one socket for everything. The signal endpoint
is now dual-purpose (client + server_config), and both the
reflect probes and the dual-path race reuse it instead of
creating fresh ones. MikroTik's port-preservation then gives us
a stable external port across all flows → classifier correctly
sees Cone NAT → advertised reflex addr is the actual listening
port → direct dials from peers land on the right socket →
`endpoint.accept()` in the A-role branch of the dual-path race
picks up the incoming connection.

## Changes

### `register_signal` (desktop/src-tauri/src/lib.rs)
- Endpoint now created with `Some(server_config())` instead of
  `None`. The socket can now accept incoming QUIC connections as
  well as dial outbound.
- Every code path that previously read `sig.endpoint` for the
  relay-dial reuse benefits automatically — same socket is now
  ALSO listening for peer dials.

### `probe_reflect_addr` (wzp-client/src/reflect.rs)
- New `existing_endpoint: Option<Endpoint>` arg. `Some` reuses
  the caller's socket (production: pass the signal endpoint).
  `None` creates a fresh one (tests + pre-registration).
- Removed the `drop(endpoint)` at the end — was correct for
  fresh endpoints (explicit early socket close) but incorrect
  for shared ones. End-of-scope drop does the right thing in
  both cases via Arc semantics.

### `detect_nat_type` (wzp-client/src/reflect.rs)
- New `shared_endpoint: Option<Endpoint>` arg, forwarded to
  every probe in the JoinSet fan-out. One shared socket means
  the classifier sees the true NAT type.

### `detect_nat_type` Tauri command (desktop/src-tauri/src/lib.rs)
- Reads `state.signal.endpoint` and passes it as the shared
  endpoint. Falls back to None when not registered. NAT detection
  now produces accurate classifications against MikroTik / most
  consumer NATs.

### `dual_path::race` (wzp-client/src/dual_path.rs)
- New `shared_endpoint: Option<Endpoint>` arg.
- A-role: when `Some`, reuses it for `accept()`. This is the
  critical change — the reflex addr advertised to peers is now
  the address listening for incoming direct dials.
- D-role: when `Some`, reuses it for the outbound direct dial.
  MikroTik keeps the same external port for the dial as for
  the signal flow → direct dial through a cone-mapped NAT.
- Relay path: also reuses the shared endpoint so MikroTik has
  a single consistent mapping across the whole call (saves one
  extra external port and makes firewall traces cleaner).
- When `None`, falls back to fresh per-role endpoints as before.

### `connect` Tauri command (desktop/src-tauri/src/lib.rs)
- Reads `state.signal.endpoint` once when acquiring own reflex
  addr and passes it through to `dual_path::race`.

### Tests
- `wzp-client/tests/dual_path.rs` and
  `wzp-relay/tests/multi_reflect.rs` updated to pass `None` for
  the new endpoint arg — tests use fresh sockets and that's
  fine because the loopback harness doesn't care about
  port-preserving NAT behavior.

Full workspace test: 423 passing (no regressions).

## Expected behavior after this commit on real hardware

Behind MikroTik + Starlink-bypass (the reporter's setup):
- Phase 2 NAT detect → **Cone NAT** (was SymmetricPort — false
  positive from the measurement artifact)
- Phase 3.5 direct-P2P dial → succeeds for both cone-cone and
  cone-CGNAT cases where the remote side was previously blocked
  by our own socket mismatch
- LTE ↔ LTE cross-carrier → still likely relay fallback; that's
  genuinely strict symmetric and needs Phase 5.5 port prediction.

## Phase 5.5 (next, separate PRD)

Multi-candidate port prediction + ICE-style candidate aggregation
for truly strict symmetric NATs. Not needed for the 95% case —
Phase 5 alone fixes most consumer-router setups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:47:20 +04:00
Siavash Sameni
05ec926317 fix(ui): don't nuke the registered panel's children on status update
Regression from 20375ec: the `signal-event reconnecting` and
`signal-event registered` handlers were assigning to
`directRegistered.textContent`, which is the PARENT element that
holds the entire registered UI — the "Registered — waiting"
header, incoming-call panel, recent-contacts section, call
history, the fingerprint-input bar, and the Call button. Setting
textContent on that parent wiped every child with a single text
node, so after registration the user saw " Registered" with
NOTHING below it — no call input, no history, no call button.
App unusable post-registration.

Fix:
- Add a dedicated `#registered-status` <p> inside the header of
  `#direct-registered` (this element already existed as a plain
  paragraph without an id; just giving it an id).
- Rewrite both handlers to target that element by id instead of
  the parent, so `textContent =` only touches the status line
  and leaves the rest of the panel intact.
- The `registered` handler now also explicitly
  `registerBtn.classList.add("hidden")` and
  `directRegistered.classList.remove("hidden")` so the first
  register event correctly reveals the UI. Belt-and-braces for
  the transparent-reconnect case too — if the supervisor
  re-registers after a drop, the UI stays in the registered
  state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:28:16 +04:00
Siavash Sameni
b7a48bf13b feat(ui): incoming-call ring tone + system notification
Previously: incoming calls silently popped an "Accept/Reject"
panel. Easy to miss — no audible cue, no system-level alert if
the app was backgrounded. Now the incoming-call path triggers
both a synthesized ring tone and a system notification banner.

## Ring tone (desktop/src/main.ts)

New `Ringer` class using Web Audio API directly — no external
asset files, no new npm dep. Synthesizes a classic NANP two-tone
cadence (440Hz + 480Hz sine mix, 2s tone + 4s silence, looped)
through an envelope-gated gain node that ramps on/off to avoid
clicks. Audible on every Tauri-supported platform because
WebView carries Web Audio.

- `start()` — lazily creates AudioContext on first use
  (platforms that require a user gesture for AudioContext
  creation still work because the incoming-call event is
  user-adjacent from the webview's perspective), starts
  setInterval(6000) loop.
- `stop()` — clears the timer AND disconnects any active
  oscillators so there's no tail audio.
- Active-nodes array is swept every cycle so it doesn't grow
  unbounded across long rings.

Hooked into signal-event handlers:
- `"incoming"` → `ringer.start()` + notifyIncomingCall
- `"answered"`, `"setup"`, `"hangup"` → `ringer.stop()`
- Accept/Reject button click handlers → `ringer.stop()` as
  the first thing they do (before any await)

## System notification (desktop/src-tauri + main.ts)

Added `tauri-plugin-notification = "2"` to the Tauri app and
registered in the builder. Capabilities updated with the four
notification permissions.

Frontend calls the plugin commands via the generic `invoke`
instead of adding `@tauri-apps/plugin-notification` as a JS
dep — Tauri plugins expose `plugin:notification|notify` etc.
directly. Flow:

1. `is_permission_granted` — check cached
2. If not granted → `request_permission` (Android prompts the
   user once, cached thereafter)
3. `notify` with title="Incoming call", body="From <alias>"

All wrapped in try/catch with console.debug fallback — plugin
missing or permission denied is non-fatal, the visible panel +
ring tone still alert the user.

## Known gaps (deferred)

- Android native system ringtone (RingtoneManager) + full-
  screen intent for lockscreen-visible ringer. Requires
  platform-specific Java/Kotlin glue in the Tauri Android
  shell — bigger lift.
- Desktop window flash / taskbar attention-seek on incoming
  call when app is backgrounded.
- Vibration pattern on Android.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:46:13 +04:00
Siavash Sameni
e75b045470 fix(ui): auto-dismiss call screen when peer hangs up
Previously: peer hangs up → Rust emits signal-event {type:hangup}
→ JS clears callStatusText + hides incoming panel, but the call
screen stays on with a dangling Hangup button the user has to
press to acknowledge a call that's already over. Dead UX.

Now: the hangup event handler tears down our side of the media
engine via `invoke("disconnect")` and transitions back to the
connect screen when we're currently in the call screen.
Incoming-call panel still hides as before.

`userDisconnected = true` is set so the existing call-event
"disconnected" auto-reconnect path (which fires on transport
drop) doesn't kick in — the peer-hangup signal is an intentional
end-of-call, not a transport blip worth retrying.

Also documented: "not connected" errors from the `disconnect`
command are silently swallowed because they happen when there's
no engine to tear down (e.g. incoming call that was never
answered — caller bailed), which is the correct outcome there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:41:26 +04:00
Siavash Sameni
20375eceb9 feat(signal): transparent reconnect + auto-swap on relay change
Two related UX fixes, same state-machine surface:

1. Relay drops / goes offline / restarts: the client now auto-
   reconnects in the background instead of silently falling to
   "not registered" and requiring the user to tap Deregister +
   Register.
2. User switches relay in settings: client auto-swaps — close
   old transport, register against new, all transparent.

## Signal state additions (desktop/src-tauri/src/lib.rs)

- `SignalState.desired_relay_addr: Option<String>` — what the
  user CURRENTLY wants. `Some(x)` means "keep me connected to x",
  `None` means "user explicitly asked for idle". This is the
  pivot that distinguishes "connection dropped, retry" from
  "user deregistered, stop".
- `SignalState.reconnect_in_progress: bool` — single-flight
  guard so concurrent triggers (recv-loop exit + manual
  register_signal + another recv-loop exit after a brief
  success) don't spawn duplicate supervisors.

## Refactor

The old `register_signal` Tauri command was doing the whole
connect + Register + spawn-recv-loop flow inline. Split into:

- `internal_deregister(signal_state, keep_desired)` — shared
  teardown helper that nulls out transport/endpoint/call state
  and optionally clears `desired_relay_addr`.
- `do_register_signal(signal_state, app, relay)` — core
  connect + register + spawn-recv-loop flow, callable from both
  the Tauri command and the reconnect supervisor. Returns an
  explicit `impl Future<...> + Send` to avoid auto-trait
  inference bailing inside the tokio::spawn chain (rustc loses
  the Send trail through the recv-loop spawn inside the fn
  body).
- `register_signal` Tauri command — now thin: if already
  registered to the same relay, no-op; otherwise
  internal_deregister(keep_desired=false), set
  desired_relay_addr = Some(new), call do_register_signal. The
  Rust side handles the "change of server" transition entirely
  on its own, no deregister+register dance from JS needed.
- `deregister` Tauri command — internal_deregister(keep_desired
  = false) so the recv-loop exit path sees the cleared desired
  addr and does NOT spawn a supervisor.

## Reconnect supervisor

New `signal_reconnect_supervisor(signal_state, app, relay)`
task. Spawned from the recv-loop exit path when the loop exits
unexpectedly AND `desired_relay_addr.is_some()` AND no
supervisor is already running.

- Exponential backoff: 1s, 2s, 4s, 8s, 15s, 30s (capped at 30s,
  never gives up). First attempt is immediate (attempt 0 skips
  the wait).
- On each iteration checks whether `desired_relay_addr` was
  cleared (user deregistered mid-flight) or another path
  already re-registered; either short-circuits the supervisor.
- Also detects if the user changed relays while the supervisor
  was sleeping — resets the backoff counter and retries against
  the new addr.
- On success, exits so the newly-spawned recv loop owns the
  connection from that point. If THAT drops again, a fresh
  supervisor spawns.
- Emits `call-debug-log` and `signal-event` events at every
  state transition so the GUI can display "reconnecting...",
  "registered" banners.

## UI wiring (desktop/src/main.ts)

- signal-event handler gets two new cases:
  - `"reconnecting"` — amber "🔄 reconnecting to <relay>…" in
    the registered banner area
  - `"registered"` — green "✓ registered (<fp prefix>…)" to
    clear the reconnecting badge
- Relay-selection click handler checks if a signal is
  currently registered and, if the user picked a different
  relay, fires `register_signal` with the new address. Rust
  side handles the swap transparently.

Full workspace test: 423 passing (no regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:40:11 +04:00
Siavash Sameni
00deb97a5d fix(reflect): drop LAN/private reflex addrs from NAT classification
Real-world report: a user with one LAN relay + one internet relay
got "Multiple IPs — treating as symmetric" because the LAN relay
saw the client's LAN IP (172.16.81.172) while the internet relay
saw the WAN IP (150.228.49.65). Two observations of "different
public IPs" from the classifier's perspective, but semantically
they describe two different network paths and shouldn't be
compared.

The LAN relay's reflection is always true, just not useful for
public NAT classification: there's no NAT between the client and
the LAN relay, so that path's reflex addr is always the LAN
interface IP regardless of what the public-facing NAT beyond it
looks like.

Fix: new `is_private_or_loopback` helper filters the probe set
before classification. Drops:
 - 127.0.0.0/8 loopback
 - 10/8, 172.16/12, 192.168/16 RFC1918 private
 - 169.254/16 link-local
 - 100.64/10 CGNAT shared-transition (same reasoning: a relay
   that sees the client with a CGNAT addr is on the same carrier
   network and can't describe public NAT state)
 - IPv6 loopback, unspecified, fe80::/10 link-local

Failed probes still filtered out of classification (they were
already) but now dimmed in the UI list instead of highlighted
amber. Same rationale: a momentarily-offline probe target isn't
a warning-worthy state, it's just a fact about the probe run.

UI palette rebalance: only Cone gets green, everything else
neutral text-dim. Wording changed from warning-tone
"⚠ must use relay" to informational "ℹ P2P falls back to relay,
calls still work" — symmetric NAT isn't broken state, it just
means media takes the relay path.

Tests added (4 new in wzp_client::reflect):
- classify_drops_private_ip_probes — LAN + public → Unknown
- classify_drops_loopback_probes — loopback + 2 public → Cone
- classify_drops_cgnat_probes — CGNAT + 2 public same-IP-
  diff-port → SymmetricPort
- classify_two_lan_probes_is_unknown_not_cone — all LAN → Unknown

Existing multi_reflect integration test updated: two loopback
relays now correctly classify as Unknown (because loopback reflex
addrs are filtered) with the plumbing-works invariant preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:29:09 +04:00
63 changed files with 6648 additions and 490 deletions

467
Cargo.lock generated
View File

@@ -121,6 +121,126 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -167,7 +287,7 @@ version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi 0.1.19",
"libc", "libc",
"winapi", "winapi",
] ]
@@ -475,6 +595,19 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -760,6 +893,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@@ -1467,6 +1609,33 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1494,6 +1663,27 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "failure" name = "failure"
version = "0.1.8" version = "0.1.8"
@@ -1728,6 +1918,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.32" version = "0.3.32"
@@ -2174,6 +2377,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -2357,7 +2566,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2 0.6.3",
"system-configuration", "system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
@@ -2514,6 +2723,16 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "if-addrs"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2870,6 +3089,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]] [[package]]
name = "mach2" name = "mach2"
version = "0.4.3" version = "0.4.3"
@@ -3129,6 +3360,20 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "notify-rust"
version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3275,6 +3520,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -3452,6 +3698,16 @@ dependencies = [
"cmake", "cmake",
] ]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "1.2.3" version = "1.2.3"
@@ -3493,6 +3749,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -3731,6 +3993,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pkcs8" name = "pkcs8"
version = "0.10.2" version = "0.10.2"
@@ -3755,7 +4028,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -3773,6 +4046,20 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.2",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -3922,6 +4209,15 @@ version = "2.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -3944,7 +4240,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2 0.6.3",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -3983,7 +4279,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2", "socket2 0.6.3",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@@ -4945,6 +5241,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.3"
@@ -5377,6 +5683,25 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-shell" name = "tauri-plugin-shell"
version = "2.3.5" version = "2.3.5"
@@ -5498,6 +5823,18 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5673,7 +6010,7 @@ dependencies = [
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2 0.6.3",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -6065,6 +6402,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"
@@ -7121,6 +7469,9 @@ name = "winnow"
version = "0.7.15" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
@@ -7315,6 +7666,7 @@ dependencies = [
"chrono", "chrono",
"coreaudio-rs", "coreaudio-rs",
"cpal", "cpal",
"if-addrs",
"libc", "libc",
"rustls", "rustls",
"serde", "serde",
@@ -7381,6 +7733,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-notification",
"tauri-plugin-shell", "tauri-plugin-shell",
"tokio", "tokio",
"tracing", "tracing",
@@ -7467,6 +7820,7 @@ dependencies = [
"rustls", "rustls",
"serde_json", "serde_json",
"sha2", "sha2",
"socket2 0.5.10",
"tokio", "tokio",
"tracing", "tracing",
"wzp-proto", "wzp-proto",
@@ -7566,6 +7920,67 @@ dependencies = [
"synstructure 0.13.2", "synstructure 0.13.2",
] ]
[[package]]
name = "zbus"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.15",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.15",
"zvariant",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.48"
@@ -7665,3 +8080,43 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 0.7.15",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow 0.7.15",
]

View File

@@ -32,6 +32,7 @@ serde = { version = "1", features = ["derive"] }
# Transport # Transport
quinn = "0.11" quinn = "0.11"
socket2 = "0.5"
# FEC # FEC
raptorq = "2" raptorq = "2"

View File

@@ -96,6 +96,17 @@ class WzpEngine(private val callback: WzpCallback) {
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile) if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
} }
/**
* Signal a network transport change (e.g. WiFi → LTE handoff).
*
* @param networkType matches Rust `NetworkContext` ordinals:
* 0=WiFi, 1=LTE, 2=5G, 3=3G, 4=Unknown, 5=None
* @param bandwidthKbps reported downstream bandwidth in kbps
*/
fun onNetworkChanged(networkType: Int, bandwidthKbps: Int) {
if (nativeHandle != 0L) nativeOnNetworkChanged(nativeHandle, networkType, bandwidthKbps)
}
/** Destroy the native engine and free all resources. The instance must not be reused. */ /** Destroy the native engine and free all resources. The instance must not be reused. */
@Synchronized @Synchronized
fun destroy() { fun destroy() {
@@ -163,6 +174,7 @@ class WzpEngine(private val callback: WzpCallback) {
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
private external fun nativePlaceCall(handle: Long, targetFp: String): Int private external fun nativePlaceCall(handle: Long, targetFp: String): Int
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
private external fun nativeOnNetworkChanged(handle: Long, networkType: Int, bandwidthKbps: Int)
/** /**
* Ping a relay server. Requires engine to be initialized. * Ping a relay server. Requires engine to be initialized.

View File

@@ -0,0 +1,141 @@
package com.wzp.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Handler
import android.os.Looper
/**
* Monitors network connectivity changes via [ConnectivityManager.NetworkCallback]
* and classifies the active transport (WiFi, LTE, 5G, 3G).
*
* Callbacks fire on the main looper so callers can safely update UI state or
* dispatch to a native engine from any callback.
*
* Usage:
* 1. Set [onNetworkChanged] to receive `(type: Int, downlinkKbps: Int)` events
* 2. Optionally set [onIpChanged] for IP address change events (mid-call ICE refresh)
* 3. Call [register] when the call starts
* 4. Call [unregister] when the call ends
*/
class NetworkMonitor(context: Context) {
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Called when the network transport type or bandwidth changes.
* `type` constants match the Rust `NetworkContext` enum ordinals.
*/
var onNetworkChanged: ((type: Int, downlinkKbps: Int) -> Unit)? = null
/**
* Called when the device's IP address changes (link properties changed).
* Useful for triggering mid-call ICE candidate re-gathering.
*/
var onIpChanged: (() -> Unit)? = null
// Track the last emitted type to avoid redundant callbacks
@Volatile
private var lastEmittedType: Int = TYPE_UNKNOWN
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
classifyAndEmit(network)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
classifyFromCaps(caps)
}
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: android.net.LinkProperties
) {
// IP address may have changed — notify for ICE refresh
onIpChanged?.invoke()
// Also re-classify in case the transport changed simultaneously
classifyAndEmit(network)
}
override fun onLost(network: Network) {
lastEmittedType = TYPE_NONE
onNetworkChanged?.invoke(TYPE_NONE, 0)
}
}
// -- Public API -----------------------------------------------------------
/** Register the network callback. Call when a call starts. */
fun register() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback, mainHandler)
}
/** Unregister the network callback. Call when the call ends. */
fun unregister() {
try {
cm.unregisterNetworkCallback(callback)
} catch (_: IllegalArgumentException) {
// Already unregistered — safe to ignore
}
}
// -- Classification -------------------------------------------------------
private fun classifyAndEmit(network: Network) {
val caps = cm.getNetworkCapabilities(network) ?: return
classifyFromCaps(caps)
}
private fun classifyFromCaps(caps: NetworkCapabilities) {
val type = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_WIFI // treat as WiFi
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> classifyCellular(caps)
else -> TYPE_UNKNOWN
}
val bw = caps.getLinkDownstreamBandwidthKbps()
// Deduplicate: only emit when the transport type actually changes
if (type != lastEmittedType) {
lastEmittedType = type
onNetworkChanged?.invoke(type, bw)
}
}
/**
* Approximate cellular generation from reported downstream bandwidth.
* This avoids requiring READ_PHONE_STATE permission (needed for
* TelephonyManager.getNetworkType on API 30+).
*
* Thresholds are conservative — carriers over-report bandwidth, so we
* classify based on what's actually usable for VoIP:
* - >= 100 Mbps → 5G NR
* - >= 10 Mbps → LTE
* - < 10 Mbps → 3G or worse
*/
private fun classifyCellular(caps: NetworkCapabilities): Int {
val bw = caps.getLinkDownstreamBandwidthKbps()
return when {
bw >= 100_000 -> TYPE_CELLULAR_5G
bw >= 10_000 -> TYPE_CELLULAR_LTE
else -> TYPE_CELLULAR_3G
}
}
companion object {
/** Constants matching Rust `NetworkContext` enum ordinals. */
const val TYPE_WIFI = 0
const val TYPE_CELLULAR_LTE = 1
const val TYPE_CELLULAR_5G = 2
const val TYPE_CELLULAR_3G = 3
const val TYPE_UNKNOWN = 4
const val TYPE_NONE = 5
}
}

View File

@@ -5,6 +5,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRoute
import com.wzp.audio.AudioRouteManager import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter import com.wzp.debug.DebugReporter
@@ -12,6 +13,7 @@ import com.wzp.engine.CallStats
import com.wzp.service.CallService import com.wzp.service.CallService
import com.wzp.engine.WzpCallback import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine import com.wzp.engine.WzpEngine
import com.wzp.net.NetworkMonitor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -43,6 +45,7 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engineInitialized = false private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null private var audioRouteManager: AudioRouteManager? = null
private var networkMonitor: NetworkMonitor? = null
private var audioStarted = false private var audioStarted = false
private var appContext: Context? = null private var appContext: Context? = null
private var settings: SettingsRepository? = null private var settings: SettingsRepository? = null
@@ -60,6 +63,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _isSpeaker = MutableStateFlow(false) private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow() val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
private val _stats = MutableStateFlow(CallStats()) private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow() val stats: StateFlow<CallStats> = _stats.asStateFlow()
@@ -226,7 +232,19 @@ class CallViewModel : ViewModel(), WzpCallback {
audioPipeline = AudioPipeline(appCtx) audioPipeline = AudioPipeline(appCtx)
} }
if (audioRouteManager == null) { if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx) audioRouteManager = AudioRouteManager(appCtx).also { arm ->
arm.onRouteChanged = { route ->
_audioRoute.value = route
_isSpeaker.value = (route == AudioRoute.SPEAKER)
}
}
}
if (networkMonitor == null) {
networkMonitor = NetworkMonitor(appCtx).also { nm ->
nm.onNetworkChanged = { type, bw ->
engine?.onNetworkChanged(type, bw)
}
}
} }
if (debugReporter == null) { if (debugReporter == null) {
debugReporter = DebugReporter(appCtx) debugReporter = DebugReporter(appCtx)
@@ -607,6 +625,27 @@ class CallViewModel : ViewModel(), WzpCallback {
audioRouteManager?.setSpeaker(newSpeaker) audioRouteManager?.setSpeaker(newSpeaker)
} }
/** Cycle audio output: Earpiece → Speaker → Bluetooth (if available) → Earpiece. */
fun cycleAudioRoute() {
val routes = audioRouteManager?.availableRoutes() ?: return
val currentIdx = routes.indexOf(_audioRoute.value)
val next = routes[(currentIdx + 1) % routes.size]
when (next) {
AudioRoute.EARPIECE -> {
audioRouteManager?.setBluetoothSco(false)
audioRouteManager?.setSpeaker(false)
}
AudioRoute.SPEAKER -> {
audioRouteManager?.setSpeaker(true)
}
AudioRoute.BLUETOOTH -> {
audioRouteManager?.setBluetoothSco(true)
}
}
_audioRoute.value = next
_isSpeaker.value = (next == AudioRoute.SPEAKER)
}
fun clearError() { _errorMessage.value = null } fun clearError() { _errorMessage.value = null }
fun sendDebugReport() { fun sendDebugReport() {
@@ -661,6 +700,7 @@ class CallViewModel : ViewModel(), WzpCallback {
it.start(e) it.start(e)
} }
audioRouteManager?.register() audioRouteManager?.register()
networkMonitor?.register()
audioStarted = true audioStarted = true
} }
@@ -668,8 +708,10 @@ class CallViewModel : ViewModel(), WzpCallback {
if (!audioStarted) return if (!audioStarted) return
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain() audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioRouteManager?.unregister() audioRouteManager?.unregister()
networkMonitor?.unregister()
audioRouteManager?.setSpeaker(false) audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false _isSpeaker.value = false
_audioRoute.value = AudioRoute.EARPIECE
audioStarted = false audioStarted = false
} }

View File

@@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wzp.audio.AudioRoute
import com.wzp.engine.CallStats import com.wzp.engine.CallStats
import com.wzp.ui.components.CopyableFingerprint import com.wzp.ui.components.CopyableFingerprint
import com.wzp.ui.components.Identicon import com.wzp.ui.components.Identicon
@@ -74,6 +75,7 @@ fun InCallScreen(
val callState by viewModel.callState.collectAsState() val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState() val isMuted by viewModel.isMuted.collectAsState()
val isSpeaker by viewModel.isSpeaker.collectAsState() val isSpeaker by viewModel.isSpeaker.collectAsState()
val audioRoute by viewModel.audioRoute.collectAsState()
val stats by viewModel.stats.collectAsState() val stats by viewModel.stats.collectAsState()
val qualityTier by viewModel.qualityTier.collectAsState() val qualityTier by viewModel.qualityTier.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState()
@@ -621,12 +623,12 @@ fun InCallScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Controls: Mic / End / Spk // Controls: Mic / End / Route (Ear/Spk/BT)
ControlRow( ControlRow(
isMuted = isMuted, isMuted = isMuted,
isSpeaker = isSpeaker, audioRoute = audioRoute,
onToggleMute = viewModel::toggleMute, onToggleMute = viewModel::toggleMute,
onToggleSpeaker = viewModel::toggleSpeaker, onCycleRoute = viewModel::cycleAudioRoute,
onHangUp = { viewModel.stopCall() } onHangUp = { viewModel.stopCall() }
) )
@@ -915,9 +917,9 @@ private fun AudioLevelBar(audioLevel: Int) {
@Composable @Composable
private fun ControlRow( private fun ControlRow(
isMuted: Boolean, isMuted: Boolean,
isSpeaker: Boolean, audioRoute: AudioRoute,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit, onCycleRoute: () -> Unit,
onHangUp: () -> Unit onHangUp: () -> Unit
) { ) {
Row( Row(
@@ -959,22 +961,28 @@ private fun ControlRow(
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
} }
// Speaker // Audio route: cycles Earpiece → Speaker → Bluetooth (when available)
FilledTonalIconButton( FilledTonalIconButton(
onClick = onToggleSpeaker, onClick = onCycleRoute,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
colors = if (isSpeaker) { colors = when (audioRoute) {
IconButtonDefaults.filledTonalIconButtonColors( AudioRoute.SPEAKER -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0xFF0F3460), contentColor = Color.White containerColor = Color(0xFF0F3460), contentColor = Color.White
) )
} else { AudioRoute.BLUETOOTH -> IconButtonDefaults.filledTonalIconButtonColors(
IconButtonDefaults.filledTonalIconButtonColors( containerColor = Color(0xFF2563EB), contentColor = Color.White
)
else -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = DarkSurface2, contentColor = Color.White containerColor = DarkSurface2, contentColor = Color.White
) )
} }
) { ) {
Text( Text(
text = if (isSpeaker) "Spk\nOn" else "Spk", text = when (audioRoute) {
AudioRoute.EARPIECE -> "Ear"
AudioRoute.SPEAKER -> "Spk"
AudioRoute.BLUETOOTH -> "BT"
},
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp lineHeight = 12.sp

View File

@@ -99,6 +99,9 @@ pub(crate) struct EngineState {
/// QUIC transport handle — stored so stop_call() can close it immediately, /// QUIC transport handle — stored so stop_call() can close it immediately,
/// triggering relay-side leave + RoomUpdate broadcast. /// triggering relay-side leave + RoomUpdate broadcast.
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>, pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
/// Network type from Android ConnectivityManager, polled by recv task.
/// 0xFF = no change pending; 0-5 = NetworkContext ordinal.
pub pending_network_type: AtomicU8,
} }
pub struct WzpEngine { pub struct WzpEngine {
@@ -120,6 +123,7 @@ impl WzpEngine {
playout_ring: AudioRing::new(), playout_ring: AudioRing::new(),
audio_level_rms: AtomicU32::new(0), audio_level_rms: AtomicU32::new(0),
quic_transport: Mutex::new(None), quic_transport: Mutex::new(None),
pending_network_type: AtomicU8::new(PROFILE_NO_CHANGE),
}); });
Self { Self {
state, state,
@@ -342,7 +346,7 @@ impl WzpEngine {
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => { Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered"); info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
} }
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => { Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr, .. })) => {
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup"); info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
// Connect to media room via the existing start_call mechanism // Connect to media room via the existing start_call mechanism
// Store the room info so Kotlin can call startCall with it // Store the room info so Kotlin can call startCall with it
@@ -351,7 +355,7 @@ impl WzpEngine {
// Store call setup info for Kotlin to pick up // Store call setup info for Kotlin to pick up
stats.incoming_call_id = Some(format!("{relay_addr}|{room}")); stats.incoming_call_id = Some(format!("{relay_addr}|{room}"));
} }
Ok(Some(SignalMessage::Hangup { reason })) => { Ok(Some(SignalMessage::Hangup { reason, .. })) => {
info!(reason = ?reason, "signal: call ended by remote"); info!(reason = ?reason, "signal: call ended by remote");
let mut stats = signal_state.stats.lock().unwrap(); let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Closed; stats.state = crate::stats::CallState::Closed;
@@ -404,6 +408,13 @@ impl WzpEngine {
pub fn force_profile(&self, _profile: QualityProfile) {} pub fn force_profile(&self, _profile: QualityProfile) {}
/// Signal a network transport change from Android ConnectivityManager.
/// Stores the type atomically; the recv task polls it on each packet.
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
info!(network_type, bandwidth_kbps, "on_network_changed");
self.state.pending_network_type.store(network_type, Ordering::Release);
}
pub fn get_stats(&self) -> CallStats { pub fn get_stats(&self) -> CallStats {
let mut stats = self.state.stats.lock().unwrap().clone(); let mut stats = self.state.stats.lock().unwrap().clone();
if let Some(start) = self.call_start { if let Some(start) = self.call_start {
@@ -871,6 +882,23 @@ async fn run_call(
); );
} }
// Check for network transport change from ConnectivityManager
{
let net = state.pending_network_type.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if net != PROFILE_NO_CHANGE {
use wzp_proto::NetworkContext;
let ctx = match net {
0 => NetworkContext::WiFi,
1 => NetworkContext::CellularLte,
2 => NetworkContext::Cellular5g,
3 => NetworkContext::Cellular3g,
_ => NetworkContext::Unknown,
};
quality_ctrl.signal_network_change(ctx);
info!(?ctx, "quality controller: network context updated");
}
}
// Adaptive quality: ingest quality reports from relay // Adaptive quality: ingest quality reports from relay
if auto_profile { if auto_profile {
if let Some(ref qr) = pkt.quality_report { if let Some(ref qr) = pkt.quality_report {

View File

@@ -222,6 +222,29 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
})); }));
} }
/// Signal a network transport change from the Android ConnectivityManager.
///
/// `network_type` matches the Rust `NetworkContext` enum:
/// 0=WiFi, 1=CellularLte, 2=Cellular5g, 3=Cellular3g, 4=Unknown, 5=None
///
/// The engine forwards this to the `AdaptiveQualityController` which:
/// - Preemptively downgrades one tier on WiFi→cellular
/// - Activates a 10-second FEC boost
/// - Uses faster downgrade thresholds on cellular
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged(
_env: JNIEnv,
_class: JClass,
handle: jlong,
network_type: jint,
bandwidth_kbps: jint,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.on_network_changed(network_type as u8, bandwidth_kbps as u32);
}));
}
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring. /// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
/// pcm is a Java short[] array. /// pcm is a Java short[] array.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]

View File

@@ -24,6 +24,12 @@ chrono = "0.4"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
cpal = { version = "0.15", optional = true } cpal = { version = "0.15", optional = true }
libc = "0.2" libc = "0.2"
# Phase 5.5 — LAN host-candidate ICE: enumerate local network
# interface addresses for inclusion in DirectCallOffer/Answer so
# peers on the same LAN can direct-connect without NAT hairpinning
# through the WAN reflex addr (which many consumer NATs, including
# MikroTik's default masquerade, don't support).
if-addrs = "0.13"
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling # coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
# the `vpio` feature from a non-macOS target builds cleanly instead of # the `vpio` feature from a non-macOS target builds cleanly instead of

View File

@@ -445,6 +445,15 @@ impl CallEncoder {
self.aec.feed_farend(farend); self.aec.feed_farend(farend);
} }
/// Apply DRED tuning output to the encoder.
///
/// Called by the send loop after `DredTuner::update()` returns `Some`.
/// No-op when the active codec is Codec2 (DRED is Opus-only).
pub fn apply_dred_tuning(&mut self, tuning: wzp_proto::DredTuning) {
self.audio_enc.set_dred_duration(tuning.dred_frames);
self.audio_enc.set_expected_loss(tuning.expected_loss_pct);
}
/// Enable or disable acoustic echo cancellation. /// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) { pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled); self.aec.set_enabled(enabled);
@@ -1442,4 +1451,131 @@ mod tests {
"frames_suppressed should be > 0" "frames_suppressed should be > 0"
); );
} }
// ---- DredTuner integration tests ----
/// End-to-end test: DredTuner reacts to simulated network degradation
/// and adjusts the encoder's DRED parameters via `apply_dred_tuning`.
#[test]
fn dred_tuner_adjusts_encoder_on_loss() {
use wzp_proto::DredTuner;
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
// Baseline: good network → baseline DRED (20 frames = 200 ms).
let baseline = tuner.current();
assert_eq!(baseline.dred_frames, 20);
// Warm up the tuner — first few updates may return Some as the
// EWMA initializes and expected_loss settles from the initial 15%.
for _ in 0..10 {
tuner.update(0.0, 50, 5);
}
// After settling, the tuning should be at baseline.
assert_eq!(tuner.current().dred_frames, 20);
// Simulate network degradation: 30% loss, 300ms RTT.
// The tuner should increase DRED frames above baseline.
let tuning = tuner.update(30.0, 300, 15);
assert!(tuning.is_some(), "loss spike should trigger tuning change");
let t = tuning.unwrap();
assert!(
t.dred_frames > 20,
"30% loss should increase DRED above baseline 20, got {}",
t.dred_frames
);
// Apply to encoder — should not panic.
enc.apply_dred_tuning(t);
// Verify the encoder still works after tuning.
let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning");
}
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
#[test]
fn dred_tuner_spike_boosts_to_ceiling() {
use wzp_proto::DredTuner;
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish low-jitter baseline.
for _ in 0..20 {
tuner.update(0.0, 50, 5);
}
assert!(!tuner.spike_boost_active());
// Jitter spikes to 40ms (8x baseline of ~5ms).
let tuning = tuner.update(0.0, 50, 40);
assert!(tuner.spike_boost_active(), "jitter spike should activate boost");
assert!(tuning.is_some());
// Ceiling for Opus24k is 50 frames = 500 ms.
assert_eq!(
tuning.unwrap().dred_frames, 50,
"spike should push to ceiling"
);
}
/// DredTuner is a no-op for Codec2 profiles.
#[test]
fn dred_tuner_noop_for_codec2() {
use wzp_proto::DredTuner;
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
// Even extreme conditions produce no tuning output.
assert!(tuner.update(50.0, 800, 100).is_none());
assert_eq!(tuner.current().dred_frames, 0);
}
/// DredTuner + CallEncoder: full cycle through profile switch.
#[test]
fn dred_tuner_handles_profile_switch() {
use wzp_proto::DredTuner;
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
// Apply initial tuning on good network.
if let Some(t) = tuner.update(0.0, 50, 5) {
enc.apply_dred_tuning(t);
}
// Switch to degraded profile.
enc.set_profile(QualityProfile::DEGRADED).unwrap();
tuner.set_codec(QualityProfile::DEGRADED.codec);
// Opus6k baseline is 50 frames (500 ms), ceiling is 104 (1040 ms).
let baseline = tuner.current();
// After set_codec, the cached tuning should reflect old state;
// a fresh update gives the new codec's mapping.
let tuning = tuner.update(20.0, 200, 10);
assert!(tuning.is_some());
let t = tuning.unwrap();
assert!(
t.dred_frames >= 50,
"Opus6k with 20% loss should be at least baseline 50, got {}",
t.dred_frames
);
enc.apply_dred_tuning(t);
// Encode a 40ms frame (Opus6k uses 40ms frames = 1920 samples).
let pcm: Vec<i16> = (0..1920)
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
.collect();
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
}
} }

View File

@@ -424,6 +424,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
info!(total_source, total_repair, total_bytes, "done — closing"); info!(total_source, total_repair, total_bytes, "done — closing");
let hangup = wzp_proto::SignalMessage::Hangup { let hangup = wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}; };
transport.send_signal(&hangup).await.ok(); transport.send_signal(&hangup).await.ok();
transport.close().await?; transport.close().await?;
@@ -575,6 +576,7 @@ async fn run_file_mode(
// Send Hangup signal so the relay knows we're done // Send Hangup signal so the relay knows we're done
let hangup = wzp_proto::SignalMessage::Hangup { let hangup = wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}; };
transport.send_signal(&hangup).await.ok(); transport.send_signal(&hangup).await.ok();
@@ -747,7 +749,7 @@ async fn run_signal_mode(
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => { Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
info!(fingerprint = %fp, "registered on relay — waiting for calls"); info!(fingerprint = %fp, "registered on relay — waiting for calls");
} }
Some(SignalMessage::RegisterPresenceAck { success: false, error }) => { Some(SignalMessage::RegisterPresenceAck { success: false, error, .. }) => {
anyhow::bail!("registration failed: {}", error.unwrap_or_default()); anyhow::bail!("registration failed: {}", error.unwrap_or_default());
} }
other => { other => {
@@ -773,6 +775,8 @@ async fn run_signal_mode(
// CLI client doesn't attempt hole-punching; always // CLI client doesn't attempt hole-punching; always
// relay-path. // relay-path.
caller_reflexive_addr: None, caller_reflexive_addr: None,
caller_local_addrs: Vec::new(),
caller_build_version: None,
}).await?; }).await?;
} }
@@ -805,12 +809,14 @@ async fn run_signal_mode(
// CLI auto-accept uses generic (privacy) mode, // CLI auto-accept uses generic (privacy) mode,
// so callee addr stays hidden from the caller. // so callee addr stays hidden from the caller.
callee_reflexive_addr: None, callee_reflexive_addr: None,
callee_local_addrs: Vec::new(),
callee_build_version: None,
}).await; }).await;
} }
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => { SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
info!(call_id = %call_id, mode = ?accept_mode, "call answered"); info!(call_id = %call_id, mode = ?accept_mode, "call answered");
} }
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _ } => { SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _ } => {
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room"); info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
// Connect to the media room // Connect to the media room
@@ -861,6 +867,7 @@ async fn run_signal_mode(
info!("hanging up..."); info!("hanging up...");
let _ = signal_transport.send_signal(&SignalMessage::Hangup { let _ = signal_transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}).await; }).await;
break; break;
} }
@@ -877,7 +884,7 @@ async fn run_signal_mode(
Err(e) => error!("media connect failed: {e}"), Err(e) => error!("media connect failed: {e}"),
} }
} }
SignalMessage::Hangup { reason } => { SignalMessage::Hangup { reason, .. } => {
info!(reason = ?reason, "call ended by remote"); info!(reason = ?reason, "call ended by remote");
} }
SignalMessage::Pong { .. } => {} SignalMessage::Pong { .. } => {}

View File

@@ -38,6 +38,24 @@ pub enum WinningPath {
Relay, Relay,
} }
/// Phase 6: the race now returns BOTH transports (when available)
/// so the connect command can negotiate with the peer before
/// committing. The negotiation decides which transport to use
/// based on whether BOTH sides report `direct_ok = true`.
pub struct RaceResult {
/// The direct P2P transport, if the direct path completed.
/// `None` if the direct dial/accept failed or timed out.
pub direct_transport: Option<Arc<QuinnTransport>>,
/// The relay transport, if the relay dial completed.
/// `None` if the relay dial failed (shouldn't happen in
/// practice since relay is always reachable).
pub relay_transport: Option<Arc<QuinnTransport>>,
/// Which future completed first in the local race.
/// Informational — the actual path used is decided by the
/// Phase 6 negotiation after both sides exchange reports.
pub local_winner: WinningPath,
}
/// Attempt a direct QUIC connection to the peer in parallel with /// Attempt a direct QUIC connection to the peer in parallel with
/// the relay dial and return the winning `QuinnTransport`. /// the relay dial and return the winning `QuinnTransport`.
/// ///
@@ -52,22 +70,94 @@ pub enum WinningPath {
/// genuinely fail (network partition). Returns /// genuinely fail (network partition). Returns
/// `Err(anyhow::anyhow!(...))` if both paths fail within the /// `Err(anyhow::anyhow!(...))` if both paths fail within the
/// timeout. /// timeout.
/// Phase 5.5 candidate bundle — full ICE-ish candidate list for
/// the peer. The race tries them all in parallel alongside the
/// relay path. At minimum this should contain the peer's
/// server-reflexive address; `local_addrs` carries LAN host
/// candidates gathered from their physical interfaces.
///
/// Empty is valid: the D-role has nothing to dial and the race
/// reduces to "relay only" + (if A-role) accepting on the
/// shared endpoint.
#[derive(Debug, Clone, Default)]
pub struct PeerCandidates {
/// Peer's server-reflexive address (Phase 3). `None` if the
/// peer didn't advertise one.
pub reflexive: Option<SocketAddr>,
/// Peer's LAN host addresses (Phase 5.5). Tried first on
/// same-LAN pairs — direct dials to these bypass the NAT
/// entirely.
pub local: Vec<SocketAddr>,
}
impl PeerCandidates {
/// Flatten into the list of addrs the D-role should dial.
/// Order: LAN host candidates first (fastest when they
/// work), then reflexive (covers the non-LAN case).
pub fn dial_order(&self) -> Vec<SocketAddr> {
let mut out = Vec::with_capacity(self.local.len() + 1);
out.extend(self.local.iter().copied());
if let Some(a) = self.reflexive {
// Only add if it's not already in the list (some
// edge cases on same-LAN could have the same addr
// in both).
if !out.contains(&a) {
out.push(a);
}
}
out
}
/// Is there anything for the D-role to dial? If not, the
/// race reduces to relay-only.
pub fn is_empty(&self) -> bool {
self.reflexive.is_none() && self.local.is_empty()
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn race( pub async fn race(
role: Role, role: Role,
peer_direct_addr: SocketAddr, peer_candidates: PeerCandidates,
relay_addr: SocketAddr, relay_addr: SocketAddr,
room_sni: String, room_sni: String,
call_sni: String, call_sni: String,
) -> anyhow::Result<(Arc<QuinnTransport>, WinningPath)> { // Phase 5: when `Some`, reuse this endpoint for BOTH the
// direct-path branch AND the relay dial. Pass the signal
// endpoint. The endpoint MUST be server-capable (created
// with a server config) for the A-role accept branch to
// work.
//
// When `None`, falls back to fresh endpoints per role.
// Used by tests.
shared_endpoint: Option<wzp_transport::Endpoint>,
// Phase 7: dedicated IPv6 endpoint with IPV6_V6ONLY=1.
// When `Some`, A-role accepts on both v4+v6, D-role routes
// each candidate to its matching-AF endpoint. When `None`,
// IPv6 candidates are skipped (IPv4-only, pre-Phase-7).
ipv6_endpoint: Option<wzp_transport::Endpoint>,
) -> anyhow::Result<RaceResult> {
// Rustls provider must be installed before any quinn endpoint // Rustls provider must be installed before any quinn endpoint
// is created. Install attempt is idempotent. // is created. Install attempt is idempotent.
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
// Build the direct-path endpoint + future based on role. // Build the direct-path endpoint + future based on role.
// Each future returns an already-wrapped `QuinnTransport` so we //
// don't need a direct `quinn::Connection` type in scope here // A-role: one accept future on the shared endpoint. The
// (this crate doesn't depend on quinn directly). // first incoming QUIC connection wins — we don't care
// which peer candidate the dialer used to reach us.
//
// D-role: N parallel dial futures, one per peer candidate
// (all LAN host addrs + the reflex addr), consolidated
// into a single direct_fut via FuturesUnordered-style
// "first OK wins" semantics. The first successful dial
// becomes the direct path; the losers are dropped (quinn
// will abort the in-flight handshakes via the dropped
// Connecting futures).
//
// Either way, direct_fut resolves to a single QuinnTransport
// (or an error) and is raced against the relay_fut by the
// outer tokio::select!.
let direct_ep: wzp_transport::Endpoint; let direct_ep: wzp_transport::Endpoint;
let direct_fut: std::pin::Pin< let direct_fut: std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<QuinnTransport>> + Send>, Box<dyn std::future::Future<Output = anyhow::Result<QuinnTransport>> + Send>,
@@ -75,54 +165,246 @@ pub async fn race(
match role { match role {
Role::Acceptor => { Role::Acceptor => {
let (sc, _cert_der) = wzp_transport::server_config(); let ep = match shared_endpoint.clone() {
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); Some(ep) => {
let ep = wzp_transport::create_endpoint(bind, Some(sc))?;
tracing::info!( tracing::info!(
local_addr = ?ep.local_addr().ok(), local_addr = ?ep.local_addr().ok(),
"dual_path: A-role endpoint up, awaiting peer dial" "dual_path: A-role reusing shared endpoint for accept"
); );
ep
}
None => {
let (sc, _cert_der) = wzp_transport::server_config();
// 0.0.0.0:0 = IPv4 socket. [::]:0 dual-stack was
// tried but breaks on Android devices where
// IPV6_V6ONLY=1 (default on some kernels) —
// IPv4 candidates silently fail. IPv6 host
// candidates are skipped for now; they need a
// dedicated IPv6 socket alongside the v4 one
// (like WebRTC's dual-socket approach).
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let fresh = wzp_transport::create_endpoint(bind, Some(sc))?;
tracing::info!(
local_addr = ?fresh.local_addr().ok(),
"dual_path: A-role fresh endpoint up, awaiting peer dial"
);
fresh
}
};
let ep_for_fut = ep.clone(); let ep_for_fut = ep.clone();
// Phase 7: IPv6 accept temporarily disabled (same reason
// as dial — IPv6 connections die on datagram send).
// Accept on IPv4 shared endpoint only.
let _v6_ep_unused = ipv6_endpoint.clone();
direct_fut = Box::pin(async move { direct_fut = Box::pin(async move {
// `wzp_transport::accept` wraps the same // Accept loop: retry if we get a stale/closed
// `endpoint.accept().await?.await?` dance we want // connection from a previous call. Max 3 retries
// and maps errors into TransportError for us. // to avoid spinning until the race timeout.
const MAX_STALE: usize = 3;
let mut stale_count: usize = 0;
loop {
let conn = wzp_transport::accept(&ep_for_fut) let conn = wzp_transport::accept(&ep_for_fut)
.await .await
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?; .map_err(|e| anyhow::anyhow!("direct accept: {e}"))?;
Ok(QuinnTransport::new(conn))
if let Some(reason) = conn.close_reason() {
// Explicitly close so the peer gets a
// close frame instead of idle timeout.
conn.close(0u32.into(), b"stale");
stale_count += 1;
tracing::warn!(
remote = %conn.remote_address(),
stable_id = conn.stable_id(),
stale_count,
?reason,
"dual_path: A-role skipping stale connection"
);
if stale_count >= MAX_STALE {
return Err(anyhow::anyhow!(
"A-role: {stale_count} stale connections, aborting"
));
}
continue;
}
let has_dgram = conn.max_datagram_size().is_some();
tracing::info!(
remote = %conn.remote_address(),
stable_id = conn.stable_id(),
has_dgram,
"dual_path: A-role accepted direct connection"
);
break Ok(QuinnTransport::new(conn));
}
}); });
direct_ep = ep; direct_ep = ep;
} }
Role::Dialer => { Role::Dialer => {
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); let ep = match shared_endpoint.clone() {
let ep = wzp_transport::create_endpoint(bind, None)?; Some(ep) => {
tracing::info!( tracing::info!(
local_addr = ?ep.local_addr().ok(), local_addr = ?ep.local_addr().ok(),
%peer_direct_addr, candidates = ?peer_candidates.dial_order(),
"dual_path: D-role endpoint up, dialing peer" "dual_path: D-role reusing shared endpoint to dial peer candidates"
); );
ep
}
None => {
// 0.0.0.0:0 = IPv4 socket. [::]:0 dual-stack was
// tried but breaks on Android devices where
// IPV6_V6ONLY=1 (default on some kernels) —
// IPv4 candidates silently fail. IPv6 host
// candidates are skipped for now; they need a
// dedicated IPv6 socket alongside the v4 one
// (like WebRTC's dual-socket approach).
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let fresh = wzp_transport::create_endpoint(bind, None)?;
tracing::info!(
local_addr = ?fresh.local_addr().ok(),
candidates = ?peer_candidates.dial_order(),
"dual_path: D-role fresh endpoint up, dialing peer candidates"
);
fresh
}
};
let ep_for_fut = ep.clone(); let ep_for_fut = ep.clone();
let client_cfg = wzp_transport::client_config(); let _v6_ep_for_dial = ipv6_endpoint.clone();
let dial_order = peer_candidates.dial_order();
let sni = call_sni.clone(); let sni = call_sni.clone();
direct_fut = Box::pin(async move { direct_fut = Box::pin(async move {
let conn = if dial_order.is_empty() {
wzp_transport::connect(&ep_for_fut, peer_direct_addr, &sni, client_cfg) // No candidates — the race reduces to
.await // relay-only. Surface a stable error so the
.map_err(|e| anyhow::anyhow!("direct dial: {e}"))?; // outer select falls through to relay_fut
Ok(QuinnTransport::new(conn)) // without a spurious "direct failed" warning.
// Use a pending future that never resolves so
// the select's "other side wins" branch is
// the natural outcome.
std::future::pending::<anyhow::Result<QuinnTransport>>().await
} else {
// Fan out N parallel dials via JoinSet. First
// `Ok` wins; `Err` from a single candidate is
// not fatal — we wait for the others. Only
// when ALL have failed do we return Err.
let mut set = tokio::task::JoinSet::new();
for (idx, candidate) in dial_order.iter().enumerate() {
// Phase 7: route each candidate to the
// endpoint matching its address family.
let candidate = *candidate;
// Phase 7: IPv6 dials temporarily disabled.
// IPv6 QUIC handshakes succeed but the
// connection dies immediately on datagram
// send ("connection lost"). Root cause is
// likely router-level IPv6 UDP filtering.
// Re-enable once IPv6 datagram delivery is
// verified on target networks.
if candidate.is_ipv6() {
tracing::debug!(
%candidate,
candidate_idx = idx,
"dual_path: skipping IPv6 candidate (disabled)"
);
continue;
}
let ep = ep_for_fut.clone();
let client_cfg = wzp_transport::client_config();
let sni = sni.clone();
set.spawn(async move {
let result = wzp_transport::connect(
&ep,
candidate,
&sni,
client_cfg,
)
.await;
(idx, candidate, result)
});
}
let mut last_err: Option<String> = None;
while let Some(join_res) = set.join_next().await {
let (idx, candidate, dial_res) = match join_res {
Ok(t) => t,
Err(e) => {
last_err = Some(format!("join {e}"));
continue;
}
};
match dial_res {
Ok(conn) => {
tracing::info!(
%candidate,
candidate_idx = idx,
remote = %conn.remote_address(),
stable_id = conn.stable_id(),
"dual_path: direct dial succeeded on candidate"
);
// Abort the remaining in-flight
// dials so they don't complete
// and leak QUIC sessions.
set.abort_all();
return Ok(QuinnTransport::new(conn));
}
Err(e) => {
tracing::debug!(
%candidate,
candidate_idx = idx,
error = %e,
"dual_path: direct dial failed, trying others"
);
last_err = Some(format!("candidate {candidate}: {e}"));
}
}
}
Err(anyhow::anyhow!(
"all {} direct candidates failed; last: {}",
dial_order.len(),
last_err.unwrap_or_else(|| "n/a".into())
))
}
}); });
direct_ep = ep; direct_ep = ep;
} }
} }
// Relay path: classic dial to the relay's media room. // Relay path: classic dial to the relay's media room. Phase 5:
let relay_bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); // reuse the shared endpoint here too so MikroTik-style NATs
let relay_ep = wzp_transport::create_endpoint(relay_bind, None)?; // keep a stable external port across all flows from this
// client. Falls back to a fresh endpoint when not shared.
let relay_ep = match shared_endpoint.clone() {
Some(ep) => ep,
None => {
let relay_bind: SocketAddr = "[::]:0".parse().unwrap();
wzp_transport::create_endpoint(relay_bind, None)?
}
};
let relay_ep_for_fut = relay_ep.clone(); let relay_ep_for_fut = relay_ep.clone();
let relay_client_cfg = wzp_transport::client_config(); let relay_client_cfg = wzp_transport::client_config();
let relay_sni = room_sni.clone(); let relay_sni = room_sni.clone();
// Phase 5.5 direct-path head-start: hold the relay dial for
// 500ms before attempting it. On same-LAN cone-NAT pairs the
// direct dial finishes in ~30-100ms, so giving direct a 500ms
// head start means direct reliably wins when it's going to
// work at all. The worst case adds 500ms to the fall-back-
// to-relay scenario, which is imperceptible for users on
// setups where direct isn't available anyway.
//
// Prior behavior (immediate race) caused the relay to win
// ~105ms races on a MikroTik LAN because:
// - Acceptor role's direct_fut = accept() can only fire
// when the peer has completed its outbound LAN dial
// - Dialer role's parallel LAN dials need the peer's
// CallSetup processed + the race started on the other
// side before they can reach us
// - Meanwhile relay_fut is a plain dial that completes in
// whatever the client→relay RTT is (often <100ms)
//
// The 500ms head start is the minimum that empirically makes
// same-LAN direct reliably beat relay, without penalizing
// users who genuinely need the relay path.
const DIRECT_HEAD_START: Duration = Duration::from_millis(500);
let relay_fut = async move { let relay_fut = async move {
tokio::time::sleep(DIRECT_HEAD_START).await;
let conn = let conn =
wzp_transport::connect(&relay_ep_for_fut, relay_addr, &relay_sni, relay_client_cfg) wzp_transport::connect(&relay_ep_for_fut, relay_addr, &relay_sni, relay_client_cfg)
.await .await
@@ -130,66 +412,135 @@ pub async fn race(
Ok::<_, anyhow::Error>(QuinnTransport::new(conn)) Ok::<_, anyhow::Error>(QuinnTransport::new(conn))
}; };
// Race the two with a shared 2s ceiling on the direct attempt. // Phase 6: run both paths concurrently via tokio::spawn and
// Pin both so we can poll them from multiple branches of the // collect BOTH results. The old tokio::select! approach dropped
// select without moving the futures — the "direct failed, wait // the loser, which meant the connect command couldn't negotiate
// for relay" and "relay failed, wait for direct" fallback paths // with the peer — it had to commit to whichever path won locally.
// below need to await the OPPOSITE future after the winning //
// branch fires. Without pinning, tokio::select! moves the // Now we spawn both as tasks, wait for the first to complete
// future out and we can't touch it again. // (that determines `local_winner`), then give the loser a short
tracing::info!(?role, %peer_direct_addr, %relay_addr, "dual_path: racing direct vs relay"); // grace period to also complete. The connect command gets a
let direct_timed = tokio::time::timeout(Duration::from_secs(2), direct_fut); // RaceResult with both transports (when available) and uses the
tokio::pin!(direct_timed, relay_fut); // Phase 6 MediaPathReport exchange to decide which one to
// actually use for media.
tracing::info!(
?role,
candidates = ?peer_candidates.dial_order(),
%relay_addr,
"dual_path: racing direct vs relay"
);
let result = tokio::select! { let mut direct_task = tokio::spawn(
biased; // prefer direct win if both arrive in the same tick tokio::time::timeout(Duration::from_secs(2), direct_fut),
direct_result = &mut direct_timed => { );
match direct_result { let mut relay_task = tokio::spawn(async move {
Ok(Ok(transport)) => { // Keep the 500ms head start so direct has a chance
tracing::info!(%peer_direct_addr, "dual_path: direct WON"); tokio::time::sleep(Duration::from_millis(500)).await;
Ok((Arc::new(transport), WinningPath::Direct)) tokio::time::timeout(Duration::from_secs(5), relay_fut).await
});
// Wait for the first one to complete. This tells us the
// local_winner — but we DON'T commit to it yet. Phase 6
// negotiation decides the actual path.
let (mut direct_result, mut relay_result): (
Option<anyhow::Result<QuinnTransport>>,
Option<anyhow::Result<QuinnTransport>>,
) = (None, None);
let local_winner;
tokio::select! {
biased;
d = &mut direct_task => {
match d {
Ok(Ok(Ok(t))) => {
tracing::info!("dual_path: direct completed first");
direct_result = Some(Ok(t));
local_winner = WinningPath::Direct;
} }
Ok(Err(e)) => { Ok(Ok(Err(e))) => {
// Direct failed — fall back to waiting for relay. tracing::warn!(error = %e, "dual_path: direct failed");
tracing::warn!(error = %e, "dual_path: direct failed, awaiting relay"); direct_result = Some(Err(anyhow::anyhow!("{e}")));
match tokio::time::timeout(Duration::from_secs(5), &mut relay_fut).await { local_winner = WinningPath::Relay; // direct failed → relay is our only hope
Ok(Ok(transport)) => Ok((Arc::new(transport), WinningPath::Relay)),
Ok(Err(e2)) => Err(anyhow::anyhow!("both paths failed: direct={e}, relay={e2}")),
Err(_) => Err(anyhow::anyhow!("both paths failed: direct={e}, relay=timeout(5s)")),
} }
} Ok(Err(_)) => {
Err(_elapsed) => { tracing::warn!("dual_path: direct timed out (2s)");
tracing::warn!("dual_path: direct timed out (2s), awaiting relay"); direct_result = Some(Err(anyhow::anyhow!("direct timeout")));
match tokio::time::timeout(Duration::from_secs(5), &mut relay_fut).await { local_winner = WinningPath::Relay;
Ok(Ok(transport)) => Ok((Arc::new(transport), WinningPath::Relay)),
Ok(Err(e2)) => Err(anyhow::anyhow!("direct timeout + relay failed: {e2}")),
Err(_) => Err(anyhow::anyhow!("direct timeout + relay timeout")),
}
}
}
}
relay_result = &mut relay_fut => {
match relay_result {
Ok(transport) => {
tracing::info!("dual_path: relay WON (direct still pending)");
Ok((Arc::new(transport), WinningPath::Relay))
} }
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "dual_path: relay failed, awaiting direct remainder"); tracing::warn!(error = %e, "dual_path: direct task panicked");
match tokio::time::timeout(Duration::from_millis(1500), &mut direct_timed).await { direct_result = Some(Err(anyhow::anyhow!("direct task panic")));
Ok(Ok(Ok(transport))) => Ok((Arc::new(transport), WinningPath::Direct)), local_winner = WinningPath::Relay;
_ => Err(anyhow::anyhow!("relay failed + direct unavailable: {e}")), }
}
}
r = &mut relay_task => {
match r {
Ok(Ok(Ok(t))) => {
tracing::info!("dual_path: relay completed first");
relay_result = Some(Ok(t));
local_winner = WinningPath::Relay;
}
Ok(Ok(Err(e))) => {
tracing::warn!(error = %e, "dual_path: relay failed");
relay_result = Some(Err(anyhow::anyhow!("{e}")));
local_winner = WinningPath::Direct;
}
Ok(Err(_)) => {
tracing::warn!("dual_path: relay timed out");
relay_result = Some(Err(anyhow::anyhow!("relay timeout")));
local_winner = WinningPath::Direct;
}
Err(e) => {
relay_result = Some(Err(anyhow::anyhow!("relay task panic: {e}")));
local_winner = WinningPath::Direct;
} }
} }
} }
} }
};
// Drop both endpoints once the winner is stored in result. The // Give the loser a short grace period (1s) to also complete.
// winning transport owns its own connection so dropping the // If it does, we have both transports for Phase 6 negotiation.
// endpoint won't kill it. // If it doesn't, we still proceed with just the winner.
drop(direct_ep); if direct_result.is_none() {
drop(relay_ep); match tokio::time::timeout(Duration::from_secs(1), direct_task).await {
Ok(Ok(Ok(Ok(t)))) => { direct_result = Some(Ok(t)); }
Ok(Ok(Ok(Err(e)))) => { direct_result = Some(Err(anyhow::anyhow!("{e}"))); }
_ => { direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period"))); }
}
}
if relay_result.is_none() {
match tokio::time::timeout(Duration::from_secs(1), relay_task).await {
Ok(Ok(Ok(Ok(t)))) => { relay_result = Some(Ok(t)); }
Ok(Ok(Ok(Err(e)))) => { relay_result = Some(Err(anyhow::anyhow!("{e}"))); }
_ => { relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period"))); }
}
}
result let direct_ok = direct_result.as_ref().map(|r| r.is_ok()).unwrap_or(false);
let relay_ok = relay_result.as_ref().map(|r| r.is_ok()).unwrap_or(false);
tracing::info!(
?local_winner,
direct_ok,
relay_ok,
"dual_path: race finished, both results collected for Phase 6 negotiation"
);
if !direct_ok && !relay_ok {
return Err(anyhow::anyhow!("both paths failed: no media transport available"));
}
let _ = (direct_ep, relay_ep, ipv6_endpoint);
Ok(RaceResult {
direct_transport: direct_result
.and_then(|r| r.ok())
.map(|t| Arc::new(t)),
relay_transport: relay_result
.and_then(|r| r.ok())
.map(|t| Arc::new(t)),
local_winner,
})
} }

View File

@@ -130,6 +130,8 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
// relay-to-relay message, never rides the featherChat // relay-to-relay message, never rides the featherChat
// bridge. Catch-all mapping for completeness. // bridge. Catch-all mapping for completeness.
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer, SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
} }
} }
@@ -169,6 +171,7 @@ mod tests {
let hangup = SignalMessage::Hangup { let hangup = SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}; };
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup)); assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));

View File

@@ -67,22 +67,45 @@ pub enum NatType {
Unknown, Unknown,
} }
/// Probe a single relay with a throwaway QUIC connection. /// Probe a single relay with a QUIC connection.
/// ///
/// Each call creates a fresh `quinn::Endpoint` so the OS hands out a /// # Endpoint reuse (Phase 5 — Nebula-style architecture)
/// fresh ephemeral source port — essential for NAT-type detection ///
/// because a shared socket would produce the same mapping against /// If `existing_endpoint` is `Some`, the probe uses that socket
/// every relay and mask symmetric NAT. /// instead of creating a fresh one. This is the desired mode in
/// production: a port-preserving NAT (MikroTik masquerade, most
/// consumer routers) gives a **stable** external port for the
/// one socket, so the reflex addr observed by ANY relay is the
/// SAME addr and matches what a peer would see on a direct dial.
/// Pass the signal endpoint here.
///
/// If `None`, creates a fresh one-shot endpoint. Kept for:
/// - tests that spin up isolated probes
/// - the "I'm not registered yet" case where there's no signal
/// endpoint to reuse
///
/// NOTE on NAT-type detection: the pre-Phase-5 behavior of
/// forcing a fresh endpoint per probe was wrong — it made every
/// port-preserving NAT look symmetric because the classifier saw
/// a different external port for each fresh source port. With
/// one shared socket, the classifier reflects the REAL NAT
/// behavior.
pub async fn probe_reflect_addr( pub async fn probe_reflect_addr(
relay: SocketAddr, relay: SocketAddr,
timeout_ms: u64, timeout_ms: u64,
existing_endpoint: Option<wzp_transport::Endpoint>,
) -> Result<(SocketAddr, u32), String> { ) -> Result<(SocketAddr, u32), String> {
// Install rustls provider idempotently — a second install on the // Install rustls provider idempotently — a second install on the
// same thread is a no-op. // same thread is a no-op.
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let endpoint = match existing_endpoint {
Some(ep) => ep,
None => {
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = create_endpoint(bind, None).map_err(|e| format!("endpoint: {e}"))?; create_endpoint(bind, None).map_err(|e| format!("endpoint: {e}"))?
}
};
let start = Instant::now(); let start = Instant::now();
let probe = async { let probe = async {
@@ -153,9 +176,10 @@ pub async fn probe_reflect_addr(
.await .await
.map_err(|_| format!("probe timeout ({timeout_ms}ms)"))??; .map_err(|_| format!("probe timeout ({timeout_ms}ms)"))??;
// Drop the endpoint explicitly AFTER the probe finishes so the // `endpoint` is a quinn::Endpoint clone — an Arc under the
// UDP socket is released before we return. // hood. Letting it drop at end-of-scope is correct whether it
drop(endpoint); // was fresh (last ref → socket closes) or shared (ref count
// decrements, socket stays alive for the signal loop).
Ok(out) Ok(out)
} }
@@ -163,17 +187,32 @@ pub async fn probe_reflect_addr(
/// classifying the returned addresses. Never errors — failing /// classifying the returned addresses. Never errors — failing
/// probes surface via `NatProbeResult.error`; aggregate is always /// probes surface via `NatProbeResult.error`; aggregate is always
/// returned. /// returned.
///
/// # Endpoint reuse (Phase 5)
///
/// If `shared_endpoint` is `Some`, every probe reuses it. This is
/// the PRODUCTION behavior: all probes source from the same UDP
/// port, so port-preserving NATs map them to the same external
/// port, and the classifier reflects the real NAT type. Pass the
/// signal endpoint.
///
/// If `None`, each probe creates its own fresh endpoint — useful
/// in tests that don't have a signal endpoint, but produces
/// spurious `SymmetricPort` classifications against NATs that
/// would otherwise look cone-like.
pub async fn detect_nat_type( pub async fn detect_nat_type(
relays: Vec<(String, SocketAddr)>, relays: Vec<(String, SocketAddr)>,
timeout_ms: u64, timeout_ms: u64,
shared_endpoint: Option<wzp_transport::Endpoint>,
) -> NatDetection { ) -> NatDetection {
// Parallel probes via tokio::task::JoinSet so the wall-clock is // Parallel probes via tokio::task::JoinSet so the wall-clock is
// bounded by the slowest probe, not the sum. JoinSet keeps the // bounded by the slowest probe, not the sum. JoinSet keeps the
// dep surface at just tokio — we already depend on it. // dep surface at just tokio — we already depend on it.
let mut set = tokio::task::JoinSet::new(); let mut set = tokio::task::JoinSet::new();
for (name, addr) in relays { for (name, addr) in relays {
let ep = shared_endpoint.clone();
set.spawn(async move { set.spawn(async move {
let result = probe_reflect_addr(addr, timeout_ms).await; let result = probe_reflect_addr(addr, timeout_ms, ep).await;
(name, addr, result) (name, addr, result)
}); });
} }
@@ -223,6 +262,90 @@ pub async fn detect_nat_type(
} }
} }
/// Enumerate LAN-local host candidates this client is reachable
/// on, paired with the given port (typically the signal
/// endpoint's bound port so that incoming dials land on the same
/// socket the advertised reflex addr points to).
///
/// Gathers BOTH IPv4 and IPv6 candidates:
///
/// - **IPv4**: RFC1918 private ranges (10/8, 172.16/12, 192.168/16)
/// and CGNAT shared-transition (100.64/10). Public IPv4 is
/// skipped because the reflex-addr path already covers it.
/// Loopback and link-local (169.254/16) are skipped.
///
/// - **IPv6**: ALL global-unicast addresses (2000::/3 — the real
/// routable IPv6 space) AND unique-local (fc00::/7). These
/// are directly dialable from a peer on the same LAN, and on
/// true dual-stack LANs (which most consumer ISPs now provide,
/// including Starlink) IPv6 often gives a direct path even
/// when IPv4 can't hairpin. Loopback (::1), unspecified (::),
/// and link-local (fe80::/10) are skipped — link-local would
/// require a scope ID to be useful and is basically never
/// reachable across interface boundaries.
///
/// The port must come from the caller — typically
/// `signal_endpoint.local_addr()?.port()`, so that the peer's
/// dials to these addresses land on the same socket that's
/// already listening (Phase 5 shared-endpoint architecture).
///
/// Safe to call from any thread; no I/O, no async. The `if-addrs`
/// crate reads the kernel's interface table via a single
/// getifaddrs(3) syscall.
pub fn local_host_candidates(v4_port: u16, v6_port: Option<u16>) -> Vec<SocketAddr> {
let Ok(ifaces) = if_addrs::get_if_addrs() else {
return Vec::new();
};
let mut out = Vec::new();
for iface in ifaces {
if iface.is_loopback() {
continue;
}
match iface.ip() {
std::net::IpAddr::V4(v4) => {
if v4.is_link_local() {
continue;
}
// Keep RFC1918 private ranges and CGNAT — those
// are the LAN-dialable addrs we actually want.
// Skip public v4 because the reflex addr already
// covers that path.
if v4.is_private() {
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
} else if v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40 {
// 100.64/10 CGNAT — rare but valid if two
// phones are on the same CGNAT-hairpinned
// carrier LAN (some hotspot setups).
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
}
}
std::net::IpAddr::V6(v6) => {
// Phase 7: IPv6 host candidates via dedicated
// IPv6 socket. When v6_port is None, no IPv6
// endpoint exists — skip silently.
let Some(port) = v6_port else { continue };
if v6.is_loopback() || v6.is_unspecified() {
continue;
}
// fe80::/10 link-local — needs scope ID, not
// routable across interfaces.
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
continue;
}
// Accept global unicast (2000::/3) and
// unique-local (fc00::/7).
let first_seg = v6.segments()[0];
let is_global = (first_seg & 0xe000) == 0x2000;
let is_ula = (first_seg & 0xfe00) == 0xfc00;
if is_global || is_ula {
out.push(SocketAddr::new(std::net::IpAddr::V6(v6), port));
}
}
}
}
out
}
/// Role assignment for the Phase 3.5 dual-path QUIC race. /// Role assignment for the Phase 3.5 dual-path QUIC race.
/// ///
/// Both peers already know two strings at CallSetup time: their /// Both peers already know two strings at CallSetup time: their
@@ -275,14 +398,63 @@ pub fn determine_role(
} }
} }
/// Returns `true` if the address is in an RFC1918 / link-local /
/// loopback range and therefore cannot possibly be a post-NAT
/// reflex address from the public internet's point of view.
///
/// A probe against a relay ON THE SAME LAN as the client will
/// naturally report the client's LAN IP back (because there's no
/// NAT between them) — that observation is real but says nothing
/// about the client's public-internet-facing NAT state. Mixing
/// LAN reflex addrs with public-internet reflex addrs in
/// `classify_nat` would always report `Multiple` (different IPs)
/// and falsely warn about symmetric NAT. Filter them out before
/// classifying.
fn is_private_or_loopback(addr: &SocketAddr) -> bool {
match addr.ip() {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|| v4.is_link_local() // 169.254/16
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40) // 100.64/10 CGNAT shared
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
}
}
}
/// Pure-function NAT classifier — split out for unit testing /// Pure-function NAT classifier — split out for unit testing
/// without touching the network. /// without touching the network.
///
/// Only considers probes whose reflex addr is a **public-internet**
/// address. LAN / private / loopback reflex addrs are dropped
/// because they reflect the same-network path rather than the
/// real NAT state. CGNAT (100.64/10) is also treated as private
/// because the post-CGNAT address would be what we actually want
/// to classify on — but CGNAT is unreachable from outside the
/// carrier, so a relay seeing the CGNAT addr is on the same
/// carrier network and again not useful for classification.
pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option<String>) { pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option<String>) {
let successes: Vec<SocketAddr> = probes // First: parse every successful probe's observed addr.
let parsed: Vec<SocketAddr> = probes
.iter() .iter()
.filter_map(|p| p.observed_addr.as_deref().and_then(|s| s.parse().ok())) .filter_map(|p| p.observed_addr.as_deref().and_then(|s| s.parse().ok()))
.collect(); .collect();
// Then: drop LAN / private / loopback reflex addrs. Those are
// legitimate observations by same-network relays, but they
// don't contribute to NAT-type classification because the
// client's real public-facing NAT mapping is not involved on
// that path. A relay on the same LAN always sees the client's
// LAN IP, regardless of whether the NAT beyond it is cone or
// symmetric.
let successes: Vec<SocketAddr> = parsed
.into_iter()
.filter(|a| !is_private_or_loopback(a))
.collect();
if successes.len() < 2 { if successes.len() < 2 {
return (NatType::Unknown, None); return (NatType::Unknown, None);
} }
@@ -365,6 +537,66 @@ mod tests {
assert!(addr.is_none()); assert!(addr.is_none());
} }
#[test]
fn classify_drops_private_ip_probes() {
// One LAN probe + one public probe should behave like a
// single public probe — i.e. Unknown (not enough data to
// classify). This is the common real-world case: the user
// has a LAN relay + an internet relay configured, the LAN
// relay sees the LAN IP, the internet relay sees the WAN
// IP, and the old classifier would flag "Multiple" and
// falsely warn about symmetric NAT.
let probes = vec![
mk(Some("192.168.1.100:4433")), // LAN — must be dropped
mk(Some("203.0.113.5:4433")), // public (TEST-NET-3)
];
let (nt, _) = classify_nat(&probes);
assert_eq!(nt, NatType::Unknown);
}
#[test]
fn classify_drops_loopback_probes() {
let probes = vec![
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:4433")), // public, same addr
];
let (nt, addr) = classify_nat(&probes);
// Two public probes with identical addrs → Cone.
assert_eq!(nt, NatType::Cone);
assert_eq!(addr.as_deref(), Some("203.0.113.5:4433"));
}
#[test]
fn classify_drops_cgnat_probes() {
// 100.64.0.0/10 is the CGNAT shared-transition range.
// Filter treats it like RFC1918 — a relay that sees the
// client with a 100.64/10 addr is on the same CGNAT
// network and can't contribute to public NAT classification.
let probes = vec![
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:12345")), // public, different port
];
let (nt, _) = classify_nat(&probes);
// Two public probes same IP different port → SymmetricPort.
assert_eq!(nt, NatType::SymmetricPort);
}
#[test]
fn classify_two_lan_probes_is_unknown_not_cone() {
// Even if both probes come back from LAN relays, we can't
// say anything useful about the public NAT state. Unknown,
// not Cone.
let probes = vec![
mk(Some("192.168.1.100:4433")),
mk(Some("192.168.1.100:4433")),
];
let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::Unknown);
assert!(addr.is_none());
}
#[test] #[test]
fn classify_mix_of_success_and_failure() { fn classify_mix_of_success_and_failure() {
let probes = vec![ let probes = vec![

View File

@@ -19,7 +19,7 @@
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration; use std::time::Duration;
use wzp_client::dual_path::{race, WinningPath}; use wzp_client::dual_path::{race, PeerCandidates, WinningPath};
use wzp_client::reflect::Role; use wzp_client::reflect::Role;
use wzp_transport::{create_endpoint, server_config}; use wzp_transport::{create_endpoint, server_config};
@@ -110,15 +110,20 @@ async fn dual_path_direct_wins_on_loopback() {
// should win. // should win.
let result = race( let result = race(
Role::Dialer, Role::Dialer,
acceptor_listen_addr, PeerCandidates {
reflexive: Some(acceptor_listen_addr),
local: Vec::new(),
},
relay_addr, relay_addr,
"test-room".into(), "test-room".into(),
"call-test".into(), "call-test".into(),
None, // Phase 5: tests use fresh endpoints (no shared signal)
) )
.await .await
.expect("race must succeed"); .expect("race must succeed");
assert_eq!(result.1, WinningPath::Direct, "direct should win on loopback"); assert!(result.direct_transport.is_some(), "direct transport should be available");
assert_eq!(result.local_winner, WinningPath::Direct, "direct should win on loopback");
// Cancel the acceptor accept task so the test finishes. // Cancel the acceptor accept task so the test finishes.
acceptor_accept_task.abort(); acceptor_accept_task.abort();
@@ -147,16 +152,21 @@ async fn dual_path_relay_wins_when_direct_is_dead() {
let result = race( let result = race(
Role::Dialer, Role::Dialer,
dead_peer, PeerCandidates {
reflexive: Some(dead_peer),
local: Vec::new(),
},
relay_addr, relay_addr,
"test-room".into(), "test-room".into(),
"call-test".into(), "call-test".into(),
None, // Phase 5: tests use fresh endpoints (no shared signal)
) )
.await .await
.expect("race must succeed via relay fallback"); .expect("race must succeed via relay fallback");
assert!(result.relay_transport.is_some(), "relay transport should be available");
assert_eq!( assert_eq!(
result.1, result.local_winner,
WinningPath::Relay, WinningPath::Relay,
"relay should win when direct dial has nowhere to land" "relay should win when direct dial has nowhere to land"
); );
@@ -180,10 +190,14 @@ async fn dual_path_errors_cleanly_when_both_paths_dead() {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let result = race( let result = race(
Role::Dialer, Role::Dialer,
dead_peer, PeerCandidates {
reflexive: Some(dead_peer),
local: Vec::new(),
},
dead_relay, dead_relay,
"test-room".into(), "test-room".into(),
"call-test".into(), "call-test".into(),
None, // Phase 5: tests use fresh endpoints (no shared signal)
) )
.await; .await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();

View File

@@ -116,6 +116,14 @@ impl AudioEncoder for AdaptiveEncoder {
fn set_dtx(&mut self, enabled: bool) { fn set_dtx(&mut self, enabled: bool) {
self.opus.set_dtx(enabled); self.opus.set_dtx(enabled);
} }
fn set_expected_loss(&mut self, loss_pct: u8) {
self.opus.set_expected_loss(loss_pct);
}
fn set_dred_duration(&mut self, frames: u8) {
self.opus.set_dred_duration(frames);
}
} }
// ─── AdaptiveDecoder ───────────────────────────────────────────────────────── // ─── AdaptiveDecoder ─────────────────────────────────────────────────────────

View File

@@ -14,8 +14,9 @@
//! networks; short window keeps decoder CPU modest. //! networks; short window keeps decoder CPU modest.
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common //! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
//! VoIP loss patterns (20150 ms bursts from wifi roam, transient congestion). //! VoIP loss patterns (20150 ms bursts from wifi roam, transient congestion).
//! - Degraded tier (Opus 6k): 500 ms — users on 6k are by definition on a //! - Degraded tier (Opus 6k): 1040 ms — users on 6k are by definition on a
//! bad link; longer DRED buys maximum burst resilience where it matters. //! bad link; the maximum libopus DRED window buys the best burst resilience
//! where it matters. The RDO-VAE naturally degrades quality at longer offsets.
//! //!
//! # Why the 15% packet loss floor //! # Why the 15% packet loss floor
//! //!
@@ -78,8 +79,12 @@ pub fn dred_duration_for(codec: CodecId) -> u8 {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
// Normal tiers — balanced baseline. // Normal tiers — balanced baseline.
CodecId::Opus16k | CodecId::Opus24k => 20, CodecId::Opus16k | CodecId::Opus24k => 20,
// Degraded tier — maximum burst resilience. // Degraded tier — maximum burst resilience. 104 × 10 ms = 1040 ms,
CodecId::Opus6k => 50, // the highest value libopus 1.5 supports. Users on 6k are on a bad
// link by definition; the RDO-VAE naturally degrades quality at longer
// offsets, so the extra window costs only ~1-2 kbps additional overhead
// while buying substantially better burst resilience (up from 500 ms).
CodecId::Opus6k => 104,
// Non-Opus (Codec2 / CN): DRED is N/A. // Non-Opus (Codec2 / CN): DRED is N/A.
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0, CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
} }
@@ -334,6 +339,14 @@ impl AudioEncoder for OpusEncoder {
fn set_dtx(&mut self, enabled: bool) { fn set_dtx(&mut self, enabled: bool) {
let _ = self.inner.set_dtx(enabled); let _ = self.inner.set_dtx(enabled);
} }
fn set_expected_loss(&mut self, loss_pct: u8) {
OpusEncoder::set_expected_loss(self, loss_pct);
}
fn set_dred_duration(&mut self, frames: u8) {
OpusEncoder::set_dred_duration(self, frames);
}
} }
#[cfg(test)] #[cfg(test)]
@@ -389,8 +402,8 @@ mod tests {
} }
#[test] #[test]
fn dred_duration_for_degraded_tier_is_500ms() { fn dred_duration_for_degraded_tier_is_1040ms() {
assert_eq!(dred_duration_for(CodecId::Opus6k), 50); assert_eq!(dred_duration_for(CodecId::Opus6k), 104);
} }
#[test] #[test]

View File

@@ -199,6 +199,7 @@ fn wzp_answer_round_trips_through_fc_callsignal() {
fn wzp_hangup_round_trips_through_fc_callsignal() { fn wzp_hangup_round_trips_through_fc_callsignal() {
let hangup = wzp_proto::SignalMessage::Hangup { let hangup = wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}; };
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None); let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
@@ -302,6 +303,7 @@ fn all_signal_types_map_correctly() {
( (
wzp_proto::SignalMessage::Hangup { wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}, },
"Hangup", "Hangup",
), ),

View File

@@ -8,6 +8,8 @@
#include <android/log.h> #include <android/log.h>
#include <cstring> #include <cstring>
#include <atomic> #include <atomic>
#include <chrono>
#include <thread>
#define LOG_TAG "wzp-oboe" #define LOG_TAG "wzp-oboe"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -254,14 +256,28 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
oboe::AudioStreamBuilder captureBuilder; oboe::AudioStreamBuilder captureBuilder;
captureBuilder.setDirection(oboe::Direction::Input) captureBuilder.setDirection(oboe::Direction::Input)
->setPerformanceMode(oboe::PerformanceMode::LowLatency) ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive) ->setSharingMode(oboe::SharingMode::Shared)
->setFormat(oboe::AudioFormat::I16) ->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count) ->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
->setFramesPerDataCallback(config->frames_per_burst)
->setInputPreset(oboe::InputPreset::VoiceCommunication)
->setDataCallback(&g_capture_cb); ->setDataCallback(&g_capture_cb);
if (config->bt_active) {
// BT SCO mode: do NOT set sample rate or input preset.
// Requesting 48kHz against a BT SCO device fails with
// "getInputProfile could not find profile". Letting the system
// choose the native rate (8/16kHz) and relying on Oboe's
// resampler (SampleRateConversionQuality::Best) to bridge
// to our 48kHz ring buffer is the only path that works.
// InputPreset::VoiceCommunication can also prevent BT SCO
// routing on some devices — skip it for BT.
LOGI("capture: BT mode — no sample rate or input preset set");
} else {
captureBuilder.setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setInputPreset(oboe::InputPreset::VoiceCommunication);
}
oboe::Result result = captureBuilder.openStream(g_capture_stream); oboe::Result result = captureBuilder.openStream(g_capture_stream);
if (result != oboe::Result::OK) { if (result != oboe::Result::OK) {
LOGE("Failed to open capture stream: %s", oboe::convertToText(result)); LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
@@ -314,14 +330,23 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
oboe::AudioStreamBuilder playoutBuilder; oboe::AudioStreamBuilder playoutBuilder;
playoutBuilder.setDirection(oboe::Direction::Output) playoutBuilder.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency) ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive) ->setSharingMode(oboe::SharingMode::Shared)
->setFormat(oboe::AudioFormat::I16) ->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count) ->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
->setFramesPerDataCallback(config->frames_per_burst)
->setUsage(oboe::Usage::VoiceCommunication)
->setDataCallback(&g_playout_cb); ->setDataCallback(&g_playout_cb);
if (config->bt_active) {
LOGI("playout: BT mode — no sample rate set, using Usage::Media");
// Usage::Media instead of VoiceCommunication for BT output
// to avoid conflicts with the communication device routing.
playoutBuilder.setUsage(oboe::Usage::Media);
} else {
playoutBuilder.setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setUsage(oboe::Usage::VoiceCommunication);
}
result = playoutBuilder.openStream(g_playout_stream); result = playoutBuilder.openStream(g_playout_stream);
if (result != oboe::Result::OK) { if (result != oboe::Result::OK) {
LOGE("Failed to open playout stream: %s", oboe::convertToText(result)); LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
@@ -365,6 +390,38 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
return -5; return -5;
} }
// Log initial stream states right after requestStart() returns.
// On well-behaved HALs both will already be Started; on others
// (Nothing A059) they may still be in Starting state.
LOGI("requestStart returned: capture_state=%d playout_state=%d",
(int)g_capture_stream->getState(),
(int)g_playout_stream->getState());
// Poll until both streams report Started state, up to 2s timeout.
// Some Android HALs (Nothing A059) delay transitioning from Starting
// to Started; proceeding before the transition completes causes the
// first capture/playout callbacks to be dropped silently.
{
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
int poll_count = 0;
while (std::chrono::steady_clock::now() < deadline) {
auto cap_state = g_capture_stream->getState();
auto play_state = g_playout_stream->getState();
if (cap_state == oboe::StreamState::Started &&
play_state == oboe::StreamState::Started) {
LOGI("both streams Started after %d polls", poll_count);
break;
}
poll_count++;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
// Log final state even on timeout (helps diagnose HAL quirks)
LOGI("stream states after poll: capture=%d playout=%d (polls=%d)",
(int)g_capture_stream->getState(),
(int)g_playout_stream->getState(),
poll_count);
}
LOGI("Oboe started: sr=%d burst=%d ch=%d", LOGI("Oboe started: sr=%d burst=%d ch=%d",
config->sample_rate, config->frames_per_burst, config->channel_count); config->sample_rate, config->frames_per_burst, config->channel_count);
return 0; return 0;

View File

@@ -16,6 +16,7 @@ typedef struct {
int32_t sample_rate; int32_t sample_rate;
int32_t frames_per_burst; int32_t frames_per_burst;
int32_t channel_count; int32_t channel_count;
int32_t bt_active; /* nonzero = BT SCO mode: skip sample rate + input preset */
} WzpOboeConfig; } WzpOboeConfig;
typedef struct { typedef struct {

View File

@@ -47,6 +47,10 @@ struct WzpOboeConfig {
sample_rate: i32, sample_rate: i32,
frames_per_burst: i32, frames_per_burst: i32,
channel_count: i32, channel_count: i32,
/// When nonzero, capture stream skips setSampleRate and setInputPreset
/// so the system can route to BT SCO at its native rate (8/16kHz).
/// Oboe's SampleRateConversionQuality::Best resamples to 48kHz.
bt_active: i32,
} }
#[repr(C)] #[repr(C)]
@@ -174,6 +178,13 @@ struct AudioBackend {
started: std::sync::Mutex<bool>, started: std::sync::Mutex<bool>,
/// Per-write logging throttle counter for wzp_native_audio_write_playout. /// Per-write logging throttle counter for wzp_native_audio_write_playout.
playout_write_log_count: std::sync::atomic::AtomicU64, playout_write_log_count: std::sync::atomic::AtomicU64,
/// Fix A (task #35): the playout ring's read_idx at the last
/// check. If audio_write_playout observes read_idx hasn't
/// advanced after N writes, the Oboe playout callback has
/// stopped firing → restart the streams.
playout_last_read_idx: std::sync::atomic::AtomicI32,
/// Number of writes since the last read_idx advance.
playout_stall_writes: std::sync::atomic::AtomicU32,
} }
static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new(); static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new();
@@ -185,6 +196,8 @@ fn backend() -> &'static AudioBackend {
playout: RingBuffer::new(RING_CAPACITY), playout: RingBuffer::new(RING_CAPACITY),
started: std::sync::Mutex::new(false), started: std::sync::Mutex::new(false),
playout_write_log_count: std::sync::atomic::AtomicU64::new(0), playout_write_log_count: std::sync::atomic::AtomicU64::new(0),
playout_last_read_idx: std::sync::atomic::AtomicI32::new(0),
playout_stall_writes: std::sync::atomic::AtomicU32::new(0),
})) }))
}) })
} }
@@ -195,6 +208,17 @@ fn backend() -> &'static AudioBackend {
/// Idempotent — calling while already running is a no-op that returns 0. /// Idempotent — calling while already running is a no-op that returns 0.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_start() -> i32 { pub extern "C" fn wzp_native_audio_start() -> i32 {
audio_start_inner(false)
}
/// Start Oboe in Bluetooth SCO mode — skips sample rate and input preset
/// on capture so the system can route to the BT SCO device natively.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_start_bt() -> i32 {
audio_start_inner(true)
}
fn audio_start_inner(bt: bool) -> i32 {
let b = backend(); let b = backend();
let mut started = match b.started.lock() { let mut started = match b.started.lock() {
Ok(g) => g, Ok(g) => g,
@@ -208,6 +232,7 @@ pub extern "C" fn wzp_native_audio_start() -> i32 {
sample_rate: 48_000, sample_rate: 48_000,
frames_per_burst: FRAME_SAMPLES as i32, frames_per_burst: FRAME_SAMPLES as i32,
channel_count: 1, channel_count: 1,
bt_active: if bt { 1 } else { 0 },
}; };
let rings = WzpOboeRings { let rings = WzpOboeRings {
capture_buf: b.capture.buf_ptr(), capture_buf: b.capture.buf_ptr(),
@@ -262,6 +287,77 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
} }
let slice = unsafe { std::slice::from_raw_parts(input, in_len) }; let slice = unsafe { std::slice::from_raw_parts(input, in_len) };
let b = backend(); let b = backend();
// Fix A (task #35): detect playout callback stall. If the
// playout ring's read_idx hasn't advanced in 50+ writes
// (~1 second at 50 writes/sec), the Oboe playout callback
// has stopped firing → restart the streams. This is the
// self-healing behavior that makes rejoin work: teardown +
// rebuild clears whatever HAL state locked up the callback.
let current_read_idx = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
let last_read_idx = b.playout_last_read_idx.load(std::sync::atomic::Ordering::Relaxed);
if current_read_idx == last_read_idx {
let stall = b.playout_stall_writes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if stall >= 50 {
// Callback hasn't drained anything in ~1 second.
// Force a stream restart.
unsafe {
android_log("playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams");
}
b.playout_stall_writes.store(0, std::sync::atomic::Ordering::Relaxed);
// Release the started lock, stop, re-start.
// This is the same logic as the Rust-side
// audio_stop() + audio_start() but done inline
// because we can't call the extern "C" fns
// recursively. Just call the C++ side directly.
{
if let Ok(mut started) = b.started.lock() {
if *started {
unsafe { wzp_oboe_stop() };
*started = false;
}
}
}
// Clear the rings so the restart doesn't read stale data
b.playout.write_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout.read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.write_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
// Re-start (stall detector — always non-BT mode)
let config = WzpOboeConfig {
sample_rate: 48_000,
frames_per_burst: FRAME_SAMPLES as i32,
channel_count: 1,
bt_active: 0,
};
let rings = WzpOboeRings {
capture_buf: b.capture.buf_ptr(),
capture_capacity: b.capture.capacity as i32,
capture_write_idx: b.capture.write_idx_ptr(),
capture_read_idx: b.capture.read_idx_ptr(),
playout_buf: b.playout.buf_ptr(),
playout_capacity: b.playout.capacity as i32,
playout_write_idx: b.playout.write_idx_ptr(),
playout_read_idx: b.playout.read_idx_ptr(),
};
let ret = unsafe { wzp_oboe_start(&config, &rings) };
if ret == 0 {
if let Ok(mut started) = b.started.lock() {
*started = true;
}
unsafe { android_log("playout restart OK — Oboe streams rebuilt"); }
} else {
unsafe { android_log(&format!("playout restart FAILED: {ret}")); }
}
b.playout_last_read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
return 0; // caller will retry on next frame
}
} else {
// read_idx advanced — callback is alive, reset counter
b.playout_stall_writes.store(0, std::sync::atomic::Ordering::Relaxed);
b.playout_last_read_idx.store(current_read_idx, std::sync::atomic::Ordering::Relaxed);
}
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed); let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed);
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed); let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
let written = b.playout.write(slice); let written = b.playout.write(slice);

View File

@@ -0,0 +1,312 @@
//! Continuous DRED tuning from real-time network metrics.
//!
//! Instead of locking DRED duration to 3 discrete quality tiers (100/200/500 ms),
//! `DredTuner` maps live path quality metrics to a continuous DRED duration and
//! expected-loss hint, updated every N packets. This makes DRED reactive within
//! ~200 ms instead of waiting for 3+ consecutive bad quality reports to trigger
//! a full tier transition.
//!
//! The tuner also implements pre-emptive jitter-spike detection ("sawtooth"
//! prediction): when jitter variance spikes >30% over a 200 ms window — typical
//! of Starlink satellite handovers — it temporarily boosts DRED to the maximum
//! allowed for the current codec before packets actually start dropping.
use crate::CodecId;
/// Output of a single tuning cycle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DredTuning {
/// DRED duration in 10 ms frame units (0104). Passed directly to
/// `OpusEncoder::set_dred_duration()`.
pub dred_frames: u8,
/// Expected packet loss percentage (0100). Passed to
/// `OpusEncoder::set_expected_loss()`. Floored at 15% by the encoder
/// itself, but we pass the real value so the encoder can override upward.
pub expected_loss_pct: u8,
}
/// Minimum DRED frames for any Opus codec (matches DRED_LOSS_FLOOR_PCT logic:
/// at 15% loss, libopus 1.5 emits ~95 ms of DRED, which needs at least 10
/// frames configured to be useful).
const MIN_DRED_FRAMES: u8 = 5;
/// Maximum DRED frames libopus supports (104 × 10 ms = 1040 ms).
const MAX_DRED_FRAMES: u8 = 104;
/// Jitter variance spike ratio that triggers pre-emptive DRED boost.
const JITTER_SPIKE_RATIO: f32 = 1.3;
/// How many tuning cycles a jitter-spike boost persists (at 25 packets/cycle
/// and 20 ms/packet, 10 cycles ≈ 5 seconds).
const SPIKE_BOOST_COOLDOWN_CYCLES: u32 = 10;
/// Maps codec tier to its baseline DRED frames (used when network is healthy).
fn baseline_dred_frames(codec: CodecId) -> u8 {
match codec {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
CodecId::Opus6k => 50, // 500 ms
_ => 0,
}
}
/// Maps codec tier to its maximum allowed DRED frames under spike/bad conditions.
fn max_dred_frames_for(codec: CodecId) -> u8 {
match codec {
// Studio: cap at 300 ms (don't waste bitrate on good links)
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 30,
// Normal: cap at 500 ms
CodecId::Opus16k | CodecId::Opus24k => 50,
// Degraded: allow full 1040 ms
CodecId::Opus6k => MAX_DRED_FRAMES,
_ => 0,
}
}
/// Continuous DRED tuner driven by network path metrics.
pub struct DredTuner {
/// Current codec (determines baseline and ceiling).
codec: CodecId,
/// Last computed tuning output.
last_tuning: DredTuning,
/// EWMA-smoothed jitter for spike detection (in ms).
jitter_ewma: f32,
/// Remaining cooldown cycles for a jitter-spike boost.
spike_cooldown: u32,
/// Whether the tuner has received at least one observation.
initialized: bool,
}
impl DredTuner {
/// Create a new tuner for the given codec.
pub fn new(codec: CodecId) -> Self {
let baseline = baseline_dred_frames(codec);
Self {
codec,
last_tuning: DredTuning {
dred_frames: baseline,
expected_loss_pct: 15, // match DRED_LOSS_FLOOR_PCT
},
jitter_ewma: 0.0,
spike_cooldown: 0,
initialized: false,
}
}
/// Update the active codec (e.g. on tier transition). Resets spike state.
pub fn set_codec(&mut self, codec: CodecId) {
self.codec = codec;
self.spike_cooldown = 0;
}
/// Feed network metrics and compute new DRED parameters.
///
/// Call this every tuning cycle (e.g. every 25 packets ≈ 500 ms at 20 ms
/// frame duration).
///
/// - `loss_pct`: observed packet loss (0.0100.0)
/// - `rtt_ms`: smoothed round-trip time
/// - `jitter_ms`: current jitter estimate (RTT variance)
///
/// Returns `Some(tuning)` if the output changed, `None` if unchanged.
pub fn update(&mut self, loss_pct: f32, rtt_ms: u32, jitter_ms: u32) -> Option<DredTuning> {
if !self.codec.is_opus() {
return None;
}
let baseline = baseline_dred_frames(self.codec);
let ceiling = max_dred_frames_for(self.codec);
// --- Jitter spike detection ---
let jitter_f = jitter_ms as f32;
if !self.initialized {
self.jitter_ewma = jitter_f;
self.initialized = true;
} else {
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
let alpha = if jitter_f > self.jitter_ewma { 0.3 } else { 0.05 };
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
}
// Detect spike: instantaneous jitter > EWMA × 1.3
if self.jitter_ewma > 1.0 && jitter_f > self.jitter_ewma * JITTER_SPIKE_RATIO {
self.spike_cooldown = SPIKE_BOOST_COOLDOWN_CYCLES;
}
// Decrement cooldown
if self.spike_cooldown > 0 {
self.spike_cooldown -= 1;
}
// --- Compute DRED frames ---
let dred_frames = if self.spike_cooldown > 0 {
// During spike boost: jump to ceiling
ceiling
} else {
// Continuous mapping: scale linearly between baseline and ceiling
// based on loss percentage.
// 0% loss → baseline
// 40% loss → ceiling
let loss_clamped = loss_pct.clamp(0.0, 40.0);
let t = loss_clamped / 40.0;
let raw = baseline as f32 + t * (ceiling - baseline) as f32;
(raw as u8).clamp(MIN_DRED_FRAMES, ceiling)
};
// --- Compute expected loss hint ---
// Pass the real loss so the encoder can clamp at its own floor (15%).
// For RTT-driven boost: high RTT suggests impending loss, so add a
// phantom loss contribution to keep DRED emitting generously.
let rtt_loss_phantom = if rtt_ms > 200 {
((rtt_ms - 200) as f32 / 40.0).min(15.0)
} else {
0.0
};
let expected_loss = (loss_pct + rtt_loss_phantom).clamp(0.0, 100.0) as u8;
let tuning = DredTuning {
dred_frames,
expected_loss_pct: expected_loss,
};
if tuning != self.last_tuning {
self.last_tuning = tuning;
Some(tuning)
} else {
None
}
}
/// Get the last computed tuning without updating.
pub fn current(&self) -> DredTuning {
self.last_tuning
}
/// Whether a jitter-spike boost is currently active.
pub fn spike_boost_active(&self) -> bool {
self.spike_cooldown > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_for_opus24k() {
let tuner = DredTuner::new(CodecId::Opus24k);
assert_eq!(tuner.current().dred_frames, 20); // 200 ms
}
#[test]
fn baseline_for_opus6k() {
let tuner = DredTuner::new(CodecId::Opus6k);
assert_eq!(tuner.current().dred_frames, 50); // 500 ms
}
#[test]
fn codec2_returns_none() {
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
assert!(tuner.update(10.0, 100, 20).is_none());
}
#[test]
fn scales_with_loss() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// 0% loss → baseline (20 frames)
tuner.update(0.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 20);
// 20% loss → midpoint between 20 and 50 = 35
tuner.update(20.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 35);
// 40%+ loss → ceiling (50 frames)
tuner.update(40.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 50);
}
#[test]
fn jitter_spike_triggers_boost() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish baseline jitter
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
assert!(!tuner.spike_boost_active());
// Spike: jitter jumps to 50 ms (5x the EWMA of ~10)
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Should be at ceiling (50 frames = 500 ms for Opus24k)
assert_eq!(tuner.current().dred_frames, 50);
}
#[test]
fn spike_cooldown_decays() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish baseline then spike
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Run through cooldown
for _ in 0..SPIKE_BOOST_COOLDOWN_CYCLES {
tuner.update(0.0, 50, 10);
}
assert!(!tuner.spike_boost_active());
// Should return to baseline
assert_eq!(tuner.current().dred_frames, 20);
}
#[test]
fn rtt_phantom_loss() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// High RTT (400ms) with 0% real loss
tuner.update(0.0, 400, 10);
// Phantom loss = (400-200)/40 = 5
assert_eq!(tuner.current().expected_loss_pct, 5);
}
#[test]
fn set_codec_resets_spike() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Trigger spike
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Switch codec — spike should reset
tuner.set_codec(CodecId::Opus6k);
assert!(!tuner.spike_boost_active());
}
#[test]
fn opus6k_reaches_max_1040ms() {
let mut tuner = DredTuner::new(CodecId::Opus6k);
// High loss → should reach 104 frames (1040 ms)
tuner.update(40.0, 50, 5);
assert_eq!(tuner.current().dred_frames, MAX_DRED_FRAMES);
}
#[test]
fn returns_none_when_unchanged() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// First update always returns Some (initial → computed)
let first = tuner.update(0.0, 50, 5);
// Same inputs → None
let second = tuner.update(0.0, 50, 5);
assert!(first.is_some() || second.is_none());
}
}

View File

@@ -14,6 +14,7 @@
pub mod bandwidth; pub mod bandwidth;
pub mod codec_id; pub mod codec_id;
pub mod dred_tuner;
pub mod error; pub mod error;
pub mod jitter; pub mod jitter;
pub mod packet; pub mod packet;
@@ -30,6 +31,7 @@ pub use packet::{
FRAME_TYPE_MINI, FRAME_TYPE_MINI,
}; };
pub use bandwidth::{BandwidthEstimator, CongestionState}; pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use dred_tuner::{DredTuner, DredTuning};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier}; pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
pub use session::{Session, SessionEvent, SessionState}; pub use session::{Session, SessionEvent, SessionState};
pub use traits::*; pub use traits::*;

View File

@@ -608,8 +608,14 @@ pub enum SignalMessage {
Ping { timestamp_ms: u64 }, Ping { timestamp_ms: u64 },
Pong { timestamp_ms: u64 }, Pong { timestamp_ms: u64 },
/// End the call. /// End the call. `call_id` is optional for backwards compatibility
Hangup { reason: HangupReason }, /// with older clients that send Hangup without it — the relay falls
/// back to ending ALL active calls for the sender in that case.
Hangup {
reason: HangupReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
call_id: Option<String>,
},
/// featherChat bearer token for relay authentication. /// featherChat bearer token for relay authentication.
/// Sent as the first signal message when --auth-url is configured. /// Sent as the first signal message when --auth-url is configured.
@@ -716,6 +722,9 @@ pub enum SignalMessage {
success: bool, success: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
/// Relay's build version (git short hash).
#[serde(default, skip_serializing_if = "Option::is_none")]
relay_build: Option<String>,
}, },
/// Direct call offer routed through the relay to a specific peer. /// Direct call offer routed through the relay to a specific peer.
@@ -745,6 +754,19 @@ pub enum SignalMessage {
/// `None` means "caller doesn't want P2P, use relay only". /// `None` means "caller doesn't want P2P, use relay only".
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
caller_reflexive_addr: Option<String>, caller_reflexive_addr: Option<String>,
/// Phase 5.5 (ICE host candidates): caller's LAN-local
/// interface addresses paired with its signal endpoint's
/// port. Peers on the same physical LAN can direct-dial
/// these without going through the WAN reflex addr,
/// which is important because most consumer NATs
/// (including MikroTik masquerade) don't support NAT
/// hairpinning — the reflex addr is unreachable from
/// the same LAN.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
caller_local_addrs: Vec<String>,
/// Build version (git short hash) for debugging.
#[serde(default, skip_serializing_if = "Option::is_none")]
caller_build_version: Option<String>,
}, },
/// Callee's response to a direct call. /// Callee's response to a direct call.
@@ -771,6 +793,16 @@ pub enum SignalMessage {
/// carries it opaquely into the caller's `CallSetup`. /// carries it opaquely into the caller's `CallSetup`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
callee_reflexive_addr: Option<String>, callee_reflexive_addr: Option<String>,
/// Phase 5.5 (ICE host candidates): callee's LAN-local
/// interface addresses. Same purpose as
/// `caller_local_addrs` in `DirectCallOffer`. Only
/// populated on `AcceptTrusted` alongside
/// `callee_reflexive_addr`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
callee_local_addrs: Vec<String>,
/// Build version (git short hash) for debugging.
#[serde(default, skip_serializing_if = "Option::is_none")]
callee_build_version: Option<String>,
}, },
/// Relay tells both parties: media room is ready. /// Relay tells both parties: media room is ready.
@@ -791,6 +823,14 @@ pub enum SignalMessage {
/// wasn't viable. /// wasn't viable.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
peer_direct_addr: Option<String>, peer_direct_addr: Option<String>,
/// Phase 5.5 (ICE host candidates): the OTHER party's LAN
/// host addresses (RFC1918 IPv4 + CGNAT + non-link-local
/// IPv6). On same-LAN calls these are directly dialable
/// and bypass the NAT-hairpinning problem that blocks
/// same-LAN peers from using `peer_direct_addr`.
/// Client-side race tries all of these in parallel.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
peer_local_addrs: Vec<String>,
}, },
/// Ringing notification (relay → caller, callee received the offer). /// Ringing notification (relay → caller, callee received the offer).
@@ -821,6 +861,31 @@ pub enum SignalMessage {
observed_addr: String, observed_addr: String,
}, },
// ── Phase 6: ICE-style path negotiation ─────────────────────
/// Phase 6: each side reports the result of its local dual-
/// path race to the other side through the relay. Both peers
/// send this after their race completes; both wait for the
/// other's report before committing a transport to the
/// CallEngine.
///
/// The decision rule is: if BOTH sides report `direct_ok =
/// true`, use the direct P2P connection. If EITHER reports
/// `direct_ok = false`, BOTH fall back to relay. This
/// eliminates the race condition where one side picks Direct
/// and the other picks Relay — they now agree on the path
/// before any media flows.
MediaPathReport {
call_id: String,
/// Did the direct QUIC connection (P2P dial or accept)
/// complete successfully on this side?
direct_ok: bool,
/// Which future won the local tokio::select race?
/// "Direct" or "Relay" — informational for debug logs.
#[serde(default)]
race_winner: String,
},
// ── Phase 4: cross-relay direct-call signaling ──────────────────── // ── Phase 4: cross-relay direct-call signaling ────────────────────
/// Phase 4: relay-to-relay envelope for forwarding direct-call /// Phase 4: relay-to-relay envelope for forwarding direct-call
@@ -852,6 +917,14 @@ pub enum SignalMessage {
/// federation link via `send_signal_to_peer`. /// federation link via `send_signal_to_peer`.
origin_relay_fp: String, origin_relay_fp: String,
}, },
/// Relay-initiated quality directive: all participants should switch
/// to the recommended profile to match the weakest link.
QualityDirective {
recommended_profile: crate::QualityProfile,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
} }
/// How the callee responds to a direct call. /// How the callee responds to a direct call.
@@ -1034,6 +1107,8 @@ mod tests {
signature: vec![3u8; 64], signature: vec![3u8; 64],
supported_profiles: vec![], supported_profiles: vec![],
caller_reflexive_addr: Some("192.0.2.1:4433".into()), caller_reflexive_addr: Some("192.0.2.1:4433".into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
}; };
let forward = SignalMessage::FederatedSignalForward { let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(inner), inner: Box::new(inner),
@@ -1075,9 +1150,11 @@ mod tests {
signature: None, signature: None,
chosen_profile: None, chosen_profile: None,
callee_reflexive_addr: Some("198.51.100.9:4433".into()), callee_reflexive_addr: Some("198.51.100.9:4433".into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
}, },
SignalMessage::CallRinging { call_id: "c1".into() }, SignalMessage::CallRinging { call_id: "c1".into() },
SignalMessage::Hangup { reason: HangupReason::Normal }, SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
]; ];
for inner in cases { for inner in cases {
let inner_disc = std::mem::discriminant(&inner); let inner_disc = std::mem::discriminant(&inner);
@@ -1109,6 +1186,8 @@ mod tests {
signature: vec![], signature: vec![],
supported_profiles: vec![], supported_profiles: vec![],
caller_reflexive_addr: Some("192.0.2.1:4433".into()), caller_reflexive_addr: Some("192.0.2.1:4433".into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
}; };
let json = serde_json::to_string(&offer).unwrap(); let json = serde_json::to_string(&offer).unwrap();
assert!( assert!(
@@ -1136,6 +1215,8 @@ mod tests {
signature: vec![], signature: vec![],
supported_profiles: vec![], supported_profiles: vec![],
caller_reflexive_addr: None, caller_reflexive_addr: None,
caller_local_addrs: Vec::new(),
caller_build_version: None,
}; };
let json_none = serde_json::to_string(&offer_none).unwrap(); let json_none = serde_json::to_string(&offer_none).unwrap();
assert!( assert!(
@@ -1152,6 +1233,8 @@ mod tests {
signature: None, signature: None,
chosen_profile: None, chosen_profile: None,
callee_reflexive_addr: Some("198.51.100.9:4433".into()), callee_reflexive_addr: Some("198.51.100.9:4433".into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
}; };
let decoded: SignalMessage = let decoded: SignalMessage =
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap(); serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
@@ -1171,6 +1254,7 @@ mod tests {
room: "call-c1".into(), room: "call-c1".into(),
relay_addr: "203.0.113.5:4433".into(), relay_addr: "203.0.113.5:4433".into(),
peer_direct_addr: Some("192.0.2.1:4433".into()), peer_direct_addr: Some("192.0.2.1:4433".into()),
peer_local_addrs: Vec::new(),
}; };
let decoded: SignalMessage = let decoded: SignalMessage =
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap(); serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
@@ -1231,7 +1315,7 @@ mod tests {
let cases = vec![ let cases = vec![
SignalMessage::Ping { timestamp_ms: 12345 }, SignalMessage::Ping { timestamp_ms: 12345 },
SignalMessage::Hold, SignalMessage::Hold,
SignalMessage::Hangup { reason: HangupReason::Normal }, SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
SignalMessage::CallRinging { call_id: "abcd".into() }, SignalMessage::CallRinging { call_id: "abcd".into() },
]; ];
for m in cases { for m in cases {
@@ -1589,6 +1673,41 @@ mod tests {
} }
} }
#[test]
fn quality_directive_roundtrip() {
let msg = SignalMessage::QualityDirective {
recommended_profile: crate::QualityProfile::DEGRADED,
reason: Some("weakest link degraded".into()),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::QualityDirective { recommended_profile, reason } => {
assert_eq!(recommended_profile.codec, CodecId::Opus6k);
assert_eq!(reason.as_deref(), Some("weakest link degraded"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn quality_directive_without_reason_roundtrip() {
let msg = SignalMessage::QualityDirective {
recommended_profile: crate::QualityProfile::GOOD,
reason: None,
};
let json = serde_json::to_string(&msg).unwrap();
// None reason should be omitted from JSON
assert!(!json.contains("reason"));
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::QualityDirective { reason, .. } => {
assert!(reason.is_none());
}
_ => panic!("wrong variant"),
}
}
#[test] #[test]
fn mini_frame_disabled() { fn mini_frame_disabled() {
// Simulate disabled mini-frames by always keeping frames_since_full at 0 // Simulate disabled mini-frames by always keeping frames_since_full at 0

View File

@@ -28,6 +28,13 @@ pub trait AudioEncoder: Send + Sync {
/// Enable/disable DTX (discontinuous transmission). No-op for Codec2. /// Enable/disable DTX (discontinuous transmission). No-op for Codec2.
fn set_dtx(&mut self, _enabled: bool) {} fn set_dtx(&mut self, _enabled: bool) {}
/// Hint the encoder about expected packet loss (0100). In DRED mode the
/// encoder floors this at 15% internally. No-op for Codec2.
fn set_expected_loss(&mut self, _loss_pct: u8) {}
/// Set DRED duration in 10 ms frame units (0104). No-op for Codec2.
fn set_dred_duration(&mut self, _frames: u8) {}
} }
/// Decodes compressed frames back to PCM audio. /// Decodes compressed frames back to PCM audio.

View File

@@ -50,6 +50,17 @@ pub struct DirectCall {
/// `DirectCallAnswer` handling uses this to route the reply /// `DirectCallAnswer` handling uses this to route the reply
/// back through the SAME link instead of broadcasting again. /// back through the SAME link instead of broadcasting again.
pub peer_relay_fp: Option<String>, pub peer_relay_fp: Option<String>,
/// Phase 5.5 (ICE host candidates): caller's LAN-local
/// interface addresses from the `DirectCallOffer`. Cross-
/// wired into the callee's `CallSetup.peer_local_addrs` so
/// the callee can direct-dial the caller over the same LAN
/// without going through the WAN reflex addr (NAT
/// hairpinning often doesn't work for same-LAN peers).
pub caller_local_addrs: Vec<String>,
/// Phase 5.5 (ICE host candidates): callee's LAN-local
/// interface addresses from the `DirectCallAnswer`. Cross-
/// wired into the caller's `CallSetup.peer_local_addrs`.
pub callee_local_addrs: Vec<String>,
} }
/// Registry of active direct calls. /// Registry of active direct calls.
@@ -79,11 +90,30 @@ impl CallRegistry {
caller_reflexive_addr: None, caller_reflexive_addr: None,
callee_reflexive_addr: None, callee_reflexive_addr: None,
peer_relay_fp: None, peer_relay_fp: None,
caller_local_addrs: Vec::new(),
callee_local_addrs: Vec::new(),
}; };
self.calls.insert(call_id.clone(), call); self.calls.insert(call_id.clone(), call);
self.calls.get(&call_id).unwrap() self.calls.get(&call_id).unwrap()
} }
/// Phase 5.5: stash the caller's LAN host candidates from
/// the `DirectCallOffer`. Empty Vec is a valid value meaning
/// "caller has no LAN candidates" (e.g. old client).
pub fn set_caller_local_addrs(&mut self, call_id: &str, addrs: Vec<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.caller_local_addrs = addrs;
}
}
/// Phase 5.5: stash the callee's LAN host candidates from
/// the `DirectCallAnswer`.
pub fn set_callee_local_addrs(&mut self, call_id: &str, addrs: Vec<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.callee_local_addrs = addrs;
}
}
/// Phase 4: stash the federation TLS fingerprint of the peer /// Phase 4: stash the federation TLS fingerprint of the peer
/// relay that originated (or will receive) the cross-relay /// relay that originated (or will receive) the cross-relay
/// forward for this call. Safe to call with `None` to clear /// forward for this call. Safe to call with `None` to clear

View File

@@ -255,27 +255,55 @@ impl FederationManager {
} }
/// Check if a room name (which may be hashed) is a global room. /// Check if a room name (which may be hashed) is a global room.
///
/// Phase 4.1: ALL `call-*` rooms are implicitly global for
/// federation. This is the simplest path to cross-relay direct
/// calling with relay-mediated media fallback: when both peers
/// join the same `call-<id>` room on their respective relays,
/// the federation media pipeline automatically forwards
/// datagrams between them. The relay's existing ACL (`call-*`
/// rooms are restricted to the two authorized participants in
/// the call registry) prevents random clients from creating or
/// joining `call-*` rooms.
pub fn is_global_room(&self, room: &str) -> bool { pub fn is_global_room(&self, room: &str) -> bool {
if room.starts_with("call-") {
return true;
}
self.resolve_global_room(room).is_some() self.resolve_global_room(room).is_some()
} }
/// Resolve a room name (raw or hashed) to the canonical global room name. /// Resolve a room name (raw or hashed) to the canonical global room name.
/// Returns the configured global room name if it matches. /// Returns the configured global room name if it matches.
pub fn resolve_global_room(&self, room: &str) -> Option<&str> { ///
/// Phase 4.1: `call-*` rooms resolve to themselves (they ARE
/// the canonical name — no hashing or aliasing involved).
///
/// Returns `Option<String>` (owned) instead of `Option<&str>`
/// because call-* room names aren't stored on `self` — they
/// come from the caller and we just confirm "yes, this is
/// global" by returning it back. Pre-4.1 callers that used
/// the reference for equality checks or hashing work
/// unchanged via String/&str auto-deref.
pub fn resolve_global_room(&self, room: &str) -> Option<String> {
// Phase 4.1: call-* rooms are implicitly global, resolve
// to themselves
if room.starts_with("call-") {
return Some(room.to_string());
}
// Direct match (raw room name, e.g. Android clients) // Direct match (raw room name, e.g. Android clients)
if self.global_rooms.contains(room) { if self.global_rooms.contains(room) {
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap()); return Some(room.to_string());
} }
// Hashed match (desktop clients hash room names for SNI privacy) // Hashed match (desktop clients hash room names for SNI privacy)
self.global_rooms.iter().find(|name| { self.global_rooms.iter().find(|name| {
wzp_crypto::hash_room_name(name) == room wzp_crypto::hash_room_name(name) == room
}).map(|s| s.as_str()) }).map(|s| s.to_string())
} }
/// Get the canonical federation room hash for a room. /// Get the canonical federation room hash for a room.
/// Always uses the configured global room name, not the client-provided name. /// Always uses the configured global room name, not the client-provided name.
pub fn global_room_hash(&self, room: &str) -> [u8; 8] { pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
if let Some(canonical) = self.resolve_global_room(room) { if let Some(ref canonical) = self.resolve_global_room(room) {
room_hash(canonical) room_hash(canonical)
} else { } else {
room_hash(room) room_hash(room)
@@ -347,8 +375,8 @@ impl FederationManager {
let mut result = Vec::new(); let mut result = Vec::new();
for link in links.values() { for link in links.values() {
// Check canonical name // Check canonical name
if let Some(c) = canonical { if let Some(ref c) = canonical {
if let Some(remote) = link.remote_participants.get(c) { if let Some(remote) = link.remote_participants.get(c.as_str()) {
result.extend(remote.iter().cloned()); result.extend(remote.iter().cloned());
} }
// Also check raw room name, but only if different from canonical // Also check raw room name, but only if different from canonical
@@ -807,12 +835,12 @@ async fn handle_signal(
let mut all_participants = mgr.local_participant_list(&local_room); let mut all_participants = mgr.local_participant_list(&local_room);
let links = fm.peer_links.lock().await; let links = fm.peer_links.lock().await;
for link in links.values() { for link in links.values() {
if let Some(canonical) = fm.resolve_global_room(&local_room) { if let Some(ref canonical) = fm.resolve_global_room(&local_room) {
if let Some(remote) = link.remote_participants.get(canonical) { if let Some(remote) = link.remote_participants.get(canonical.as_str()) {
all_participants.extend(remote.iter().cloned()); all_participants.extend(remote.iter().cloned());
} }
// Also check raw room name, but only if different from canonical // Also check raw room name, but only if different from canonical
if canonical != local_room { if canonical != &local_room {
if let Some(remote) = link.remote_participants.get(&local_room) { if let Some(remote) = link.remote_participants.get(&local_room) {
all_participants.extend(remote.iter().cloned()); all_participants.extend(remote.iter().cloned());
} }
@@ -843,8 +871,8 @@ async fn handle_signal(
// Clear remote participants for this peer+room // Clear remote participants for this peer+room
link.remote_participants.remove(&room); link.remote_participants.remove(&room);
// Also try canonical name // Also try canonical name
if let Some(canonical) = fm.resolve_global_room(&room) { if let Some(ref canonical) = fm.resolve_global_room(&room) {
link.remote_participants.remove(canonical); link.remote_participants.remove(canonical.as_str());
} }
} }
@@ -858,8 +886,8 @@ async fn handle_signal(
let mut result = Vec::new(); let mut result = Vec::new();
for (fp, link) in links.iter() { for (fp, link) in links.iter() {
if fp == peer_fp { continue; } if fp == peer_fp { continue; }
if let Some(c) = canonical { if let Some(ref c) = canonical {
if let Some(remote) = link.remote_participants.get(c) { if let Some(remote) = link.remote_participants.get(c.as_str()) {
result.extend(remote.iter().cloned()); result.extend(remote.iter().cloned());
} }
} }
@@ -1049,7 +1077,7 @@ async fn handle_datagram(
// First: check local rooms (has participants) // First: check local rooms (has participants)
active.iter().find(|r| room_hash(r) == rh).cloned() active.iter().find(|r| room_hash(r) == rh).cloned()
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned()) .or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
// Second: check global room config (hub relay may have no local participants) // Second: check static global room config (hub relay may have no local participants)
.or_else(|| { .or_else(|| {
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned() fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
}) })
@@ -1059,6 +1087,23 @@ async fn handle_datagram(
Some(r) => r, Some(r) => r,
None => { None => {
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label)); fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
// Phase 4.1 diagnostic: log the hash + active rooms
// so we can diagnose cross-relay call-* media routing
// failures. This fires when a peer relay sends media
// for a room we don't have locally — could be a
// timing issue (peer joined before us) or a hash
// mismatch.
let active = {
let mgr = fm.room_mgr.lock().await;
mgr.active_rooms()
};
warn!(
room_hash = ?rh,
active_rooms = ?active,
seq = pkt.header.seq,
peer = %peer_label,
"federation datagram for unknown room — no local room matches hash"
);
return; return;
} }
}; };

View File

@@ -543,6 +543,7 @@ async fn main() -> anyhow::Result<()> {
ref caller_fingerprint, ref caller_fingerprint,
ref call_id, ref call_id,
ref caller_reflexive_addr, ref caller_reflexive_addr,
ref caller_local_addrs,
.. ..
} => { } => {
// Is the target on THIS relay? If not, drop — // Is the target on THIS relay? If not, drop —
@@ -561,7 +562,8 @@ async fn main() -> anyhow::Result<()> {
} }
// Stash in local registry so the answer path // Stash in local registry so the answer path
// can find the call + route the reply back // can find the call + route the reply back
// through the same federation link. // through the same federation link. Include
// Phase 5.5 LAN host candidates too.
{ {
let mut reg = call_registry_d.lock().await; let mut reg = call_registry_d.lock().await;
reg.create_call( reg.create_call(
@@ -570,6 +572,7 @@ async fn main() -> anyhow::Result<()> {
target_fingerprint.clone(), target_fingerprint.clone(),
); );
reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone()); reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
reg.set_caller_local_addrs(call_id, caller_local_addrs.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone())); reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
} }
// Deliver the offer to the local target. // Deliver the offer to the local target.
@@ -587,6 +590,7 @@ async fn main() -> anyhow::Result<()> {
ref call_id, ref call_id,
accept_mode, accept_mode,
ref callee_reflexive_addr, ref callee_reflexive_addr,
ref callee_local_addrs,
.. ..
} => { } => {
// Look up the local caller fp from the registry. // Look up the local caller fp from the registry.
@@ -607,6 +611,7 @@ async fn main() -> anyhow::Result<()> {
&caller_fp, &caller_fp,
&SignalMessage::Hangup { &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}, },
) )
.await; .await;
@@ -616,24 +621,29 @@ async fn main() -> anyhow::Result<()> {
continue; continue;
} }
// Accept — stash the callee's reflex addr + mark // Accept — stash the callee's reflex addr + LAN
// the call active, then read back BOTH addrs so // host candidates + mark the call active,
// we can cross-wire peer_direct_addr in CallSetup. // then read back everything needed to cross-
// wire peer_direct_addr + peer_local_addrs in
// the local CallSetup.
// Also set peer_relay_fp so the originating
// relay knows where to forward MediaPathReport.
let room_name = format!("call-{call_id}"); let room_name = format!("call-{call_id}");
let (caller_addr, callee_addr_for_setup) = { let (callee_addr_for_setup, callee_local_for_setup) = {
let mut reg = call_registry_d.lock().await; let mut reg = call_registry_d.lock().await;
reg.set_active(call_id, accept_mode, room_name.clone()); reg.set_active(call_id, accept_mode, room_name.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
reg.set_callee_reflexive_addr( reg.set_callee_reflexive_addr(
call_id, call_id,
callee_reflexive_addr.clone(), callee_reflexive_addr.clone(),
); );
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
let c = reg.get(call_id); let c = reg.get(call_id);
( (
c.and_then(|c| c.caller_reflexive_addr.clone()),
c.and_then(|c| c.callee_reflexive_addr.clone()), c.and_then(|c| c.callee_reflexive_addr.clone()),
c.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
) )
}; };
let _ = caller_addr; // unused on the caller side; callee holds the relevant addr
// Forward the raw answer to the local caller so // Forward the raw answer to the local caller so
// the JS side sees DirectCallAnswer (fires any // the JS side sees DirectCallAnswer (fires any
@@ -649,12 +659,14 @@ async fn main() -> anyhow::Result<()> {
// (single-relay fallback — Phase 4.1 will wire // (single-relay fallback — Phase 4.1 will wire
// federated media so that actually reaches the // federated media so that actually reaches the
// peer). peer_direct_addr = the callee's reflex // peer). peer_direct_addr = the callee's reflex
// addr carried in the answer. // addr carried in the answer. peer_local_addrs
// = callee's LAN host candidates (Phase 5.5 ICE).
let setup = SignalMessage::CallSetup { let setup = SignalMessage::CallSetup {
call_id: call_id.clone(), call_id: call_id.clone(),
room: room_name.clone(), room: room_name.clone(),
relay_addr: advertised_addr_d.clone(), relay_addr: advertised_addr_d.clone(),
peer_direct_addr: callee_addr_for_setup, peer_direct_addr: callee_addr_for_setup,
peer_local_addrs: callee_local_for_setup,
}; };
let hub = signal_hub_d.lock().await; let hub = signal_hub_d.lock().await;
let _ = hub.send_to(&caller_fp, &setup).await; let _ = hub.send_to(&caller_fp, &setup).await;
@@ -679,6 +691,33 @@ async fn main() -> anyhow::Result<()> {
} }
} }
// Phase 6: MediaPathReport forwarded across
// federation — deliver to the LOCAL participant.
// The report comes from the remote side, so we
// deliver to whichever participant is local. In
// the cross-relay case, one is local and one is
// remote. Try both — send_to is a no-op if the
// target isn't connected to this relay.
SignalMessage::MediaPathReport { ref call_id, .. } => {
let (caller_fp, callee_fp) = {
let reg = call_registry_d.lock().await;
match reg.get(call_id) {
Some(c) => (
Some(c.caller_fingerprint.clone()),
Some(c.callee_fingerprint.clone()),
),
None => (None, None),
}
};
let hub = signal_hub_d.lock().await;
if let Some(fp) = caller_fp {
let _ = hub.send_to(&fp, &inner).await;
}
if let Some(fp) = callee_fp {
let _ = hub.send_to(&fp, &inner).await;
}
}
SignalMessage::Hangup { .. } => { SignalMessage::Hangup { .. } => {
// Best-effort: broadcast the hangup to every // Best-effort: broadcast the hangup to every
// local participant of any call that currently // local participant of any call that currently
@@ -971,6 +1010,7 @@ async fn main() -> anyhow::Result<()> {
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck { let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
success: true, success: true,
error: None, error: None,
relay_build: Some(BUILD_GIT_HASH.to_string()),
}).await; }).await;
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered"); info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
@@ -984,11 +1024,13 @@ async fn main() -> anyhow::Result<()> {
ref target_fingerprint, ref target_fingerprint,
ref call_id, ref call_id,
ref caller_reflexive_addr, ref caller_reflexive_addr,
ref caller_local_addrs,
.. ..
} => { } => {
let target_fp = target_fingerprint.clone(); let target_fp = target_fingerprint.clone();
let call_id = call_id.clone(); let call_id = call_id.clone();
let caller_addr_for_registry = caller_reflexive_addr.clone(); let caller_addr_for_registry = caller_reflexive_addr.clone();
let caller_local_for_registry = caller_local_addrs.clone();
// Check if target is online // Check if target is online
let online = { let online = {
@@ -1030,12 +1072,14 @@ async fn main() -> anyhow::Result<()> {
info!(%addr, target = %target_fp, "call target not online (no federation route)"); info!(%addr, target = %target_fp, "call target not online (no federation route)");
let _ = transport.send_signal(&SignalMessage::Hangup { let _ = transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None,
}).await; }).await;
continue; continue;
} }
// Create call in registry with the // Create call in registry with the
// caller's reflex addr + mark it as // caller's reflex addr + LAN host
// candidates, and mark it as
// cross-relay so the answer path knows // cross-relay so the answer path knows
// to route the CallSetup's // to route the CallSetup's
// peer_direct_addr from what the // peer_direct_addr from what the
@@ -1053,7 +1097,11 @@ async fn main() -> anyhow::Result<()> {
); );
reg.set_caller_reflexive_addr( reg.set_caller_reflexive_addr(
&call_id, &call_id,
caller_addr_for_registry, caller_addr_for_registry.clone(),
);
reg.set_caller_local_addrs(
&call_id,
caller_local_for_registry.clone(),
); );
} }
@@ -1067,14 +1115,15 @@ async fn main() -> anyhow::Result<()> {
} }
// Create call in registry + stash the caller's // Create call in registry + stash the caller's
// reflex addr (Phase 3 hole-punching). The relay // reflex addr (Phase 3 hole-punching) AND its
// treats the addr as opaque — no validation. // LAN host candidates (Phase 5.5 ICE). The
// Injected later into the callee's CallSetup as // relay treats both as opaque. Both are
// peer_direct_addr. // injected later into the callee's CallSetup.
{ {
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone()); reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry); reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry);
reg.set_caller_local_addrs(&call_id, caller_local_for_registry);
} }
// Forward offer to callee // Forward offer to callee
@@ -1095,11 +1144,13 @@ async fn main() -> anyhow::Result<()> {
ref call_id, ref call_id,
ref accept_mode, ref accept_mode,
ref callee_reflexive_addr, ref callee_reflexive_addr,
ref callee_local_addrs,
.. ..
} => { } => {
let call_id = call_id.clone(); let call_id = call_id.clone();
let mode = *accept_mode; let mode = *accept_mode;
let callee_addr_for_registry = callee_reflexive_addr.clone(); let callee_addr_for_registry = callee_reflexive_addr.clone();
let callee_local_for_registry = callee_local_addrs.clone();
// Phase 4: look up peer fingerprint AND // Phase 4: look up peer fingerprint AND
// peer_relay_fp in one lock acquisition. // peer_relay_fp in one lock acquisition.
@@ -1137,6 +1188,7 @@ async fn main() -> anyhow::Result<()> {
if let Some(ref fm) = federation_mgr { if let Some(ref fm) = federation_mgr {
let hangup = SignalMessage::Hangup { let hangup = SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}; };
let forward = SignalMessage::FederatedSignalForward { let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(hangup), inner: Box::new(hangup),
@@ -1150,6 +1202,7 @@ async fn main() -> anyhow::Result<()> {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup { let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}).await; }).await;
} }
} else { } else {
@@ -1160,14 +1213,17 @@ async fn main() -> anyhow::Result<()> {
// BOTH parties' addrs so we can cross-wire // BOTH parties' addrs so we can cross-wire
// peer_direct_addr on the CallSetups below. // peer_direct_addr on the CallSetups below.
let room = format!("call-{call_id}"); let room = format!("call-{call_id}");
let (caller_addr, callee_addr) = { let (caller_addr, callee_addr, caller_local, callee_local) = {
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;
reg.set_active(&call_id, mode, room.clone()); reg.set_active(&call_id, mode, room.clone());
reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry); reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry);
reg.set_callee_local_addrs(&call_id, callee_local_for_registry.clone());
let call = reg.get(&call_id); let call = reg.get(&call_id);
( (
call.and_then(|c| c.caller_reflexive_addr.clone()), call.and_then(|c| c.caller_reflexive_addr.clone()),
call.and_then(|c| c.callee_reflexive_addr.clone()), call.and_then(|c| c.callee_reflexive_addr.clone()),
call.map(|c| c.caller_local_addrs.clone()).unwrap_or_default(),
call.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
) )
}; };
info!( info!(
@@ -1215,6 +1271,7 @@ async fn main() -> anyhow::Result<()> {
room: room.clone(), room: room.clone(),
relay_addr: relay_addr_for_setup, relay_addr: relay_addr_for_setup,
peer_direct_addr: caller_addr.clone(), peer_direct_addr: caller_addr.clone(),
peer_local_addrs: caller_local.clone(),
}; };
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(&client_fp, &setup_for_callee).await; let _ = hub.send_to(&client_fp, &setup_for_callee).await;
@@ -1227,18 +1284,21 @@ async fn main() -> anyhow::Result<()> {
} }
// Send CallSetup to BOTH parties with // Send CallSetup to BOTH parties with
// cross-wired peer_direct_addr. // cross-wired peer_direct_addr +
// peer_local_addrs (Phase 5.5 ICE).
let setup_for_caller = SignalMessage::CallSetup { let setup_for_caller = SignalMessage::CallSetup {
call_id: call_id.clone(), call_id: call_id.clone(),
room: room.clone(), room: room.clone(),
relay_addr: relay_addr_for_setup.clone(), relay_addr: relay_addr_for_setup.clone(),
peer_direct_addr: callee_addr.clone(), peer_direct_addr: callee_addr.clone(),
peer_local_addrs: callee_local.clone(),
}; };
let setup_for_callee = SignalMessage::CallSetup { let setup_for_callee = SignalMessage::CallSetup {
call_id: call_id.clone(), call_id: call_id.clone(),
room: room.clone(), room: room.clone(),
relay_addr: relay_addr_for_setup, relay_addr: relay_addr_for_setup,
peer_direct_addr: caller_addr.clone(), peer_direct_addr: caller_addr.clone(),
peer_local_addrs: caller_local.clone(),
}; };
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &setup_for_caller).await; let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
@@ -1247,10 +1307,24 @@ async fn main() -> anyhow::Result<()> {
} }
} }
SignalMessage::Hangup { .. } => { SignalMessage::Hangup { ref call_id, .. } => {
// Forward hangup to all active calls for this user // If the client sent a call_id, only end
// that specific call. Otherwise (old clients)
// fall back to ending ALL active calls for
// this user — which can race with new calls.
let calls = { let calls = {
let reg = call_registry.lock().await; let reg = call_registry.lock().await;
if let Some(cid) = call_id {
// Targeted hangup: only the named call
reg.get(cid)
.map(|c| vec![(c.call_id.clone(), if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
})])
.unwrap_or_default()
} else {
// Legacy: end all calls for this user
reg.calls_for_fingerprint(&client_fp) reg.calls_for_fingerprint(&client_fp)
.iter() .iter()
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp { .map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
@@ -1259,13 +1333,58 @@ async fn main() -> anyhow::Result<()> {
c.caller_fingerprint.clone() c.caller_fingerprint.clone()
})) }))
.collect::<Vec<_>>() .collect::<Vec<_>>()
}
}; };
for (call_id, peer_fp) in &calls { for (cid, peer_fp) in &calls {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(peer_fp, &msg).await; let _ = hub.send_to(peer_fp, &msg).await;
drop(hub); drop(hub);
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;
reg.end_call(call_id); reg.end_call(cid);
}
}
// Phase 6: forward MediaPathReport to the
// call peer so both sides can negotiate
// the media path before committing.
SignalMessage::MediaPathReport { ref call_id, .. } => {
// Look up peer AND check if this is a
// cross-relay call (same pattern as
// DirectCallAnswer).
let (peer_fp, peer_relay_fp) = {
let reg = call_registry.lock().await;
match reg.get(call_id) {
Some(c) => (
reg.peer_fingerprint(call_id, &client_fp)
.map(|s| s.to_string()),
c.peer_relay_fp.clone(),
),
None => (None, None),
}
};
if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp {
// Cross-relay: wrap and forward
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
warn!(
%call_id,
%origin_fp,
error = %e,
"cross-relay MediaPathReport forward failed"
);
}
}
} else {
// Local call
let hub = signal_hub.lock().await;
let _ = hub.send_to(&fp, &msg).await;
}
} }
} }
@@ -1340,6 +1459,7 @@ async fn main() -> anyhow::Result<()> {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup { let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}).await; }).await;
drop(hub); drop(hub);
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;

View File

@@ -13,6 +13,8 @@ use tokio::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use wzp_proto::packet::TrunkFrame; use wzp_proto::packet::TrunkFrame;
use wzp_proto::quality::{AdaptiveQualityController, Tier};
use wzp_proto::traits::QualityController;
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
use crate::metrics::RelayMetrics; use crate::metrics::RelayMetrics;
@@ -50,6 +52,45 @@ impl DebugTap {
} }
} }
/// Tracks network quality for a single participant in a room.
struct ParticipantQuality {
controller: AdaptiveQualityController,
current_tier: Tier,
}
impl ParticipantQuality {
fn new() -> Self {
Self {
controller: AdaptiveQualityController::new(),
current_tier: Tier::Good,
}
}
/// Feed a quality report and return the new tier if it changed.
fn observe(&mut self, report: &wzp_proto::packet::QualityReport) -> Option<Tier> {
let _ = self.controller.observe(report);
let new_tier = self.controller.tier();
if new_tier != self.current_tier {
self.current_tier = new_tier;
Some(new_tier)
} else {
None
}
}
}
/// Compute the weakest (worst) quality tier across all tracked participants.
fn weakest_tier<'a>(qualities: impl Iterator<Item = &'a ParticipantQuality>) -> Tier {
qualities
.map(|pq| pq.current_tier)
.min_by_key(|t| match t {
Tier::Good => 2,
Tier::Degraded => 1,
Tier::Catastrophic => 0,
})
.unwrap_or(Tier::Good)
}
/// Unique participant ID within a room. /// Unique participant ID within a room.
pub type ParticipantId = u64; pub type ParticipantId = u64;
@@ -208,6 +249,10 @@ pub struct RoomManager {
acl: Option<HashMap<String, HashSet<String>>>, acl: Option<HashMap<String, HashSet<String>>>,
/// Channel for room lifecycle events (federation subscribes). /// Channel for room lifecycle events (federation subscribes).
event_tx: tokio::sync::broadcast::Sender<RoomEvent>, event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
/// Per-participant quality tracking, keyed by (room_name, participant_id).
qualities: HashMap<(String, ParticipantId), ParticipantQuality>,
/// Current room-wide tier per room (to avoid repeated broadcasts).
room_tiers: HashMap<String, Tier>,
} }
impl RoomManager { impl RoomManager {
@@ -217,6 +262,8 @@ impl RoomManager {
rooms: HashMap::new(), rooms: HashMap::new(),
acl: None, acl: None,
event_tx, event_tx,
qualities: HashMap::new(),
room_tiers: HashMap::new(),
} }
} }
@@ -227,6 +274,8 @@ impl RoomManager {
rooms: HashMap::new(), rooms: HashMap::new(),
acl: Some(HashMap::new()), acl: Some(HashMap::new()),
event_tx, event_tx,
qualities: HashMap::new(),
room_tiers: HashMap::new(),
} }
} }
@@ -277,6 +326,7 @@ impl RoomManager {
|| self.rooms.get(room_name).map_or(true, |r| r.is_empty()); || self.rooms.get(room_name).map_or(true, |r| r.is_empty());
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new); let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string())); let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
self.qualities.insert((room_name.to_string(), id), ParticipantQuality::new());
if was_empty { if was_empty {
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() }); let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
} }
@@ -323,10 +373,12 @@ impl RoomManager {
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty. /// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> { pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
self.qualities.remove(&(room_name.to_string(), participant_id));
if let Some(room) = self.rooms.get_mut(room_name) { if let Some(room) = self.rooms.get_mut(room_name) {
room.remove(participant_id); room.remove(participant_id);
if room.is_empty() { if room.is_empty() {
self.rooms.remove(room_name); self.rooms.remove(room_name);
self.room_tiers.remove(room_name);
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() }); let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
info!(room = room_name, "room closed (empty)"); info!(room = room_name, "room closed (empty)");
return None; return None;
@@ -363,6 +415,58 @@ impl RoomManager {
pub fn list(&self) -> Vec<(String, usize)> { pub fn list(&self) -> Vec<(String, usize)> {
self.rooms.iter().map(|(k, v)| (k.clone(), v.len())).collect() self.rooms.iter().map(|(k, v)| (k.clone(), v.len())).collect()
} }
/// Feed a quality report from a participant. If the room-wide weakest
/// tier changes, returns `(QualityDirective signal, all senders)` for
/// broadcasting.
pub fn observe_quality(
&mut self,
room_name: &str,
participant_id: ParticipantId,
report: &wzp_proto::packet::QualityReport,
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
let key = (room_name.to_string(), participant_id);
let tier_changed = self.qualities
.get_mut(&key)
.and_then(|pq| pq.observe(report))
.is_some();
if !tier_changed {
return None;
}
// Compute the weakest tier across all participants in this room
let room_qualities = self.qualities.iter()
.filter(|((rn, _), _)| rn == room_name)
.map(|(_, pq)| pq);
let weakest = weakest_tier(room_qualities);
let current_room_tier = self.room_tiers.get(room_name).copied().unwrap_or(Tier::Good);
if weakest == current_room_tier {
return None;
}
// Room-wide tier changed — update and broadcast directive
self.room_tiers.insert(room_name.to_string(), weakest);
let profile = weakest.profile();
info!(
room = room_name,
old_tier = ?current_room_tier,
new_tier = ?weakest,
codec = ?profile.codec,
fec_ratio = profile.fec_ratio,
"room quality directive"
);
let directive = wzp_proto::SignalMessage::QualityDirective {
recommended_profile: profile,
reason: Some(format!("weakest link: {weakest:?}")),
};
let senders = self.rooms.get(room_name)
.map(|r| r.all_senders())
.unwrap_or_default();
Some((directive, senders))
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -382,18 +486,32 @@ impl TrunkedForwarder {
/// Create a new trunked forwarder. /// Create a new trunked forwarder.
/// ///
/// `session_id` tags every entry pushed into the batcher so the receiver /// `session_id` tags every entry pushed into the batcher so the receiver
/// can demultiplex packets by session. /// can demultiplex packets by session. The batcher's `max_bytes` is
/// initialized from the transport's current PMTUD-discovered MTU so that
/// trunk frames fill the largest datagram the path supports (instead of
/// the conservative 1200-byte default).
pub fn new(transport: Arc<wzp_transport::QuinnTransport>, session_id: [u8; 2]) -> Self { pub fn new(transport: Arc<wzp_transport::QuinnTransport>, session_id: [u8; 2]) -> Self {
let mut batcher = TrunkBatcher::new();
if let Some(mtu) = transport.max_datagram_size() {
batcher.max_bytes = mtu;
}
Self { Self {
transport, transport,
batcher: TrunkBatcher::new(), batcher,
session_id, session_id,
} }
} }
/// Push a media packet into the batcher. If the batcher is full it will /// Push a media packet into the batcher. If the batcher is full it will
/// flush automatically and the resulting trunk frame is sent immediately. /// flush automatically and the resulting trunk frame is sent immediately.
///
/// Also refreshes `max_bytes` from the transport's PMTUD-discovered MTU
/// so the batcher fills larger datagrams as the path MTU grows.
pub async fn send(&mut self, pkt: &wzp_proto::MediaPacket) -> anyhow::Result<()> { pub async fn send(&mut self, pkt: &wzp_proto::MediaPacket) -> anyhow::Result<()> {
// Refresh batcher limit from PMTUD (cheap: reads an atomic in quinn).
if let Some(mtu) = self.transport.max_datagram_size() {
self.batcher.max_bytes = mtu;
}
let payload: Bytes = pkt.to_bytes(); let payload: Bytes = pkt.to_bytes();
if let Some(frame) = self.batcher.push(self.session_id, payload) { if let Some(frame) = self.batcher.push(self.session_id, payload) {
self.send_frame(&frame)?; self.send_frame(&frame)?;
@@ -521,11 +639,17 @@ async fn run_participant_plain(
metrics.update_session_quality(session_id, report); metrics.update_session_quality(session_id, report);
} }
// Get current list of other participants // Get current list of other participants + check quality directive
let lock_start = std::time::Instant::now(); let lock_start = std::time::Instant::now();
let others = { let (others, quality_directive) = {
let mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id) let directive = if let Some(ref report) = pkt.quality_report {
mgr.observe_quality(&room_name, participant_id, report)
} else {
None
};
let o = mgr.others(&room_name, participant_id);
(o, directive)
}; };
let lock_ms = lock_start.elapsed().as_millis() as u64; let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 { if lock_ms > 10 {
@@ -537,6 +661,11 @@ async fn run_participant_plain(
); );
} }
// Broadcast quality directive to all participants if tier changed
if let Some((directive, all_senders)) = quality_directive {
broadcast_signal(&all_senders, &directive).await;
}
// Debug tap: log packet metadata // Debug tap: log packet metadata
if let Some(ref tap) = debug_tap { if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) { if tap.matches(&room_name) {
@@ -705,9 +834,15 @@ async fn run_participant_trunked(
} }
let lock_start = std::time::Instant::now(); let lock_start = std::time::Instant::now();
let others = { let (others, quality_directive) = {
let mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id) let directive = if let Some(ref report) = pkt.quality_report {
mgr.observe_quality(&room_name, participant_id, report)
} else {
None
};
let o = mgr.others(&room_name, participant_id);
(o, directive)
}; };
let lock_ms = lock_start.elapsed().as_millis() as u64; let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 { if lock_ms > 10 {
@@ -719,6 +854,11 @@ async fn run_participant_trunked(
); );
} }
// Broadcast quality directive to all participants if tier changed
if let Some((directive, all_senders)) = quality_directive {
broadcast_signal(&all_senders, &directive).await;
}
let fwd_start = std::time::Instant::now(); let fwd_start = std::time::Instant::now();
let pkt_bytes = pkt.payload.len() as u64; let pkt_bytes = pkt.payload.len() as u64;
for other in &others { for other in &others {
@@ -959,4 +1099,47 @@ mod tests {
// Batcher should now be empty — nothing to flush. // Batcher should now be empty — nothing to flush.
assert!(batcher.flush().is_none()); assert!(batcher.flush().is_none());
} }
fn make_report(loss_pct_f: f32, rtt_ms: u16) -> wzp_proto::packet::QualityReport {
wzp_proto::packet::QualityReport {
loss_pct: (loss_pct_f / 100.0 * 255.0) as u8,
rtt_4ms: (rtt_ms / 4) as u8,
jitter_ms: 10,
bitrate_cap_kbps: 200,
}
}
#[test]
fn participant_quality_starts_good() {
let pq = ParticipantQuality::new();
assert_eq!(pq.current_tier, Tier::Good);
}
#[test]
fn participant_quality_degrades_on_bad_reports() {
let mut pq = ParticipantQuality::new();
let bad = make_report(50.0, 300);
// Feed enough bad reports to trigger downgrade (3 consecutive)
for _ in 0..5 {
pq.observe(&bad);
}
assert_ne!(pq.current_tier, Tier::Good, "should degrade from Good");
}
#[test]
fn weakest_tier_picks_worst() {
let good = ParticipantQuality::new();
// good stays at Good tier
let mut bad = ParticipantQuality::new();
let bad_report = make_report(50.0, 300);
for _ in 0..5 {
bad.observe(&bad_report);
}
// bad should be degraded or catastrophic
let participants = vec![good, bad];
let weakest = weakest_tier(participants.iter());
assert_ne!(weakest, Tier::Good, "weakest should not be Good when one participant is bad");
}
} }

View File

@@ -51,6 +51,8 @@ fn alice_offer(call_id: &str) -> SignalMessage {
signature: vec![], signature: vec![],
supported_profiles: vec![], supported_profiles: vec![],
caller_reflexive_addr: Some(ALICE_ADDR.into()), caller_reflexive_addr: Some(ALICE_ADDR.into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
} }
} }
@@ -130,6 +132,8 @@ fn bob_answer(call_id: &str) -> SignalMessage {
signature: None, signature: None,
chosen_profile: None, chosen_profile: None,
callee_reflexive_addr: Some(BOB_ADDR.into()), callee_reflexive_addr: Some(BOB_ADDR.into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
} }
} }
@@ -173,6 +177,7 @@ fn relay_b_handle_local_answer(
room: format!("call-{call_id}"), room: format!("call-{call_id}"),
relay_addr: RELAY_B_ADDR.into(), relay_addr: RELAY_B_ADDR.into(),
peer_direct_addr: caller_addr, peer_direct_addr: caller_addr,
peer_local_addrs: Vec::new(),
}; };
let _ = callee_addr; let _ = callee_addr;
(forward, setup_for_bob) (forward, setup_for_bob)
@@ -213,6 +218,7 @@ fn relay_a_handle_forwarded_answer(
room: format!("call-{call_id}"), room: format!("call-{call_id}"),
relay_addr: RELAY_A_ADDR.into(), relay_addr: RELAY_A_ADDR.into(),
peer_direct_addr: callee_reflexive_addr, peer_direct_addr: callee_reflexive_addr,
peer_local_addrs: Vec::new(),
} }
} }

View File

@@ -81,12 +81,14 @@ fn handle_answer_and_build_setups(
room: room.clone(), room: room.clone(),
relay_addr: "203.0.113.5:4433".into(), relay_addr: "203.0.113.5:4433".into(),
peer_direct_addr: callee_addr, peer_direct_addr: callee_addr,
peer_local_addrs: Vec::new(),
}; };
let setup_for_callee = SignalMessage::CallSetup { let setup_for_callee = SignalMessage::CallSetup {
call_id, call_id,
room, room,
relay_addr: "203.0.113.5:4433".into(), relay_addr: "203.0.113.5:4433".into(),
peer_direct_addr: caller_addr, peer_direct_addr: caller_addr,
peer_local_addrs: Vec::new(),
}; };
(setup_for_caller, setup_for_callee) (setup_for_caller, setup_for_callee)
} }
@@ -102,6 +104,8 @@ fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage
signature: vec![], signature: vec![],
supported_profiles: vec![], supported_profiles: vec![],
caller_reflexive_addr: caller_reflexive_addr.map(String::from), caller_reflexive_addr: caller_reflexive_addr.map(String::from),
caller_local_addrs: Vec::new(),
caller_build_version: None,
} }
} }
@@ -118,6 +122,8 @@ fn mk_answer(
signature: None, signature: None,
chosen_profile: None, chosen_profile: None,
callee_reflexive_addr: callee_reflexive_addr.map(String::from), callee_reflexive_addr: callee_reflexive_addr.map(String::from),
callee_local_addrs: Vec::new(),
callee_build_version: None,
} }
} }

View File

@@ -65,6 +65,7 @@ async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
.send_signal(&SignalMessage::RegisterPresenceAck { .send_signal(&SignalMessage::RegisterPresenceAck {
success: true, success: true,
error: None, error: None,
relay_build: None,
}) })
.await; .await;
} }
@@ -97,7 +98,7 @@ async fn probe_reflect_addr_happy_path() {
let (observed, latency_ms) = tokio::time::timeout( let (observed, latency_ms) = tokio::time::timeout(
Duration::from_secs(3), Duration::from_secs(3),
probe_reflect_addr(relay_addr, 2000), probe_reflect_addr(relay_addr, 2000, None),
) )
.await .await
.expect("probe must complete within 3s") .expect("probe must complete within 3s")
@@ -116,11 +117,19 @@ async fn probe_reflect_addr_happy_path() {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Test 2: two loopback relays → Cone classification // Test 2: two loopback relays → probes succeed, classification is Unknown
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
//
// With the private-IP filter added in the NAT classifier, loopback
// reflex addrs (127.0.0.1) are dropped before classification —
// they can't possibly indicate public-internet NAT state. So the
// test now asserts:
// - both probes succeed end-to-end (wire plumbing works)
// - both return 127.0.0.1 (same-host is visible)
// - the aggregated verdict is Unknown (no public probes)
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn detect_nat_type_two_loopback_relays_is_cone() { async fn detect_nat_type_two_loopback_relays_probes_work_but_classify_unknown() {
let (addr_a, _h_a) = spawn_mock_relay().await; let (addr_a, _h_a) = spawn_mock_relay().await;
let (addr_b, _h_b) = spawn_mock_relay().await; let (addr_b, _h_b) = spawn_mock_relay().await;
@@ -130,29 +139,19 @@ async fn detect_nat_type_two_loopback_relays_is_cone() {
("RelayB".into(), addr_b), ("RelayB".into(), addr_b),
], ],
2000, 2000,
None,
) )
.await; .await;
assert_eq!(detection.probes.len(), 2); assert_eq!(detection.probes.len(), 2);
for p in &detection.probes { for p in &detection.probes {
assert!(p.observed_addr.is_some(), "probe {:?} failed: {:?}", p.relay_name, p.error); assert!(
p.observed_addr.is_some(),
"probe {:?} failed: {:?}",
p.relay_name,
p.error
);
} }
// Loopback single-host: every probe sees 127.0.0.1 and, crucially,
// uses a different ephemeral source port (since probe_reflect_addr
// spins up a fresh quinn::Endpoint per probe). Wait — that makes
// this look like Symmetric to the classifier, not Cone!
//
// The classifier cares about the *observed* addr, which is what
// the relay sees as the client's source. Two different client
// endpoints on loopback → two different observed ports → the
// classifier correctly labels this as SymmetricPort in the test
// environment. That's still a valid verification of the
// plumbing, just not of the Cone classification.
//
// Accept either Cone OR SymmetricPort for this test, then
// assert the more specific invariant that matters: both probes
// returned the same observed IP.
let observed_ips: Vec<String> = detection let observed_ips: Vec<String> = detection
.probes .probes
.iter() .iter()
@@ -167,14 +166,15 @@ async fn detect_nat_type_two_loopback_relays_is_cone() {
assert_eq!(observed_ips[0], "127.0.0.1"); assert_eq!(observed_ips[0], "127.0.0.1");
assert_eq!(observed_ips[1], "127.0.0.1"); assert_eq!(observed_ips[1], "127.0.0.1");
// Either classification is valid on loopback (see long comment // Classification: loopback probes are filtered out of the
// above). Explicitly assert the set so a future refactor that // public-NAT classifier, so with 0 public probes the result
// accidentally returns `Multiple` or `Unknown` fails the test. // is Unknown.
assert!( assert_eq!(
matches!(detection.nat_type, NatType::Cone | NatType::SymmetricPort), detection.nat_type,
"expected Cone or SymmetricPort on loopback, got {:?}", NatType::Unknown,
detection.nat_type "loopback-only probes must not contribute to public NAT classification"
); );
assert!(detection.consensus_addr.is_none());
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -197,6 +197,7 @@ async fn detect_nat_type_dead_relay_is_unknown() {
("Dead".into(), dead_addr), ("Dead".into(), dead_addr),
], ],
600, // tight timeout so the dead probe fails fast 600, // tight timeout so the dead probe fails fast
None,
) )
.await; .await;

View File

@@ -15,6 +15,7 @@ tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde_json = "1" serde_json = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
socket2 = { workspace = true }
rcgen = "0.13" rcgen = "0.13"
ed25519-dalek = { workspace = true } ed25519-dalek = { workspace = true }
hkdf = { workspace = true } hkdf = { workspace = true }

View File

@@ -123,7 +123,6 @@ fn transport_config() -> quinn::TransportConfig {
config.keep_alive_interval(Some(Duration::from_secs(5))); config.keep_alive_interval(Some(Duration::from_secs(5)));
// Enable DATAGRAM extension for unreliable media packets. // Enable DATAGRAM extension for unreliable media packets.
// Allow datagrams up to 1200 bytes (conservative for lossy links).
config.datagram_receive_buffer_size(Some(65536)); config.datagram_receive_buffer_size(Some(65536));
// Conservative flow control for bandwidth-constrained links // Conservative flow control for bandwidth-constrained links
@@ -134,6 +133,26 @@ fn transport_config() -> quinn::TransportConfig {
// Aggressive initial RTT estimate for high-latency links // Aggressive initial RTT estimate for high-latency links
config.initial_rtt(Duration::from_millis(300)); config.initial_rtt(Duration::from_millis(300));
// PMTUD (Path MTU Discovery) — quinn 0.11 enables this by default but
// with conservative bounds (initial 1200, upper 1452). We keep the safe
// initial_mtu of 1200 so the first packets always get through, but raise
// upper_bound so the binary search can discover larger MTUs on paths that
// support them. Typical results:
// - Ethernet/fiber: discovers ~1452 (Ethernet MTU minus IP/UDP/QUIC)
// - WireGuard/VPN: discovers ~1380-1420
// - Starlink: discovers ~1400-1452
// - Cellular: stays at 1200-1300
// Black hole detection automatically falls back to 1200 if probes fail.
// This matters for future video frames which can be 1-50 KB and benefit
// from fewer application-layer fragments per frame.
let mut mtu_config = quinn::MtuDiscoveryConfig::default();
mtu_config
.upper_bound(1452)
.interval(Duration::from_secs(300)) // re-probe every 5 min
.black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links
config.mtu_discovery_config(Some(mtu_config));
config.initial_mtu(1200); // safe starting point
config config
} }

View File

@@ -39,6 +39,71 @@ pub async fn connect(
Ok(connection) Ok(connection)
} }
/// Create an IPv6-only QUIC endpoint with `IPV6_V6ONLY=1`.
///
/// Tries `[::]:preferred_port` first (same port as the IPv4 signal
/// endpoint — allowed on Linux/Android when the AFs differ and
/// V6ONLY is set). Falls back to `[::]:0` (OS-assigned) if the
/// preferred port is already taken.
///
/// Must be called from within a tokio runtime (quinn needs the
/// async runtime handle for its I/O driver).
pub fn create_ipv6_endpoint(
preferred_port: u16,
server_config: Option<quinn::ServerConfig>,
) -> Result<quinn::Endpoint, TransportError> {
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{Ipv6Addr, SocketAddrV6};
let sock = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))
.map_err(|e| TransportError::Internal(format!("ipv6 socket: {e}")))?;
// Critical: IPv6-only so this socket never intercepts IPv4.
// On Android some kernels default to V6ONLY=1 anyway, but we
// set it explicitly for cross-platform consistency.
sock.set_only_v6(true)
.map_err(|e| TransportError::Internal(format!("set_only_v6: {e}")))?;
sock.set_reuse_address(true)
.map_err(|e| TransportError::Internal(format!("set_reuse_address: {e}")))?;
// Try the preferred port (same as IPv4 signal endpoint), fall
// back to ephemeral if the OS rejects it.
let bind_addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, preferred_port, 0, 0);
if let Err(e) = sock.bind(&bind_addr.into()) {
if preferred_port != 0 {
tracing::debug!(
preferred_port,
error = %e,
"ipv6 bind to preferred port failed, falling back to ephemeral"
);
let fallback = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0);
sock.bind(&fallback.into())
.map_err(|e| TransportError::Internal(format!("ipv6 bind fallback: {e}")))?;
} else {
return Err(TransportError::Internal(format!("ipv6 bind: {e}")));
}
}
sock.set_nonblocking(true)
.map_err(|e| TransportError::Internal(format!("set_nonblocking: {e}")))?;
let udp_socket: std::net::UdpSocket = sock.into();
let runtime = quinn::default_runtime()
.ok_or_else(|| TransportError::Internal("no async runtime for ipv6 endpoint".into()))?;
let endpoint = quinn::Endpoint::new(
quinn::EndpointConfig::default(),
server_config,
udp_socket,
runtime,
)
.map_err(|e| TransportError::Internal(format!("ipv6 endpoint: {e}")))?;
Ok(endpoint)
}
/// Accept the next incoming connection on an endpoint. /// Accept the next incoming connection on an endpoint.
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError> { pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError> {
let incoming = endpoint let incoming = endpoint

View File

@@ -23,9 +23,9 @@ pub mod quic;
pub mod reliable; pub mod reliable;
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint}; pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
pub use connection::{accept, connect, create_endpoint}; pub use connection::{accept, connect, create_endpoint, create_ipv6_endpoint};
pub use path_monitor::PathMonitor; pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport; pub use quic::{QuinnPathSnapshot, QuinnTransport};
pub use wzp_proto::{MediaTransport, PathQuality, TransportError}; pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can // Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can

View File

@@ -2,11 +2,17 @@
//! //!
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth. //! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
use std::collections::VecDeque;
use wzp_proto::PathQuality; use wzp_proto::PathQuality;
/// EWMA smoothing factor. /// EWMA smoothing factor.
const ALPHA: f64 = 0.1; const ALPHA: f64 = 0.1;
/// Maximum number of RTT samples in the jitter variance sliding window.
/// At ~50 packets/sec (20 ms frame), 10 samples ≈ 200 ms.
const JITTER_VARIANCE_WINDOW_SIZE: usize = 10;
/// Monitors network path quality metrics. /// Monitors network path quality metrics.
pub struct PathMonitor { pub struct PathMonitor {
/// EWMA-smoothed loss percentage (0.0 - 100.0). /// EWMA-smoothed loss percentage (0.0 - 100.0).
@@ -31,6 +37,8 @@ pub struct PathMonitor {
last_rtt_ms: Option<f64>, last_rtt_ms: Option<f64>,
/// Whether we have any observations yet. /// Whether we have any observations yet.
initialized: bool, initialized: bool,
/// Sliding window of recent RTT samples for variance calculation.
rtt_window: VecDeque<f64>,
} }
impl PathMonitor { impl PathMonitor {
@@ -51,6 +59,7 @@ impl PathMonitor {
total_received: 0, total_received: 0,
last_rtt_ms: None, last_rtt_ms: None,
initialized: false, initialized: false,
rtt_window: VecDeque::with_capacity(JITTER_VARIANCE_WINDOW_SIZE),
} }
} }
@@ -122,6 +131,12 @@ impl PathMonitor {
} else { } else {
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma; self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
} }
// Maintain sliding window for variance calculation
if self.rtt_window.len() >= JITTER_VARIANCE_WINDOW_SIZE {
self.rtt_window.pop_front();
}
self.rtt_window.push_back(rtt);
} }
/// Get the current estimated path quality. /// Get the current estimated path quality.
@@ -155,6 +170,20 @@ impl PathMonitor {
0 0
} }
/// Compute the jitter (RTT standard deviation) over the sliding window.
///
/// Returns the standard deviation in milliseconds, or 0.0 if insufficient
/// samples. Used by `DredTuner` for spike detection.
pub fn jitter_variance_ms(&self) -> f64 {
let n = self.rtt_window.len();
if n < 2 {
return 0.0;
}
let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
var.sqrt()
}
/// Detect whether a network handoff likely occurred. /// Detect whether a network handoff likely occurred.
/// ///
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x /// Returns `true` if the most recent RTT jitter measurement exceeds 3x

View File

@@ -13,6 +13,29 @@ use crate::datagram;
use crate::path_monitor::PathMonitor; use crate::path_monitor::PathMonitor;
use crate::reliable; use crate::reliable;
/// Snapshot of quinn's QUIC-level path statistics.
///
/// Provides more accurate loss/RTT data than `PathMonitor`'s sequence-gap
/// heuristic because quinn sees ACK frames and congestion signals directly.
#[derive(Clone, Copy, Debug)]
pub struct QuinnPathSnapshot {
/// Smoothed RTT in milliseconds (from quinn's congestion controller).
pub rtt_ms: u32,
/// Cumulative loss percentage (lost_packets / sent_packets × 100).
pub loss_pct: f32,
/// Total congestion events observed by the QUIC stack.
pub congestion_events: u64,
/// Current congestion window in bytes.
pub cwnd: u64,
/// Total packets sent on this path.
pub sent_packets: u64,
/// Total packets lost on this path.
pub lost_packets: u64,
/// Current PMTUD-discovered maximum datagram payload size (bytes).
/// Starts at `initial_mtu` (1200) and grows as PMTUD probes succeed.
pub current_mtu: usize,
}
/// QUIC-based transport implementing the `MediaTransport` trait. /// QUIC-based transport implementing the `MediaTransport` trait.
pub struct QuinnTransport { pub struct QuinnTransport {
connection: quinn::Connection, connection: quinn::Connection,
@@ -33,6 +56,11 @@ impl QuinnTransport {
&self.connection &self.connection
} }
/// Remote address of the peer on this connection.
pub fn remote_address(&self) -> std::net::SocketAddr {
self.connection.remote_address()
}
/// Send raw bytes as a QUIC datagram (no MediaPacket framing). /// Send raw bytes as a QUIC datagram (no MediaPacket framing).
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> { pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
self.connection self.connection
@@ -61,6 +89,31 @@ impl QuinnTransport {
datagram::max_datagram_payload(&self.connection) datagram::max_datagram_payload(&self.connection)
} }
/// Snapshot of QUIC-level path stats from quinn, useful for DRED tuning.
///
/// Returns `(rtt_ms, loss_pct, congestion_events)` derived from quinn's
/// internal congestion controller — more accurate than our own sequence-gap
/// heuristic in `PathMonitor` because quinn sees ACK frames directly.
pub fn quinn_path_stats(&self) -> QuinnPathSnapshot {
let stats = self.connection.stats();
let rtt_ms = stats.path.rtt.as_millis() as u32;
let loss_pct = if stats.path.sent_packets > 0 {
(stats.path.lost_packets as f32 / stats.path.sent_packets as f32) * 100.0
} else {
0.0
};
let current_mtu = self.connection.max_datagram_size().unwrap_or(1200);
QuinnPathSnapshot {
rtt_ms,
loss_pct,
congestion_events: stats.path.congestion_events,
cwnd: stats.path.cwnd,
sent_packets: stats.path.sent_packets,
lost_packets: stats.path.lost_packets,
current_mtu,
}
}
/// Send an encoded [`TrunkFrame`] as a single QUIC datagram. /// Send an encoded [`TrunkFrame`] as a single QUIC datagram.
pub fn send_trunk(&self, frame: &TrunkFrame) -> Result<(), TransportError> { pub fn send_trunk(&self, frame: &TrunkFrame) -> Result<(), TransportError> {
let data = frame.encode(); let data = frame.encode();

View File

@@ -52,7 +52,7 @@
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button> <button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
<div id="direct-registered" class="hidden" style="margin-top:12px"> <div id="direct-registered" class="hidden" style="margin-top:12px">
<div class="direct-registered-header"> <div class="direct-registered-header">
<p style="color:var(--green);font-size:13px;margin:0">&#x2705; Registered — waiting for calls</p> <p id="registered-status" style="color:var(--green);font-size:13px;margin:0">&#x2705; Registered — waiting for calls</p>
<button id="deregister-btn" class="secondary-btn small">Deregister</button> <button id="deregister-btn" class="secondary-btn small">Deregister</button>
</div> </div>
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0"> <div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
@@ -111,6 +111,16 @@
<div class="level-meter"> <div class="level-meter">
<div id="level-bar" class="level-bar-fill"></div> <div id="level-bar" class="level-bar-fill"></div>
</div> </div>
<!-- Direct-call phone layout — shown instead of the group
participant list when directCallPeer is set. Centered
identicon, name, fp, connection badge. Hidden for
room calls (directCallPeer == null). -->
<div id="direct-call-view" class="direct-call-view hidden">
<div id="dc-identicon" class="dc-identicon"></div>
<div id="dc-name" class="dc-name">Unknown</div>
<div id="dc-fp" class="dc-fp"></div>
<div id="dc-badge" class="dc-badge">Connecting...</div>
</div>
<div id="participants" class="participants"></div> <div id="participants" class="participants"></div>
<div class="controls"> <div class="controls">
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)"> <button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
@@ -181,7 +191,12 @@
<div class="settings-section" id="s-call-debug-section" style="display:none"> <div class="settings-section" id="s-call-debug-section" style="display:none">
<h3>Call Debug Log</h3> <h3>Call Debug Log</h3>
<div id="s-call-debug-log" style="max-height:220px;overflow-y:auto;background:#0a0a0a;color:#e0e0e0;font-family:ui-monospace,Menlo,Monaco,'Courier New',monospace;font-size:10px;padding:6px;border-radius:4px;line-height:1.4;white-space:pre-wrap"></div> <div id="s-call-debug-log" style="max-height:220px;overflow-y:auto;background:#0a0a0a;color:#e0e0e0;font-family:ui-monospace,Menlo,Monaco,'Courier New',monospace;font-size:10px;padding:6px;border-radius:4px;line-height:1.4;white-space:pre-wrap"></div>
<button id="s-call-debug-clear" class="secondary-btn" style="margin-top:6px">Clear log</button> <div style="display:flex;gap:6px;margin-top:6px">
<button id="s-call-debug-copy" class="secondary-btn" style="flex:1">Copy log</button>
<button id="s-call-debug-share" class="secondary-btn" style="flex:1">Share</button>
<button id="s-call-debug-clear" class="secondary-btn" style="flex:1">Clear log</button>
</div>
<small id="s-call-debug-copy-status" style="display:block;margin-top:4px;color:var(--text-dim);font-size:10px"></small>
<small style="color:var(--text-dim);display:block;margin-top:4px"> <small style="color:var(--text-dim);display:block;margin-top:4px">
Rolling buffer of the last 200 call-flow events. Turned off by Rolling buffer of the last 200 call-flow events. Turned off by
default — the GUI overlay only populates when the checkbox above default — the GUI overlay only populates when the checkbox above

View File

@@ -36,6 +36,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View File

@@ -21,6 +21,10 @@
"core:window:default", "core:window:default",
"core:app:default", "core:app:default",
"core:webview:default", "core:webview:default",
"shell:default" "shell:default",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
"notification:allow-is-permission-granted"
] ]
} }

View File

@@ -72,18 +72,22 @@ class MainActivity : TauriActivity() {
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume * STREAM_VOICE_CALL volume is cranked to max since the in-call volume
* slider is separate from media volume on most devices. * slider is separate from media volume on most devices.
*/ */
/**
* Pre-flight: only set volumes. Do NOT set MODE_IN_COMMUNICATION here —
* that hijacks the entire audio routing (music stops, BT A2DP drops to
* earpiece) even before a call starts. The Rust side sets the mode via
* JNI when the call engine actually starts, and restores MODE_NORMAL
* when the call ends.
*/
private fun configureAudioForCall() { private fun configureAudioForCall() {
try { try {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " + Log.i(TAG, "audio state: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" + "voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " + "${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" + "musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}") "${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = false // default: handset / earpiece
// Crank both voice-call and music volumes so nothing silent slips // Crank both voice-call and music volumes so nothing silent slips
// through regardless of which stream actually ends up driving. // through regardless of which stream actually ends up driving.
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL) val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
@@ -91,9 +95,7 @@ class MainActivity : TauriActivity() {
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0) am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " + Log.i(TAG, "volumes set: voiceVol=$maxVoice musicVol=$maxMusic (mode left at ${am.mode})")
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e) Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default"],"platforms":["linux","macOS","windows","android","iOS"]}} {"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default","notification:default","notification:allow-notify","notification:allow-request-permission","notification:allow-is-permission-granted"],"platforms":["linux","macOS","windows","android","iOS"]}}

View File

@@ -2354,6 +2354,204 @@
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",

View File

@@ -2354,6 +2354,204 @@
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",

View File

@@ -57,11 +57,37 @@ fn audio_manager<'local>(
Ok(am) Ok(am)
} }
/// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts.
/// This tells the audio policy to route through the communication device
/// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP).
pub fn set_audio_mode_communication() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// MODE_IN_COMMUNICATION = 3
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(3)])
.map_err(|e| format!("setMode(MODE_IN_COMMUNICATION): {e}"))?;
tracing::info!("AudioManager: mode set to MODE_IN_COMMUNICATION");
Ok(())
}
/// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends.
pub fn set_audio_mode_normal() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// MODE_NORMAL = 0
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(0)])
.map_err(|e| format!("setMode(MODE_NORMAL): {e}"))?;
tracing::info!("AudioManager: mode set to MODE_NORMAL");
Ok(())
}
/// Switch between loud speaker (`true`) and earpiece/handset (`false`). /// Switch between loud speaker (`true`) and earpiece/handset (`false`).
///
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
/// sets this at startup, so by the time a call is up this is always true.
pub fn set_speakerphone(on: bool) -> Result<(), String> { pub fn set_speakerphone(on: bool) -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?; let (vm, activity) = jvm_and_activity()?;
let mut env = vm let mut env = vm
@@ -96,3 +122,238 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?; .map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
Ok(on) Ok(on)
} }
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
/// Start Bluetooth SCO audio routing.
///
/// On API 31+ uses `setCommunicationDevice()` which is the modern way to
/// route voice audio to a specific device. Falls back to the deprecated
/// `startBluetoothSco()` path on older APIs.
///
/// The caller must restart Oboe streams after this call.
pub fn start_bluetooth_sco() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// Ensure speaker is off — mutually exclusive with BT.
env.call_method(
&am,
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
// Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo)
// Find a BT SCO or BLE device from getAvailableCommunicationDevices()
let used_modern = try_set_communication_device(&mut env, &am, true)?;
if !used_modern {
// Fallback: deprecated startBluetoothSco (API < 31)
tracing::info!("start_bluetooth_sco: falling back to deprecated startBluetoothSco");
env.call_method(&am, "startBluetoothSco", "()V", &[])
.map_err(|e| format!("startBluetoothSco: {e}"))?;
}
tracing::info!(used_modern, "AudioManager: Bluetooth SCO started");
Ok(())
}
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
///
/// The caller must restart Oboe streams after this call.
pub fn stop_bluetooth_sco() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// Modern API: clearCommunicationDevice() (API 31+)
let cleared = try_set_communication_device(&mut env, &am, false)?;
if !cleared {
// Fallback: deprecated stopBluetoothSco
env.call_method(&am, "stopBluetoothSco", "()V", &[])
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
}
tracing::info!(cleared, "AudioManager: Bluetooth SCO stopped");
Ok(())
}
/// Try to use the modern `setCommunicationDevice` / `clearCommunicationDevice`
/// API (Android 12 / API 31+). Returns `true` if the modern API was used.
fn try_set_communication_device(
env: &mut jni::AttachGuard<'_>,
am: &JObject<'_>,
enable: bool,
) -> Result<bool, String> {
// Check SDK_INT >= 31 (Android 12)
let sdk_int = env
.get_static_field(
"android/os/Build$VERSION",
"SDK_INT",
"I",
)
.and_then(|v| v.i())
.unwrap_or(0);
if sdk_int < 31 {
return Ok(false);
}
if !enable {
// clearCommunicationDevice()
env.call_method(am, "clearCommunicationDevice", "()V", &[])
.map_err(|e| format!("clearCommunicationDevice: {e}"))?;
tracing::info!("clearCommunicationDevice: done");
return Ok(true);
}
// getAvailableCommunicationDevices() → List<AudioDeviceInfo>
let device_list = env
.call_method(
am,
"getAvailableCommunicationDevices",
"()Ljava/util/List;",
&[],
)
.and_then(|v| v.l())
.map_err(|e| format!("getAvailableCommunicationDevices: {e}"))?;
let size = env
.call_method(&device_list, "size", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// Find first BT device: TYPE_BLUETOOTH_SCO (7), TYPE_BLUETOOTH_A2DP (8),
// TYPE_BLE_HEADSET (26), TYPE_BLE_SPEAKER (27)
for i in 0..size {
let device = env
.call_method(
&device_list,
"get",
"(I)Ljava/lang/Object;",
&[JValue::Int(i)],
)
.and_then(|v| v.l())
.map_err(|e| format!("list.get({i}): {e}"))?;
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
if matches!(device_type, 7 | 8 | 26 | 27) {
let ok = env
.call_method(
am,
"setCommunicationDevice",
"(Landroid/media/AudioDeviceInfo;)Z",
&[JValue::Object(&device)],
)
.and_then(|v| v.z())
.unwrap_or(false);
tracing::info!(
device_type,
ok,
"setCommunicationDevice: set BT device"
);
return Ok(ok);
}
}
tracing::warn!("setCommunicationDevice: no BT device in available list");
Ok(false)
}
/// Query whether Bluetooth audio is currently the active communication device.
///
/// On API 31+ checks `getCommunicationDevice()` type. Falls back to the
/// deprecated `isBluetoothScoOn()` on older APIs.
pub fn is_bluetooth_sco_on() -> Result<bool, String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
let sdk_int = env
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
.and_then(|v| v.i())
.unwrap_or(0);
if sdk_int >= 31 {
// getCommunicationDevice() → AudioDeviceInfo (nullable)
let device = env
.call_method(am, "getCommunicationDevice", "()Landroid/media/AudioDeviceInfo;", &[])
.and_then(|v| v.l())
.unwrap_or(JObject::null());
if device.is_null() {
return Ok(false);
}
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
return Ok(matches!(device_type, 7 | 8 | 26 | 27));
}
// Fallback: deprecated API
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
.and_then(|v| v.z())
.map_err(|e| format!("isBluetoothScoOn: {e}"))
}
/// Check whether a Bluetooth audio device is currently connected.
///
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
/// any Bluetooth device type. Many headsets only register as A2DP until
/// SCO is explicitly started, so we check for both SCO and A2DP types.
pub fn is_bluetooth_available() -> Result<bool, String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// AudioManager.GET_DEVICES_OUTPUTS = 2
let devices = env
.call_method(
&am,
"getDevices",
"(I)[Landroid/media/AudioDeviceInfo;",
&[JValue::Int(2)],
)
.and_then(|v| v.l())
.map_err(|e| format!("getDevices(OUTPUTS): {e}"))?;
let arr = jni::objects::JObjectArray::from(devices);
let len = env
.get_array_length(&arr)
.map_err(|e| format!("get_array_length: {e}"))?;
for i in 0..len {
let device = env
.get_object_array_element(&arr, i)
.map_err(|e| format!("get_object_array_element({i}): {e}"))?;
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8
if device_type == 7 || device_type == 8 {
tracing::info!(device_type, idx = i, "is_bluetooth_available: found BT device");
return Ok(true);
}
}
Ok(false)
}

View File

@@ -9,9 +9,10 @@
//! still fails cleanly but the rest of the engine code links in. //! still fails cleanly but the rest of the engine code links in.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tauri::Emitter;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{error, info}; use tracing::{error, info};
@@ -25,11 +26,38 @@ use wzp_client::audio_io::{AudioCapture, AudioPlayback};
// Android (where wzp-client is pulled in with default-features=false). // Android (where wzp-client is pulled in with default-features=false).
use wzp_client::call::{CallConfig, CallEncoder}; use wzp_client::call::{CallConfig, CallEncoder};
use wzp_proto::traits::AudioDecoder; use wzp_proto::traits::{AudioDecoder, QualityController};
use wzp_proto::{CodecId, MediaTransport, QualityProfile}; use wzp_proto::{AdaptiveQualityController, CodecId, MediaTransport, QualityProfile};
const FRAME_SAMPLES_40MS: usize = 1920; const FRAME_SAMPLES_40MS: usize = 1920;
/// Profile index mapping for the AtomicU8 adaptive-quality bridge.
const PROFILE_NO_CHANGE: u8 = 0xFF;
fn profile_to_index(p: &QualityProfile) -> u8 {
match p.codec {
CodecId::Opus64k => 0,
CodecId::Opus48k => 1,
CodecId::Opus32k => 2,
CodecId::Opus24k => 3,
CodecId::Opus6k => 4,
CodecId::Codec2_1200 => 5,
_ => 3, // default to GOOD
}
}
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
match idx {
0 => Some(QualityProfile::STUDIO_64K),
1 => Some(QualityProfile::STUDIO_48K),
2 => Some(QualityProfile::STUDIO_32K),
3 => Some(QualityProfile::GOOD),
4 => Some(QualityProfile::DEGRADED),
5 => Some(QualityProfile::CATASTROPHIC),
_ => None,
}
}
/// Resolve a quality string from the UI to a QualityProfile. /// Resolve a quality string from the UI to a QualityProfile.
/// Returns None for "auto" (use default adaptive behavior). /// Returns None for "auto" (use default adaptive behavior).
fn resolve_quality(quality: &str) -> Option<QualityProfile> { fn resolve_quality(quality: &str) -> Option<QualityProfile> {
@@ -302,22 +330,31 @@ impl CallEngine {
// our own wzp_transport::connect step and use this // our own wzp_transport::connect step and use this
// directly. If None, existing Phase 0 behavior. // directly. If None, existing Phase 0 behavior.
pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>>, pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>>,
// Phase 6: explicit flag for whether the agreed media path
// is truly direct P2P (skip handshake) or relay-mediated
// (must run handshake). Previously derived from
// pre_connected_transport.is_some() which was WRONG: when
// Phase 6 negotiated relay but delivered the relay transport
// via pre_connected_transport, the engine skipped the
// handshake → relay couldn't authenticate the participant
// → silent call.
is_direct_p2p: bool,
// Phase 5.6: Tauri AppHandle for emitting call-debug
// events from inside the send/recv tasks. Lets the
// debug log pane show first-send/first-recv/heartbeat
// events when the user has call debug logs enabled.
app: tauri::AppHandle,
event_cb: F, event_cb: F,
) -> Result<Self, anyhow::Error> ) -> Result<Self, anyhow::Error>
where where
F: Fn(&str, &str) + Send + Sync + 'static, F: Fn(&str, &str) + Send + Sync + 'static,
{ {
// Single "call epoch" timestamp threaded through send + recv tasks
// so every milestone log can carry t_ms_since_call_start. Used to
// diagnose the first-join no-audio regression by giving us a clean
// ordering between audio_start, first capture, first recv, first
// decode, first playout-ring write, and the C++ Oboe first-callback
// logs (which already exist in cpp/oboe_bridge.cpp).
let call_t0 = std::time::Instant::now(); let call_t0 = std::time::Instant::now();
info!( info!(
%relay, %room, %alias, %quality, %relay, %room, %alias, %quality,
has_reuse = reuse_endpoint.is_some(), has_reuse = reuse_endpoint.is_some(),
has_pre_connected = pre_connected_transport.is_some(), has_pre_connected = pre_connected_transport.is_some(),
is_direct_p2p,
t_ms = 0u128, t_ms = 0u128,
"CallEngine::start (android) invoked" "CallEngine::start (android) invoked"
); );
@@ -326,7 +363,6 @@ impl CallEngine {
let relay_addr: SocketAddr = relay.parse()?; let relay_addr: SocketAddr = relay.parse()?;
info!(%relay_addr, "resolved relay addr"); info!(%relay_addr, "resolved relay addr");
// Identity via shared helper (uses Tauri path().app_data_dir()).
let seed = crate::load_or_create_seed() let seed = crate::load_or_create_seed()
.map_err(|e| anyhow::anyhow!("identity: {e}"))?; .map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let fp = seed.derive_identity().public_identity().fingerprint; let fp = seed.derive_identity().public_identity().fingerprint;
@@ -334,9 +370,9 @@ impl CallEngine {
info!(%fp, "identity loaded"); info!(%fp, "identity loaded");
// Transport source: either the pre-connected one from the // Transport source: either the pre-connected one from the
// dual-path race (Phase 3.5) or build a fresh one here. // dual-path race or build a fresh one here.
let transport = if let Some(t) = pre_connected_transport { let transport = if let Some(t) = pre_connected_transport {
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: using pre-connected transport from dual-path race"); info!(t_ms = call_t0.elapsed().as_millis(), is_direct_p2p, "first-join diag: using pre-connected transport");
t t
} else { } else {
// QUIC transport + handshake (Phase 0 relay-only path). // QUIC transport + handshake (Phase 0 relay-only path).
@@ -376,6 +412,16 @@ impl CallEngine {
Arc::new(wzp_transport::QuinnTransport::new(conn)) Arc::new(wzp_transport::QuinnTransport::new(conn))
}; };
// The media handshake (CallOffer/CallAnswer + crypto key
// exchange) is a relay-specific protocol: the relay runs
// `accept_handshake` on its side. On a direct P2P
// connection the peer is a phone, not a relay — nobody on
// the other end handles the handshake. So skip it when
// is_direct_p2p. The QUIC transport already provides TLS
// encryption, and both peers' identities were verified
// through the signal channel (DirectCallOffer/Answer carry
// identity_pub + ephemeral_pub + signature).
if !is_direct_p2p {
let _session = wzp_client::handshake::perform_handshake( let _session = wzp_client::handshake::perform_handshake(
&*transport, &*transport,
&seed.0, &seed.0,
@@ -384,6 +430,9 @@ impl CallEngine {
.await .await
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?; .map_err(|e| { error!("perform_handshake failed: {e}"); e })?;
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: connected to relay, handshake complete"); info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: connected to relay, handshake complete");
} else {
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
}
event_cb("connected", &format!("joined room {room}")); event_cb("connected", &format!("joined room {room}"));
// Oboe audio via the wzp-native cdylib that was dlopen'd at // Oboe audio via the wzp-native cdylib that was dlopen'd at
@@ -395,20 +444,57 @@ impl CallEngine {
"wzp-native not loaded — dlopen failed at startup" "wzp-native not loaded — dlopen failed at startup"
)); ));
} }
// Fix D (task #37): explicit stop+start cycle on EVERY call
// start — not just rejoin. Empirically, the first call after
// app launch on Nothing Phone has the Oboe playout callback
// fire once (cb#0) and then stop draining the ring, causing
// written_samples to freeze at 7679 (ring capacity minus
// one burst). Rejoin (second call) always works because
// audio_stop tears down the streams and audio_start rebuilds
// them in a state that the audio driver accepts. By always
// running stop first (no-op on cold start when not yet
// started), we get the same "fresh rebuild" behavior on
// every call.
crate::wzp_native::audio_stop();
// Brief pause to let Android's audio routing + AudioManager
// settle after the stop. 50ms is enough for the driver to
// release the audio session; shorter risks the new start
// hitting a "device busy" on some HALs.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Set MODE_IN_COMMUNICATION right before audio starts — NOT at
// app launch. Setting it early hijacks system audio routing
// (music drops from BT A2DP to earpiece, etc.).
#[cfg(target_os = "android")]
{
if let Err(e) = crate::android_audio::set_audio_mode_communication() {
tracing::warn!("set_audio_mode_communication failed: {e}");
}
}
let t_pre_audio = call_t0.elapsed().as_millis(); let t_pre_audio = call_t0.elapsed().as_millis();
if let Err(code) = crate::wzp_native::audio_start() { if let Err(code) = crate::wzp_native::audio_start() {
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}")); return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
} }
// Diagnostic: how long did audio_start() take, and at what
// wall-clock offset from CallEngine::start did it complete? // Fix C (task #36): prime the playout ring with 20ms of
// Compare to the C++ "playout cb#0" log timestamp in logcat to // silence immediately after audio_start so the Oboe playout
// see whether the Oboe playout callback fires before or after // callback has data to drain on its FIRST invocation. On
// the recv task starts pushing decoded frames. // devices where the callback only fires when the ring is
// non-empty (or where an empty-ring callback causes the
// stream to self-pause), this ensures the callback keeps
// running until real decoded audio arrives.
{
let silence = vec![0i16; 960]; // 20ms @ 48kHz mono
let _ = crate::wzp_native::audio_write_playout(&silence);
}
let t_audio_start_done = call_t0.elapsed().as_millis(); let t_audio_start_done = call_t0.elapsed().as_millis();
info!( info!(
t_ms = t_audio_start_done, t_ms = t_audio_start_done,
audio_start_ms = t_audio_start_done.saturating_sub(t_pre_audio), audio_start_ms = t_audio_start_done.saturating_sub(t_pre_audio),
"first-join diag: wzp-native audio started" "first-join diag: wzp-native audio started (with stop+prime cycle)"
); );
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
@@ -421,6 +507,10 @@ impl CallEngine {
let tx_codec = Arc::new(Mutex::new(String::new())); let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new())); let rx_codec = Arc::new(Mutex::new(String::new()));
// Adaptive quality: shared pending-profile bridge between recv → send.
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
// Send task — drain Oboe capture ring, Opus-encode, push to transport. // Send task — drain Oboe capture ring, Opus-encode, push to transport.
let send_t = transport.clone(); let send_t = transport.clone();
let send_r = running.clone(); let send_r = running.clone();
@@ -428,9 +518,12 @@ impl CallEngine {
let send_fs = frames_sent.clone(); let send_fs = frames_sent.clone();
let send_level = audio_level.clone(); let send_level = audio_level.clone();
let send_drops = Arc::new(AtomicU64::new(0)); let send_drops = Arc::new(AtomicU64::new(0));
let send_last_err: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let send_quality = quality.clone(); let send_quality = quality.clone();
let send_tx_codec = tx_codec.clone(); let send_tx_codec = tx_codec.clone();
let send_t0 = call_t0; let send_t0 = call_t0;
let send_app = app.clone();
let send_pending_profile = pending_profile.clone();
tokio::spawn(async move { tokio::spawn(async move {
let profile = resolve_quality(&send_quality); let profile = resolve_quality(&send_quality);
let config = match profile { let config = match profile {
@@ -452,6 +545,13 @@ impl CallEngine {
encoder.set_aec_enabled(false); encoder.set_aec_enabled(false);
let mut buf = vec![0i16; frame_samples]; let mut buf = vec![0i16; frame_samples];
// Continuous DRED tuning: poll quinn path stats every 25
// frames (~500 ms at 20 ms/frame) and adjust DRED duration +
// expected-loss hint based on real-time network conditions.
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
const DRED_POLL_INTERVAL: u32 = 25;
let mut heartbeat = std::time::Instant::now(); let mut heartbeat = std::time::Instant::now();
let mut last_rms: u32 = 0; let mut last_rms: u32 = 0;
let mut last_pkt_bytes: usize = 0; let mut last_pkt_bytes: usize = 0;
@@ -511,16 +611,78 @@ impl CallEngine {
last_pkt_bytes = pkt.payload.len(); last_pkt_bytes = pkt.payload.len();
if let Err(e) = send_t.send_media(pkt).await { if let Err(e) = send_t.send_media(pkt).await {
send_drops.fetch_add(1, Ordering::Relaxed); send_drops.fetch_add(1, Ordering::Relaxed);
if send_drops.load(Ordering::Relaxed) <= 3 { let count = send_drops.load(Ordering::Relaxed);
if count <= 3 {
tracing::warn!("send_media error (dropping packet): {e}"); tracing::warn!("send_media error (dropping packet): {e}");
} }
// Latch last error for heartbeat
if count == 1 {
*send_last_err.lock().await = Some(format!("{e}"));
} }
} }
send_fs.fetch_add(1, Ordering::Relaxed); }
let before = send_fs.fetch_add(1, Ordering::Relaxed);
if before == 0 {
// First encoded frame successfully handed
// to the transport. Useful for diagnosing
// 1-way audio: if this fires but the
// peer's media:first_recv never does,
// outbound is broken on our side.
crate::emit_call_debug(
&send_app,
"media:first_send",
serde_json::json!({
"t_ms": send_t0.elapsed().as_millis() as u64,
"pkt_bytes": last_pkt_bytes,
}),
);
}
} }
Err(e) => error!("encode: {e}"), Err(e) => error!("encode: {e}"),
} }
// Adaptive quality: check if recv task recommended a profile switch.
if auto_profile {
let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if p != PROFILE_NO_CHANGE {
if let Some(new_profile) = index_to_profile(p) {
info!(to = ?new_profile.codec, "auto: switching encoder profile");
if encoder.set_profile(new_profile).is_ok() {
dred_tuner.set_codec(new_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", new_profile.codec);
}
}
}
}
// DRED tuner: poll quinn path stats periodically and
// adjust encoder DRED duration + expected-loss hint.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning);
if wzp_codec::dred_verbose_logs() {
info!(
dred_frames = tuning.dred_frames,
dred_ms = tuning.dred_frames as u32 * 10,
expected_loss = tuning.expected_loss_pct,
quinn_loss = format!("{:.1}", snap.loss_pct),
quinn_rtt = snap.rtt_ms,
jitter = pq.jitter_ms,
spike = dred_tuner.spike_boost_active(),
"DRED tuner adjusted encoder"
);
}
}
}
// Heartbeat every 2s with capture+encode+send state // Heartbeat every 2s with capture+encode+send state
if heartbeat.elapsed() >= std::time::Duration::from_secs(2) { if heartbeat.elapsed() >= std::time::Duration::from_secs(2) {
let fs = send_fs.load(Ordering::Relaxed); let fs = send_fs.load(Ordering::Relaxed);
@@ -533,6 +695,26 @@ impl CallEngine {
send_drops = drops, send_drops = drops,
"send heartbeat (android)" "send heartbeat (android)"
); );
// Phase 5.6: also emit to the GUI debug log
// when call debug is enabled. Helps diagnose
// 1-way audio — a stalled send heartbeat
// (frames_sent == 0 or last_rms == 0) tells
// you capture/mic is broken; a live one with
// no peer recv tells you outbound is being
// dropped somewhere in the media path.
let err_str = send_last_err.lock().await.clone();
crate::emit_call_debug(
&send_app,
"media:send_heartbeat",
serde_json::json!({
"frames_sent": fs,
"last_rms": last_rms,
"last_pkt_bytes": last_pkt_bytes,
"short_reads": short_reads,
"drops": drops,
"last_send_err": err_str,
}),
);
heartbeat = std::time::Instant::now(); heartbeat = std::time::Instant::now();
} }
} }
@@ -545,6 +727,8 @@ impl CallEngine {
let recv_fr = frames_received.clone(); let recv_fr = frames_received.clone();
let recv_rx_codec = rx_codec.clone(); let recv_rx_codec = rx_codec.clone();
let recv_t0 = call_t0; let recv_t0 = call_t0;
let recv_app = app.clone();
let pending_profile_recv = pending_profile.clone();
tokio::spawn(async move { tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD); let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: use concrete AdaptiveDecoder (not Box<dyn // Phase 3b/3c: use concrete AdaptiveDecoder (not Box<dyn
@@ -559,6 +743,7 @@ impl CallEngine {
// Phase 3b/3c DRED reconstruction state — see DredRecvState // Phase 3b/3c DRED reconstruction state — see DredRecvState
// above for the full flow. // above for the full flow.
let mut dred_recv = DredRecvState::new(); let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
info!(codec = ?current_codec, t_ms = recv_t0.elapsed().as_millis(), "first-join diag: recv task spawned (android/oboe)"); info!(codec = ?current_codec, t_ms = recv_t0.elapsed().as_millis(), "first-join diag: recv task spawned (android/oboe)");
// First-join diagnostic latches — see send task above for the // First-join diagnostic latches — see send task above for the
// sibling capture milestones. // sibling capture milestones.
@@ -599,6 +784,17 @@ impl CallEngine {
let mut last_written: usize = 0; let mut last_written: usize = 0;
let mut decode_errs: u64 = 0; let mut decode_errs: u64 = 0;
let mut first_packet_logged = false; 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 { loop {
if !recv_r.load(Ordering::Relaxed) { if !recv_r.load(Ordering::Relaxed) {
@@ -620,6 +816,22 @@ impl CallEngine {
"first-join diag: recv first media packet" "first-join diag: recv first media packet"
); );
first_packet_logged = true; first_packet_logged = true;
// Phase 5.6 GUI debug: first packet from
// the peer. Useful for diagnosing 1-way
// audio — if this fires and the peer
// never sees media:first_recv, our
// inbound path is fine and theirs is
// broken, and vice versa.
crate::emit_call_debug(
&recv_app,
"media:first_recv",
serde_json::json!({
"t_ms": recv_t0.elapsed().as_millis() as u64,
"codec": format!("{:?}", pkt.header.codec_id),
"payload_bytes": pkt.payload.len(),
"is_repair": pkt.header.is_repair,
}),
);
} }
if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise { if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
{ {
@@ -681,6 +893,15 @@ impl CallEngine {
); );
} }
// Adaptive quality: ingest quality reports from peer
if let Some(ref qr) = pkt.quality_report {
if let Some(new_profile) = quality_ctrl.observe(qr) {
let idx = profile_to_index(&new_profile);
info!(to = ?new_profile.codec, "auto: quality adapter recommends switch");
pending_profile_recv.store(idx, Ordering::Release);
}
}
match decoder.decode(&pkt.payload, &mut pcm) { match decoder.decode(&pkt.payload, &mut pcm) {
Ok(n) => { Ok(n) => {
last_decode_n = n; last_decode_n = n;
@@ -814,6 +1035,80 @@ impl CallEngine {
"recv heartbeat (android)" "recv heartbeat (android)"
); );
} }
// Phase 5.6: compact GUI debug emit.
// recv_fr == 0 over time indicates inbound
// media is not reaching the client — either
// nothing is being sent by the peer, or the
// transport is dropping packets, or we're
// connected to the wrong side of the media
// path. Combined with the peer's send_heartbeat
// from the other log, this tells us exactly
// where 1-way audio breaks.
crate::emit_call_debug(
&recv_app,
"media:recv_heartbeat",
serde_json::json!({
"recv_fr": fr,
"decoded_frames": decoded_frames,
"last_written": last_written,
"written_samples": written_samples,
"decode_errs": decode_errs,
"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(); heartbeat = std::time::Instant::now();
} }
} }
@@ -893,6 +1188,9 @@ impl CallEngine {
// Phase 3.5: caller did the dual-path race and picked a // Phase 3.5: caller did the dual-path race and picked a
// winning transport. If Some, skip our own connect step. // winning transport. If Some, skip our own connect step.
pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>>, pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>>,
// Phase 6: explicit is_direct_p2p flag (see android branch).
is_direct_p2p: bool,
_app: tauri::AppHandle,
event_cb: F, event_cb: F,
) -> Result<Self, anyhow::Error> ) -> Result<Self, anyhow::Error>
where where
@@ -902,33 +1200,27 @@ impl CallEngine {
%relay, %room, %alias, %quality, %relay, %room, %alias, %quality,
has_reuse = reuse_endpoint.is_some(), has_reuse = reuse_endpoint.is_some(),
has_pre_connected = pre_connected_transport.is_some(), has_pre_connected = pre_connected_transport.is_some(),
is_direct_p2p,
"CallEngine::start (desktop) invoked" "CallEngine::start (desktop) invoked"
); );
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let relay_addr: SocketAddr = relay.parse()?; let relay_addr: SocketAddr = relay.parse()?;
// Identity via the SHARED helper — same path resolution as
// register_signal (Tauri app_data_dir, e.g. on macOS
// ~/Library/Application Support/com.wzp.desktop/.wzp/identity).
//
// The previous implementation loaded the seed manually from
// $HOME/.wzp/identity which is a DIFFERENT file on macOS, so
// register_signal and CallEngine::start were using different
// identities — direct calls placed from desktop were routed
// by the relay under the CallEngine fingerprint but the callee
// had registered under a different fingerprint, making the
// call unroutable.
let seed = crate::load_or_create_seed() let seed = crate::load_or_create_seed()
.map_err(|e| anyhow::anyhow!("identity: {e}"))?; .map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let fp = seed.derive_identity().public_identity().fingerprint; let fp = seed.derive_identity().public_identity().fingerprint;
let fingerprint = fp.to_string(); let fingerprint = fp.to_string();
info!(%fp, "identity loaded"); info!(%fp, "identity loaded");
// Transport source: either the pre-connected dual-path // Transport source: either pre-connected or fresh.
// winner (Phase 3.5) or build a fresh relay connection here.
let transport = if let Some(t) = pre_connected_transport { let transport = if let Some(t) = pre_connected_transport {
info!("using pre-connected transport from dual-path race"); info!(
is_direct_p2p,
remote = %t.remote_address(),
max_datagram = ?t.max_datagram_size(),
"using pre-connected transport"
);
t t
} else { } else {
// Connect — reuse the signal endpoint if the direct-call path gave // Connect — reuse the signal endpoint if the direct-call path gave
@@ -951,7 +1243,11 @@ impl CallEngine {
Arc::new(wzp_transport::QuinnTransport::new(conn)) Arc::new(wzp_transport::QuinnTransport::new(conn))
}; };
// Handshake // Handshake — relay-specific. Direct P2P connections skip
// this because the peer is a phone, not a relay with an
// accept_handshake handler. See the android branch's
// comment for the full rationale.
if !is_direct_p2p {
let _session = wzp_client::handshake::perform_handshake( let _session = wzp_client::handshake::perform_handshake(
&*transport, &*transport,
&seed.0, &seed.0,
@@ -959,6 +1255,9 @@ impl CallEngine {
) )
.await .await
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?; .map_err(|e| { error!("perform_handshake failed: {e}"); e })?;
} else {
info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
}
info!("connected to relay, handshake complete"); info!("connected to relay, handshake complete");
event_cb("connected", &format!("joined room {room}")); event_cb("connected", &format!("joined room {room}"));
@@ -1013,6 +1312,10 @@ impl CallEngine {
let tx_codec = Arc::new(Mutex::new(String::new())); let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new())); let rx_codec = Arc::new(Mutex::new(String::new()));
// Adaptive quality: shared pending-profile bridge between recv → send.
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
// Send task // Send task
let send_t = transport.clone(); let send_t = transport.clone();
let send_r = running.clone(); let send_r = running.clone();
@@ -1022,6 +1325,7 @@ impl CallEngine {
let send_drops = Arc::new(AtomicU64::new(0)); let send_drops = Arc::new(AtomicU64::new(0));
let send_quality = quality.clone(); let send_quality = quality.clone();
let send_tx_codec = tx_codec.clone(); let send_tx_codec = tx_codec.clone();
let send_pending_profile = pending_profile.clone();
tokio::spawn(async move { tokio::spawn(async move {
let profile = resolve_quality(&send_quality); let profile = resolve_quality(&send_quality);
let config = match profile { let config = match profile {
@@ -1043,6 +1347,11 @@ impl CallEngine {
encoder.set_aec_enabled(false); // OS AEC or none encoder.set_aec_enabled(false); // OS AEC or none
let mut buf = vec![0i16; frame_samples]; let mut buf = vec![0i16; frame_samples];
// Continuous DRED tuning (same as Android send task).
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
const DRED_POLL_INTERVAL: u32 = 25;
loop { loop {
if !send_r.load(Ordering::Relaxed) { if !send_r.load(Ordering::Relaxed) {
break; break;
@@ -1078,6 +1387,35 @@ impl CallEngine {
} }
Err(e) => error!("encode: {e}"), Err(e) => error!("encode: {e}"),
} }
// Adaptive quality: check if recv task recommended a profile switch.
if auto_profile {
let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if p != PROFILE_NO_CHANGE {
if let Some(new_profile) = index_to_profile(p) {
info!(to = ?new_profile.codec, "auto: switching encoder profile");
if encoder.set_profile(new_profile).is_ok() {
dred_tuner.set_codec(new_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", new_profile.codec);
}
}
}
}
// DRED tuner: poll quinn path stats periodically.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning);
}
}
} }
}); });
@@ -1087,6 +1425,7 @@ impl CallEngine {
let recv_spk = spk_muted.clone(); let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone(); let recv_fr = frames_received.clone();
let recv_rx_codec = rx_codec.clone(); let recv_rx_codec = rx_codec.clone();
let pending_profile_recv = pending_profile.clone();
tokio::spawn(async move { tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD); let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: concrete AdaptiveDecoder (not Box<dyn>) so we // Phase 3b/3c: concrete AdaptiveDecoder (not Box<dyn>) so we
@@ -1099,6 +1438,7 @@ impl CallEngine {
let mut agc = wzp_codec::AutoGainControl::new(); let mut agc = wzp_codec::AutoGainControl::new();
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
let mut dred_recv = DredRecvState::new(); let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
loop { loop {
if !recv_r.load(Ordering::Relaxed) { if !recv_r.load(Ordering::Relaxed) {
@@ -1163,6 +1503,15 @@ impl CallEngine {
); );
} }
// Adaptive quality: ingest quality reports from peer
if let Some(ref qr) = pkt.quality_report {
if let Some(new_profile) = quality_ctrl.observe(qr) {
let idx = profile_to_index(&new_profile);
info!(to = ?new_profile.codec, "auto: quality adapter recommends switch");
pending_profile_recv.store(idx, Ordering::Release);
}
}
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) { if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
agc.process_frame(&mut pcm[..n]); agc.process_frame(&mut pcm[..n]);
if !recv_spk.load(Ordering::Relaxed) { if !recv_spk.load(Ordering::Relaxed) {
@@ -1293,6 +1642,26 @@ impl CallEngine {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
crate::wzp_native::audio_stop(); crate::wzp_native::audio_stop();
// Release the BT SCO communication device so Android can
// route media (video, music) back to BT A2DP. Without this,
// setCommunicationDevice locks BT to SCO mode and other apps
// can't use the headset for media playback until reboot.
if let Err(e) = crate::android_audio::stop_bluetooth_sco() {
tracing::warn!("stop_bluetooth_sco on call end failed: {e}");
}
// Restore MODE_NORMAL so other apps' audio routes normally.
if let Err(e) = crate::android_audio::set_audio_mode_normal() {
tracing::warn!("set_audio_mode_normal failed: {e}");
}
} }
} }
} }
impl Drop for CallEngine {
fn drop(&mut self) {
// Safety net: if stop() was never called (crash, app
// backgrounding), signal tasks to exit so they don't
// spin on a dropped transport.
self.running.store(false, Ordering::SeqCst);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ static LIB: OnceLock<libloading::Library> = OnceLock::new();
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new(); static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new(); static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new(); static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new(); static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new(); static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new(); static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
@@ -65,6 +66,7 @@ pub fn init() -> Result<(), String> {
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version"); resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello"); resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start"); resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt");
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop"); resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture"); resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout"); resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
@@ -104,6 +106,14 @@ pub fn audio_start() -> Result<(), i32> {
if ret == 0 { Ok(()) } else { Err(ret) } if ret == 0 { Ok(()) } else { Err(ret) }
} }
/// Start Oboe in Bluetooth SCO mode — capture skips sample rate and
/// input preset so the system routes to the BT SCO device natively.
pub fn audio_start_bt() -> Result<(), i32> {
let f = AUDIO_START_BT.get().ok_or(-100_i32)?;
let ret = unsafe { f() };
if ret == 0 { Ok(()) } else { Err(ret) }
}
/// Stop both streams. Safe to call even if not running. /// Stop both streams. Safe to call even if not running.
pub fn audio_stop() { pub fn audio_stop() {
if let Some(f) = AUDIO_STOP.get() { if let Some(f) = AUDIO_STOP.get() {

View File

@@ -2,6 +2,125 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { generateIdenticon, createIdenticonEl } from "./identicon"; import { generateIdenticon, createIdenticonEl } from "./identicon";
// ── Incoming-call ringer ─────────────────────────────────────────────
//
// Web Audio synthesized two-tone ring that loops until stop() is
// called. No external asset file — works immediately on every
// platform Tauri has a WebView on (Android, macOS, Windows, Linux).
//
// The pattern is a classic North American ring cadence: 440Hz +
// 480Hz tone for 2s, 4s silence, repeat. Volume ramps to ~30%
// peak so it's audible without being obnoxious on laptop
// speakers. Stops cleanly on stop() — cancels the timer AND
// disconnects the active oscillators so there's no tail audio.
class Ringer {
private ctx: AudioContext | null = null;
private timer: number | null = null;
private activeNodes: AudioNode[] = [];
private running = false;
start() {
if (this.running) return;
this.running = true;
// Construct the AudioContext lazily on the first ring — some
// platforms (iOS WebView, Android WebView) refuse to create
// one until after a user gesture, so we MUST be past that
// point by the time start() is called. Incoming call event is
// user-adjacent enough that the WebView normally allows it.
try {
if (!this.ctx) {
this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
} catch (e) {
console.warn("Ringer: AudioContext unavailable", e);
this.running = false;
return;
}
this.playOnce();
// 2s tone + 4s silence = 6s cadence. Loop with setInterval.
this.timer = window.setInterval(() => this.playOnce(), 6000);
}
stop() {
this.running = false;
if (this.timer != null) {
window.clearInterval(this.timer);
this.timer = null;
}
for (const n of this.activeNodes) {
try {
(n as any).disconnect();
} catch {}
}
this.activeNodes = [];
}
private playOnce() {
if (!this.ctx || !this.running) return;
const ctx = this.ctx;
const now = ctx.currentTime;
const toneDurSec = 2.0;
// Two-tone ring: 440Hz (A4) + 480Hz (close to B4). Mix both
// through one gain node for envelope control.
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + 0.05);
gain.gain.setValueAtTime(0.3, now + toneDurSec - 0.05);
gain.gain.linearRampToValueAtTime(0, now + toneDurSec);
gain.connect(ctx.destination);
for (const freq of [440, 480]) {
const osc = ctx.createOscillator();
osc.type = "sine";
osc.frequency.value = freq;
osc.connect(gain);
osc.start(now);
osc.stop(now + toneDurSec);
this.activeNodes.push(osc);
}
this.activeNodes.push(gain);
// Schedule a cleanup of old nodes after this tone finishes so
// the activeNodes array doesn't grow unbounded across long
// rings.
window.setTimeout(() => {
this.activeNodes = this.activeNodes.filter((n) => n !== gain);
}, (toneDurSec + 0.1) * 1000);
}
}
const ringer = new Ringer();
/// Best-effort system notification via the tauri-plugin-notification
/// plugin. Uses raw `invoke` so we don't need to import
/// `@tauri-apps/plugin-notification` — just invoke the plugin
/// commands directly. Silently no-ops if the plugin isn't
/// available or permission is denied.
async function notifyIncomingCall(from: string) {
try {
// Make sure we have permission first. On Android this prompts
// the user once; after that it's cached.
const granted = await invoke<boolean>(
"plugin:notification|is_permission_granted",
).catch(() => false);
if (!granted) {
const result = await invoke<string>(
"plugin:notification|request_permission",
).catch(() => "denied");
if (result !== "granted") return;
}
await invoke("plugin:notification|notify", {
options: {
title: "Incoming call",
body: `From ${from}`,
},
});
} catch (e) {
// Notification plugin missing or refused — not fatal, the
// visible panel + ringer still alert the user.
console.debug("notify: plugin unavailable or refused", e);
}
}
// ── WebView hardening ── // ── WebView hardening ──
// Suppress the browser-style right-click context menu on desktop Tauri — it // Suppress the browser-style right-click context menu on desktop Tauri — it
// exposes Inspect/Reload/Back/Forward entries that don't belong in a native- // exposes Inspect/Reload/Back/Forward entries that don't belong in a native-
@@ -50,6 +169,11 @@ const callTimer = document.getElementById("call-timer")!;
const callStatus = document.getElementById("call-status")!; const callStatus = document.getElementById("call-status")!;
const levelBar = document.getElementById("level-bar")!; const levelBar = document.getElementById("level-bar")!;
const participantsDiv = document.getElementById("participants")!; const participantsDiv = document.getElementById("participants")!;
const directCallView = document.getElementById("direct-call-view")!;
const dcIdenticon = document.getElementById("dc-identicon")!;
const dcName = document.getElementById("dc-name")!;
const dcFp = document.getElementById("dc-fp")!;
const dcBadge = document.getElementById("dc-badge")!;
const micBtn = document.getElementById("mic-btn")!; const micBtn = document.getElementById("mic-btn")!;
const micIcon = document.getElementById("mic-icon")!; const micIcon = document.getElementById("mic-icon")!;
const spkBtn = document.getElementById("spk-btn")!; const spkBtn = document.getElementById("spk-btn")!;
@@ -87,6 +211,9 @@ const sCallDebug = document.getElementById("s-call-debug") as HTMLInputElement;
const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement; const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement;
const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement; const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement;
const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement; const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement;
const sCallDebugCopyBtn = document.getElementById("s-call-debug-copy") as HTMLButtonElement;
const sCallDebugShareBtn = document.getElementById("s-call-debug-share") as HTMLButtonElement;
const sCallDebugCopyStatus = document.getElementById("s-call-debug-copy-status") as HTMLElement;
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement; const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement; const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement;
const sNatType = document.getElementById("s-nat-type") as HTMLSpanElement; const sNatType = document.getElementById("s-nat-type") as HTMLSpanElement;
@@ -347,6 +474,9 @@ function renderRelayDialogList() {
// Click to select // Click to select
item.addEventListener("click", () => { item.addEventListener("click", () => {
const prev = loadSettings();
const prevRelayAddr = prev.relays[prev.selectedRelay]?.address;
const s = loadSettings(); const s = loadSettings();
s.selectedRelay = i; s.selectedRelay = i;
@@ -358,6 +488,30 @@ function renderRelayDialogList() {
saveSettingsObj(s); saveSettingsObj(s);
renderRelayDialogList(); renderRelayDialogList();
renderRelayButton(); renderRelayButton();
// If the user switched relays and we're currently registered,
// transparently re-register against the new one. The Rust
// `register_signal` command is idempotent and handles the
// swap internally (close old transport → connect new). This
// makes "change server" a single-click operation instead of
// manual deregister + re-register.
const newRelayAddr = r.address;
if (newRelayAddr && newRelayAddr !== prevRelayAddr) {
(async () => {
// Is a signal currently registered? get_signal_status is
// cheap and lets us decide whether to kick the swap.
try {
const st: any = await invoke("get_signal_status");
if (st && st.status === "registered") {
await invoke<string>("register_signal", { relay: newRelayAddr });
// `signal-event { type: "registered" }` from Rust will
// update directRegistered for us — no manual render here.
}
} catch (e) {
console.warn("relay swap: failed to re-register", e);
}
})();
}
}); });
relayDialogList.appendChild(item); relayDialogList.appendChild(item);
@@ -471,6 +625,100 @@ sCallDebugClearBtn.addEventListener("click", () => {
sCallDebugLogEl.textContent = ""; sCallDebugLogEl.textContent = "";
}); });
/// Serialise the rolling call-debug buffer as plain text for
/// copy/share. One entry per line, HH:MM:SS.mmm + step +
/// compact JSON details. Same format the on-screen panel uses.
function formatCallDebugLog(): string {
return callDebugBuffer
.map((e) => {
const iso = new Date(e.ts_ms).toISOString().slice(11, 23);
const details =
e.details && Object.keys(e.details).length > 0
? " " + JSON.stringify(e.details)
: "";
return `${iso} ${e.step}${details}`;
})
.join("\n");
}
/// One-shot status helper for the copy/share buttons.
function flashCallDebugStatus(msg: string, isError: boolean = false) {
sCallDebugCopyStatus.textContent = msg;
sCallDebugCopyStatus.style.color = isError ? "var(--yellow)" : "var(--green)";
setTimeout(() => {
sCallDebugCopyStatus.textContent = "";
}, 2500);
}
sCallDebugCopyBtn.addEventListener("click", async () => {
const text = formatCallDebugLog();
if (!text) {
flashCallDebugStatus("Log is empty", true);
return;
}
try {
await navigator.clipboard.writeText(text);
flashCallDebugStatus(`✓ Copied ${callDebugBuffer.length} entries`);
} catch (e) {
// Some WebViews refuse clipboard access without a user
// permission prompt; fall back to a selection-based copy.
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
if (ok) {
flashCallDebugStatus(`✓ Copied ${callDebugBuffer.length} entries`);
} else {
throw new Error("execCommand returned false");
}
} catch (e2) {
flashCallDebugStatus(`⚠ Copy failed: ${String(e2)}`, true);
}
}
});
sCallDebugShareBtn.addEventListener("click", async () => {
const text = formatCallDebugLog();
if (!text) {
flashCallDebugStatus("Log is empty", true);
return;
}
// Try the Web Share API first — on Android WebView, this opens
// the standard Share sheet and the user can send the text to
// any messaging app. Falls back to clipboard copy if the
// WebView doesn't expose navigator.share (most desktop
// WebViews don't).
const nav: any = navigator;
if (nav.share) {
try {
await nav.share({
title: "WarzonePhone debug log",
text,
});
flashCallDebugStatus(`✓ Shared ${callDebugBuffer.length} entries`);
return;
} catch (e) {
// User cancelled or WebView rejected — fall through to
// clipboard copy as a best-effort.
console.debug("share failed, falling back to clipboard", e);
}
}
try {
await navigator.clipboard.writeText(text);
flashCallDebugStatus(`✓ Copied (no share API)`);
} catch (e) {
flashCallDebugStatus(`⚠ Share + copy both failed`, true);
}
});
// Load fingerprint + alias + git hash + render identicon // Load fingerprint + alias + git hash + render identicon
interface AppInfo { git_hash: string; alias: string; fingerprint: string; data_dir: string } interface AppInfo { git_hash: string; alias: string; fingerprint: string; data_dir: string }
@@ -592,18 +840,43 @@ async function doConnect() {
} }
} }
// Phase 5.6: when we're in a direct P2P call (not relay-
// mediated), the relay's room infrastructure never sends a
// RoomUpdate because neither peer actually joined the room.
// pollStatus sees an empty participant list and shows "Waiting
// for participants...". Track the peer's identity from the
// signal plane and render a synthetic participant entry instead.
let directCallPeer: { fingerprint: string; alias: string | null } | null = null;
function showCallScreen() { function showCallScreen() {
connectScreen.classList.add("hidden"); connectScreen.classList.add("hidden");
callScreen.classList.remove("hidden"); callScreen.classList.remove("hidden");
// Direct call → phone-style layout; room call → group layout.
if (directCallPeer) {
const fp = directCallPeer.fingerprint || "";
const alias = directCallPeer.alias;
roomName.textContent = alias || fp.substring(0, 16) || "Direct Call";
dcName.textContent = alias || "Unknown";
dcFp.textContent = fp;
dcIdenticon.innerHTML = "";
dcIdenticon.appendChild(createIdenticonEl(fp || "?", 96, true));
dcBadge.textContent = "Connecting...";
dcBadge.className = "dc-badge connecting";
directCallView.classList.remove("hidden");
participantsDiv.classList.add("hidden");
} else {
roomName.textContent = roomInput.value; roomName.textContent = roomInput.value;
directCallView.classList.add("hidden");
participantsDiv.classList.remove("hidden");
}
callStatus.className = "status-dot"; callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250); statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on // Sync the audio route label with the OS state (Android only; on desktop
// desktop the command is a no-op returning false so we land on "Earpiece" // get_audio_route returns "earpiece" so we land on the default).
// which is fine because desktop has no routing concept). invoke<string>("get_audio_route")
invoke<boolean>("is_speakerphone_on") .then((route) => { currentAudioRoute = (route as AudioRoute) || "earpiece"; updateRouteLabel(); })
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); }) .catch(() => { currentAudioRoute = "earpiece"; updateRouteLabel(); });
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
} }
function showConnectScreen() { function showConnectScreen() {
@@ -612,6 +885,10 @@ function showConnectScreen() {
connectBtn.disabled = false; connectBtn.disabled = false;
connectBtn.textContent = "Connect"; connectBtn.textContent = "Connect";
levelBar.style.width = "0%"; levelBar.style.width = "0%";
directCallPeer = null;
// Clear the media-degraded banner if present
const banner = document.getElementById("media-degraded-banner");
if (banner) banner.remove();
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
} }
@@ -620,41 +897,92 @@ micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {} try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
}); });
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then // Audio routing (Android) — cycles between earpiece, speaker, and Bluetooth
// stops and restarts the Oboe streams so AAudio reconfigures with the new // SCO. Each transition calls the corresponding Tauri command which sets the
// routing. The Rust-side Tauri command handles the restart, we just swap // AudioManager state and restarts Oboe streams so AAudio picks up the new
// the button label. // route. On desktop all commands are no-ops.
// //
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class // Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
// (which would tint the button red); that was a bug in 0178cbd that made // (which would tint the button red); that was a bug in 0178cbd that made
// earpiece mode look like playback was off. A separate `.speaker-on` class // earpiece mode look like playback was off.
// is available for css styling if we want to visually indicate loud mode. type AudioRoute = "earpiece" | "speaker" | "bluetooth";
let speakerphoneOn = false; let currentAudioRoute: AudioRoute = "earpiece";
let speakerphoneBusy = false; let routeBusy = false;
function updateSpkLabel() {
spkBtn.classList.toggle("speaker-on", speakerphoneOn); function updateRouteLabel() {
spkBtn.classList.remove("speaker-on", "bt-on");
spkBtn.classList.remove("muted"); spkBtn.classList.remove("muted");
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece"; switch (currentAudioRoute) {
case "speaker":
spkIcon.textContent = "🔊 Speaker";
spkBtn.classList.add("speaker-on");
break;
case "bluetooth":
spkIcon.textContent = "🎧 BT";
spkBtn.classList.add("bt-on");
break;
default:
spkIcon.textContent = "🔈 Earpiece";
break;
}
} }
spkBtn.addEventListener("click", async () => {
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms async function cycleAudioRoute() {
speakerphoneBusy = true; if (routeBusy) return; // debounce — Oboe restart takes ~60-400ms
const next = !speakerphoneOn; routeBusy = true;
spkBtn.disabled = true; spkBtn.disabled = true;
try { try {
await invoke("set_speakerphone", { on: next }); const btAvailable = await invoke<boolean>("is_bluetooth_available");
speakerphoneOn = next; const routes: AudioRoute[] = btAvailable
updateSpkLabel(); ? ["earpiece", "speaker", "bluetooth"]
: ["earpiece", "speaker"];
const idx = routes.indexOf(currentAudioRoute);
const next = routes[(idx + 1) % routes.length];
// Tear down current route, then activate next.
// start_bluetooth_sco() already calls setSpeakerphoneOn(false)
// internally, so we skip the separate speakerphone toggle when
// transitioning to BT to avoid a redundant Oboe restart.
if (currentAudioRoute === "bluetooth") {
await invoke("set_bluetooth_sco", { on: false });
}
if (next === "speaker") {
await invoke("set_speakerphone", { on: true });
} else if (next === "bluetooth") {
// BT start handles speaker-off internally + waits for SCO link
await invoke("set_bluetooth_sco", { on: true });
} else {
// earpiece — turn everything off
await invoke("set_speakerphone", { on: false });
}
currentAudioRoute = next;
updateRouteLabel();
} catch (e) { } catch (e) {
console.error("set_speakerphone failed:", e); console.error("cycleAudioRoute failed:", e);
} finally { } finally {
spkBtn.disabled = false; spkBtn.disabled = false;
speakerphoneBusy = false; routeBusy = false;
} }
}); }
spkBtn.addEventListener("click", cycleAudioRoute);
hangupBtn.addEventListener("click", async () => { hangupBtn.addEventListener("click", async () => {
userDisconnected = true; userDisconnected = true;
try { await invoke("disconnect"); } catch {} // Use the new hangup_call command instead of raw disconnect —
// it sends a Hangup signal to the relay FIRST so the peer
// gets auto-dismissed from the call screen, then tears down
// our local engine. Plain `disconnect` would leave the peer
// stuck on the call screen with silent audio.
try {
await invoke("hangup_call");
} catch {
// Fall back to plain disconnect if hangup_call errors
// (older Rust build without the new command).
try {
await invoke("disconnect");
} catch {}
}
showConnectScreen(); showConnectScreen();
}); });
@@ -711,7 +1039,7 @@ async function pollStatus() {
micBtn.classList.toggle("muted", st.mic_muted); micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic"; micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
// NB: spkBtn label is driven by the Android audio routing state // NB: spkBtn label is driven by the Android audio routing state
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted. // (currentAudioRoute / updateRouteLabel), not by the engine's spk_muted.
// Skip that here so pollStatus doesn't clobber the routing UI. // Skip that here so pollStatus doesn't clobber the routing UI.
callTimer.textContent = formatDuration(st.call_duration_secs); callTimer.textContent = formatDuration(st.call_duration_secs);
@@ -719,8 +1047,32 @@ async function pollStatus() {
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0; const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`; levelBar.style.width = `${pct}%`;
// Participants grouped by relay // Direct-call phone-style layout: update the connection
if (st.participants.length === 0) { // badge from the call-debug buffer or from participants.
if (directCallPeer) {
// Check the debug buffer for the race result to label
// the connection type (P2P Direct vs Relay).
const pathNeg = callDebugBuffer.find((e) => e.step === "connect:path_negotiated");
const engineOk = callDebugBuffer.find((e) => e.step === "connect:call_engine_started");
if (engineOk) {
if (pathNeg?.details?.use_direct === true) {
dcBadge.textContent = "P2P Direct";
dcBadge.className = "dc-badge";
} else {
dcBadge.textContent = "Via Relay";
dcBadge.className = "dc-badge relay";
}
}
// Skip the group participant rendering — direct-call
// view is already visible and showing the peer.
}
// Participants grouped by relay (group/room calls only).
// Hidden when directCallPeer is set — the phone-style
// layout above handles the 1:1 display.
if (directCallPeer) {
// no-op: direct call view handles it
} else if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>'; participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else { } else {
participantsDiv.innerHTML = ""; participantsDiv.innerHTML = "";
@@ -776,6 +1128,42 @@ listen("call-event", (event: any) => {
const { kind } = event.payload; const { kind } = event.payload;
if (kind === "room-update") pollStatus(); if (kind === "room-update") pollStatus();
if (kind === "disconnected" && !userDisconnected) pollStatus(); if (kind === "disconnected" && !userDisconnected) pollStatus();
// Phase 5.6: media health watchdog — show/clear a warning
// banner when the media path dies (e.g., P2P direct
// established but the network path changed, or cross-relay
// media forwarding isn't working).
if (kind === "media-degraded") {
// Show a warning banner on the call screen. Don't auto-
// disconnect — the user might be on a briefly-unstable
// network and recovery is possible (the engine tracks
// "media-recovered" and clears the banner if packets
// resume).
let banner = document.getElementById("media-degraded-banner");
if (!banner) {
banner = document.createElement("div");
banner.id = "media-degraded-banner";
banner.style.cssText =
"background:rgba(239,68,68,0.15);color:var(--red);padding:8px 12px;" +
"border-radius:8px;text-align:center;font-size:13px;margin:8px 0;";
banner.innerHTML =
'⚠ No audio — connection may be lost.<br>' +
'<small style="color:var(--text-dim)">Try hanging up and reconnecting, or switch to a different relay.</small>';
// Insert at the top of the call screen, below the header
const participants = document.getElementById("participants");
const directView = document.getElementById("direct-call-view");
const insertBefore = (directView && !directView.classList.contains("hidden"))
? directView
: participants;
if (insertBefore?.parentNode) {
insertBefore.parentNode.insertBefore(banner, insertBefore);
}
}
}
if (kind === "media-recovered") {
const banner = document.getElementById("media-degraded-banner");
if (banner) banner.remove();
}
}); });
// ── Settings ── // ── Settings ──
@@ -826,9 +1214,18 @@ settingsBtnCall.addEventListener("click", openSettings);
// shows its working state inline so the user knows it's waiting on // shows its working state inline so the user knows it's waiting on
// the relay rather than the network. // the relay rather than the network.
// Phase 2 multi-relay NAT type detection. Probes every configured // Phase 2 multi-relay NAT type detection. Probes every configured
// relay in parallel through transient QUIC connections and // relay in parallel and classifies the result.
// classifies the result. Green = Cone (P2P viable), //
// amber = SymmetricPort (must relay), gray = Multiple / Unknown. // Cone = P2P direct path viable, green cue
// SymmetricPort = per-destination port mapping, informational
// (P2P will fall back to relay but calls still work)
// Multiple = classifier saw different public IPs; informational
// Unknown = not enough public probes, neutral
//
// The classifier drops LAN / private / CGNAT reflex addrs before
// deciding, so a mixed "LAN relay + internet relay" setup does NOT
// falsely flag as symmetric. Failed probes are shown in the list
// for transparency but dimmed, not highlighted.
sNatDetectBtn.addEventListener("click", async () => { sNatDetectBtn.addEventListener("click", async () => {
const s = loadSettings(); const s = loadSettings();
if (!s.relays || s.relays.length === 0) { if (!s.relays || s.relays.length === 0) {
@@ -859,17 +1256,18 @@ sNatDetectBtn.addEventListener("click", async () => {
detection.nat_type === "Cone" detection.nat_type === "Cone"
? `✓ Cone NAT — P2P viable (${detection.consensus_addr})` ? `✓ Cone NAT — P2P viable (${detection.consensus_addr})`
: detection.nat_type === "SymmetricPort" : detection.nat_type === "SymmetricPort"
? " Symmetric NAT — must use relay" ? " Symmetric NAT — P2P falls back to relay, calls still work"
: detection.nat_type === "Multiple" : detection.nat_type === "Multiple"
? " Multiple IPs — treating as symmetric" ? " Multiple public IPs observed"
: "? Unknown (not enough successful probes)"; : "? Unknown (not enough public probes)";
// Only Cone is "good news green". Everything else is neutral
// informational — the user has configured relays so any
// classification result just describes their network; none
// are "wrong" per se.
const verdictColor = const verdictColor =
detection.nat_type === "Cone" detection.nat_type === "Cone"
? "var(--green)" ? "var(--green)"
: detection.nat_type === "SymmetricPort" ||
detection.nat_type === "Multiple"
? "var(--yellow)"
: "var(--text-dim)"; : "var(--text-dim)";
sNatType.textContent = verdictLabel; sNatType.textContent = verdictLabel;
@@ -882,7 +1280,10 @@ sNatDetectBtn.addEventListener("click", async () => {
p.relay_addr p.relay_addr
)}) → ${escapeHtml(p.observed_addr)} [${p.latency_ms ?? "?"}ms]</div>`; )}) → ${escapeHtml(p.observed_addr)} [${p.latency_ms ?? "?"}ms]</div>`;
} else { } else {
return `<div style="color:var(--yellow)">• ${escapeHtml( // Failed probes are dimmed, not highlighted — the classifier
// already ignores them, and the user doesn't need to be
// alarmed by a momentarily-offline relay.
return `<div style="color:var(--text-dim);opacity:0.7">• ${escapeHtml(
p.relay_name p.relay_name
)} (${escapeHtml(p.relay_addr)}) → ${escapeHtml( )} (${escapeHtml(p.relay_addr)}) → ${escapeHtml(
p.error ?? "probe failed" p.error ?? "probe failed"
@@ -1125,11 +1526,51 @@ clearHistoryBtn.addEventListener("click", async () => {
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}); });
// Track whether a registration is in flight so the same button
// can toggle between "Register" and "Cancel". The cancel path
// calls deregister which closes the transport and makes the
// in-flight connect fail, breaking the await cleanly.
let registerInFlight = false;
registerBtn.addEventListener("click", async () => { registerBtn.addEventListener("click", async () => {
// ── Cancel path: user tapped the button while registration
// is in flight (it says "Cancel") → tear down the attempt
// so we don't block for 30s on an unreachable relay.
if (registerInFlight) {
registerInFlight = false;
try { await invoke("deregister"); } catch {}
registerBtn.textContent = "Register on Relay";
registerBtn.disabled = false;
connectError.textContent = "Registration cancelled";
return;
}
const relay = getSelectedRelay(); const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; } if (!relay) { connectError.textContent = "No relay selected"; return; }
connectError.textContent = "";
// ── Pre-flight ping: quick 3s QUIC handshake to check if
// the relay is reachable BEFORE committing to the full
// register flow (which takes ~10s to time out against a dead
// host). If the ping fails, show "server unavailable"
// immediately without blocking.
registerBtn.textContent = "Checking...";
registerBtn.disabled = true; registerBtn.disabled = true;
registerBtn.textContent = "Registering..."; try {
await invoke("ping_relay", { relay: relay.address });
} catch (e: any) {
connectError.textContent = `Server unavailable: ${String(e)}`;
registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay";
return;
}
// ── Register path: ping succeeded, proceed with the full
// registration. Show "Cancel" on the button so the user
// can bail if the relay goes unreachable mid-handshake.
registerInFlight = true;
registerBtn.disabled = false;
registerBtn.textContent = "Cancel";
try { try {
const fp = await invoke<string>("register_signal", { relay: relay.address }); const fp = await invoke<string>("register_signal", { relay: relay.address });
registerBtn.classList.add("hidden"); registerBtn.classList.add("hidden");
@@ -1137,9 +1578,14 @@ registerBtn.addEventListener("click", async () => {
callStatusText.textContent = `Your fingerprint: ${fp}`; callStatusText.textContent = `Your fingerprint: ${fp}`;
refreshHistory(); refreshHistory();
} catch (e: any) { } catch (e: any) {
if (registerInFlight) {
// Real failure, not a user cancel
connectError.textContent = String(e); connectError.textContent = String(e);
}
registerBtn.disabled = false; registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay"; registerBtn.textContent = "Register on Relay";
} finally {
registerInFlight = false;
} }
}); });
@@ -1161,6 +1607,10 @@ callBtn.addEventListener("click", async () => {
const target = targetFpInput.value.trim(); const target = targetFpInput.value.trim();
if (!target) return; if (!target) return;
callStatusText.textContent = "Calling..."; callStatusText.textContent = "Calling...";
// Remember the target for P2P participant display — on a
// direct call the relay never sends RoomUpdate so pollStatus
// would otherwise show "Waiting for participants...".
directCallPeer = { fingerprint: target, alias: null };
try { try {
await invoke("place_call", { targetFp: target }); await invoke("place_call", { targetFp: target });
} catch (e: any) { } catch (e: any) {
@@ -1169,14 +1619,24 @@ callBtn.addEventListener("click", async () => {
}); });
acceptCallBtn.addEventListener("click", async () => { acceptCallBtn.addEventListener("click", async () => {
ringer.stop();
const status = await invoke<any>("get_signal_status"); const status = await invoke<any>("get_signal_status");
if (status.incoming_call_id) { if (status.incoming_call_id) {
await invoke("answer_call", { callId: status.incoming_call_id, mode: 2 }); // mode=1 → AcceptTrusted — enables P2P direct path by
// querying + advertising the callee's reflex addr in the
// answer. The alternative is mode=2 → AcceptGeneric
// (privacy mode) which intentionally skips the reflex query
// to keep the callee's IP hidden from the caller but forces
// the call onto the relay path. Default to trusted so the
// Accept button gets real P2P; privacy can be a future
// dedicated button if anyone needs it.
await invoke("answer_call", { callId: status.incoming_call_id, mode: 1 });
incomingCallPanel.classList.add("hidden"); incomingCallPanel.classList.add("hidden");
} }
}); });
rejectCallBtn.addEventListener("click", async () => { rejectCallBtn.addEventListener("click", async () => {
ringer.stop();
const status = await invoke<any>("get_signal_status"); const status = await invoke<any>("get_signal_status");
if (status.incoming_call_id) { if (status.incoming_call_id) {
await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 }); await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 });
@@ -1194,12 +1654,26 @@ listen("signal-event", (event: any) => {
case "incoming": case "incoming":
incomingCallPanel.classList.remove("hidden"); incomingCallPanel.classList.remove("hidden");
incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`; incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`;
// Remember the peer for the P2P participant display.
directCallPeer = {
fingerprint: data.caller_fp || "",
alias: data.caller_alias || null,
};
// Start ringing + fire a system notification. Both stop in
// the hangup/answered/accepted paths below (and via the
// accept/reject button handlers).
ringer.start();
notifyIncomingCall(
data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown",
);
break; break;
case "answered": case "answered":
callStatusText.textContent = `Call answered (${data.mode})`; callStatusText.textContent = `Call answered (${data.mode})`;
ringer.stop();
break; break;
case "setup": case "setup":
callStatusText.textContent = "Connecting to media..."; callStatusText.textContent = "Connecting to media...";
ringer.stop();
// Phase 3 hole-punching: peer_direct_addr carries the OTHER // Phase 3 hole-punching: peer_direct_addr carries the OTHER
// party's reflex addr when both sides advertised one. Forward // party's reflex addr when both sides advertised one. Forward
// to Rust connect() which currently logs it + takes the relay // to Rust connect() which currently logs it + takes the relay
@@ -1213,6 +1687,7 @@ listen("signal-event", (event: any) => {
osAec: osAecCheckbox.checked, osAec: osAecCheckbox.checked,
quality: loadSettings().quality || "auto", quality: loadSettings().quality || "auto",
peerDirectAddr: data.peer_direct_addr ?? null, peerDirectAddr: data.peer_direct_addr ?? null,
peerLocalAddrs: data.peer_local_addrs ?? [],
}); });
showCallScreen(); showCallScreen();
} catch (e: any) { } catch (e: any) {
@@ -1221,8 +1696,71 @@ listen("signal-event", (event: any) => {
})(); })();
break; break;
case "hangup": case "hangup":
// Peer (or the relay) ended the call. Tear down OUR side
// of the media engine and return to the connect screen
// automatically — the user shouldn't have to hit End Call
// on a call that's already over.
//
// Scenarios this handles:
// * active direct call, peer hung up → disconnect + back
// to connect screen
// * incoming call was ringing but caller bailed → hide
// incoming panel (no engine to disconnect)
// * setup failure mid-handshake → same as above
callStatusText.textContent = ""; callStatusText.textContent = "";
incomingCallPanel.classList.add("hidden"); incomingCallPanel.classList.add("hidden");
ringer.stop();
(async () => {
try {
// disconnect errors out with "not connected" if there's
// no active engine — safe to ignore, we just want to
// make sure any engine IS torn down.
await invoke("disconnect");
} catch {}
// Suppress the call-event "disconnected" auto-reconnect
// path since this was a peer-initiated hangup, not a
// transport drop.
userDisconnected = true;
if (!callScreen.classList.contains("hidden")) {
showConnectScreen();
}
})();
break;
case "reconnecting":
// Signal supervisor is retrying the relay connection. Show
// a non-blocking indicator on the small status line INSIDE
// the registered panel — do NOT touch directRegistered
// itself, that's the parent that holds the entire
// registered UI (address bar, call button, history, ...)
// and overwriting its textContent wipes all children.
{
const relay = typeof data.relay === "string" ? data.relay : "relay";
const status = document.getElementById("registered-status");
if (status) {
status.textContent = `🔄 reconnecting to ${relay}`;
(status as HTMLElement).style.color = "var(--yellow)";
}
}
break;
case "registered":
// Supervisor (re-)succeeded, or the first register landed.
// Clear the reconnecting badge and keep the registered UI.
{
const fp = typeof data.fingerprint === "string" ? data.fingerprint : "";
const status = document.getElementById("registered-status");
if (status) {
status.textContent = fp
? `✅ Registered (${fp.slice(0, 16)}…)`
: "✅ Registered — waiting for calls";
(status as HTMLElement).style.color = "var(--green)";
}
// Make sure the registered panel is visible and the
// Register button is hidden. This is the critical path
// both for the first register and for a transparent
// supervisor-driven reconnect.
directRegistered.classList.remove("hidden");
registerBtn.classList.add("hidden");
}
break; break;
} }
}); });

View File

@@ -371,7 +371,65 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
transition: width 0.1s ease-out; transition: width 0.1s ease-out;
} }
/* ── Participants ── */ /* ── Direct call phone-style layout ── */
.direct-call-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 32px 16px;
gap: 8px;
}
.dc-identicon {
width: 96px;
height: 96px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 12px;
box-shadow: 0 0 24px rgba(74, 222, 128, 0.15);
}
.dc-identicon canvas,
.dc-identicon svg,
.dc-identicon img {
width: 100% !important;
height: 100% !important;
display: block;
}
.dc-name {
font-size: 22px;
font-weight: 600;
color: var(--text);
text-align: center;
}
.dc-fp {
font-size: 11px;
font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace;
color: var(--text-dim);
text-align: center;
word-break: break-all;
max-width: 280px;
}
.dc-badge {
display: inline-block;
margin-top: 8px;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: rgba(74, 222, 128, 0.12);
color: var(--green);
}
.dc-badge.relay {
background: rgba(96, 165, 250, 0.12);
color: #60a5fa;
}
.dc-badge.connecting {
background: rgba(250, 204, 21, 0.12);
color: var(--yellow);
}
/* ── Participants (group call layout) ── */
.participants { .participants {
background: var(--surface); background: var(--surface);
border-radius: var(--radius); border-radius: var(--radius);
@@ -1025,7 +1083,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
color: white; color: white;
} }
/* Speaker routing button (non-muted earpiece state should not look red) */ /* Audio routing button — highlight color depends on active route */
#spk-btn.speaker-on .icon { #spk-btn.speaker-on .icon {
color: var(--accent); color: var(--accent);
} }
#spk-btn.bt-on .icon {
color: #60a5fa; /* blue-400 for Bluetooth */
}

View File

@@ -103,11 +103,13 @@ sequenceDiagram
participant RNN as RNNoise<br/>(2 x 480) participant RNN as RNNoise<br/>(2 x 480)
participant VAD as SilenceDetector participant VAD as SilenceDetector
participant Codec as Opus / Codec2 participant Codec as Opus / Codec2
participant DT as DredTuner<br/>(wzp-proto)
participant FEC as RaptorQ FEC participant FEC as RaptorQ FEC
participant INT as Interleaver<br/>(depth=3) participant INT as Interleaver<br/>(depth=3)
participant HDR as MediaHeader<br/>(12B or Mini 4B) participant HDR as MediaHeader<br/>(12B or Mini 4B)
participant Enc as ChaCha20-Poly1305 participant Enc as ChaCha20-Poly1305
participant QUIC as QUIC Datagram participant QUIC as QUIC Datagram
participant QPS as QuinnPathSnapshot
Mic->>Ring: f32 x 512 (macOS callback) Mic->>Ring: f32 x 512 (macOS callback)
Ring->>Ring: Accumulate to 960 samples Ring->>Ring: Accumulate to 960 samples
@@ -118,10 +120,19 @@ sequenceDiagram
else Silence (>100ms) else Silence (>100ms)
VAD->>Codec: ComfortNoise (every 200ms) VAD->>Codec: ComfortNoise (every 200ms)
end end
Note over QPS,DT: Every 25 frames (~500ms)
QPS->>DT: loss_pct, rtt_ms, jitter_ms
DT->>Codec: set_dred_duration() + set_expected_loss()
alt Opus tier (any bitrate)
Codec->>HDR: Compressed bytes + DRED side-channel (no RaptorQ)
else Codec2 tier
Codec->>FEC: Compressed bytes (pad to 256B symbol) Codec->>FEC: Compressed bytes (pad to 256B symbol)
FEC->>FEC: Accumulate block (5-10 symbols) FEC->>FEC: Accumulate block (5-10 symbols)
FEC->>INT: Source + repair symbols FEC->>INT: Source + repair symbols
INT->>HDR: Interleaved packets INT->>HDR: Interleaved packets
end
HDR->>Enc: Header as AAD HDR->>Enc: Header as AAD
Enc->>QUIC: Encrypted payload + 16B tag Enc->>QUIC: Encrypted payload + 16B tag
``` ```
@@ -134,6 +145,9 @@ sequenceDiagram
- Silence detection uses VAD + 100ms hangover before switching to ComfortNoise - Silence detection uses VAD + 100ms hangover before switching to ComfortNoise
- FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix - FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix
- MiniHeaders (4 bytes) replace full headers (12 bytes) for 49 of every 50 frames - MiniHeaders (4 bytes) replace full headers (12 bytes) for 49 of every 50 frames
- DRED tuner polls quinn path stats every 25 frames (~500ms) and adjusts DRED lookback duration continuously
- Opus tiers bypass RaptorQ entirely -- DRED handles loss recovery at the codec layer
- Opus6k DRED window: 1040ms (maximum libopus allows)
## Audio Decode Pipeline ## Audio Decode Pipeline
@@ -154,13 +168,30 @@ sequenceDiagram
Dec->>AR: Decrypt (header = AAD) Dec->>AR: Decrypt (header = AAD)
AR->>AR: Check seq window (reject replay) AR->>AR: Check seq window (reject replay)
AR->>HDR: Verified packet AR->>HDR: Verified packet
alt Opus packet
HDR->>JIT: Direct to jitter buffer (no FEC/interleave)
else Codec2 packet
HDR->>DEINT: MediaHeader + payload HDR->>DEINT: MediaHeader + payload
DEINT->>FEC: Reordered symbols by block DEINT->>FEC: Reordered symbols by block
FEC->>FEC: Attempt decode (need K of K+R) FEC->>FEC: Attempt decode (need K of K+R)
FEC->>JIT: Recovered audio frames FEC->>JIT: Recovered audio frames
end
JIT->>JIT: BTreeMap ordered by seq JIT->>JIT: BTreeMap ordered by seq
JIT->>JIT: Wait until depth >= target JIT->>JIT: Wait until depth >= target
alt Packet present
JIT->>Codec: Pop lowest seq frame JIT->>Codec: Pop lowest seq frame
else Packet missing (Opus)
JIT->>Codec: DRED reconstruction (neural)
alt DRED fails or unavailable
Codec->>Codec: Classical PLC fallback
end
else Packet missing (Codec2)
Codec->>Codec: Classical PLC
end
Codec->>Ring: PCM i16 x 960 Codec->>Ring: PCM i16 x 960
Ring->>SPK: Audio callback pulls samples Ring->>SPK: Audio callback pulls samples
``` ```
@@ -172,6 +203,8 @@ sequenceDiagram
- Jitter buffer target: **10 packets (200ms)** for client, **50 packets (1s)** for relay - Jitter buffer target: **10 packets (200ms)** for client, **50 packets (1s)** for relay
- Desktop client uses **direct playout** (no jitter buffer) with lock-free ring - Desktop client uses **direct playout** (no jitter buffer) with lock-free ring
- Codec2 frames at 8 kHz are resampled to 48 kHz transparently - Codec2 frames at 8 kHz are resampled to 48 kHz transparently
- DRED reconstruction: on packet loss, decoder tries neural DRED reconstruction before falling back to classical PLC
- Jitter-spike detection pre-emptively boosts DRED to ceiling when jitter variance spikes >30%
## Relay SFU Forwarding ## Relay SFU Forwarding
@@ -211,6 +244,7 @@ graph TB
3. If one send fails, the relay continues to the next participant (best-effort) 3. If one send fails, the relay continues to the next participant (best-effort)
4. The relay never decodes or re-encodes audio (preserves E2E encryption) 4. The relay never decodes or re-encodes audio (preserves E2E encryption)
5. With trunking enabled, packets to the same receiver are batched into TrunkFrames (flushed every 5ms) 5. With trunking enabled, packets to the same receiver are batched into TrunkFrames (flushed every 5ms)
6. Relay tracks per-participant quality from QualityReport trailers and broadcasts `QualityDirective` when the room-wide tier degrades (coordinated codec switching)
## Federation Topology ## Federation Topology
@@ -348,7 +382,7 @@ Used for 49 of every 50 frames (~1s cycle). Saves 8 bytes per packet (67% header
[session_id: 2][len: u16][payload: len] x count [session_id: 2][len: u16][payload: len] x count
``` ```
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or 1200 bytes, flushed every 5ms. Packs multiple session packets into one QUIC datagram. Maximum 10 entries or PMTUD-discovered MTU (starts at 1200, grows to ~1452 on Ethernet), flushed every 5ms.
### QualityReport (4 bytes, optional trailer) ### QualityReport (4 bytes, optional trailer)
@@ -361,6 +395,40 @@ Byte 3: bitrate_cap_kbps (0-255 kbps)
Appended to a media packet when the Q flag is set in the MediaHeader. Appended to a media packet when the Q flag is set in the MediaHeader.
## Path MTU Discovery
Quinn's PLPMTUD is enabled with:
- `initial_mtu`: 1200 bytes (QUIC minimum, always safe)
- `upper_bound`: 1452 bytes (Ethernet minus IP/UDP/QUIC headers)
- `interval`: 300s (re-probe every 5 minutes)
- `black_hole_cooldown`: 30s (faster retry on lossy links)
The discovered MTU is exposed via `QuinnPathSnapshot::current_mtu` and used by:
- `TrunkedForwarder`: refreshes `max_bytes` on every send to fill larger datagrams
- Future video framer: larger MTU = fewer application-layer fragments per frame
## Continuous DRED Tuning
Instead of locking DRED duration to 3 discrete quality tiers, the `DredTuner` (in `wzp-proto::dred_tuner`) maps live path quality to a continuous DRED duration:
| Input | Source | Update Rate |
|-------|--------|-------------|
| Loss % | `QuinnPathSnapshot::loss_pct` (from quinn ACK frames) | Every 25 packets (~500ms) |
| RTT ms | `QuinnPathSnapshot::rtt_ms` (quinn congestion controller) | Every 25 packets |
| Jitter ms | `PathMonitor::jitter_ms` (EWMA of RTT variance) | Every 25 packets |
### Mapping Logic
- **Baseline**: codec-tier default (Studio=100ms, Good=200ms, Degraded=500ms)
- **Ceiling**: codec-tier max (Studio=300ms, Good=500ms, Degraded=1040ms)
- **Continuous**: linear interpolation between baseline and ceiling based on loss (0%->baseline, 40%->ceiling)
- **RTT phantom loss**: high RTT (>200ms) adds phantom loss contribution to keep DRED generous
- **Jitter spike**: >30% EWMA spike pre-emptively boosts to ceiling for ~5s cooldown
### Output
`DredTuning { dred_frames: u8, expected_loss_pct: u8 }` -> fed to `CallEncoder::apply_dred_tuning()` -> `OpusEncoder::set_dred_duration()` + `set_expected_loss()`
## Signal Message Handshake Flow ## Signal Message Handshake Flow
```mermaid ```mermaid
@@ -940,3 +1008,67 @@ The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (
This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream. This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream.
Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale). Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale).
## Network Awareness (Android)
The adaptive quality controller (`AdaptiveQualityController` in `wzp-proto`) supports proactive network-aware adaptation via `signal_network_change(NetworkContext)`. On Android, this is fed by `NetworkMonitor.kt` which wraps `ConnectivityManager.NetworkCallback`.
```
ConnectivityManager
│ onCapabilitiesChanged / onLost
NetworkMonitor.kt ──classify──► type: Int (WiFi=0, LTE=1, 5G=2, 3G=3)
│ onNetworkChanged(type, bw)
CallViewModel ──► WzpEngine.onNetworkChanged()
│ JNI
jni_bridge.rs
EngineState.pending_network_type (AtomicU8, lock-free)
│ polled every ~20ms
recv task: quality_ctrl.signal_network_change(ctx)
├─ WiFi → Cellular: preemptive 1-tier downgrade
├─ Any change: 10s FEC boost (+0.2 ratio)
└─ Cellular: faster downgrade thresholds (2 vs 3)
```
Cellular generation is approximated from `getLinkDownstreamBandwidthKbps()` to avoid requiring `READ_PHONE_STATE` permission.
## Audio Routing (Android)
Both Android app variants support 3-way audio routing: **Earpiece → Speaker → Bluetooth SCO**.
### Audio Mode Lifecycle
`MODE_IN_COMMUNICATION` is set by the Rust call engine (via JNI `AudioManager.setMode()`) right before Oboe streams open — NOT at app launch. Restored to `MODE_NORMAL` when the call ends. This prevents hijacking system audio routing (music, BT A2DP) before a call is active.
### Native Kotlin App
`AudioRouteManager.kt` handles device detection (via `AudioDeviceCallback`), SCO lifecycle, and auto-fallback on BT disconnect. `CallViewModel.cycleAudioRoute()` cycles through available routes.
### Tauri Desktop App
`android_audio.rs` provides JNI bridges to `AudioManager` for speakerphone and Bluetooth SCO control. After each route change, Oboe streams are stopped and restarted via `spawn_blocking`.
```
User tap ──► cycleAudioRoute()
├─ Earpiece: setSpeakerphoneOn(false) + clearCommunicationDevice()
├─ Speaker: setSpeakerphoneOn(true)
└─ BT SCO: setCommunicationDevice(bt_device) [API 31+]
│ fallback: startBluetoothSco() [API < 31]
Oboe stop + start_bt() for BT / start() for others
```
### BT SCO and Oboe
BT SCO only supports 8/16kHz. When `bt_active=1`, Oboe capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system choose the native BT rate. Oboe's `SampleRateConversionQuality::Best` bridges to our 48kHz ring buffers. Playout uses `Usage::Media` in BT mode to avoid conflicts with the communication device routing.
### Hangup Signal Fix
`SignalMessage::Hangup` now carries an optional `call_id` field. The relay uses it to end only the specific call instead of broadcasting to all active calls for the user — preventing a race where a hangup for call 1 kills a newly-placed call 2.

View File

@@ -583,9 +583,79 @@ Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep | | wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics | | wzp-web | 2 | Metrics |
## Audio Routing (Android)
WarzonePhone supports three audio output routes on Android: **Earpiece**, **Speaker**, and **Bluetooth SCO**. The user cycles through available routes with a single button.
### Audio mode lifecycle
`MODE_IN_COMMUNICATION` is set **when the call engine starts** (right before Oboe `audio_start()`), not at app launch. This is critical — setting it early hijacks system audio routing (e.g. music drops from BT A2DP to earpiece). `MODE_NORMAL` is restored when the call engine stops.
```
App launch → MODE_NORMAL (other apps' audio unaffected)
Call start → set_audio_mode_communication() → MODE_IN_COMMUNICATION
Call end → audio_stop() → set_audio_mode_normal() → MODE_NORMAL
```
### Route lifecycle
1. Call starts → Earpiece (default).
2. User taps route button → cycles to next available route.
3. Route change requires Oboe stream restart (~60-400ms) because AAudio silently tears down streams on some OEMs when the routing target changes mid-stream.
4. Bluetooth disconnect mid-call → `AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece or Speaker.
### Bluetooth SCO
SCO (Synchronous Connection Oriented) is the correct Bluetooth profile for VoIP — it provides bidirectional mono audio at 8/16 kHz with ~30ms latency. A2DP (stereo, high-quality) is unidirectional and adds 100-200ms of buffering, making it unsuitable for real-time voice.
On API 31+ (Android 12), we use the modern `setCommunicationDevice(AudioDeviceInfo)` API to route audio to the BT SCO device. The deprecated `startBluetoothSco()` + `setBluetoothScoOn()` path is used as fallback on older APIs. `setBluetoothScoOn()` is silently rejected on Android 12+ for non-system apps.
BT SCO devices only support 8/16kHz sample rates, but our pipeline runs at 48kHz. When BT is active, Oboe opens in **BT mode** (`bt_active=1`): capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system open at the device's native rate. Oboe's `SampleRateConversionQuality::Best` resamples to/from 48kHz for our ring buffers.
### Two app variants
Both the native Kotlin app (`AudioRouteManager.kt`) and the Tauri app (`android_audio.rs` JNI bridge) support BT SCO routing. The native app uses `AudioDeviceCallback` for automatic device detection; the Tauri app uses `getAvailableCommunicationDevices()` (API 31+) or `getDevices()` on demand.
## Network Change Response
The `AdaptiveQualityController` in `wzp-proto` reacts to network transport changes signaled via `signal_network_change(NetworkContext)`:
| Transition | Response |
|-----------|----------|
| WiFi → Cellular | Preemptive 1-tier quality downgrade + 10s FEC boost |
| Cellular → WiFi | FEC boost only (quality recovers via normal adaptive logic) |
| Any change | Reset hysteresis counters to avoid stale state |
On Android, `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback` and classifies the transport type using bandwidth heuristics (no `READ_PHONE_STATE` needed). The classification is delivered to the Rust engine via JNI → `AtomicU8` → recv task polling — the same lock-free cross-task signaling pattern used for adaptive profile switches.
### Cellular generation heuristics
| Downstream bandwidth | Classification |
|---------------------|---------------|
| >= 100 Mbps | 5G NR |
| >= 10 Mbps | LTE |
| < 10 Mbps | 3G or worse |
These thresholds are conservative. Carriers over-report bandwidth, but for VoIP quality decisions the exact generation matters less than the rough category.
## Build Requirements ## Build Requirements
- **Rust** 1.85+ (2024 edition) - **Rust** 1.85+ (2024 edition)
- **Linux**: cmake, pkg-config, libasound2-dev (for audio feature) - **Linux**: cmake, pkg-config, libasound2-dev (for audio feature)
- **macOS**: Xcode command line tools (CoreAudio included) - **macOS**: Xcode command line tools (CoreAudio included)
- **Android**: NDK r27c, cmake 3.28+ (from pip) - **Android**: NDK 26.1 (r26b), cmake 3.25-3.28 (system package)
### Android APK Builds
```bash
# arm64 only (default, 25MB release APK)
./scripts/build-tauri-android.sh --init --release --arch arm64
# armv7 only (smaller devices)
./scripts/build-tauri-android.sh --init --release --arch armv7
# both architectures as separate APKs
./scripts/build-tauri-android.sh --init --release --arch all
```
Release APKs are signed with `android/keystore/wzp-release.jks` via `apksigner`. Per-arch builds produce separate APKs (~25MB each vs ~50MB universal) for easier sharing with testers.

105
docs/PRD-bluetooth-audio.md Normal file
View File

@@ -0,0 +1,105 @@
# PRD: Bluetooth Audio Routing
> Phase: Implemented
> Status: Ready for testing
> Platforms: Android (native Kotlin app + Tauri desktop app)
## Problem
WarzonePhone had `AudioRouteManager.kt` with complete Bluetooth SCO support, but it was disconnected from both UIs. Users with Bluetooth headsets had no way to route call audio to them.
## Solution
Wire Bluetooth SCO routing end-to-end through both app variants, replacing the binary speaker toggle with a 3-way audio route cycle: **Earpiece → Speaker → Bluetooth**.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Native Kotlin App (com.wzp) │
│ │
│ InCallScreen ──► CallViewModel ──► AudioRouteManager
│ (Compose UI) cycleAudioRoute() setSpeaker() │
│ "Ear/Spk/BT" audioRoute Flow setBluetoothSco()
│ isBluetoothAvailable()
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Tauri Desktop App (com.wzp.desktop) │
│ │
│ main.ts ──► Tauri Commands ──► android_audio.rs │
│ cycleAudioRoute() set_bluetooth_sco() JNI calls │
│ "Ear/Spk/BT" is_bluetooth_available() │
│ get_audio_route() │
│ │
│ After each route change: Oboe stop + start │
│ (spawn_blocking to avoid stalling tokio) │
└─────────────────────────────────────────────────────┘
```
## Components Modified
### Native Kotlin App
| File | Change |
|------|--------|
| `CallViewModel.kt` | Added `audioRoute: StateFlow<AudioRoute>`, `cycleAudioRoute()`, wired `onRouteChanged` callback |
| `InCallScreen.kt` | `ControlRow` now takes `audioRoute: AudioRoute` + `onCycleRoute`, displays Ear/Spk/BT with distinct colors |
### Tauri App
| File | Change |
|------|--------|
| `android_audio.rs` | `setCommunicationDevice()` (API 31+) with `startBluetoothSco()` fallback; `set_audio_mode_communication/normal()` for call lifecycle |
| `lib.rs` | `set_bluetooth_sco`, `is_bluetooth_available`, `get_audio_route` Tauri commands; SCO polling + 500ms route delay |
| `wzp_native.rs` | Added `audio_start_bt()` for BT-mode Oboe (skips 48kHz + VoiceCommunication preset) |
| `oboe_bridge.cpp` | `bt_active` flag: capture skips sample rate + input preset; playout uses `Usage::Media`; both use `Shared` mode + `SampleRateConversionQuality::Best` |
| `engine.rs` | `set_audio_mode_communication()` before `audio_start()`; `set_audio_mode_normal()` after `audio_stop()` |
| `MainActivity.kt` | Removed `MODE_IN_COMMUNICATION` from app launch — deferred to call start |
| `main.ts` | Replaced `speakerphoneOn` toggle with `currentAudioRoute` cycling logic |
| `style.css` | Added `.bt-on` CSS class (blue-400 highlight) |
## Audio Route Lifecycle
1. **App launch**`MODE_NORMAL` (other apps' audio unaffected — BT A2DP music keeps playing)
2. **Call starts**`MODE_IN_COMMUNICATION` set via JNI, Oboe opens with earpiece routing
3. **User taps route button** → cycles to next available route
4. **Route changes**`setCommunicationDevice()` (API 31+) + Oboe restart in BT mode or normal mode
5. **BT device disconnects mid-call**`AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece/Speaker
6. **Call ends** → route reset, `MODE_NORMAL` restored
## Route Cycling Logic
```
Available routes = [Earpiece, Speaker] + [Bluetooth] if SCO device connected
Tap cycle:
Earpiece → Speaker → Bluetooth (if available) → Earpiece → ...
If BT not available:
Earpiece → Speaker → Earpiece → ...
```
## Permissions
- `BLUETOOTH_CONNECT` (Android 12+) — already in `AndroidManifest.xml`
- `MODIFY_AUDIO_SETTINGS` — already in manifest
## Known Limitations
- **SCO only** — no A2DP (stereo music profile). SCO is correct for VoIP (bidirectional mono).
- **API 31+ required for modern path** — `setCommunicationDevice()` is the primary BT routing API. Fallback to deprecated `startBluetoothSco()` on API < 31 (untested).
- **BT SCO capture at 8/16kHz** — Oboe resamples to 48kHz via `SampleRateConversionQuality::Best`. Quality is inherently limited by the SCO codec (CVSD at 8kHz or mSBC at 16kHz).
- **No auto-switch on BT connect** — when a BT device connects mid-call, user must tap the route button.
- **500ms route switch delay** — after `setCommunicationDevice()` returns, the audio policy needs time to apply the bt-sco route. We wait 500ms before restarting Oboe.
## Testing
1. Pair a Bluetooth SCO headset with Android device
2. Start call → verify Earpiece is default
3. Tap route → Speaker (audio moves to loudspeaker, button shows "Spk")
4. Tap route → BT (audio moves to headset, button shows "BT", blue highlight)
5. Tap route → Earpiece (audio back to earpiece, button shows "Ear")
6. Disconnect BT mid-call → verify auto-fallback
7. Verify both app variants work identically
8. Verify no audio glitches during route transitions

View File

@@ -196,3 +196,19 @@ Implementation strategy: build for P2P first (simpler, 2 parties), then wrap the
| 4 | Upgrade proposal + negotiation protocol | 2 days | | 4 | Upgrade proposal + negotiation protocol | 2 days |
| 5 | P2P quality adaptation (direct observation) | 1 day | | 5 | P2P quality adaptation (direct observation) | 1 day |
| 6 | Per-participant asymmetric encoding (Option 2) | 1 day | | 6 | Per-participant asymmetric encoding (Option 2) | 1 day |
## Implementation Status (2026-04-12)
Phases 1-2 are now implemented:
### What was built
- **`QualityDirective` signal** (`crates/wzp-proto/src/packet.rs`): New `SignalMessage` variant with `recommended_profile` and optional `reason`
- **`ParticipantQuality`** (`crates/wzp-relay/src/room.rs`): Per-participant quality tracking using `AdaptiveQualityController`, created on join, removed on leave
- **Weakest-link broadcast**: `observe_quality()` method computes room-wide worst tier, broadcasts `QualityDirective` to all participants when tier changes
- **Desktop engine handling** (`desktop/src-tauri/src/engine.rs`): `AdaptiveQualityController` in recv task, `pending_profile` AtomicU8 bridge to send task, auto-mode profile switching
### Phases 3-4 remaining
- Phase 3: Client-side handling of `QualityDirective` (reacting to relay-pushed profile)
- Phase 4: Upgrade proposal/negotiation protocol for quality recovery

View File

@@ -358,3 +358,31 @@ End-to-end testing, in order:
- **OSCE enable**: opusic-c has an `osce` feature flag for Opus Speech Coding Enhancement, a separate libopus 1.5 neural post-processor. Out of scope for this PRD but should be the next audio-quality follow-up. Probably one-line enable once opusic-c is in. - **OSCE enable**: opusic-c has an `osce` feature flag for Opus Speech Coding Enhancement, a separate libopus 1.5 neural post-processor. Out of scope for this PRD but should be the next audio-quality follow-up. Probably one-line enable once opusic-c is in.
- **Upstream PR to opusic-c**: our own `dred_ffi.rs` wrapper should be proven in production first, then the fixes upstreamed to `opusic-c/src/dred.rs` (preserve `dred_end`, fix `dred_offset` double-pass, expose `DredPacket` externally). Follow-up task, not blocking this PRD. - **Upstream PR to opusic-c**: our own `dred_ffi.rs` wrapper should be proven in production first, then the fixes upstreamed to `opusic-c/src/dred.rs` (preserve `dred_end`, fix `dred_offset` double-pass, expose `DredPacket` externally). Follow-up task, not blocking this PRD.
- **`feat/desktop-audio-rewrite` merge**: the vendored `audiopus_sys` patch on that branch becomes obsolete under this PRD. Coordinate removal with whoever owns that branch. - **`feat/desktop-audio-rewrite` merge**: the vendored `audiopus_sys` patch on that branch becomes obsolete under this PRD. Coordinate removal with whoever owns that branch.
## Phase A: Continuous DRED Tuning (Implemented 2026-04-12)
Phase A extends the discrete tier-locked DRED durations from Phases 1-3 with continuous, network-driven tuning.
### What was built
- **`DredTuner`** (`crates/wzp-proto/src/dred_tuner.rs`): Maps `(loss_pct, rtt_ms, jitter_ms)``(dred_frames, expected_loss_pct)` continuously
- **Quinn stats exposure** (`crates/wzp-transport/src/quic.rs`): `QuinnPathSnapshot` provides quinn's internal RTT, loss, congestion events — more accurate than sequence-gap heuristics
- **Jitter variance window** (`crates/wzp-transport/src/path_monitor.rs`): 10-sample sliding window for RTT standard deviation, used for spike detection
- **`AudioEncoder` trait extensions** (`crates/wzp-proto/src/traits.rs`): `set_expected_loss()` and `set_dred_duration()` with default no-op, overridden by `OpusEncoder` and `AdaptiveEncoder`
- **Engine integration** (`desktop/src-tauri/src/engine.rs`): Both Android and desktop send tasks poll every 25 frames and apply tuning
### Opus6k DRED extended
`dred_duration_for(Opus6k)` changed from 50 (500ms) to 104 (1040ms) — the maximum libopus 1.5 supports. The RDO-VAE's quality-vs-offset curve makes this nearly free in bitrate terms while doubling burst resilience on the worst links.
### Jitter spike detection ("Sawtooth" prediction)
When instantaneous jitter exceeds the EWMA × 1.3 (asymmetric: fast-up α=0.3, slow-down α=0.05), the tuner enters spike-boost mode:
- DRED immediately jumps to the codec tier's ceiling
- Cooldown: 10 cycles (~5 seconds at 25 packets/cycle)
- Designed for Starlink satellite handover sawtooth jitter pattern
### Test coverage
- 10 unit tests for tuner math (baseline, scaling, spike, cooldown, codec switch, Codec2 no-op)
- 4 integration tests (encoder adjustment, spike boost, Codec2 no-op, profile switch with encode verification)

View File

@@ -57,3 +57,28 @@ When the path MTU is small, the relay or client should:
- MTU-based codec selection (future, needs adaptive quality) - MTU-based codec selection (future, needs adaptive quality)
## Effort: 1 day ## Effort: 1 day
## Implementation Status (2026-04-12)
Phase 1 is now implemented:
### What was built
- **Transport config** (`crates/wzp-transport/src/config.rs`):
- `MtuDiscoveryConfig` with `upper_bound=1452`, `interval=300s`, `black_hole_cooldown=30s`
- `initial_mtu=1200` (safe QUIC minimum)
- Quinn's PLPMTUD binary-searches from 1200 up to 1452 automatically
- **`QuinnPathSnapshot::current_mtu`** (`crates/wzp-transport/src/quic.rs`):
- Reads `connection.max_datagram_size()` which reflects the PMTUD-discovered value
- Available to all callers via `transport.quinn_path_stats()`
- **Trunk batcher MTU-aware** (`crates/wzp-relay/src/room.rs`):
- `TrunkedForwarder::new()` initializes `max_bytes` from discovered MTU
- `send()` refreshes `max_bytes` on every call (cheap atomic read in quinn)
- Federation trunk frames grow automatically as PMTUD discovers larger paths
### Phases 2-3 status
- Phase 2 (handle MTU failures): Already handled — `send_media()`/`send_trunk()` check `max_datagram_size()` and return `DatagramTooLarge` errors. These are logged and the packet is dropped gracefully.
- Phase 3 (codec-aware MTU): Not yet implemented. Future video frames will need application-layer fragmentation when they exceed the discovered MTU.

View File

@@ -0,0 +1,129 @@
# PRD: Network Awareness
> Phase: Implemented (core path)
> Status: Ready for testing
> Platform: Android native Kotlin app (com.wzp)
## Problem
WarzonePhone's quality controller (`AdaptiveQualityController`) had a `signal_network_change()` API for proactive adaptation to WiFi↔cellular transitions, but nothing called it. Network handoffs during calls were only detected reactively via jitter spikes — by which time the user had already experienced degraded audio.
## Solution
Integrate Android's `ConnectivityManager.NetworkCallback` to detect network transport changes in real-time and feed them to the quality controller. This enables:
1. **Preemptive quality downgrade** when switching from WiFi to cellular
2. **FEC boost** (10-second window with +0.2 ratio) after any network change
3. **Faster downgrade thresholds** on cellular (2 consecutive reports vs 3 on WiFi)
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Android │
│ │
│ ConnectivityManager │
│ │ NetworkCallback │
│ ▼ │
│ NetworkMonitor.kt │
│ │ onNetworkChanged(type, bandwidthKbps) │
│ ▼ │
│ CallViewModel.kt ──► WzpEngine.onNetworkChanged() │
│ │ JNI │
│ ▼ │
│ jni_bridge.rs: nativeOnNetworkChanged(handle, type, bw) │
│ │ │
│ ▼ │
│ engine.rs: state.pending_network_type.store(type) │
│ │ AtomicU8 (lock-free) │
│ ▼ │
│ recv task: quality_ctrl.signal_network_change(ctx) │
│ │ │
│ ├─ Preemptive downgrade (WiFi → cellular) │
│ ├─ FEC boost 10s │
│ └─ Faster cellular thresholds │
└──────────────────────────────────────────────────────────────┘
```
## Network Classification
`NetworkMonitor` classifies the active transport without requiring `READ_PHONE_STATE` permission by using bandwidth heuristics:
| Downstream Bandwidth | Classification | Rust `NetworkContext` |
|----------------------|---------------|----------------------|
| N/A (WiFi transport) | WiFi | `WiFi` |
| >= 100 Mbps | 5G NR | `Cellular5g` |
| >= 10 Mbps | LTE | `CellularLte` |
| < 10 Mbps | 3G or worse | `Cellular3g` |
| Ethernet | WiFi (equivalent) | `WiFi` |
| Network lost | None | `Unknown` |
## Cross-Task Signaling
The network type is communicated from the JNI thread to the recv task via `AtomicU8` — the same pattern used for `pending_profile` (adaptive quality profile switches):
```
JNI thread recv task (tokio)
│ │
│ store(type, Release) │
│──────────────────────────────►│
│ │ swap(0xFF, Acquire)
│ │ if != 0xFF:
│ │ quality_ctrl.signal_network_change(ctx)
│ │
```
Sentinel value `0xFF` means "no change pending". The recv task polls on every received packet (~20-40ms), so latency is bounded by the inter-packet interval.
## Components
### New File
| File | Purpose |
|------|---------|
| `android/.../net/NetworkMonitor.kt` | ConnectivityManager callback, transport classification, deduplication |
### Modified Files
| File | Change |
|------|--------|
| `android/.../engine/WzpEngine.kt` | Added `onNetworkChanged()` method + `nativeOnNetworkChanged` external |
| `android/.../ui/call/CallViewModel.kt` | Instantiates NetworkMonitor, wires callback, register/unregister lifecycle |
| `crates/wzp-android/src/jni_bridge.rs` | Added `Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged` JNI entry |
| `crates/wzp-android/src/engine.rs` | Added `pending_network_type: AtomicU8` to EngineState, recv task polls it |
### Unchanged (already implemented)
| File | API |
|------|-----|
| `crates/wzp-proto/src/quality.rs` | `AdaptiveQualityController::signal_network_change(NetworkContext)` |
| `crates/wzp-transport/src/path_monitor.rs` | `PathMonitor::detect_handoff()` (available for future use) |
## Deferred Work
### Tauri Desktop App (com.wzp.desktop)
The Tauri engine doesn't use `AdaptiveQualityController` — quality is resolved once at call start. Adding network monitoring requires first adding adaptive quality to the Tauri call engine, which is a larger change.
### Mid-Call ICE Re-gathering
When the device's IP address changes, ideally we should:
1. Re-gather local host candidates (`local_host_candidates()`)
2. Re-probe STUN (`probe_reflect_addr()`)
3. Send updated candidates to the peer (`CandidateUpdate` signal message)
4. Attempt new dual-path race for path upgrade
`NetworkMonitor.onIpChanged` fires on `onLinkPropertiesChanged` — the hook is ready, but the signaling and re-racing logic is not yet implemented.
## Testing
1. Build native APK
2. Start a call on WiFi
3. Verify logcat: `quality controller: network context updated` with `ctx=WiFi`
4. Disable WiFi → device falls to cellular
5. Verify logcat: `ctx=CellularLte` (or `Cellular5g`/`Cellular3g`)
6. Verify FEC boost activates (check quality_ctrl logs)
7. Verify preemptive quality downgrade (tier drops one level on WiFi→cellular)
8. Re-enable WiFi → verify transition back
9. Rapid WiFi toggle (5x in 10s) → verify no crashes, deduplication works
10. Airplane mode → verify `onLost` fires with `TYPE_NONE`

View File

@@ -120,7 +120,7 @@
- **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches. - **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches.
- **No adaptive loop integration**: The `PathMonitor` feeds and `AdaptiveQualityController` are implemented but not wired together in the client's main loop. Quality reports are consumed when present in packets, but the client does not currently generate periodic quality reports from transport metrics. - **Adaptive loop integration (resolved)**: AdaptiveQualityController is now fully wired into both desktop and Android send/recv tasks. Relay-coordinated codec switching broadcasts QualityDirective to all participants based on weakest-link policy.
- **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode. - **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode.
@@ -128,18 +128,18 @@
## Test Coverage ## Test Coverage
119 tests across 7 crates (wzp-web has no Rust tests): 307+ tests across 7 crates (wzp-web has no Rust tests):
| Crate | Test Files | Test Count | | Crate | Test Count |
|-------|-----------|------------| |-------|------------|
| wzp-proto | 5 | 27 | | wzp-proto | ~79 |
| wzp-codec | 3 | 24 | | wzp-codec | ~69 |
| wzp-fec | 5 | 21 | | wzp-fec | ~21 |
| wzp-crypto | 5 | 21 | | wzp-crypto | ~21 |
| wzp-transport | 3 | 12 | | wzp-transport | ~11 |
| wzp-relay | 4 | 10 | | wzp-relay | ~50 |
| wzp-client | 3 | 8 | | wzp-client | ~57 |
| **Total** | **28** | **119** | | **Total** | **307+** |
Tests cover: Tests cover:
- Wire format roundtrip (header, quality report, full packet) - Wire format roundtrip (header, quality report, full packet)
@@ -191,3 +191,72 @@ Run with `wzp-bench --all`. Representative results (Apple M-series, single core)
- **Hetzner VPS**: Build script (`scripts/build-linux.sh`) tested for provisioning, building, and downloading Linux binaries - **Hetzner VPS**: Build script (`scripts/build-linux.sh`) tested for provisioning, building, and downloading Linux binaries
- **CI**: Gitea workflow defined for amd64/arm64/armv7 builds - **CI**: Gitea workflow defined for amd64/arm64/armv7 builds
- **Production**: Not yet deployed to production networks - **Production**: Not yet deployed to production networks
## Recent Changes (2026-04-12)
### Bluetooth Audio Routing
- 3-way route cycling: Earpiece → Speaker → Bluetooth SCO
- `setCommunicationDevice()` API 31+ with `startBluetoothSco()` fallback
- BT-mode Oboe: capture skips 48kHz + VoiceCommunication, Oboe resamples 8/16kHz ↔ 48kHz
- `MODE_IN_COMMUNICATION` deferred to call start (was at app launch — hijacked system audio)
### Network Change Detection
- `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback`
- WiFi/cellular classification via bandwidth heuristics (no READ_PHONE_STATE needed)
- Feeds `AdaptiveQualityController::signal_network_change()` via JNI → AtomicU8 → recv task
### Hangup Signal Fix
- `SignalMessage::Hangup` now carries optional `call_id`
- Relay only ends the named call (not all calls for the user)
- Fixes race: hangup for call 1 no longer kills newly-placed call 2
### Per-Architecture APK Builds
- `build-tauri-android.sh --arch arm64|armv7|all`
- Separate per-arch APKs (~25MB each vs ~50MB universal)
- Release APKs signed with `wzp-release.jks` via `apksigner`
### Continuous DRED Tuning (Phase A: opus-DRED-v2)
- `DredTuner` in `wzp-proto::dred_tuner` maps live network metrics to continuous DRED duration
- Polls quinn path stats every 25 frames (~500ms): loss%, RTT, jitter
- Linear interpolation between baseline and ceiling per codec tier (not discrete tier jumps)
- Jitter-spike detection: >30% EWMA spike pre-emptively boosts DRED to ceiling for ~5s
- RTT phantom loss: high RTT (>200ms) adds phantom contribution to keep DRED generous
- `set_expected_loss()` and `set_dred_duration()` added to `AudioEncoder` trait
- Integrated into both Android and desktop send tasks in engine.rs
### Extended DRED Window
- Opus6k DRED duration increased from 500ms to 1040ms (max libopus 1.5 supports)
- RDO-VAE naturally degrades quality at longer offsets — extra window costs ~1-2 kbps
### PMTUD (Path MTU Discovery)
- Quinn's PLPMTUD explicitly configured: initial 1200, upper bound 1452, 300s interval
- `QuinnPathSnapshot` exposes discovered MTU via `current_mtu` field
- `TrunkedForwarder` refreshes `max_bytes` from PMTUD (was hard-coded 1200)
- Federation trunk frames now fill the discovered path MTU automatically
### New Tests
- 4 DRED tuner integration tests in wzp-client (encoder adjustment, spike boost, Codec2 no-op, profile switch)
- 10 unit tests in wzp-proto for DredTuner mapping logic
- Jitter variance window tests in wzp-transport PathMonitor
- Pre-existing test fixes: added missing `build_version` fields to 7 SignalMessage constructors
### Desktop Adaptive Quality (#7, #31)
- `AdaptiveQualityController` wired into both Android and desktop send/recv tasks
- `pending_profile: Arc<AtomicU8>` bridge between recv (writer) and send (reader)
- Auto mode: ingests QualityReports from relay, switches encoder profile when adapter recommends
- `tx_codec` display string updated on profile switch for UI indicator
- `profile_to_index()` / `index_to_profile()` mapping for 6-tier range
### Relay Coordinated Codec Switching (#25, #26)
- `ParticipantQuality` struct in relay RoomManager tracks per-participant quality
- Quality reports from forwarded packets feed per-participant `AdaptiveQualityController`
- `weakest_tier()` computes room-wide worst tier across all participants
- `QualityDirective` SignalMessage variant: relay broadcasts recommended profile to all participants
- Triggered on tier change — instant, no negotiation (weakest-link policy)
### Oboe Stream State Polling (#35)
- C++ polling loop after `requestStart()`: checks `getState()` every 10ms for up to 2s
- Waits for both capture and playout streams to reach `Started` state
- Logs initial state, poll count, and final state for HAL debugging
- Does NOT fail on timeout — Rust-side stall detector remains as safety net
- Targets Nothing Phone A059 intermittent silent calls on cold start

View File

@@ -15,11 +15,14 @@ set -euo pipefail
# - Output: desktop/src-tauri/gen/android/.../*.apk # - Output: desktop/src-tauri/gen/android/.../*.apk
# #
# Usage: # Usage:
# ./scripts/build-tauri-android.sh # full pipeline (debug) # ./scripts/build-tauri-android.sh # full pipeline (debug, arm64 only)
# ./scripts/build-tauri-android.sh --release # release APK # ./scripts/build-tauri-android.sh --release # release APK
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch # ./scripts/build-tauri-android.sh --no-pull # skip git fetch
# ./scripts/build-tauri-android.sh --rust # force-clean rust target # ./scripts/build-tauri-android.sh --rust # force-clean rust target
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init` # ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
# ./scripts/build-tauri-android.sh --arch arm64 # arm64 only (default)
# ./scripts/build-tauri-android.sh --arch armv7 # armv7 only (smaller APK)
# ./scripts/build-tauri-android.sh --arch all # both arm64 + armv7 (separate APKs)
# #
# Environment: # Environment:
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite) # WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
@@ -29,27 +32,47 @@ REMOTE_HOST="SepehrHomeserverdk"
BASE_DIR="/mnt/storage/manBuilder" BASE_DIR="/mnt/storage/manBuilder"
NTFY_TOPIC="https://ntfy.sh/wzp" NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/tauri-android-apk" LOCAL_OUTPUT="target/tauri-android-apk"
BRANCH="${WZP_BRANCH:-feat/desktop-audio-rewrite}" BRANCH="${WZP_BRANCH:-$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "")}"
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR" SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
REBUILD_RUST=0 REBUILD_RUST=0
DO_PULL=1 DO_PULL=1
DO_INIT=0 DO_INIT=0
BUILD_RELEASE=0 BUILD_RELEASE=0
BUILD_ARCH="arm64"
NEXT_IS_ARCH=0
for arg in "$@"; do for arg in "$@"; do
if [ "$NEXT_IS_ARCH" = "1" ]; then
BUILD_ARCH="$arg"
NEXT_IS_ARCH=0
continue
fi
case "$arg" in case "$arg" in
--rust) REBUILD_RUST=1 ;; --rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;; --pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;; --no-pull) DO_PULL=0 ;;
--init) DO_INIT=1 ;; --init) DO_INIT=1 ;;
--release) BUILD_RELEASE=1 ;; --release) BUILD_RELEASE=1 ;;
--arch) NEXT_IS_ARCH=1 ;;
-h|--help) -h|--help)
sed -n '3,30p' "$0" sed -n '3,32p' "$0"
exit 0 exit 0
;; ;;
esac esac
done done
# Validate --arch
case "$BUILD_ARCH" in
arm64|armv7|all) ;;
*) echo "ERROR: --arch must be arm64, armv7, or all (got: $BUILD_ARCH)"; exit 1 ;;
esac
if [ -z "$BRANCH" ]; then
echo "ERROR: could not determine target branch (detached HEAD?). Pass WZP_BRANCH=name."
exit 1
fi
echo "Target branch: $BRANCH arch: $BUILD_ARCH"
log() { echo -e "\033[1;36m>>> $*\033[0m"; } log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; } ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
@@ -69,6 +92,7 @@ DO_PULL="${2:-1}"
REBUILD_RUST="${3:-0}" REBUILD_RUST="${3:-0}"
DO_INIT="${4:-0}" DO_INIT="${4:-0}"
BUILD_RELEASE="${5:-0}" BUILD_RELEASE="${5:-0}"
BUILD_ARCH="${6:-arm64}"
LOG_FILE=/tmp/wzp-tauri-build.log LOG_FILE=/tmp/wzp-tauri-build.log
GIT_HASH="unknown" # populated after fetch GIT_HASH="unknown" # populated after fetch
@@ -149,10 +173,25 @@ PROFILE_FLAG="--debug"
mkdir -p "$BASE_DIR/data/cache/android-home" mkdir -p "$BASE_DIR/data/cache/android-home"
chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true
# ─── Determine target architectures ──────────────────────────────────────
# Maps BUILD_ARCH to cargo-ndk ABI names and cargo-tauri target names.
# BUILD_ARCH=arm64 → one APK; BUILD_ARCH=armv7 → one APK; BUILD_ARCH=all → two APKs.
case "$BUILD_ARCH" in
arm64) ARCH_LIST="arm64" ;;
armv7) ARCH_LIST="armv7" ;;
all) ARCH_LIST="arm64 armv7" ;;
esac
# Mapping functions (used inside docker via env vars)
# cargo-ndk ABI: arm64-v8a | armeabi-v7a
# cargo-tauri: aarch64 | armv7
# NDK sysroot: aarch64-linux-android | arm-linux-androideabi
docker run --rm \ docker run --rm \
--user 1000:1000 \ --user 1000:1000 \
-e DO_INIT="$DO_INIT" \ -e DO_INIT="$DO_INIT" \
-e PROFILE_FLAG="$PROFILE_FLAG" \ -e PROFILE_FLAG="$PROFILE_FLAG" \
-e BUILD_ARCH="$BUILD_ARCH" \
-v "$BASE_DIR/data/source:/build/source" \ -v "$BASE_DIR/data/source:/build/source" \
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \ -v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \ -v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
@@ -179,60 +218,179 @@ if [ "${DO_INIT}" = "1" ] || [ ! -x gen/android/gradlew ]; then
cargo tauri android init 2>&1 | tail -20 cargo tauri android init 2>&1 | tail -20
fi fi
# ─── Arch list from BUILD_ARCH env var ───────────────────────────────────
case "${BUILD_ARCH}" in
arm64) ARCHS="arm64" ;;
armv7) ARCHS="armv7" ;;
all) ARCHS="arm64 armv7" ;;
*) ARCHS="arm64" ;;
esac
ndk_abi() {
case "$1" in
arm64) echo "arm64-v8a" ;;
armv7) echo "armeabi-v7a" ;;
esac
}
tauri_target() {
case "$1" in
arm64) echo "aarch64" ;;
armv7) echo "armv7" ;;
esac
}
ndk_sysroot_dir() {
case "$1" in
arm64) echo "aarch64-linux-android" ;;
armv7) echo "arm-linux-androideabi" ;;
esac
}
# ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ── # ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ──
# Produces libwzp_native.so which wzp-desktop dlopens at runtime via # Produces libwzp_native.so which wzp-desktop dlopens at runtime via
# libloading. Split exists because cargo-tauri`s linker wiring pulls # libloading. Split exists because cargo-tauri linker wiring pulls
# bionic private symbols into any cdylib with cc::Build C++, causing # bionic private symbols into any cdylib with cc::Build C++, causing
# __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the # __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the
# legacy wzp-android crate which works. # legacy wzp-android crate which works.
echo ">>> cargo ndk build -p wzp-native --release" JNILIBS_BASE=gen/android/app/src/main/jniLibs
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
mkdir -p "$JNI_ABI_DIR"
(
cd /build/source
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
build --release -p wzp-native 2>&1 | tail -10
)
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
else
echo ">>> WARNING: libwzp_native.so not produced"
fi
# ─── libc++_shared.so — required by wzp-native at runtime ────────────── for ARCH in $ARCHS; do
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds ABI=$(ndk_abi "$ARCH")
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does SYSROOT_DIR=$(ndk_sysroot_dir "$ARCH")
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it JNI_ABI_DIR="$JNILIBS_BASE/$ABI"
# explicitly, the APK ships without it and the Android dynamic linker mkdir -p "$JNI_ABI_DIR"
# fails the dlopen with "library libc++_shared.so not found" at runtime.
# Same fix that build-and-notify.sh has had for the legacy wzp-android echo ">>> cargo ndk build -p wzp-native --release -t $ABI"
# path (lines 126-134 there) — ported here for the Tauri pipeline. (
# NOTE: no apostrophes in this comment block. The enclosing docker cd /build/source
# bash -c uses single quotes and a stray apostrophe closes the string cargo ndk -t "$ABI" -o "desktop/src-tauri/$JNILIBS_BASE" \
# prematurely, breaking variable scope for everything below. build --release -p wzp-native 2>&1 | tail -10
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then )
echo ">>> libc++_shared.so missing, copying from NDK..." if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1) ls -lh "$JNI_ABI_DIR/libwzp_native.so"
else
echo ">>> WARNING: libwzp_native.so not produced for $ABI"
fi
# ─── libc++_shared.so — required by wzp-native at runtime ────────────
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it
# explicitly, the APK ships without it and the Android dynamic linker
# fails the dlopen with "library libc++_shared.so not found" at runtime.
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then
echo ">>> libc++_shared.so missing for $ABI, copying from NDK..."
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/${SYSROOT_DIR}/*" | head -1)
if [ -n "$NDK_LIBCXX" ]; then if [ -n "$NDK_LIBCXX" ]; then
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/" cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
ls -lh "$JNI_ABI_DIR/libc++_shared.so" ls -lh "$JNI_ABI_DIR/libc++_shared.so"
else else
echo ">>> ERROR: libc++_shared.so not found in NDK — APK will crash at dlopen time" echo ">>> ERROR: libc++_shared.so not found in NDK for $ABI — APK will crash at dlopen time"
exit 1 exit 1
fi fi
fi fi
done
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk" # ─── Build per-arch APKs ────────────────────────────────────────────────
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk # When building for a single arch, only that arch jniLibs dir exists so
# the APK is naturally single-arch and smaller.
# When building --arch all, we produce SEPARATE per-arch APKs by:
# 1. Building each target individually with cargo tauri android build
# 2. Temporarily hiding the other arch jniLibs so the APK only contains one
# This keeps APKs small (~15-20MB instead of ~30-40MB for universal).
APK_OUTPUT_DIR="/build/source/target/apk-output"
mkdir -p "$APK_OUTPUT_DIR"
for ARCH in $ARCHS; do
TARGET=$(tauri_target "$ARCH")
ABI=$(ndk_abi "$ARCH")
# If building all, temporarily hide other arches to get single-arch APK
if [ "${BUILD_ARCH}" = "all" ]; then
for OTHER_ARCH in $ARCHS; do
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/$OTHER_ABI" ]; then
mv "$JNILIBS_BASE/$OTHER_ABI" "$JNILIBS_BASE/_hide_$OTHER_ABI"
fi
done
fi
echo ""
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target $TARGET --apk"
cargo tauri android build ${PROFILE_FLAG} --target "$TARGET" --apk
# Copy produced APK with arch suffix
BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1)
if [ -z "$BUILT_APK" ]; then
BUILT_APK=$(find gen/android -name "*.apk" -type f 2>/dev/null | sort -t/ -k1 | tail -1)
fi
if [ -n "$BUILT_APK" ]; then
OUT_APK="$APK_OUTPUT_DIR/wzp-tauri-${ARCH}.apk"
cp "$BUILT_APK" "$OUT_APK"
# ─── Sign release APKs with the project keystore ─────────────
# Release builds are unsigned by default. Sign with the release
# keystore (checked into the repo at android/keystore/) so the
# APK can be installed on real devices.
# Pick keystore + credentials (release preferred, debug fallback)
KS_RELEASE="/build/source/android/keystore/wzp-release.jks"
KS_DEBUG="/build/source/android/keystore/wzp-debug.jks"
if [ -f "$KS_RELEASE" ]; then
KEYSTORE="$KS_RELEASE"; KS_PASS="wzphone2024"; KS_ALIAS="wzp-release"
elif [ -f "$KS_DEBUG" ]; then
KEYSTORE="$KS_DEBUG"; KS_PASS="android"; KS_ALIAS="wzp-debug"
else
KEYSTORE=""
fi
if [ -n "$KEYSTORE" ]; then
ZIPALIGN=$(find "$ANDROID_HOME" -name zipalign -type f 2>/dev/null | head -1)
APKSIGNER=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | head -1)
if [ -n "$ZIPALIGN" ] && [ -n "$APKSIGNER" ]; then
echo ">>> Signing $ARCH APK with $(basename "$KEYSTORE")..."
ALIGNED="$APK_OUTPUT_DIR/wzp-tauri-${ARCH}-aligned.apk"
"$ZIPALIGN" -f 4 "$OUT_APK" "$ALIGNED"
"$APKSIGNER" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KS_PASS" \
--ks-key-alias "$KS_ALIAS" \
--key-pass "pass:$KS_PASS" \
"$ALIGNED"
mv "$ALIGNED" "$OUT_APK"
echo ">>> Signed: $(ls -lh "$OUT_APK" | awk "{print \$5}")"
else
echo ">>> WARNING: zipalign/apksigner not found — APK is unsigned"
fi
else
echo ">>> WARNING: no keystore found — APK is unsigned"
fi
echo ">>> $ARCH APK: $(ls -lh "$OUT_APK" | awk "{print \$5}")"
fi
# Restore hidden arches
if [ "${BUILD_ARCH}" = "all" ]; then
for OTHER_ARCH in $ARCHS; do
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/_hide_$OTHER_ABI" ]; then
mv "$JNILIBS_BASE/_hide_$OTHER_ABI" "$JNILIBS_BASE/$OTHER_ABI"
fi
done
fi
done
echo "" echo ""
echo ">>> Build artifacts:" echo ">>> Build artifacts:"
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null ls -lh "$APK_OUTPUT_DIR/"*.apk 2>/dev/null || echo " (none)"
' '
# Locate the produced APK # ─── Collect and upload APKs ────────────────────────────────────────────
APK=$(find "$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1) # target/ is mounted from cache, not source
if [ -z "$APK" ] || [ ! -f "$APK" ]; then APK_OUTPUT="$BASE_DIR/data/cache/target/apk-output"
APK_LIST=$(find "$APK_OUTPUT" -name "wzp-tauri-*.apk" -type f 2>/dev/null | sort)
if [ -z "$APK_LIST" ]; then
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "") LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$LOG_URL" ]; then if [ -n "$LOG_URL" ]; then
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced notify "WZP Tauri Android build [$GIT_HASH]: no APK produced
@@ -242,35 +400,56 @@ log: $LOG_URL"
fi fi
exit 1 exit 1
fi fi
APK_SIZE=$(du -h "$APK" | cut -f1)
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "") # Upload each APK and collect URLs
if [ -n "$RUSTY_URL" ]; then NOTIFY_MSG="WZP Tauri Android build OK [$GIT_HASH] ($BUILD_ARCH)"
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) APK_PATHS=""
$RUSTY_URL" for APK in $APK_LIST; do
else APK_NAME=$(basename "$APK")
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) — rustypaste upload skipped" APK_SIZE=$(du -h "$APK" | cut -f1)
fi RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
if [ -n "$RUSTY_URL" ]; then
NOTIFY_MSG="$NOTIFY_MSG
$APK_NAME ($APK_SIZE): $RUSTY_URL"
else
NOTIFY_MSG="$NOTIFY_MSG
$APK_NAME ($APK_SIZE) — upload skipped"
fi
APK_PATHS="$APK_PATHS $APK"
done
notify "$NOTIFY_MSG"
# Print path so the local script can grab it # Print paths so the local script can grab them
echo "APK_REMOTE_PATH=$APK" for APK in $APK_LIST; do
echo "APK_REMOTE_PATH=$APK"
done
REMOTE_SCRIPT REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh" ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh"
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, release=$BUILD_RELEASE)" notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, arch=$BUILD_ARCH, release=$BUILD_RELEASE)"
log "Triggering remote build (branch=$BRANCH)..." log "Triggering remote build (branch=$BRANCH, arch=$BUILD_ARCH)..."
# Run; capture full output, last line is APK_REMOTE_PATH=... # Run; last lines are APK_REMOTE_PATH=... (one per arch)
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE'" || true) REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE' '$BUILD_ARCH'" || true)
echo "$REMOTE_OUTPUT" | tail -60 echo "$REMOTE_OUTPUT" | tail -60
APK_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | tail -1 | cut -d= -f2-) # Download all produced APKs
if [ -n "$APK_REMOTE" ]; then APK_REMOTES=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | cut -d= -f2-)
log "Downloading APK to $LOCAL_OUTPUT/wzp-tauri.apk..." if [ -z "$APK_REMOTES" ]; then
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/wzp-tauri.apk"
echo " $LOCAL_OUTPUT/wzp-tauri.apk ($(du -h "$LOCAL_OUTPUT/wzp-tauri.apk" | cut -f1))"
else
log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log" log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log"
exit 1 exit 1
fi fi
DOWNLOADED=0
echo "$APK_REMOTES" | while IFS= read -r APK_REMOTE; do
[ -z "$APK_REMOTE" ] && continue
APK_NAME=$(basename "$APK_REMOTE")
log "Downloading $APK_NAME..."
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/$APK_NAME"
echo " $LOCAL_OUTPUT/$APK_NAME ($(du -h "$LOCAL_OUTPUT/$APK_NAME" | cut -f1))"
DOWNLOADED=$((DOWNLOADED + 1))
done
log "Done! APKs in $LOCAL_OUTPUT/"
ls -lh "$LOCAL_OUTPUT"/wzp-tauri-*.apk 2>/dev/null || true

363
scripts/build.sh Executable file
View File

@@ -0,0 +1,363 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# WZ Phone — unified build script
#
# Builds Tauri Android APK and/or Linux x86_64 binaries via Docker on a
# remote build server. Uploads artifacts, notifies via ntfy.sh/wzp.
#
# Two servers:
# PRIMARY (default) SepehrHomeserverdk paste.dk.manko.yoga origin (gitea)
# ALT (--alt) manwe@172.16.81.175 paste.tbs.amn.gg fj (forgejo)
#
# Usage:
# ./scripts/build.sh Android APK (current branch, primary)
# ./scripts/build.sh --alt Android APK on alt server
# ./scripts/build.sh --linux Linux binaries only
# ./scripts/build.sh --all Android + Linux
# ./scripts/build.sh --branch NAME Override branch
# ./scripts/build.sh --rust Force Rust rebuild
# ./scripts/build.sh --no-pull Skip git pull
# ./scripts/build.sh --init First-time setup (clone + Docker image)
# ./scripts/build.sh --install Download APK + adb install locally
# ./scripts/build.sh --release Release APK (not debug)
# ./scripts/build.sh --android64 Release arm64 APK (shorthand for --android --release)
# =============================================================================
NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/tauri-android-apk"
SSH_BASE_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
# ── Server profiles ─────────────────────────────────────────────────────────
USE_ALT=0
REBUILD_RUST=0
DO_PULL=1
DO_INSTALL=0
DO_INIT=0
BUILD_ANDROID=1
BUILD_LINUX=0
BUILD_RELEASE=0
BRANCH=$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "")
while [ $# -gt 0 ]; do
case "$1" in
--alt) USE_ALT=1 ;;
--rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;;
--install) DO_INSTALL=1 ;;
--init) DO_INIT=1 ;;
--android) BUILD_ANDROID=1; BUILD_LINUX=0 ;;
--android64) BUILD_ANDROID=1; BUILD_LINUX=0; BUILD_RELEASE=1; BRANCH="main" ;;
--linux) BUILD_ANDROID=0; BUILD_LINUX=1 ;;
--all) BUILD_ANDROID=1; BUILD_LINUX=1 ;;
--release) BUILD_RELEASE=1 ;;
--branch) shift; BRANCH="$1" ;;
--branch=*) BRANCH="${1#--branch=}" ;;
-h|--help) sed -n '3,22p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
shift
done
if [ -z "$BRANCH" ]; then
echo "ERROR: could not determine target branch (detached HEAD?). Pass --branch NAME."
exit 1
fi
# ── Select server profile ───────────────────────────────────────────────────
if [ "$USE_ALT" = "1" ]; then
SERVER_TAG="ALT"
REMOTE_HOST="manwe@172.16.81.175"
BASE_DIR="/home/manwe/wzp-builder"
SSH_OPTS="$SSH_BASE_OPTS"
GIT_ORIGIN="ssh://git@git.tbs.amn.gg:2222/manawenuz/wzp.git"
# Alt server uploads directly (no .env file)
UPLOAD_MODE="direct"
PASTE_URL="https://paste.tbs.manko.yoga"
PASTE_AUTH="X2j6szIQaoJGaxZjLkpl3A8IX9/mTkDgdhhgyYFcpaU="
else
SERVER_TAG="PRI"
REMOTE_HOST="SepehrHomeserverdk"
BASE_DIR="/mnt/storage/manBuilder"
SSH_OPTS="-A $SSH_BASE_OPTS"
GIT_ORIGIN="" # uses existing origin on the remote
# Primary server uses .env file for rustypaste credentials
UPLOAD_MODE="envfile"
PASTE_URL=""
PASTE_AUTH=""
fi
TARGETS=""
[ "$BUILD_ANDROID" = 1 ] && TARGETS="Android"
[ "$BUILD_LINUX" = 1 ] && TARGETS="${TARGETS:+$TARGETS + }Linux"
echo "[$SERVER_TAG] branch: $BRANCH | targets: $TARGETS"
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh $SSH_OPTS "$REMOTE_HOST" "$@"; }
# ── First-time setup (--init) ───────────────────────────────────────────────
if [ "$DO_INIT" = "1" ]; then
log "[$SERVER_TAG] First-time setup..."
ssh_cmd "mkdir -p $BASE_DIR/data/{source,cache/target,cache/cargo-registry,cache/cargo-git,cache/gradle,cache/android-home,cache-linux/target,cache-linux/cargo-registry,cache-linux/cargo-git}"
if [ -n "$GIT_ORIGIN" ]; then
log "Cloning from $GIT_ORIGIN..."
ssh_cmd "if [ ! -d $BASE_DIR/data/source/.git ]; then git clone $GIT_ORIGIN $BASE_DIR/data/source; else echo 'Repo already cloned'; fi"
fi
log "Uploading Dockerfile..."
cat scripts/Dockerfile.android-builder | ssh_cmd "cat > /tmp/Dockerfile.android-builder"
log "Building Docker image (10-20 min on first run)..."
ssh_cmd "cd /tmp && docker build -t wzp-android-builder -f Dockerfile.android-builder . 2>&1 | tail -20"
log "[$SERVER_TAG] Init done! Run without --init to build."
exit 0
fi
# ── Upload remote build script ──────────────────────────────────────────────
log "[$SERVER_TAG] Uploading build script..."
ssh_cmd "cat > /tmp/wzp-build.sh" <<REMOTE_SCRIPT
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="$BASE_DIR"
NTFY_TOPIC="$NTFY_TOPIC"
REBUILD_RUST="$REBUILD_RUST"
DO_PULL="$DO_PULL"
BRANCH="$BRANCH"
BUILD_ANDROID="$BUILD_ANDROID"
BUILD_LINUX="$BUILD_LINUX"
BUILD_RELEASE="$BUILD_RELEASE"
SERVER_TAG="$SERVER_TAG"
UPLOAD_MODE="$UPLOAD_MODE"
PASTE_URL="$PASTE_URL"
PASTE_AUTH="$PASTE_AUTH"
notify() { curl -s -d "\$1" "\$NTFY_TOPIC" > /dev/null 2>&1 || true; }
# Upload a file; print URL on stdout.
upload_file() {
local file="\$1"
if [ "\$UPLOAD_MODE" = "direct" ]; then
curl -s -F "file=@\$file" -H "Authorization: \$PASTE_AUTH" "\$PASTE_URL" || echo ""
else
local env_file="\$BASE_DIR/.env"
[ ! -f "\$env_file" ] && { echo ""; return; }
source "\$env_file"
if [ -n "\${rusty_address:-}" ] && [ -n "\${rusty_auth_token:-}" ]; then
curl -s -F "file=@\$file" -H "Authorization: \$rusty_auth_token" "\$rusty_address" || echo ""
else
echo ""
fi
fi
}
trap 'notify "WZP [\$SERVER_TAG] build FAILED [\$BRANCH]! Check /tmp/wzp-build.log"' ERR
# ── Pull source ─────────────────────────────────────────────────────────
if [ "\$DO_PULL" = "1" ]; then
echo ">>> Pulling branch '\$BRANCH' from origin..."
cd "\$BASE_DIR/data/source"
git reset --hard HEAD 2>/dev/null || true
# NOTE: do NOT git clean -fd — it wipes tauri-generated scaffold
git fetch origin "\$BRANCH" 2>&1 | tail -3
git checkout "\$BRANCH" 2>/dev/null || git checkout -b "\$BRANCH" "origin/\$BRANCH"
git reset --hard "origin/\$BRANCH"
git submodule update --init || true
echo ">>> HEAD: \$(git rev-parse --short HEAD) — \$(git log -1 --format=%s)"
fi
GIT_HASH=\$(cd "\$BASE_DIR/data/source" && git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_MSG=\$(cd "\$BASE_DIR/data/source" && git log -1 --pretty=%s 2>/dev/null | head -c 60 || echo "?")
# ── Clean Rust if requested ─────────────────────────────────────────────
if [ "\$REBUILD_RUST" = "1" ]; then
echo ">>> Cleaning Rust targets..."
rm -rf "\$BASE_DIR/data/cache/target/aarch64-linux-android" \
"\$BASE_DIR/data/cache/target/armv7-linux-androideabi" \
"\$BASE_DIR/data/cache/target/i686-linux-android" \
"\$BASE_DIR/data/cache/target/x86_64-linux-android"
rm -rf "\$BASE_DIR/data/cache-linux/target/release"
fi
# ── Fix perms ───────────────────────────────────────────────────────────
find "\$BASE_DIR/data/source" "\$BASE_DIR/data/cache" \
! -user 1000 -o ! -group 1000 2>/dev/null | \
xargs -r chown 1000:1000 2>/dev/null || true
if [ -d "\$BASE_DIR/data/cache-linux" ]; then
find "\$BASE_DIR/data/cache-linux" \
! -user 1000 -o ! -group 1000 2>/dev/null | \
xargs -r chown 1000:1000 2>/dev/null || true
fi
# ── Tauri Android APK ──────────────────────────────────────────────────
if [ "\$BUILD_ANDROID" = "1" ]; then
notify "WZP [\$SERVER_TAG] Tauri Android build STARTED [\$BRANCH @ \$GIT_HASH] — \$GIT_MSG"
echo ">>> Building Tauri Android APK..."
PROFILE_FLAG="--debug"
[ "\$BUILD_RELEASE" = "1" ] && PROFILE_FLAG=""
mkdir -p "\$BASE_DIR/data/cache/android-home"
chown 1000:1000 "\$BASE_DIR/data/cache/android-home" 2>/dev/null || true
docker run --rm --user 1000:1000 \
-e PROFILE_FLAG="\$PROFILE_FLAG" \
-v "\$BASE_DIR/data/source:/build/source" \
-v "\$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "\$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
-v "\$BASE_DIR/data/cache/target:/build/source/target" \
-v "\$BASE_DIR/data/cache/gradle:/home/builder/.gradle" \
-v "\$BASE_DIR/data/cache/android-home:/home/builder/.android" \
wzp-android-builder bash -c '
set -euo pipefail
cd /build/source/desktop
echo ">>> npm install"
npm install --silent 2>&1 | tail -5 || npm install 2>&1 | tail -20
cd src-tauri
if [ ! -x gen/android/gradlew ]; then
echo ">>> cargo tauri android init"
cargo tauri android init 2>&1 | tail -20
fi
echo ">>> cargo ndk build -p wzp-native --release"
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
mkdir -p "\$JNI_ABI_DIR"
(
cd /build/source
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
build --release -p wzp-native 2>&1 | tail -10
)
[ -f "\$JNI_ABI_DIR/libwzp_native.so" ] && ls -lh "\$JNI_ABI_DIR/libwzp_native.so"
if [ ! -f "\$JNI_ABI_DIR/libc++_shared.so" ]; then
echo ">>> libc++_shared.so missing, copying from NDK..."
NDK_LIBCXX=\$(find "\$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1)
if [ -n "\$NDK_LIBCXX" ]; then
cp "\$NDK_LIBCXX" "\$JNI_ABI_DIR/"
else
echo "ERROR: libc++_shared.so not found in NDK"; exit 1
fi
fi
echo ">>> cargo tauri android build \${PROFILE_FLAG} --target aarch64 --apk"
cargo tauri android build \${PROFILE_FLAG} --target aarch64 --apk
echo ">>> Build artifacts:"
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null
echo "APK_BUILT"
'
echo ">>> Uploading APK..."
APK=\$(find "\$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1)
if [ -n "\$APK" ]; then
APK_SIZE=\$(du -h "\$APK" | cut -f1)
URL=\$(upload_file "\$APK")
echo "APK_URL=\$URL"
notify "WZP [\$SERVER_TAG] Tauri Android OK [\$BRANCH @ \$GIT_HASH] (\$APK_SIZE)
\$URL"
echo ">>> APK: \$URL (\$APK_SIZE)"
else
notify "WZP [\$SERVER_TAG] Tauri Android FAILED [\$BRANCH @ \$GIT_HASH] - no APK"
echo "ERROR: No APK found"; exit 1
fi
fi
# ── Linux x86_64 binaries ───────────────────────────────────────────────
if [ "\$BUILD_LINUX" = "1" ]; then
mkdir -p "\$BASE_DIR/data/cache-linux/target" \
"\$BASE_DIR/data/cache-linux/cargo-registry" \
"\$BASE_DIR/data/cache-linux/cargo-git"
notify "WZP [\$SERVER_TAG] Linux x86_64 build STARTED [\$BRANCH @ \$GIT_HASH]..."
echo ">>> Building Linux binaries..."
docker run --rm --user 1000:1000 \
-v "\$BASE_DIR/data/source:/build/source" \
-v "\$BASE_DIR/data/cache-linux/cargo-registry:/home/builder/.cargo/registry" \
-v "\$BASE_DIR/data/cache-linux/cargo-git:/home/builder/.cargo/git" \
-v "\$BASE_DIR/data/cache-linux/target:/build/source/target" \
wzp-android-builder bash -c '
set -euo pipefail
cd /build/source
echo ">>> Building relay + client + web + bench..."
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5
echo ">>> Building audio client..."
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3
cp target/release/wzp-client target/release/wzp-client-audio
cargo build --release --bin wzp-client 2>&1 | tail -3
echo ">>> Binaries:"
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench
echo ">>> Packaging..."
tar czf /tmp/wzp-linux-x86_64.tar.gz \
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench
echo "BINARIES_BUILT"
'
echo ">>> Uploading Linux binaries..."
docker run --rm \
-v "\$BASE_DIR/data/cache-linux/target:/build/target" \
wzp-android-builder bash -c \
"cp /build/target/release/wzp-relay /build/target/release/wzp-client /build/target/release/wzp-client-audio /build/target/release/wzp-web /build/target/release/wzp-bench /tmp/ && tar czf /tmp/wzp-linux-x86_64.tar.gz -C /tmp wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench && cat /tmp/wzp-linux-x86_64.tar.gz" \
> /tmp/wzp-linux-x86_64.tar.gz
URL=\$(upload_file /tmp/wzp-linux-x86_64.tar.gz)
if [ -n "\$URL" ]; then
echo "LINUX_URL=\$URL"
notify "WZP [\$SERVER_TAG] Linux x86_64 OK [\$BRANCH @ \$GIT_HASH]
\$URL"
echo ">>> Linux binaries: \$URL"
else
notify "WZP [\$SERVER_TAG] Linux build FAILED - upload error"
echo "ERROR: Linux upload failed"; exit 1
fi
fi
echo ">>> All builds complete!"
REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-build.sh"
# Run in tmux
log "[$SERVER_TAG] Starting build in tmux (branch: $BRANCH)..."
ssh_cmd "tmux kill-session -t wzp-build 2>/dev/null; true"
ssh_cmd "tmux new-session -d -s wzp-build '/tmp/wzp-build.sh 2>&1 | tee /tmp/wzp-build.log'"
log "[$SERVER_TAG] Build running! Notification on ntfy.sh/wzp when done."
echo ""
echo " Monitor: ssh $REMOTE_HOST 'tail -f /tmp/wzp-build.log'"
echo " Status: ssh $REMOTE_HOST 'tail -5 /tmp/wzp-build.log'"
echo ""
# Optionally wait and install locally
if [ "$DO_INSTALL" = "1" ]; then
log "Waiting for build..."
while true; do
sleep 15
if ssh_cmd "grep -q 'APK_URL\|LINUX_URL\|ERROR\|All builds complete' /tmp/wzp-build.log 2>/dev/null"; then
break
fi
done
URL=$(ssh_cmd "grep APK_URL /tmp/wzp-build.log | tail -1 | cut -d= -f2")
if [ -n "$URL" ]; then
log "Downloading APK..."
mkdir -p "$LOCAL_OUTPUT"
curl -s -o "$LOCAL_OUTPUT/wzp-tauri.apk" "$URL"
log "Installing..."
adb uninstall com.wzp.phone 2>/dev/null || true
adb install "$LOCAL_OUTPUT/wzp-tauri.apk"
log "Done!"
else
log "No APK URL found in log"
fi
fi