Files
wz-phone/crates/wzp-client/src/featherchat.rs
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

190 lines
7.7 KiB
Rust

//! featherChat signaling bridge.
//!
//! Sends WZP call signaling (Offer/Answer/Hangup) through featherChat's
//! E2E encrypted WebSocket channel as `WireMessage::CallSignal`.
//!
//! Flow:
//! 1. Client connects to featherChat WS with bearer token
//! 2. Sends CallOffer as CallSignal(signal_type=Offer, payload=JSON SignalMessage)
//! 3. Receives CallAnswer as CallSignal(signal_type=Answer, payload=JSON SignalMessage)
//! 4. Extracts relay address from the answer
//! 5. Connects QUIC to relay for media
use serde::{Deserialize, Serialize};
use wzp_proto::packet::SignalMessage;
/// featherChat CallSignal types (mirrors warzone-protocol::message::CallSignalType).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum CallSignalType {
Offer,
Answer,
IceCandidate,
Hangup,
Reject,
Ringing,
Busy,
Hold,
Unhold,
Mute,
Unmute,
Transfer,
}
/// A CallSignal as sent through featherChat's WireMessage.
/// This is what goes in the `payload` field of `WireMessage::CallSignal`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WzpCallPayload {
/// The WZP SignalMessage (CallOffer, CallAnswer, etc.) serialized as JSON.
pub signal: SignalMessage,
/// The relay address to connect to for media (host:port).
pub relay_addr: Option<String>,
/// Room name on the relay.
pub room: Option<String>,
}
/// Parameters for initiating a call through featherChat.
pub struct CallInitParams {
/// featherChat server URL (e.g., "wss://chat.example.com/ws").
pub server_url: String,
/// Bearer token for authentication.
pub token: String,
/// Target peer fingerprint (who to call).
pub target_fingerprint: String,
/// Relay address for media transport.
pub relay_addr: String,
/// Room name on the relay.
pub room: String,
/// Our identity seed for crypto.
pub seed: [u8; 32],
}
/// Result of a successful call setup.
pub struct CallSetupResult {
/// Relay address to connect to.
pub relay_addr: String,
/// Room name.
pub room: String,
/// The peer's CallAnswer signal (contains ephemeral key, etc.)
pub answer: SignalMessage,
}
/// Serialize a WZP SignalMessage into a featherChat CallSignal payload string.
pub fn encode_call_payload(
signal: &SignalMessage,
relay_addr: Option<&str>,
room: Option<&str>,
) -> String {
let payload = WzpCallPayload {
signal: signal.clone(),
relay_addr: relay_addr.map(|s| s.to_string()),
room: room.map(|s| s.to_string()),
};
serde_json::to_string(&payload).unwrap_or_default()
}
/// Deserialize a featherChat CallSignal payload back to WZP types.
pub fn decode_call_payload(payload: &str) -> Result<WzpCallPayload, String> {
serde_json::from_str(payload).map_err(|e| format!("invalid call payload: {e}"))
}
/// Map WZP SignalMessage type to featherChat CallSignalType.
pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
match signal {
SignalMessage::CallOffer { .. } => CallSignalType::Offer,
SignalMessage::CallAnswer { .. } => CallSignalType::Answer,
SignalMessage::IceCandidate { .. } => CallSignalType::IceCandidate,
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
SignalMessage::Hold => CallSignalType::Hold,
SignalMessage::Unhold => CallSignalType::Unhold,
SignalMessage::Mute => CallSignalType::Mute,
SignalMessage::Unmute => CallSignalType::Unmute,
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::FederationHello { .. }
| SignalMessage::GlobalRoomActive { .. }
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
SignalMessage::RegisterPresence { .. }
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
// NAT reflection is a client↔relay control exchange that
// never crosses the featherChat bridge — if it ever reaches
// this mapper something is wrong, but we still have to give
// an answer. "Offer" is the generic catch-all.
SignalMessage::Reflect
| SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
// Phase 4 cross-relay forwarding envelope — strictly a
// relay-to-relay message, never rides the featherChat
// bridge. Catch-all mapping for completeness.
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
}
}
#[cfg(test)]
mod tests {
use super::*;
use wzp_proto::QualityProfile;
#[test]
fn payload_roundtrip() {
let signal = SignalMessage::CallOffer {
identity_pub: [1u8; 32],
ephemeral_pub: [2u8; 32],
signature: vec![3u8; 64],
supported_profiles: vec![QualityProfile::GOOD],
alias: None,
};
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
let decoded = decode_call_payload(&encoded).unwrap();
assert_eq!(decoded.relay_addr.unwrap(), "relay.example.com:4433");
assert_eq!(decoded.room.unwrap(), "myroom");
assert!(matches!(decoded.signal, SignalMessage::CallOffer { .. }));
}
#[test]
fn signal_type_mapping() {
let offer = SignalMessage::CallOffer {
identity_pub: [0; 32],
ephemeral_pub: [0; 32],
signature: vec![],
supported_profiles: vec![],
alias: None,
};
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
let hangup = SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
};
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold));
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold));
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute));
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute));
let transfer = SignalMessage::Transfer {
target_fingerprint: "abc".to_string(),
relay_addr: None,
};
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer));
}
}