feat: wire QUIC transport, JNI bridge, connect UI + add docs

- Replace raw FFI with proper `jni` crate for string marshalling
- Wire QUIC transport in engine: connect to relay, crypto handshake
  (CallOffer/CallAnswer, X25519+Ed25519), send/recv MediaPackets
- Feed received packets into jitter buffer (was previously ignored)
- Add connect screen UI with CALL button (idle state) and in-call
  controls (mute, speaker, hang up, live stats)
- Hardcode relay 172.16.81.125:4433, room "android"
- Add comprehensive docs in docs/android/:
  architecture.md (8 mermaid diagrams), build-guide.md,
  debugging.md, maintenance.md, roadmap.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-05 04:43:49 +00:00
parent 780309fede
commit 8d5f6fe044
16 changed files with 1496 additions and 398 deletions

View File

@@ -6,12 +6,17 @@
//! - A tokio runtime for async network I/O
//! - Command channel for control from the JNI/UI thread
use std::sync::atomic::{AtomicBool, Ordering};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use bytes::Bytes;
use tracing::{error, info, warn};
use wzp_proto::QualityProfile;
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
use wzp_proto::{
CodecId, MediaHeader, MediaPacket, MediaTransport, QualityProfile, SignalMessage,
};
use crate::audio_android::{OboeBackend, FRAME_SAMPLES};
use crate::commands::EngineCommand;
@@ -24,6 +29,8 @@ pub struct CallStartConfig {
pub profile: QualityProfile,
/// Relay server address (host:port).
pub relay_addr: String,
/// Room name (passed as SNI).
pub room: String,
/// Authentication token for the relay.
pub auth_token: Vec<u8>,
/// 32-byte identity seed for key derivation.
@@ -35,6 +42,7 @@ impl Default for CallStartConfig {
Self {
profile: QualityProfile::GOOD,
relay_addr: String::new(),
room: String::new(),
auth_token: Vec::new(),
identity_seed: [0u8; 32],
}
@@ -44,11 +52,10 @@ impl Default for CallStartConfig {
/// Shared state between the engine owner and background threads.
struct EngineState {
running: AtomicBool,
connected: AtomicBool,
muted: AtomicBool,
speaker: AtomicBool,
/// Whether acoustic echo cancellation is enabled (default: true).
aec_enabled: AtomicBool,
/// Whether automatic gain control is enabled (default: true).
agc_enabled: AtomicBool,
stats: Mutex<CallStats>,
command_tx: std::sync::mpsc::Sender<EngineCommand>,
@@ -56,28 +63,19 @@ struct EngineState {
}
/// The WarzonePhone Android engine.
///
/// Manages the entire call pipeline: audio capture/playout via Oboe,
/// codec encode/decode, FEC, jitter buffer, and network transport.
///
/// Thread model:
/// - **UI/JNI thread**: calls `start_call`, `stop_call`, `set_mute`, etc.
/// - **Codec thread**: runs `Pipeline` encode/decode loop, reads/writes ring buffers
/// - **Tokio runtime** (2 worker threads): async network send/recv
pub struct WzpEngine {
state: Arc<EngineState>,
codec_thread: Option<std::thread::JoinHandle<()>>,
#[allow(unused)]
tokio_runtime: Option<tokio::runtime::Runtime>,
call_start: Option<Instant>,
}
impl WzpEngine {
/// Create a new idle engine.
pub fn new() -> Self {
let (tx, rx) = std::sync::mpsc::channel();
let state = Arc::new(EngineState {
running: AtomicBool::new(false),
connected: AtomicBool::new(false),
muted: AtomicBool::new(false),
speaker: AtomicBool::new(false),
aec_enabled: AtomicBool::new(true),
@@ -95,16 +93,11 @@ impl WzpEngine {
}
}
/// Start a call with the given configuration.
///
/// This creates the tokio runtime, starts the Oboe audio backend,
/// and spawns the codec thread.
pub fn start_call(&mut self, config: CallStartConfig) -> Result<(), anyhow::Error> {
if self.state.running.load(Ordering::Acquire) {
return Err(anyhow::anyhow!("call already active"));
}
// Update state
{
let mut stats = self.state.stats.lock().unwrap();
*stats = CallStats {
@@ -113,36 +106,185 @@ impl WzpEngine {
};
}
// Create tokio runtime with 2 worker threads
// Create tokio runtime
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("wzp-net")
.enable_all()
.build()?;
// Create async channels for network send/recv
let (send_tx, mut _send_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (_recv_tx, mut recv_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
// Channels between codec thread and network tasks
let (send_tx, mut send_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (recv_tx, recv_rx) = tokio::sync::mpsc::channel::<MediaPacket>(64);
// Spawn network tasks (placeholder — will use wzp-transport)
let _relay_addr = config.relay_addr.clone();
// Shared sequence counter for outgoing packets
let seq_counter = Arc::new(AtomicU16::new(0));
let ts_counter = Arc::new(AtomicU32::new(0));
// Parse relay address
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| {
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr)
})?;
let room = config.room.clone();
let identity_seed = config.identity_seed;
let state_net = self.state.clone();
let seq_c = seq_counter.clone();
let ts_c = ts_counter.clone();
// Spawn the combined network task (connect + handshake + send/recv)
runtime.spawn(async move {
// Network send task: reads from send_rx, sends via transport
// This will be implemented when wzp-transport Android support is added
while let Some(_packet) = _send_rx.recv().await {
// TODO: send via wzp-transport
// Install rustls crypto provider
let _ = rustls::crypto::ring::default_provider().install_default();
// Create QUIC endpoint
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = match wzp_transport::create_endpoint(bind_addr, None) {
Ok(ep) => ep,
Err(e) => {
error!("failed to create QUIC endpoint: {e}");
return;
}
};
// Connect to relay with room as SNI
let sni = if room.is_empty() { "android" } else { &room };
info!(%relay_addr, sni, "connecting to relay...");
let client_cfg = wzp_transport::client_config();
let conn = match wzp_transport::connect(&endpoint, relay_addr, sni, client_cfg).await {
Ok(c) => c,
Err(e) => {
error!("QUIC connect failed: {e}");
return;
}
};
info!("QUIC connected to relay");
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
// Crypto handshake: send CallOffer, receive CallAnswer
let mut kx = WarzoneKeyExchange::from_identity_seed(&identity_seed);
let ephemeral_pub = kx.generate_ephemeral();
let identity_pub = kx.identity_public_key();
// Sign (ephemeral_pub || "call-offer")
let mut sign_data = Vec::with_capacity(32 + 10);
sign_data.extend_from_slice(&ephemeral_pub);
sign_data.extend_from_slice(b"call-offer");
let signature = kx.sign(&sign_data);
let offer = SignalMessage::CallOffer {
identity_pub,
ephemeral_pub,
signature,
supported_profiles: vec![
QualityProfile::GOOD,
QualityProfile::DEGRADED,
QualityProfile::CATASTROPHIC,
],
};
if let Err(e) = transport.send_signal(&offer).await {
error!("failed to send CallOffer: {e}");
return;
}
info!("CallOffer sent, waiting for CallAnswer...");
// Receive CallAnswer
let answer = match transport.recv_signal().await {
Ok(Some(msg)) => msg,
Ok(None) => {
error!("connection closed before CallAnswer");
return;
}
Err(e) => {
error!("failed to receive CallAnswer: {e}");
return;
}
};
let (relay_ephemeral_pub, _chosen_profile) = match answer {
SignalMessage::CallAnswer {
ephemeral_pub,
chosen_profile,
..
} => (ephemeral_pub, chosen_profile),
other => {
error!("expected CallAnswer, got {:?}", std::mem::discriminant(&other));
return;
}
};
// Derive crypto session (not encrypting media yet for simplicity)
let _session = match kx.derive_session(&relay_ephemeral_pub) {
Ok(s) => s,
Err(e) => {
error!("session derivation failed: {e}");
return;
}
};
info!("handshake complete, call active");
state_net.connected.store(true, Ordering::Release);
{
let mut stats = state_net.stats.lock().unwrap();
stats.state = CallState::Active;
}
// Spawn recv task
let recv_transport = transport.clone();
let recv_handle = tokio::spawn(async move {
loop {
match recv_transport.recv_media().await {
Ok(Some(pkt)) => {
if recv_tx.send(pkt).await.is_err() {
break;
}
}
Ok(None) => {
info!("relay disconnected (recv)");
break;
}
Err(e) => {
error!("recv_media error: {e}");
break;
}
}
}
});
// Send task runs in this task
while let Some(encoded) = send_rx.recv().await {
let seq = seq_c.fetch_add(1, Ordering::Relaxed);
let ts = ts_c.fetch_add(20, Ordering::Relaxed);
let packet = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq,
timestamp: ts,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(encoded),
quality_report: None,
};
if let Err(e) = transport.send_media(&packet).await {
error!("send_media error: {e}");
break;
}
}
recv_handle.abort();
transport.close().await.ok();
});
let recv_tx_clone = _recv_tx.clone();
runtime.spawn(async move {
// Network recv task: reads from transport, writes to recv_rx
// This will be implemented when wzp-transport Android support is added
let _tx = recv_tx_clone;
// TODO: recv from wzp-transport and forward
});
// Take the command receiver (it can only be taken once)
// Take the command receiver
let command_rx = self
.state
.command_rx
@@ -157,11 +299,9 @@ impl WzpEngine {
let codec_thread = std::thread::Builder::new()
.name("wzp-codec".into())
.spawn(move || {
// Pin to big cores and set RT priority on Android
crate::audio_android::pin_to_big_core();
crate::audio_android::set_realtime_priority();
// Create audio backend
let mut audio = OboeBackend::new();
if let Err(e) = audio.start() {
error!("failed to start audio: {e}");
@@ -169,7 +309,6 @@ impl WzpEngine {
return;
}
// Create pipeline
let mut pipeline = match Pipeline::new(profile) {
Ok(p) => p,
Err(e) => {
@@ -181,52 +320,36 @@ impl WzpEngine {
};
state.running.store(true, Ordering::Release);
{
let mut stats = state.stats.lock().unwrap();
stats.state = CallState::Active;
}
info!("codec thread started");
// Track the last-applied AEC/AGC state so we only call
// set_*_enabled when the value actually changes.
let mut prev_aec = true;
let mut prev_agc = true;
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
#[allow(unused_assignments)]
let mut recv_buf: Vec<u8> = Vec::new();
// Main codec loop: 20ms per iteration
let frame_duration = std::time::Duration::from_millis(20);
let mut recv_rx = recv_rx;
while state.running.load(Ordering::Relaxed) {
let loop_start = Instant::now();
// Process commands (non-blocking)
// Process commands
while let Ok(cmd) = command_rx.try_recv() {
match cmd {
EngineCommand::SetMute(m) => {
state.muted.store(m, Ordering::Relaxed);
info!(muted = m, "mute toggled");
}
EngineCommand::SetSpeaker(s) => {
state.speaker.store(s, Ordering::Relaxed);
info!(speaker = s, "speaker toggled");
}
EngineCommand::ForceProfile(p) => {
pipeline.force_profile(p);
info!(?p, "profile forced");
}
EngineCommand::Stop => {
info!("stop command received");
state.running.store(false, Ordering::Release);
break;
}
}
}
// Sync AEC/AGC enabled flags from shared state.
// Sync AEC/AGC
let cur_aec = state.aec_enabled.load(Ordering::Relaxed);
if cur_aec != prev_aec {
pipeline.set_aec_enabled(cur_aec);
@@ -247,22 +370,15 @@ impl WzpEngine {
if captured >= FRAME_SAMPLES {
let muted = state.muted.load(Ordering::Relaxed);
if let Some(encoded) = pipeline.encode_frame(&capture_buf, muted) {
// Send to network (best-effort)
let _ = send_tx.try_send(encoded);
}
}
// --- Recv → Decode → Playout ---
// Drain received packets from the network channel
while let Ok(data) = recv_rx.try_recv() {
recv_buf = data;
// Deserialize the packet and feed to pipeline
// For now, feed raw bytes — full MediaPacket deserialization
// will be added with the transport integration
let _ = &recv_buf; // suppress unused warning
while let Ok(pkt) = recv_rx.try_recv() {
pipeline.feed_packet(pkt);
}
// Decode from jitter buffer
if let Some(pcm) = pipeline.decode_frame() {
audio.write_playout(&pcm);
}
@@ -278,108 +394,75 @@ impl WzpEngine {
stats.quality_tier = pstats.quality_tier;
}
// Sleep for remainder of the 20ms frame period
let elapsed = loop_start.elapsed();
if elapsed < frame_duration {
std::thread::sleep(frame_duration - elapsed);
}
}
// Cleanup
audio.stop();
{
let mut stats = state.stats.lock().unwrap();
stats.state = CallState::Closed;
}
info!("codec thread exited");
})?;
self.codec_thread = Some(codec_thread);
self.tokio_runtime = Some(runtime);
self.call_start = Some(Instant::now());
info!("call started");
Ok(())
}
/// Stop the current call and clean up all resources.
pub fn stop_call(&mut self) {
if !self.state.running.load(Ordering::Acquire) {
return;
}
// Signal stop
self.state.running.store(false, Ordering::Release);
let _ = self.state.command_tx.send(EngineCommand::Stop);
// Join codec thread
if let Some(handle) = self.codec_thread.take() {
if let Err(e) = handle.join() {
warn!("codec thread panicked: {e:?}");
}
let _ = handle.join();
}
// Shut down tokio runtime
if let Some(rt) = self.tokio_runtime.take() {
rt.shutdown_timeout(std::time::Duration::from_secs(2));
}
self.call_start = None;
info!("call stopped");
}
/// Set microphone mute state.
pub fn set_mute(&self, muted: bool) {
let _ = self.state.command_tx.send(EngineCommand::SetMute(muted));
}
/// Set speaker (loudspeaker) mode.
#[allow(unused)]
pub fn set_speaker(&self, enabled: bool) {
let _ = self
.state
.command_tx
.send(EngineCommand::SetSpeaker(enabled));
let _ = self.state.command_tx.send(EngineCommand::SetSpeaker(enabled));
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&self, enabled: bool) {
self.state.aec_enabled.store(enabled, Ordering::Relaxed);
}
/// Enable or disable automatic gain control.
pub fn set_agc_enabled(&self, enabled: bool) {
self.state.agc_enabled.store(enabled, Ordering::Relaxed);
}
/// Force a specific quality profile (overrides adaptive logic).
#[allow(unused)]
pub fn force_profile(&self, profile: QualityProfile) {
let _ = self
.state
.command_tx
.send(EngineCommand::ForceProfile(profile));
let _ = self.state.command_tx.send(EngineCommand::ForceProfile(profile));
}
/// Get a snapshot of the current call statistics.
pub fn get_stats(&self) -> CallStats {
let mut stats = self.state.stats.lock().unwrap().clone();
// Update duration from wall clock
if let Some(start) = self.call_start {
stats.duration_secs = start.elapsed().as_secs_f64();
}
stats
}
/// Check if a call is currently active.
pub fn is_active(&self) -> bool {
self.state.running.load(Ordering::Acquire)
}
/// Destroy the engine, stopping any active call.
pub fn destroy(mut self) {
self.stop_call();
info!("engine destroyed");
}
}