diff --git a/Cargo.lock b/Cargo.lock index b9e2e30..c4baaf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4009,7 +4009,10 @@ dependencies = [ "async-trait", "bytes", "cc", + "jni", "libc", + "rand 0.8.5", + "rustls", "serde", "serde_json", "thiserror 2.0.18", diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 28644f5..6c1bb21 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,13 +10,13 @@ @@ -26,7 +26,7 @@ diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt index e67ce73..7780ca9 100644 --- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -32,12 +32,12 @@ class WzpEngine(private val callback: WzpCallback) { * Start a call. * * @param relayAddr relay server address (host:port) - * @param room room identifier - * @param seedHex 64-char hex-encoded 32-byte identity seed - * @param token authentication token + * @param room room identifier (used as QUIC SNI) + * @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random) + * @param token authentication token (empty = no auth) * @return 0 on success, negative error code on failure */ - fun startCall(relayAddr: String, room: String, seedHex: String, token: String): Int { + fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = ""): Int { check(nativeHandle != 0L) { "Engine not initialized" } val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token) if (result == 0) { diff --git a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt index eef64c0..ad50004 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt @@ -45,7 +45,6 @@ class CallActivity : ComponentActivity() { viewModel = viewModel, onHangUp = { viewModel.stopCall() - finish() } ) } diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt index 54ec612..9ac0b73 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -18,7 +18,6 @@ class CallViewModel : ViewModel(), WzpCallback { private var engine: WzpEngine? = null private var engineInitialized = false - // Observable state private val _callState = MutableStateFlow(0) val callState: StateFlow = _callState.asStateFlow() @@ -39,7 +38,15 @@ class CallViewModel : ViewModel(), WzpCallback { private var statsJob: Job? = null - fun startCall(relayAddr: String, room: String, seedHex: String, token: String) { + companion object { + const val DEFAULT_RELAY = "172.16.81.125:4433" + const val DEFAULT_ROOM = "android" + } + + fun startCall( + relayAddr: String = DEFAULT_RELAY, + room: String = DEFAULT_ROOM + ) { try { if (engine == null) { engine = WzpEngine(this) @@ -48,14 +55,16 @@ class CallViewModel : ViewModel(), WzpCallback { engine?.init() engineInitialized = true } - val result = engine?.startCall(relayAddr, room, seedHex, token) ?: -1 + _callState.value = 1 // Connecting + val result = engine?.startCall(relayAddr, room) ?: -1 if (result == 0) { - _callState.value = 1 // Connecting startStatsPolling() } else { + _callState.value = 0 _errorMessage.value = "Failed to start call (code $result)" } } catch (e: Exception) { + _callState.value = 0 _errorMessage.value = "Engine error: ${e.message}" } } @@ -94,8 +103,7 @@ class CallViewModel : ViewModel(), WzpCallback { try { val json = engine?.getStats() ?: "{}" if (json.isNotEmpty()) { - val parsed = CallStats.fromJson(json) - _stats.value = parsed + _stats.value = CallStats.fromJson(json) } } catch (_: Exception) {} delay(500L) diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt index 4cddead..9fabfdf 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -14,9 +14,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -29,7 +30,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -37,11 +37,6 @@ import androidx.compose.ui.unit.sp import com.wzp.engine.CallStats import kotlin.math.roundToInt -/** - * Main in-call Compose screen. - * - * Displays call duration, quality indicator, audio controls, and live statistics. - */ @Composable fun InCallScreen( viewModel: CallViewModel, @@ -52,6 +47,7 @@ fun InCallScreen( val isSpeaker by viewModel.isSpeaker.collectAsState() val stats by viewModel.stats.collectAsState() val qualityTier by viewModel.qualityTier.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() Surface( modifier = Modifier.fillMaxSize(), @@ -65,63 +61,121 @@ fun InCallScreen( ) { Spacer(modifier = Modifier.height(48.dp)) - // -- Call state label --------------------------------------------- - CallStateLabel(callState) - - Spacer(modifier = Modifier.height(16.dp)) - - // -- Duration ----------------------------------------------------- - DurationDisplay(stats.durationSecs) - - Spacer(modifier = Modifier.height(24.dp)) - - // -- Quality indicator -------------------------------------------- - QualityIndicator(qualityTier, stats.qualityLabel) - - Spacer(modifier = Modifier.height(32.dp)) - - // -- Audio level placeholder bar ---------------------------------- - AudioLevelBar(stats.framesEncoded) - - Spacer(modifier = Modifier.weight(1f)) - - // -- Control buttons ---------------------------------------------- - ControlRow( - isMuted = isMuted, - isSpeaker = isSpeaker, - onToggleMute = viewModel::toggleMute, - onToggleSpeaker = viewModel::toggleSpeaker, - onHangUp = onHangUp + // App title + Text( + text = "WZ Phone", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(8.dp)) - // -- Stats overlay ------------------------------------------------ - StatsOverlay(stats) + CallStateLabel(callState) - Spacer(modifier = Modifier.height(16.dp)) + if (callState == 0) { + // Idle — show connect button + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Relay: ${CallViewModel.DEFAULT_RELAY}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Room: ${CallViewModel.DEFAULT_ROOM}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { viewModel.startCall() }, + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF4CAF50) + ) + ) { + Text( + text = "CALL", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold + ), + color = Color.White + ) + } + + // Show error if any + errorMessage?.let { err -> + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = err, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } else { + // In-call UI + Spacer(modifier = Modifier.height(16.dp)) + + DurationDisplay(stats.durationSecs) + + Spacer(modifier = Modifier.height(24.dp)) + + QualityIndicator(qualityTier, stats.qualityLabel) + + Spacer(modifier = Modifier.height(32.dp)) + + AudioLevelBar(stats.framesEncoded) + + Spacer(modifier = Modifier.weight(1f)) + + ControlRow( + isMuted = isMuted, + isSpeaker = isSpeaker, + onToggleMute = viewModel::toggleMute, + onToggleSpeaker = viewModel::toggleSpeaker, + onHangUp = { + viewModel.stopCall() + // Don't finish activity — go back to idle + } + ) + + Spacer(modifier = Modifier.height(32.dp)) + + StatsOverlay(stats) + + Spacer(modifier = Modifier.height(16.dp)) + } } } } -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - @Composable private fun CallStateLabel(state: Int) { val label = when (state) { - 0 -> "Idle" + 0 -> "Ready to connect" 1 -> "Connecting..." 2 -> "Active" 3 -> "Reconnecting..." 4 -> "Call Ended" else -> "Unknown" } + val color = when (state) { + 2 -> Color(0xFF4CAF50) + 1, 3 -> Color(0xFFFFC107) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } Text( text = label, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = color ) } @@ -143,12 +197,11 @@ private fun DurationDisplay(durationSecs: Double) { @Composable private fun QualityIndicator(tier: Int, label: String) { val dotColor = when (tier) { - 0 -> Color(0xFF4CAF50) // green - 1 -> Color(0xFFFFC107) // yellow - 2 -> Color(0xFFF44336) // red + 0 -> Color(0xFF4CAF50) + 1 -> Color(0xFFFFC107) + 2 -> Color(0xFFF44336) else -> Color.Gray } - Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center @@ -170,14 +223,11 @@ private fun QualityIndicator(tier: Int, label: String) { @Composable private fun AudioLevelBar(framesEncoded: Long) { - // Placeholder: derive a fake "level" from frame count to show the bar is alive. - // In production this would be driven by actual RMS audio levels from the engine. val level = if (framesEncoded > 0) { ((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f) } else { 0f } - Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = "Audio Level", @@ -210,7 +260,6 @@ private fun ControlRow( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - // Mute button FilledTonalIconButton( onClick = onToggleMute, modifier = Modifier.size(56.dp), @@ -231,7 +280,6 @@ private fun ControlRow( ) } - // Hang up button FilledIconButton( onClick = onHangUp, modifier = Modifier.size(72.dp), @@ -249,7 +297,6 @@ private fun ControlRow( ) } - // Speaker button FilledTonalIconButton( onClick = onToggleSpeaker, modifier = Modifier.size(56.dp), @@ -304,7 +351,7 @@ private fun StatsOverlay(stats: CallStats) { ) { StatItem("Enc", "${stats.framesEncoded}") StatItem("Dec", "${stats.framesDecoded}") - StatItem("JB Depth", "${stats.jitterBufferDepth}") + StatItem("JB", "${stats.jitterBufferDepth}") StatItem("Under", "${stats.underruns}") } } diff --git a/crates/wzp-android/Cargo.toml b/crates/wzp-android/Cargo.toml index cfdbb7b..3fcd32b 100644 --- a/crates/wzp-android/Cargo.toml +++ b/crates/wzp-android/Cargo.toml @@ -25,6 +25,9 @@ thiserror = { workspace = true } async-trait = { workspace = true } anyhow = "1" libc = "0.2" +jni = { version = "0.21", default-features = false } +rand = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["ring"] } [build-dependencies] cc = "1" diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index 1da3d75..abecbbf 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -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, /// 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, command_tx: std::sync::mpsc::Sender, @@ -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, codec_thread: Option>, - #[allow(unused)] tokio_runtime: Option, call_start: Option, } 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::>(64); - let (_recv_tx, mut recv_rx) = tokio::sync::mpsc::channel::>(64); + // Channels between codec thread and network tasks + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel::>(64); + let (recv_tx, recv_rx) = tokio::sync::mpsc::channel::(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 = 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"); } } diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index 7fa5c3b..56c1457 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -1,88 +1,26 @@ //! JNI bridge for Android — thin layer between Kotlin and the WzpEngine. -//! -//! Each function converts JNI types to Rust types, delegates to WzpEngine, -//! and converts results back. No audio processing happens here. -//! -//! # Safety -//! -//! All functions in this module are called from the JVM via JNI. They use raw -//! pointers for the JNI environment and object references. The `jni` crate is -//! not yet a dependency, so we use raw FFI types and placeholder string extraction. -//! When the `jni` crate is added, the `extract_jstring` helper should be replaced -//! with proper `JNIEnv::get_string()` calls. -use std::os::raw::{c_long, c_void}; use std::panic; +use jni::objects::{JClass, JObject, JString}; +use jni::sys::{jboolean, jint, jlong, jstring}; +use jni::JNIEnv; use tracing::{error, info}; use wzp_proto::QualityProfile; use crate::engine::{CallStartConfig, WzpEngine}; /// Opaque engine handle passed to/from Kotlin as a `jlong`. -/// -/// Boxed on the heap; the raw pointer is stored on the Kotlin side. -/// Only `nativeDestroy` frees it. struct EngineHandle { engine: WzpEngine, } -// --------------------------------------------------------------------------- -// JNI type aliases (mirrors the C JNI ABI without pulling in the `jni` crate) -// --------------------------------------------------------------------------- - -/// JNI boolean — `u8` where 0 = false, non-zero = true. -type JBoolean = u8; - -/// JNI int — `i32`. -type JInt = i32; - -/// JNI long — `i64` / `c_long` on 64-bit. -type JLong = c_long; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Recover the `EngineHandle` from a raw handle value **without** taking ownership. -/// -/// # Safety -/// `handle` must be a value previously returned by `nativeInit` and not yet -/// passed to `nativeDestroy`. -unsafe fn handle_ref(handle: JLong) -> &'static mut EngineHandle { +/// Recover the `EngineHandle` from a raw handle value. +unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle { unsafe { &mut *(handle as *mut EngineHandle) } } -/// Placeholder: extract a `String` from a JNI `jstring`. -/// -/// When the `jni` crate is added this should be replaced with: -/// ```ignore -/// let env = JNIEnv::from_raw(env_ptr).unwrap(); -/// env.get_string(jstring).unwrap().into() -/// ``` -/// -/// # Safety -/// `_env` and `_jstring` are raw JNI pointers. -#[allow(unused)] -unsafe fn extract_jstring(_env: *mut c_void, _jstring: *mut c_void) -> String { - // TODO(jni): implement real string extraction once the `jni` crate is added. - // For now return a default so the rest of the bridge compiles and can be tested - // with hardcoded values from the Kotlin side. - String::new() -} - -/// Allocate a JNI `jstring` from a Rust `&str`. -/// -/// # Safety -/// `_env` is a raw JNI pointer. -#[allow(unused)] -unsafe fn new_jstring(_env: *mut c_void, _s: &str) -> *mut c_void { - // TODO(jni): implement via JNIEnv::new_string when jni crate is added. - std::ptr::null_mut() -} - -/// Map a Kotlin `profile` int to a `QualityProfile`. -fn profile_from_int(value: JInt) -> QualityProfile { +fn profile_from_int(value: jint) -> QualityProfile { match value { 1 => QualityProfile::DEGRADED, 2 => QualityProfile::CATASTROPHIC, @@ -90,79 +28,42 @@ fn profile_from_int(value: JInt) -> QualityProfile { } } -// --------------------------------------------------------------------------- -// JNI exports -// --------------------------------------------------------------------------- -// Function names follow JNI convention: Java___ -// with underscores in the package replaced by `_1` in actual JNI but here we -// use the simplified form that matches javah output for the package `com.wzp.engine`. - -/// Create a new `WzpEngine`, returning an opaque handle as `jlong`. -/// -/// Kotlin signature: `private external fun nativeInit(): Long` -/// -/// # Safety -/// Called from JNI. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit( - _env: *mut c_void, - _class: *mut c_void, -) -> JLong { + _env: JNIEnv, + _class: JClass, +) -> jlong { let result = panic::catch_unwind(|| { - // Note: tracing on Android requires android_logger or similar. - // fmt() subscriber writes to stdout which doesn't exist on Android. - // Skip tracing init here — add android_logger later. - let handle = Box::new(EngineHandle { engine: WzpEngine::new(), }); - info!("WzpEngine created via JNI"); - Box::into_raw(handle) as JLong + Box::into_raw(handle) as jlong }); - match result { Ok(h) => h, - Err(_) => { - error!("panic in nativeInit"); - 0 // null handle — Kotlin side checks for 0 - } + Err(_) => 0, } } -/// Start a call. -/// -/// Kotlin signature: -/// ```kotlin -/// private external fun nativeStartCall( -/// handle: Long, relay: String, room: String, seed: String, token: String -/// ): Int -/// ``` -/// -/// Returns 0 on success, -1 on error. -/// -/// # Safety -/// Called from JNI. `handle` must be a live engine handle. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall( - env: *mut c_void, - _class: *mut c_void, - handle: JLong, - relay_addr_ptr: *mut c_void, - room_ptr: *mut c_void, - seed_hex_ptr: *mut c_void, - token_ptr: *mut c_void, -) -> JInt { + mut env: JNIEnv, + _class: JClass, + handle: jlong, + relay_addr_j: JString, + room_j: JString, + seed_hex_j: JString, + token_j: JString, +) -> jint { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default(); + let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default(); + let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default(); + let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default(); + let h = unsafe { handle_ref(handle) }; - // Extract strings from JNI. When the `jni` crate is available these - // will use real JNI string conversion. For now, placeholders. - let relay_addr = unsafe { extract_jstring(env, relay_addr_ptr) }; - let _room = unsafe { extract_jstring(env, room_ptr) }; - let seed_hex = unsafe { extract_jstring(env, seed_hex_ptr) }; - let token = unsafe { extract_jstring(env, token_ptr) }; - - // Parse the hex-encoded 32-byte identity seed. + // Parse hex seed let mut identity_seed = [0u8; 32]; if seed_hex.len() == 64 { for i in 0..32 { @@ -170,20 +71,22 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall( identity_seed[i] = byte; } } + } else { + // Generate random seed if not provided + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut identity_seed); } let config = CallStartConfig { profile: QualityProfile::GOOD, relay_addr, - auth_token: token.into_bytes(), + room, + auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() }, identity_seed, }; match h.engine.start_call(config) { - Ok(()) => { - info!("call started via JNI"); - 0 - } + Ok(()) => 0, Err(e) => { error!("start_call failed: {e}"); -1 @@ -193,152 +96,92 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall( match result { Ok(code) => code, - Err(_) => { - error!("panic in nativeStartCall"); - -1 - } + Err(_) => -1, } } -/// Stop the active call. -/// -/// Kotlin signature: `private external fun nativeStopCall(handle: Long)` -/// -/// # Safety -/// Called from JNI. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall( - _env: *mut c_void, - _class: *mut c_void, - handle: JLong, + _env: JNIEnv, + _class: JClass, + handle: jlong, ) { let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { let h = unsafe { handle_ref(handle) }; h.engine.stop_call(); - info!("call stopped via JNI"); })); } -/// Set microphone mute state. -/// -/// Kotlin signature: `private external fun nativeSetMute(handle: Long, muted: Boolean)` -/// -/// # Safety -/// Called from JNI. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute( - _env: *mut c_void, - _class: *mut c_void, - handle: JLong, - muted: JBoolean, + _env: JNIEnv, + _class: JClass, + handle: jlong, + muted: jboolean, ) { let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { let h = unsafe { handle_ref(handle) }; - let muted = muted != 0; - h.engine.set_mute(muted); - info!(muted, "mute set via JNI"); + h.engine.set_mute(muted != 0); })); } -/// Set speaker (loudspeaker) mode. -/// -/// Kotlin signature: `private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)` -/// -/// # Safety -/// Called from JNI. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker( - _env: *mut c_void, - _class: *mut c_void, - handle: JLong, - speaker: JBoolean, + _env: JNIEnv, + _class: JClass, + handle: jlong, + speaker: jboolean, ) { let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { let h = unsafe { handle_ref(handle) }; - let speaker = speaker != 0; - h.engine.set_speaker(speaker); - info!(speaker, "speaker set via JNI"); + h.engine.set_speaker(speaker != 0); })); } -/// Get call statistics as a JSON string. -/// -/// Kotlin signature: `private external fun nativeGetStats(handle: Long): String` -/// -/// Returns a JSON-serialized `CallStats` struct, or `"{}"` on error. -/// -/// # Safety -/// Called from JNI. #[unsafe(no_mangle)] -pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats( - env: *mut c_void, - _class: *mut c_void, - handle: JLong, -) -> *mut c_void { +pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + handle: jlong, +) -> jstring { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let h = unsafe { handle_ref(handle) }; let stats = h.engine.get_stats(); - match serde_json::to_string(&stats) { - Ok(json) => unsafe { new_jstring(env, &json) }, - Err(e) => { - error!("failed to serialize stats: {e}"); - unsafe { new_jstring(env, "{}") } - } - } + serde_json::to_string(&stats).unwrap_or_else(|_| "{}".to_string()) })); - match result { - Ok(ptr) => ptr, - Err(_) => { - error!("panic in nativeGetStats"); - unsafe { new_jstring(env, "{}") } - } - } + let json = match result { + Ok(s) => s, + Err(_) => "{}".to_string(), + }; + + env.new_string(&json) + .map(|s| s.into_raw()) + .unwrap_or(JObject::null().into_raw()) } -/// Force a specific quality profile, overriding adaptive logic. -/// -/// Kotlin signature: `private external fun nativeForceProfile(handle: Long, profile: Int)` -/// -/// Profile values: 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC. -/// -/// # Safety -/// Called from JNI. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile( - _env: *mut c_void, - _class: *mut c_void, - handle: JLong, - profile: JInt, + _env: JNIEnv, + _class: JClass, + handle: jlong, + profile: jint, ) { let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { let h = unsafe { handle_ref(handle) }; let qp = profile_from_int(profile); h.engine.force_profile(qp); - info!(?qp, "profile forced via JNI"); })); } -/// Destroy the engine and free all associated memory. -/// -/// After this call the handle is invalid and must not be reused. -/// -/// Kotlin signature: `private external fun nativeDestroy(handle: Long)` -/// -/// # Safety -/// Called from JNI. `handle` must be a live engine handle. After this call -/// the handle is dangling. #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy( - _env: *mut c_void, - _class: *mut c_void, - handle: JLong, + _env: JNIEnv, + _class: JClass, + handle: jlong, ) { let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { - // Retake ownership of the Box and drop it, which calls WzpEngine::drop() - // and in turn stop_call(). let h = unsafe { Box::from_raw(handle as *mut EngineHandle) }; drop(h); - info!("engine destroyed via JNI"); })); } diff --git a/docs/android/README.md b/docs/android/README.md new file mode 100644 index 0000000..3b66354 --- /dev/null +++ b/docs/android/README.md @@ -0,0 +1,41 @@ +# WarzonePhone Android Client + +The WZP Android client is a native VoIP application built with Kotlin/Jetpack Compose on top of a Rust audio engine. It connects to WZP relay servers over QUIC, providing encrypted voice calls with adaptive quality, forward error correction, and acoustic echo cancellation. + +## Quick Start + +1. **Build**: `cd android && ./gradlew assembleRelease` (requires NDK 26.1, cargo-ndk) +2. **Install**: `adb install app/build/outputs/apk/release/app-release.apk` +3. **Run**: Open "WZ Phone", tap **CALL** to connect to the hardcoded relay +4. **Relay**: Must be running at the configured address (default `172.16.81.125:4433`) + +## Current State (April 2025) + +| Feature | Status | +|---------|--------| +| QUIC transport to relay | Working | +| Crypto handshake (X25519 + Ed25519) | Working | +| Opus 24k encoding/decoding | Working | +| Oboe audio I/O (48kHz mono) | Working | +| AEC / AGC signal processing | Working | +| RaptorQ FEC | Wired (repair symbols not sent yet) | +| Jitter buffer | Working | +| Adaptive quality switching | Codec-ready, not network-driven yet | +| Authentication (featherChat) | Skipped (relay has no --auth-url) | +| Media encryption (ChaCha20-Poly1305) | Session derived but not applied to packets | +| Foreground service / wake locks | Implemented, not started from UI | + +## Documentation Index + +- [Architecture](architecture.md) - System design, data flow diagrams, thread model +- [Build Guide](build-guide.md) - Build environment setup, dependencies, signing +- [Debugging](debugging.md) - Crash diagnosis, logcat filters, common issues +- [Maintenance](maintenance.md) - Code map, dependency management, upgrade paths +- [Roadmap](roadmap.md) - Planned work and known gaps + +## Key Design Decisions + +- **Rust native engine**: All audio processing, codecs, FEC, crypto, and networking run in Rust. Kotlin is UI-only. +- **Lock-free audio**: SPSC ring buffers with atomic ordering between Oboe C++ callbacks and the Rust codec thread. No mutexes in the audio path. +- **cargo-ndk**: The native library (`libwzp_android.so`) is cross-compiled for `arm64-v8a` using cargo-ndk, invoked automatically by Gradle's `cargoNdkBuild` task. +- **Single-activity Compose**: One `CallActivity` hosts all UI via Jetpack Compose with `CallViewModel` as the state holder. diff --git a/docs/android/architecture.md b/docs/android/architecture.md new file mode 100644 index 0000000..3b3c063 --- /dev/null +++ b/docs/android/architecture.md @@ -0,0 +1,400 @@ +# Architecture + +## System Overview + +The Android client is a four-layer stack: Kotlin UI, JNI bridge, Rust engine, and C++ audio I/O. Each layer communicates through well-defined interfaces with minimal coupling. + +```mermaid +graph TB + subgraph "Kotlin (Main Thread)" + CA[CallActivity] + VM[CallViewModel] + UI[InCallScreen
Compose UI] + CA --> VM + VM --> UI + end + + subgraph "JNI Bridge" + JB[jni_bridge.rs
panic-safe FFI] + end + + subgraph "Rust Engine" + ENG[WzpEngine
Orchestrator] + CT[Codec Thread
20ms real-time loop] + NET[Tokio Runtime
2 async workers] + PIPE[Pipeline
Encode/Decode/FEC/Jitter] + end + + subgraph "C++ Audio" + OBOE[Oboe Bridge
Capture + Playout callbacks] + RB[Ring Buffers
Lock-free SPSC] + end + + subgraph "Network" + QUIC[QUIC Connection
quinn] + RELAY[WZP Relay
SFU Room] + end + + VM <-->|"JNI calls
+ JSON stats"| JB + JB <--> ENG + ENG --> CT + ENG --> NET + CT <--> PIPE + CT <-->|"Atomic R/W"| RB + OBOE <-->|"Atomic R/W"| RB + CT <-->|"mpsc channels"| NET + NET <-->|"QUIC datagrams
+ streams"| QUIC + QUIC <--> RELAY +``` + +## Thread Model + +The engine uses four distinct thread contexts, each with specific responsibilities and real-time constraints. + +```mermaid +graph LR + subgraph "Android Main Thread" + UI_T["UI + JNI calls
startCall / stopCall / getStats"] + end + + subgraph "Oboe Audio Thread (system)" + AUD["Capture callback: mic → ring buf
Playout callback: ring buf → speaker
⚡ Highest priority, no allocations"] + end + + subgraph "Codec Thread (wzp-codec)" + COD["20ms loop:
1. Read capture ring buf
2. AEC → AGC → Encode
3. Send to network channel
4. Recv from network channel
5. FEC → Jitter → Decode
6. Write playout ring buf
⚡ Pinned to big core, RT priority"] + end + + subgraph "Tokio Runtime (2 workers)" + NET_S["Send task:
Channel → MediaPacket → QUIC datagram"] + NET_R["Recv task:
QUIC datagram → MediaPacket → Channel"] + HS["Handshake:
CallOffer → CallAnswer"] + end + + UI_T -->|"mpsc command channel"| COD + COD -->|"tokio::mpsc send_tx"| NET_S + NET_R -->|"tokio::mpsc recv_tx"| COD + AUD <-->|"Atomic ring buffers"| COD +``` + +### Thread Priorities and Constraints + +| Thread | Priority | Allocations | Blocking | Lock-free | +|--------|----------|-------------|----------|-----------| +| Oboe audio | SCHED_FIFO (system) | None | Never | Yes | +| Codec | RT priority, big core | Pre-allocated buffers | sleep(remainder of 20ms) | Ring buf: yes, Stats: Mutex | +| Tokio workers | Normal | Allowed | Async only | N/A | +| Main/JNI | Normal | Allowed | Allowed | N/A | + +## Call Lifecycle + +```mermaid +sequenceDiagram + participant User + participant UI as InCallScreen + participant VM as CallViewModel + participant ENG as WzpEngine (JNI) + participant NET as Tokio Network + participant RELAY as WZP Relay + + User->>UI: Tap CALL + UI->>VM: startCall() + VM->>ENG: init() + startCall(relay, room) + ENG->>ENG: Create tokio runtime + ENG->>NET: Spawn network task + + NET->>RELAY: QUIC connect (SNI = room name) + RELAY-->>NET: Connection established + + Note over NET,RELAY: Crypto Handshake + NET->>RELAY: CallOffer {identity_pub, ephemeral_pub, signature, profiles} + RELAY-->>NET: CallAnswer {ephemeral_pub, chosen_profile, signature} + NET->>NET: Derive ChaCha20-Poly1305 session + + ENG->>ENG: Spawn codec thread + Note over ENG: State → Active + + loop Every 20ms + ENG->>ENG: Read mic → AEC → AGC → Encode + ENG->>NET: Encoded frame via channel + NET->>RELAY: MediaPacket via QUIC DATAGRAM + RELAY->>NET: MediaPacket from other peer + NET->>ENG: MediaPacket via channel + ENG->>ENG: FEC → Jitter → Decode → Speaker + end + + User->>UI: Tap END + UI->>VM: stopCall() + VM->>ENG: stopCall() + ENG->>ENG: Set running=false, send Stop command + ENG->>ENG: Join codec thread + ENG->>NET: Drop tokio runtime + NET->>RELAY: Connection close +``` + +## Audio Pipeline Detail + +```mermaid +graph LR + subgraph "Capture Path" + MIC[Microphone] -->|"48kHz i16"| OBOE_C[Oboe Capture
Callback] + OBOE_C -->|"ring_write()"| RB_C[Capture
Ring Buffer] + RB_C -->|"read_capture()"| AEC[Echo
Canceller] + AEC --> AGC[Auto Gain
Control] + AGC --> ENC[AdaptiveEncoder
Opus 24k] + ENC -->|"Vec u8"| FEC_E[RaptorQ
FEC Encoder] + FEC_E -->|"send_tx"| CHAN_S[Send Channel] + end + + subgraph "Network" + CHAN_S --> PKT_S[MediaPacket
Header + Payload] + PKT_S -->|"QUIC DATAGRAM"| RELAY[Relay SFU] + RELAY -->|"QUIC DATAGRAM"| PKT_R[MediaPacket
Deserialize] + PKT_R -->|"recv_tx"| CHAN_R[Recv Channel] + end + + subgraph "Playout Path" + CHAN_R --> FEC_D[RaptorQ
FEC Decoder] + FEC_D --> JB[Jitter Buffer
10-250 pkts] + JB --> DEC[AdaptiveDecoder
Opus 24k] + DEC -->|"48kHz i16"| AEC_REF[AEC Far-End
Reference] + DEC -->|"write_playout()"| RB_P[Playout
Ring Buffer] + RB_P -->|"ring_read()"| OBOE_P[Oboe Playout
Callback] + OBOE_P --> SPK[Speaker] + end +``` + +### Audio Parameters + +| Parameter | Value | Notes | +|-----------|-------|-------| +| Sample rate | 48,000 Hz | Opus native rate | +| Channels | 1 (mono) | VoIP only | +| Frame size | 960 samples | 20ms at 48kHz | +| Ring buffer | 7,680 samples | 160ms (8 frames) | +| Bit depth | 16-bit signed int | PCM format | +| AEC tail | 100ms | Echo canceller filter length | + +## Crypto Handshake + +```mermaid +sequenceDiagram + participant Client as Android Client + participant Relay as WZP Relay + + Note over Client: Identity seed (32 bytes, random per launch) + Note over Client: HKDF → Ed25519 signing key + X25519 static key + + Client->>Client: Generate ephemeral X25519 keypair + Client->>Client: Sign(ephemeral_pub || "call-offer") with Ed25519 + + Client->>Relay: SignalMessage::CallOffer
{identity_pub, ephemeral_pub, signature, [GOOD, DEGRADED, CATASTROPHIC]} + + Relay->>Relay: Verify Ed25519 signature + Relay->>Relay: Generate own ephemeral X25519 + Relay->>Relay: Sign(ephemeral_pub || "call-answer") + Relay->>Relay: DH(relay_ephemeral, client_ephemeral) → shared secret + Relay->>Relay: HKDF(shared_secret) → ChaCha20-Poly1305 key + + Relay->>Client: SignalMessage::CallAnswer
{identity_pub, ephemeral_pub, signature, chosen_profile=GOOD} + + Client->>Client: Verify relay signature + Client->>Client: DH(client_ephemeral, relay_ephemeral) → same shared secret + Client->>Client: HKDF(shared_secret) → same ChaCha20-Poly1305 key + + Note over Client,Relay: Both sides now have identical session key + Note over Client,Relay: Media packets can be encrypted (not yet applied) +``` + +### Key Derivation Chain + +``` +Identity Seed (32 bytes, random) + │ + ├── HKDF(seed, info="warzone-ed25519") → Ed25519 signing key + │ └── Public key = identity_pub (32 bytes) + │ └── SHA-256(identity_pub)[:16] = fingerprint (16 bytes) + │ + └── HKDF(seed, info="warzone-x25519") → X25519 static key (unused currently) + +Per-Call Ephemeral: + Random X25519 keypair → ephemeral_pub (sent in CallOffer) + +Session Key: + DH(our_ephemeral_secret, peer_ephemeral_pub) → shared_secret + HKDF(shared_secret, info="warzone-session-key") → ChaCha20-Poly1305 key (32 bytes) +``` + +## QUIC Transport + +```mermaid +graph TB + subgraph "QUIC Connection" + EP[Client Endpoint
0.0.0.0:0 UDP] + CONN[Connection to Relay
SNI = room name] + + subgraph "Unreliable Channel" + DG_S[Send DATAGRAM
MediaPacket serialized] + DG_R[Recv DATAGRAM
MediaPacket deserialized] + end + + subgraph "Reliable Channel" + ST_S[Open bidi stream
JSON length-prefixed
SignalMessage] + ST_R[Accept bidi stream
JSON length-prefixed
SignalMessage] + end + + EP --> CONN + CONN --> DG_S + CONN --> DG_R + CONN --> ST_S + CONN --> ST_R + end +``` + +### QUIC Configuration (VoIP-tuned) + +| Setting | Value | Rationale | +|---------|-------|-----------| +| ALPN | `wzp` | Protocol identification | +| Idle timeout | 30s | Keep connection alive during silence | +| Keep-alive | 5s | Prevent NAT timeout | +| Datagram receive buffer | 65 KB | Buffer for burst arrivals | +| Flow control (recv) | 256 KB | Conservative for VoIP | +| Flow control (send) | 128 KB | Prevent bufferbloat | +| TLS | Self-signed certs | Development mode | +| Certificate verification | Disabled | Client accepts any cert | + +## MediaPacket Wire Format + +``` +12-byte header: +┌─────────────────────────────────────────────────┐ +│ Byte 0: V(1) T(1) CodecID(4) Q(1) FecHi(1) │ +│ Byte 1: FecLo(6) unused(2) │ +│ Byte 2-3: Sequence number (u16 BE) │ +│ Byte 4-7: Timestamp ms (u32 BE) │ +│ Byte 8: FEC block ID │ +│ Byte 9: FEC symbol index │ +│ Byte 10: Reserved │ +│ Byte 11: CSRC count │ +├─────────────────────────────────────────────────┤ +│ Payload: Opus-encoded audio frame │ +├─────────────────────────────────────────────────┤ +│ Optional: QualityReport (4 bytes, if Q=1) │ +│ loss_pct(u8) rtt_4ms(u8) jitter_ms(u8) │ +│ bitrate_cap_kbps(u8) │ +└─────────────────────────────────────────────────┘ +``` + +## Relay Room Mode (SFU) + +```mermaid +graph LR + subgraph "Room: android" + P1[Phone A
QUIC conn] -->|MediaPacket| RELAY[Relay SFU] + RELAY -->|MediaPacket| P2[Phone B
QUIC conn] + P2 -->|MediaPacket| RELAY + RELAY -->|MediaPacket| P1 + end + + Note1["Room name from QUIC TLS SNI
No auth required
Packets forwarded to all others"] +``` + +The relay operates as a Selective Forwarding Unit: +1. Client connects via QUIC, room name extracted from TLS SNI +2. Crypto handshake completes (relay has its own ephemeral identity) +3. Client joins named room +4. All received media packets are forwarded to every other participant in the room +5. Signaling messages are not forwarded (point-to-point with relay) + +## Adaptive Quality System + +```mermaid +graph TD + QR[QualityReport
loss%, RTT, jitter] --> AQC[AdaptiveQualityController] + + AQC -->|"loss<10%, RTT<400ms"| GOOD[GOOD
Opus 24kbps
FEC 20%
20ms frames] + AQC -->|"loss 10-40%
RTT 400-600ms"| DEG[DEGRADED
Opus 6kbps
FEC 50%
40ms frames] + AQC -->|"loss>40%
RTT>600ms"| CAT[CATASTROPHIC
Codec2 1.2kbps
FEC 100%
40ms frames] + + GOOD -->|"Hysteresis:
sustained degradation"| DEG + DEG -->|"Sustained improvement"| GOOD + DEG -->|"Further degradation"| CAT + CAT -->|"Improvement"| DEG +``` + +| Profile | Codec | Bitrate | FEC Ratio | Frame Size | FEC Block | +|---------|-------|---------|-----------|------------|-----------| +| GOOD | Opus 24k | 24 kbps | 20% | 20ms | 5 frames | +| DEGRADED | Opus 6k | 6 kbps | 50% | 40ms | 10 frames | +| CATASTROPHIC | Codec2 1.2k | 1.2 kbps | 100% | 40ms | 8 frames | + +## Module Dependency Graph + +```mermaid +graph BT + PROTO[wzp-proto
Types, traits, jitter,
quality, session] + CODEC[wzp-codec
Opus, Codec2, AEC,
AGC, resampling] + FEC[wzp-fec
RaptorQ fountain codes] + CRYPTO[wzp-crypto
Ed25519, X25519,
ChaCha20-Poly1305] + TRANSPORT[wzp-transport
QUIC, datagrams,
signaling streams] + ANDROID[wzp-android
Engine, JNI bridge,
Oboe audio, pipeline] + RELAY[wzp-relay
SFU, rooms, auth,
metrics, probes] + + CODEC --> PROTO + FEC --> PROTO + CRYPTO --> PROTO + TRANSPORT --> PROTO + ANDROID --> PROTO + ANDROID --> CODEC + ANDROID --> FEC + ANDROID --> CRYPTO + ANDROID --> TRANSPORT + RELAY --> PROTO + RELAY --> CRYPTO + RELAY --> TRANSPORT +``` + +## File Map + +### Kotlin (`android/app/src/main/java/com/wzp/`) + +| File | Purpose | +|------|---------| +| `WzpApplication.kt` | App entry, notification channel creation | +| `engine/WzpEngine.kt` | JNI wrapper for native engine | +| `engine/WzpCallback.kt` | Callback interface for engine events | +| `engine/CallStats.kt` | Stats data class with JSON deserialization | +| `ui/call/CallActivity.kt` | Activity host, permissions, theme | +| `ui/call/CallViewModel.kt` | MVVM state holder, stats polling | +| `ui/call/InCallScreen.kt` | Compose UI (idle + in-call states) | +| `service/CallService.kt` | Foreground service, wake/wifi locks | +| `audio/AudioRouteManager.kt` | Speaker/earpiece/Bluetooth routing | + +### Rust (`crates/wzp-android/src/`) + +| File | Purpose | +|------|---------| +| `lib.rs` | Module declarations | +| `jni_bridge.rs` | JNI FFI (panic-safe, proper jni crate) | +| `engine.rs` | Call orchestrator (threads, channels, lifecycle) | +| `pipeline.rs` | Codec pipeline (AEC, AGC, encode, FEC, jitter, decode) | +| `audio_android.rs` | Oboe backend, SPSC ring buffers, RT scheduling | +| `commands.rs` | Engine command enum | +| `stats.rs` | CallState/CallStats types (serde) | + +### C++ (`crates/wzp-android/cpp/`) + +| File | Purpose | +|------|---------| +| `oboe_bridge.h` | FFI header for Rust-C++ audio interface | +| `oboe_bridge.cpp` | Oboe capture/playout callbacks, ring buffer I/O | +| `oboe_stub.cpp` | No-op stub for non-Android builds | + +### Build + +| File | Purpose | +|------|---------| +| `android/app/build.gradle.kts` | Android build config, cargo-ndk task | +| `crates/wzp-android/Cargo.toml` | Rust dependencies (cdylib output) | +| `crates/wzp-android/build.rs` | C++ compilation, Oboe fetch | diff --git a/docs/android/build-guide.md b/docs/android/build-guide.md new file mode 100644 index 0000000..e74bdf7 --- /dev/null +++ b/docs/android/build-guide.md @@ -0,0 +1,155 @@ +# Build Guide + +## Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| JDK | 17 | Android Gradle builds | +| Android SDK | 34 | Compile SDK | +| Android NDK | 26.1.10909125 | Native C++/Rust compilation | +| Rust | 1.85+ | Native engine (edition 2024) | +| cargo-ndk | latest | Cross-compile Rust → Android | +| `aarch64-linux-android` target | - | Rust target for ARM64 | + +### Install Rust Android target + +```bash +rustup target add aarch64-linux-android +cargo install cargo-ndk +``` + +### Environment Variables + +```bash +export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" +export ANDROID_HOME="$HOME/android-sdk" +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.1.10909125" + +# For manual cargo-ndk builds (Gradle sets these automatically): +export CC_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang" +export CXX_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang++" +export AR_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" +``` + +## Build Commands + +### Full Build (Gradle drives everything) + +```bash +cd android +./gradlew assembleRelease +``` + +This runs: +1. `cargoNdkBuild` task: invokes `cargo ndk -t arm64-v8a -o app/src/main/jniLibs build --release -p wzp-android` +2. Compiles Kotlin/Compose code +3. Packages APK with signing + +### Native Library Only + +```bash +cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --release -p wzp-android +``` + +Output: `android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so` + +### Skip Native Rebuild + +If the `.so` hasn't changed: + +```bash +cd android +./gradlew assembleRelease -x cargoNdkBuild +``` + +### Debug Build + +```bash +cd android +./gradlew assembleDebug +``` + +Debug APK is ~8.9 MB (unstripped `.so`), release is ~6.9 MB. + +## Signing + +### Debug + +``` +Keystore: android/keystore/wzp-debug.jks +Password: android +Key alias: wzp-debug +``` + +### Release + +``` +Keystore: android/keystore/wzp-release.jks +Password: wzphone2024 +Key alias: wzp-release +``` + +Both keystores are checked into the repo for development convenience. For production, replace with proper key management. + +## Build Artifacts + +| Artifact | Path | Size | +|----------|------|------| +| Debug APK | `android/app/build/outputs/apk/debug/app-debug.apk` | ~8.9 MB | +| Release APK | `android/app/build/outputs/apk/release/app-release.apk` | ~6.9 MB | +| Native lib | `android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so` | ~5 MB | + +## ABI Support + +Currently only `arm64-v8a` (ARM64) is built. This covers 95%+ of modern Android devices. + +To add more ABIs, edit `build.gradle.kts`: + +```kotlin +ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } +``` + +And update the cargo-ndk command in `cargoNdkBuild` task: + +```kotlin +commandLine("cargo", "ndk", "-t", "arm64-v8a", "-t", "armeabi-v7a", ...) +``` + +## Oboe Dependency + +The Oboe C++ audio library is fetched at build time by `build.rs`: + +1. Attempts `git clone` of Oboe 1.8.1 into `$OUT_DIR/oboe` +2. If successful, compiles `oboe_bridge.cpp` with Oboe headers +3. If clone fails (no network), falls back to `oboe_stub.cpp` (no-op audio) + +This means **first build requires internet** to fetch Oboe. Subsequent builds use the cached checkout. + +## Common Build Issues + +### `cargo ndk` not found + +```bash +cargo install cargo-ndk +``` + +### Missing Android target + +```bash +rustup target add aarch64-linux-android +``` + +### NDK not found + +Ensure `ANDROID_NDK_HOME` points to the NDK directory containing `toolchains/llvm/`. + +### C++ compilation errors + +Check that `CXX_aarch64_linux_android` points to a valid clang++ from the NDK. + +### Gradle daemon issues + +```bash +./gradlew --stop +./gradlew assembleRelease --no-daemon +``` diff --git a/docs/android/debugging.md b/docs/android/debugging.md new file mode 100644 index 0000000..46c56bd --- /dev/null +++ b/docs/android/debugging.md @@ -0,0 +1,214 @@ +# Debugging Guide + +## Crash on Launch + +### Symptom: App crashes immediately after opening + +**Most likely cause: Namespace mismatch in AndroidManifest.xml** + +The Gradle namespace is `com.wzp.phone` but all Kotlin classes are in package `com.wzp.*`. If the manifest uses shorthand names (`.WzpApplication`, `.ui.call.CallActivity`), Android resolves them as `com.wzp.phone.WzpApplication` which doesn't exist. + +**Fix**: Always use fully-qualified class names in the manifest: + +```xml + + + + + + + +``` + +### Symptom: Crash in `System.loadLibrary("wzp_android")` + +The native `.so` is missing or incompatible. Check: + +```bash +# Verify the .so exists in the APK +unzip -l app-release.apk | grep libwzp +# Should show: lib/arm64-v8a/libwzp_android.so + +# Verify ABI matches device +adb shell getprop ro.product.cpu.abi +# Should return: arm64-v8a +``` + +### Symptom: Crash when calling `nativeGetStats()` (returns null jstring) + +The JNI bridge must return a valid `jstring`, not a null pointer. The Kotlin side declares the return as `String?` (nullable) and wraps in try/catch: + +```kotlin +fun getStats(): String { + if (nativeHandle == 0L) return "{}" + return try { + nativeGetStats(nativeHandle) ?: "{}" + } catch (_: Exception) { + "{}" + } +} +``` + +### Symptom: Tracing subscriber panic + +`tracing_subscriber::fmt()` writes to stdout, which doesn't exist on Android. The init was removed. If you need logging, use `android_logger` crate instead. + +## Logcat Filters + +### View all WZP logs + +```bash +adb logcat -s wzp-android:V wzp-codec:V wzp-net:V +``` + +### View Rust tracing output (if android_logger is added) + +```bash +adb logcat | grep -E "(wzp|WzpEngine|CallActivity)" +``` + +### View Oboe audio logs + +```bash +adb logcat -s AAudio:V oboe:V +``` + +### View native crashes + +```bash +adb logcat -s DEBUG:V libc:V +``` + +Look for `signal 11 (SIGSEGV)` or `signal 6 (SIGABRT)` with a backtrace in `libwzp_android.so`. + +### Symbolicate native crash + +```bash +# Find the .so with debug symbols (before stripping) +SO_PATH="target/aarch64-linux-android/release/libwzp_android.so" + +# Use addr2line from NDK +$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \ + -e $SO_PATH -f 0x +``` + +## Network Issues + +### Call stuck on "Connecting..." + +The QUIC handshake to the relay is failing. Common causes: + +1. **Relay not running**: Verify the relay is listening: + ```bash + nc -zvu 172.16.81.125 4433 + ``` + +2. **Wrong relay address**: Hardcoded in `CallViewModel.kt`: + ```kotlin + const val DEFAULT_RELAY = "172.16.81.125:4433" + ``` + +3. **QUIC blocked by firewall**: QUIC uses UDP. Many networks block UDP traffic. Ensure UDP port 4433 is open. + +4. **TLS handshake failure**: The client uses `client_config()` which disables certificate verification. If the relay's QUIC config changed, this may fail. + +### Connected but no audio + +1. **Microphone permission denied**: Check Android settings. The app requests `RECORD_AUDIO` on first launch. + +2. **Oboe failed to start**: The codec thread logs this. Check logcat for "failed to start audio". + +3. **Ring buffer underrun**: The stats overlay shows "Under" count. High underruns mean the codec thread isn't keeping up. + +4. **Network not forwarding**: If both phones show "Active" but frame counters aren't increasing, the relay may not be forwarding. Check relay logs. + +### High packet loss + +The stats overlay shows loss percentage. Common causes: + +- Wi-Fi congestion (try cellular or move closer to AP) +- UDP throttling by carrier/ISP +- Relay overloaded (check relay metrics) + +## Audio Issues + +### Echo + +AEC (Acoustic Echo Cancellation) is enabled by default with a 100ms tail. If echo persists: + +- The AEC may need a longer tail for the specific acoustic environment +- Speaker volume too high overwhelms the canceller +- Check that `last_decoded_farend` is being set (playout path working) + +### Robot voice / glitching + +Usually caused by jitter buffer underruns. The jitter buffer adapts between 10-250 packets. Check: + +- `jitter_buffer_depth` in stats (should be > 0 during active call) +- `underruns` counter (should not climb rapidly) +- Network jitter (high jitter_ms causes adaptation) + +### No sound from speaker + +1. Check `isSpeaker` state in the UI +2. Oboe playout stream may have failed — check logcat for Oboe errors +3. Ring buffer might be empty — check `framesDecoded` counter + +## JNI Issues + +### `UnsatisfiedLinkError: No implementation found for...` + +The JNI function name doesn't match. JNI names must follow the pattern: +``` +Java_com_wzp_engine_WzpEngine_ +``` + +If the package structure changes, all JNI function names must be updated in `jni_bridge.rs`. + +### Panic across FFI boundary + +All JNI functions wrap their body in `panic::catch_unwind()`. If a Rust panic escapes to Java, it causes a `SIGABRT`. The catch_unwind returns safe defaults: + +| Function | Panic return | +|----------|--------------| +| `nativeInit` | 0 (null handle) | +| `nativeStartCall` | -1 (error) | +| `nativeGetStats` | `JObject::null()` | +| Others | void (silently swallowed) | + +### Thread safety + +All JNI methods must be called from the same thread (Android main thread). The `EngineHandle` is a raw pointer — concurrent access is undefined behavior. + +## Stats JSON Format + +The `nativeGetStats()` returns JSON matching this Rust struct: + +```json +{ + "state": "Active", + "duration_secs": 42.5, + "quality_tier": 0, + "loss_pct": 0.5, + "rtt_ms": 45, + "jitter_ms": 12, + "jitter_buffer_depth": 3, + "frames_encoded": 2125, + "frames_decoded": 2100, + "underruns": 5 +} +``` + +Kotlin deserializes this via `CallStats.fromJson()` using `org.json.JSONObject` (Android built-in, no library needed). + +## Diagnostic Checklist + +When something doesn't work, check in this order: + +1. **APK installed for correct ABI?** (`arm64-v8a` only) +2. **Manifest class names fully qualified?** (no dots prefix) +3. **Relay running and reachable?** (`nc -zvu `) +4. **Microphone permission granted?** +5. **Stats polling working?** (check if frame counters increment) +6. **Logcat for native crashes?** (`adb logcat -s DEBUG:V`) +7. **Network connectivity?** (UDP port open, no firewall) diff --git a/docs/android/maintenance.md b/docs/android/maintenance.md new file mode 100644 index 0000000..d240520 --- /dev/null +++ b/docs/android/maintenance.md @@ -0,0 +1,190 @@ +# Maintenance Guide + +## Code Map — Where to Change Things + +### Changing the relay address or room + +Edit `CallViewModel.kt`: +```kotlin +companion object { + const val DEFAULT_RELAY = "172.16.81.125:4433" + const val DEFAULT_ROOM = "android" +} +``` + +For a proper settings screen, add a new Composable in `ui/` that persists to `SharedPreferences` and passes values to `viewModel.startCall(relay, room)`. + +### Adding authentication + +1. In `CallViewModel.startCall()`, pass a token parameter +2. In `engine.rs`, after QUIC connect but before CallOffer, send: + ```rust + transport.send_signal(&SignalMessage::AuthToken { token: auth_token }).await?; + ``` +3. Wait for the relay to accept before proceeding to handshake +4. Start relay with `--auth-url ` + +### Enabling media encryption + +The crypto session is already derived in `engine.rs` but not applied to packets. To enable: + +1. Pass `_session` (currently unused) to the send/recv tasks +2. Before `transport.send_media()`, encrypt the payload: + ```rust + let mut ciphertext = Vec::new(); + session.encrypt(&header_bytes, &payload, &mut ciphertext)?; + packet.payload = Bytes::from(ciphertext); + ``` +3. After `transport.recv_media()`, decrypt: + ```rust + let mut plaintext = Vec::new(); + session.decrypt(&header_bytes, &pkt.payload, &mut plaintext)?; + pkt.payload = Bytes::from(plaintext); + ``` + +### Adding a new codec / quality profile + +1. Define the profile in `wzp-proto/src/codec_id.rs` +2. Implement `AudioEncoder`/`AudioDecoder` traits in `wzp-codec` +3. Register in `AdaptiveEncoder`/`AdaptiveDecoder` switch logic +4. Add to `supported_profiles` in the CallOffer (engine.rs) + +### Changing audio parameters + +- **Sample rate**: Change `FRAME_SAMPLES` in `audio_android.rs` and `WzpOboeConfig.sample_rate` in `oboe_bridge.cpp`. Must match the codec's expected rate. +- **Frame duration**: Change `FRAME_SAMPLES` (960 = 20ms at 48kHz, 1920 = 40ms) +- **Ring buffer size**: Change `RING_CAPACITY` in `audio_android.rs` +- **AEC tail length**: Change the `100` in `Pipeline::new()` → `EchoCanceller::new(48000, 100)` + +### Adding x86_64 support (emulator) + +1. `build.gradle.kts`: add `"x86_64"` to `abiFilters` +2. `cargoNdkBuild` task: add `-t x86_64` +3. `build.rs`: handle `x86_64-linux-android` target for Oboe +4. Note: Oboe in the emulator uses a different audio HAL — audio quality will differ + +## Dependency Overview + +### Rust Crate Dependencies (wzp-android) + +| Crate | Version | Purpose | Upgrade risk | +|-------|---------|---------|--------------| +| `jni` | 0.21 | Java FFI | Low — stable API | +| `tokio` | 1.x | Async runtime | Low | +| `quinn` | 0.11 | QUIC transport | Medium — breaking changes between 0.x | +| `rustls` | 0.23 | TLS for QUIC | Medium — tied to quinn version | +| `serde_json` | 1.x | Stats serialization | Low | +| `anyhow` | 1.x | Error handling | Low | +| `tracing` | 0.1 | Logging | Low | +| `rand` | 0.8 | Random seed generation | Low | + +### Workspace Crate Dependencies + +| Crate | Purpose | Key trait | +|-------|---------|-----------| +| `wzp-proto` | Shared types and traits | `MediaTransport`, `AudioEncoder`, `KeyExchange` | +| `wzp-codec` | Opus + Codec2 + signal processing | `AdaptiveEncoder`, `EchoCanceller` | +| `wzp-fec` | RaptorQ FEC | `RaptorQFecEncoder` | +| `wzp-crypto` | Key exchange + encryption | `WarzoneKeyExchange`, `ChaChaSession` | +| `wzp-transport` | QUIC connection management | `QuinnTransport`, `connect()` | + +### Android/Kotlin Dependencies + +| Library | Version | Purpose | +|---------|---------|---------| +| `compose-bom` | 2024.01.00 | Compose version alignment | +| `material3` | (from BOM) | UI components | +| `activity-compose` | 1.8.2 | Activity integration | +| `lifecycle-runtime-ktx` | 2.7.0 | ViewModel + coroutines | +| `core-ktx` | 1.12.0 | Kotlin extensions | + +## Updating Dependencies + +### Rust + +```bash +cargo update -p wzp-android +cargo ndk -t arm64-v8a build --release -p wzp-android +``` + +Watch for `quinn`/`rustls` version coupling. They must be compatible: +- quinn 0.11 requires rustls 0.23 + +### Android/Kotlin + +Update versions in `android/app/build.gradle.kts`. Key compatibility: +- `kotlinCompilerExtensionVersion` must match the Kotlin version +- `compose-bom` version determines all Compose library versions +- `compileSdk` and `targetSdk` should stay in sync + +### NDK + +If upgrading the NDK: +1. Update `ndkVersion` in `build.gradle.kts` +2. Update `ANDROID_NDK_HOME` environment variable +3. Update `CC_aarch64_linux_android` and friends +4. Verify Oboe still builds with the new toolchain + +## Key Invariants to Preserve + +1. **JNI function names must match package structure**: If the Kotlin package changes, all `Java_com_wzp_engine_WzpEngine_*` functions in `jni_bridge.rs` must be renamed. + +2. **Manifest uses fully-qualified class names**: Never use `.ClassName` shorthand because the Gradle namespace (`com.wzp.phone`) differs from the Kotlin package (`com.wzp`). + +3. **Stats JSON field names are snake_case**: Rust serializes with serde defaults (snake_case). Kotlin's `CallStats.fromJson()` expects `duration_secs`, `loss_pct`, etc. + +4. **Ring buffer ordering**: Producer uses Release store on write index, consumer uses Acquire load. Breaking this causes torn reads. + +5. **Codec thread owns Pipeline**: Pipeline is `!Send` (Opus encoder state). It must never be accessed from another thread. + +6. **panic::catch_unwind on all JNI functions**: Rust panics unwinding across the FFI boundary is UB. Every JNI-exposed function must catch panics. + +7. **Channel capacity (64)**: Both `send_tx` and `recv_tx` are bounded at 64 packets. If the network is slow, packets are dropped (`try_send` best-effort). + +## Testing + +### Unit Tests (Rust) + +```bash +# Run all workspace tests (host, not Android) +cargo test + +# Run only wzp-android tests (uses oboe_stub.cpp on host) +cargo test -p wzp-android +``` + +Note: Pipeline, codec, FEC, crypto tests run on the host. Audio tests use stubs. + +### On-Device Testing + +1. Build and install debug APK +2. Open app, tap CALL +3. Verify in logcat: + - `WzpEngine created via JNI` + - `connecting to relay...` + - `QUIC connected to relay` + - `CallOffer sent` + - `handshake complete, call active` + - `codec thread started` +4. Check stats overlay: frame counters should increment +5. Speak into mic — other connected device should hear audio + +### Stress Testing + +- Run a call for 30+ minutes — check for memory leaks (stats should be stable) +- Kill and restart the relay — client should eventually get a connection error +- Toggle mute rapidly — verify no crashes +- Switch speaker on/off — verify audio route changes + +## Performance Monitoring + +Key metrics to watch during a call: + +| Metric | Healthy Range | Warning | Critical | +|--------|--------------|---------|----------| +| frames_encoded | Increasing ~50/sec | Stalled | 0 | +| frames_decoded | Increasing ~50/sec | Stalled | 0 | +| underruns | < 5/min | > 20/min | > 100/min | +| jitter_buffer_depth | 2-5 | 0 or >10 | N/A | +| loss_pct | < 5% | 5-20% | > 20% | +| rtt_ms | < 100ms | 100-300ms | > 500ms | diff --git a/docs/android/roadmap.md b/docs/android/roadmap.md new file mode 100644 index 0000000..372d425 --- /dev/null +++ b/docs/android/roadmap.md @@ -0,0 +1,112 @@ +# Roadmap & Known Gaps + +## Current State Summary + +The Android client can connect to a WZP relay, complete the crypto handshake, and exchange audio in real-time. Two phones on the same network can talk to each other through the relay. + +## What Works (April 2025) + +- QUIC transport to relay with room-based SFU +- Full crypto handshake (X25519 ephemeral + Ed25519 signatures) +- Opus 24kbps encoding/decoding at 48kHz +- Lock-free audio I/O via Oboe (capture + playout) +- AEC (acoustic echo cancellation) with 100ms tail +- AGC (automatic gain control) +- RaptorQ FEC encoder/decoder (wired to pipeline) +- Adaptive jitter buffer (10-250 packets) +- UI with connect/disconnect, mute, speaker, live stats +- Random identity seed per app launch + +## Known Gaps + +### P0 — Must fix for usable calls + +| Gap | Impact | Where to fix | +|-----|--------|--------------| +| **Media encryption not applied** | Audio sent in cleartext over QUIC | `engine.rs` — pass `_session` to send/recv, encrypt/decrypt payloads | +| **FEC repair symbols not sent** | No loss recovery — audio gaps on packet loss | `engine.rs` send task — call `fec_encoder.generate_repair()` and send repair packets | +| **Quality reports not sent** | Relay can't monitor quality, no adaptive switching | `engine.rs` — periodically attach `QualityReport` to MediaPacket header | +| **CallService not started** | Call dies when app is backgrounded | `CallViewModel.startCall()` — call `CallService.start(context)` | + +### P1 — Important for production + +| Gap | Impact | Where to fix | +|-----|--------|--------------| +| **Hardcoded relay address** | Can't change server without rebuild | Add settings screen with `SharedPreferences` | +| **No reconnection logic** | Connection drop = call over | `engine.rs` network task — detect disconnect, retry with backoff | +| **No adaptive quality switching** | Stays on GOOD profile even in bad conditions | Wire `AdaptiveQualityController` to network path quality from `QuinnTransport` | +| **Identity seed not persisted** | New identity every launch | Save seed to Android Keystore or SharedPreferences | +| **No Bluetooth audio routing** | `AudioRouteManager` exists but not wired to UI | Add Bluetooth button to InCallScreen, call `AudioRouteManager` methods | +| **No ringtone/notification for incoming** | Only outgoing calls supported | Need signaling for call setup (currently both sides initiate independently) | + +### P2 — Nice to have + +| Gap | Impact | Where to fix | +|-----|--------|--------------| +| **No android_logger** | Rust tracing output lost on Android | Add `android_logger` crate, init in `nativeInit()` | +| **Stats don't include network metrics** | Loss/RTT/jitter always 0 | Feed `QuinnTransport.path_quality()` back to stats | +| **No ProGuard/R8 minification** | Release APK larger than necessary | Enable `isMinifyEnabled = true` in build.gradle.kts | +| **Single ABI (arm64-v8a)** | No support for older 32-bit devices or emulators | Add `armeabi-v7a` and `x86_64` to cargo-ndk build | +| **No call history** | Can't see past calls | Add Room database for call log | +| **No contact integration** | Manual relay/room entry | Add contacts with fingerprint-based identity | + +## Architecture Evolution Plan + +### Phase 1: Make Calls Reliable (current → next) + +``` +[x] QUIC connection to relay +[x] Crypto handshake +[x] Audio encode/decode pipeline +[ ] Media encryption (ChaCha20-Poly1305) +[ ] FEC repair packet transmission +[ ] Foreground service for background calls +[ ] Reconnection on network change +``` + +### Phase 2: Quality & Polish + +``` +[ ] Adaptive quality (GOOD → DEGRADED → CATASTROPHIC switching) +[ ] Quality reports in MediaPacket headers +[ ] Network path quality display (real RTT, loss, jitter) +[ ] Settings screen (relay, room, seed persistence) +[ ] Bluetooth/wired headset audio routing +[ ] Rust android_logger for debugging +``` + +### Phase 3: Production Features + +``` +[ ] featherChat authentication +[ ] Persistent identity (Android Keystore) +[ ] Push notifications for incoming calls +[ ] Multi-party rooms (already supported by relay) +[ ] Call transfer +[ ] End-to-end encryption (bypass relay decryption) +``` + +## Dependency Upgrade Path + +### quinn 0.11 → 0.12 (when released) + +Quinn 0.12 will likely require rustls 0.24. Update both together: +1. `Cargo.toml`: bump quinn and rustls versions +2. Check `client_config()` and `server_config()` in wzp-transport for API changes +3. DATAGRAM API may change — check `send_datagram()` / `read_datagram()` + +### Compose BOM 2024.01 → 2025.x + +The `LinearProgressIndicator` `progress` parameter changed from `Float` to `() -> Float` in Material3 1.2+. If upgrading the BOM: + +```kotlin +// Old (current): +LinearProgressIndicator(progress = level, ...) + +// New (Material3 1.2+): +LinearProgressIndicator(progress = { level }, ...) +``` + +### Kotlin 1.9 → 2.x + +Kotlin 2.0 changed the Compose compiler plugin. Update `kotlinCompilerExtensionVersion` in `composeOptions` and the Kotlin Gradle plugin version together. diff --git a/wzp-release.apk b/wzp-release.apk index 63a234b..e14cdbf 100644 Binary files a/wzp-release.apk and b/wzp-release.apk differ