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