feat: wire QUIC transport, JNI bridge, connect UI + add docs
- Replace raw FFI with proper `jni` crate for string marshalling - Wire QUIC transport in engine: connect to relay, crypto handshake (CallOffer/CallAnswer, X25519+Ed25519), send/recv MediaPackets - Feed received packets into jitter buffer (was previously ignored) - Add connect screen UI with CALL button (idle state) and in-call controls (mute, speaker, hang up, live stats) - Hardcode relay 172.16.81.125:4433, room "android" - Add comprehensive docs in docs/android/: architecture.md (8 mermaid diagrams), build-guide.md, debugging.md, maintenance.md, roadmap.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -4009,7 +4009,10 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"cc",
|
||||
"jni",
|
||||
"libc",
|
||||
"rand 0.8.5",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name=".WzpApplication"
|
||||
android:name="com.wzp.WzpApplication"
|
||||
android:label="WZ Phone"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".ui.call.CallActivity"
|
||||
android:name="com.wzp.ui.call.CallActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
@@ -26,7 +26,7 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.CallService"
|
||||
android:name="com.wzp.service.CallService"
|
||||
android:foregroundServiceType="phoneCall"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -45,7 +45,6 @@ class CallActivity : ComponentActivity() {
|
||||
viewModel = viewModel,
|
||||
onHangUp = {
|
||||
viewModel.stopCall()
|
||||
finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Int> = _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
|
||||
if (result == 0) {
|
||||
_callState.value = 1 // Connecting
|
||||
val result = engine?.startCall(relayAddr, room) ?: -1
|
||||
if (result == 0) {
|
||||
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)
|
||||
|
||||
@@ -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 ---------------------------------------------
|
||||
// App title
|
||||
Text(
|
||||
text = "WZ Phone",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
CallStateLabel(callState)
|
||||
|
||||
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))
|
||||
|
||||
// -- 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
|
||||
onHangUp = {
|
||||
viewModel.stopCall()
|
||||
// Don't finish activity — go back to idle
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// -- Stats overlay ------------------------------------------------
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
//! - A tokio runtime for async network I/O
|
||||
//! - Command channel for control from the JNI/UI thread
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_proto::QualityProfile;
|
||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_proto::{
|
||||
CodecId, MediaHeader, MediaPacket, MediaTransport, QualityProfile, SignalMessage,
|
||||
};
|
||||
|
||||
use crate::audio_android::{OboeBackend, FRAME_SAMPLES};
|
||||
use crate::commands::EngineCommand;
|
||||
@@ -24,6 +29,8 @@ pub struct CallStartConfig {
|
||||
pub profile: QualityProfile,
|
||||
/// Relay server address (host:port).
|
||||
pub relay_addr: String,
|
||||
/// Room name (passed as SNI).
|
||||
pub room: String,
|
||||
/// Authentication token for the relay.
|
||||
pub auth_token: Vec<u8>,
|
||||
/// 32-byte identity seed for key derivation.
|
||||
@@ -35,6 +42,7 @@ impl Default for CallStartConfig {
|
||||
Self {
|
||||
profile: QualityProfile::GOOD,
|
||||
relay_addr: String::new(),
|
||||
room: String::new(),
|
||||
auth_token: Vec::new(),
|
||||
identity_seed: [0u8; 32],
|
||||
}
|
||||
@@ -44,11 +52,10 @@ impl Default for CallStartConfig {
|
||||
/// Shared state between the engine owner and background threads.
|
||||
struct EngineState {
|
||||
running: AtomicBool,
|
||||
connected: AtomicBool,
|
||||
muted: AtomicBool,
|
||||
speaker: AtomicBool,
|
||||
/// Whether acoustic echo cancellation is enabled (default: true).
|
||||
aec_enabled: AtomicBool,
|
||||
/// Whether automatic gain control is enabled (default: true).
|
||||
agc_enabled: AtomicBool,
|
||||
stats: Mutex<CallStats>,
|
||||
command_tx: std::sync::mpsc::Sender<EngineCommand>,
|
||||
@@ -56,28 +63,19 @@ struct EngineState {
|
||||
}
|
||||
|
||||
/// The WarzonePhone Android engine.
|
||||
///
|
||||
/// Manages the entire call pipeline: audio capture/playout via Oboe,
|
||||
/// codec encode/decode, FEC, jitter buffer, and network transport.
|
||||
///
|
||||
/// Thread model:
|
||||
/// - **UI/JNI thread**: calls `start_call`, `stop_call`, `set_mute`, etc.
|
||||
/// - **Codec thread**: runs `Pipeline` encode/decode loop, reads/writes ring buffers
|
||||
/// - **Tokio runtime** (2 worker threads): async network send/recv
|
||||
pub struct WzpEngine {
|
||||
state: Arc<EngineState>,
|
||||
codec_thread: Option<std::thread::JoinHandle<()>>,
|
||||
#[allow(unused)]
|
||||
tokio_runtime: Option<tokio::runtime::Runtime>,
|
||||
call_start: Option<Instant>,
|
||||
}
|
||||
|
||||
impl WzpEngine {
|
||||
/// Create a new idle engine.
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let state = Arc::new(EngineState {
|
||||
running: AtomicBool::new(false),
|
||||
connected: AtomicBool::new(false),
|
||||
muted: AtomicBool::new(false),
|
||||
speaker: AtomicBool::new(false),
|
||||
aec_enabled: AtomicBool::new(true),
|
||||
@@ -95,16 +93,11 @@ impl WzpEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a call with the given configuration.
|
||||
///
|
||||
/// This creates the tokio runtime, starts the Oboe audio backend,
|
||||
/// and spawns the codec thread.
|
||||
pub fn start_call(&mut self, config: CallStartConfig) -> Result<(), anyhow::Error> {
|
||||
if self.state.running.load(Ordering::Acquire) {
|
||||
return Err(anyhow::anyhow!("call already active"));
|
||||
}
|
||||
|
||||
// Update state
|
||||
{
|
||||
let mut stats = self.state.stats.lock().unwrap();
|
||||
*stats = CallStats {
|
||||
@@ -113,36 +106,185 @@ impl WzpEngine {
|
||||
};
|
||||
}
|
||||
|
||||
// Create tokio runtime with 2 worker threads
|
||||
// Create tokio runtime
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("wzp-net")
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
// Create async channels for network send/recv
|
||||
let (send_tx, mut _send_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
let (_recv_tx, mut recv_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
// Channels between codec thread and network tasks
|
||||
let (send_tx, mut send_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
let (recv_tx, recv_rx) = tokio::sync::mpsc::channel::<MediaPacket>(64);
|
||||
|
||||
// Spawn network tasks (placeholder — will use wzp-transport)
|
||||
let _relay_addr = config.relay_addr.clone();
|
||||
// Shared sequence counter for outgoing packets
|
||||
let seq_counter = Arc::new(AtomicU16::new(0));
|
||||
let ts_counter = Arc::new(AtomicU32::new(0));
|
||||
|
||||
// Parse relay address
|
||||
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| {
|
||||
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr)
|
||||
})?;
|
||||
|
||||
let room = config.room.clone();
|
||||
let identity_seed = config.identity_seed;
|
||||
let state_net = self.state.clone();
|
||||
let seq_c = seq_counter.clone();
|
||||
let ts_c = ts_counter.clone();
|
||||
|
||||
// Spawn the combined network task (connect + handshake + send/recv)
|
||||
runtime.spawn(async move {
|
||||
// Network send task: reads from send_rx, sends via transport
|
||||
// This will be implemented when wzp-transport Android support is added
|
||||
while let Some(_packet) = _send_rx.recv().await {
|
||||
// TODO: send via wzp-transport
|
||||
// Install rustls crypto provider
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// Create QUIC endpoint
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = match wzp_transport::create_endpoint(bind_addr, None) {
|
||||
Ok(ep) => ep,
|
||||
Err(e) => {
|
||||
error!("failed to create QUIC endpoint: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to relay with room as SNI
|
||||
let sni = if room.is_empty() { "android" } else { &room };
|
||||
info!(%relay_addr, sni, "connecting to relay...");
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let conn = match wzp_transport::connect(&endpoint, relay_addr, sni, client_cfg).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("QUIC connect failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!("QUIC connected to relay");
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Crypto handshake: send CallOffer, receive CallAnswer
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(&identity_seed);
|
||||
let ephemeral_pub = kx.generate_ephemeral();
|
||||
let identity_pub = kx.identity_public_key();
|
||||
|
||||
// Sign (ephemeral_pub || "call-offer")
|
||||
let mut sign_data = Vec::with_capacity(32 + 10);
|
||||
sign_data.extend_from_slice(&ephemeral_pub);
|
||||
sign_data.extend_from_slice(b"call-offer");
|
||||
let signature = kx.sign(&sign_data);
|
||||
|
||||
let offer = SignalMessage::CallOffer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
};
|
||||
|
||||
if let Err(e) = transport.send_signal(&offer).await {
|
||||
error!("failed to send CallOffer: {e}");
|
||||
return;
|
||||
}
|
||||
info!("CallOffer sent, waiting for CallAnswer...");
|
||||
|
||||
// Receive CallAnswer
|
||||
let answer = match transport.recv_signal().await {
|
||||
Ok(Some(msg)) => msg,
|
||||
Ok(None) => {
|
||||
error!("connection closed before CallAnswer");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to receive CallAnswer: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (relay_ephemeral_pub, _chosen_profile) = match answer {
|
||||
SignalMessage::CallAnswer {
|
||||
ephemeral_pub,
|
||||
chosen_profile,
|
||||
..
|
||||
} => (ephemeral_pub, chosen_profile),
|
||||
other => {
|
||||
error!("expected CallAnswer, got {:?}", std::mem::discriminant(&other));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Derive crypto session (not encrypting media yet for simplicity)
|
||||
let _session = match kx.derive_session(&relay_ephemeral_pub) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("session derivation failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("handshake complete, call active");
|
||||
state_net.connected.store(true, Ordering::Release);
|
||||
{
|
||||
let mut stats = state_net.stats.lock().unwrap();
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
// Spawn recv task
|
||||
let recv_transport = transport.clone();
|
||||
let recv_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
match recv_transport.recv_media().await {
|
||||
Ok(Some(pkt)) => {
|
||||
if recv_tx.send(pkt).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("relay disconnected (recv)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("recv_media error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
// 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();
|
||||
});
|
||||
|
||||
// Take the command receiver (it can only be taken once)
|
||||
// Take the command receiver
|
||||
let command_rx = self
|
||||
.state
|
||||
.command_rx
|
||||
@@ -157,11 +299,9 @@ impl WzpEngine {
|
||||
let codec_thread = std::thread::Builder::new()
|
||||
.name("wzp-codec".into())
|
||||
.spawn(move || {
|
||||
// Pin to big cores and set RT priority on Android
|
||||
crate::audio_android::pin_to_big_core();
|
||||
crate::audio_android::set_realtime_priority();
|
||||
|
||||
// Create audio backend
|
||||
let mut audio = OboeBackend::new();
|
||||
if let Err(e) = audio.start() {
|
||||
error!("failed to start audio: {e}");
|
||||
@@ -169,7 +309,6 @@ impl WzpEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pipeline
|
||||
let mut pipeline = match Pipeline::new(profile) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -181,52 +320,36 @@ impl WzpEngine {
|
||||
};
|
||||
|
||||
state.running.store(true, Ordering::Release);
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
info!("codec thread started");
|
||||
|
||||
// Track the last-applied AEC/AGC state so we only call
|
||||
// set_*_enabled when the value actually changes.
|
||||
let mut prev_aec = true;
|
||||
let mut prev_agc = true;
|
||||
|
||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||
#[allow(unused_assignments)]
|
||||
let mut recv_buf: Vec<u8> = Vec::new();
|
||||
|
||||
// Main codec loop: 20ms per iteration
|
||||
let frame_duration = std::time::Duration::from_millis(20);
|
||||
let mut recv_rx = recv_rx;
|
||||
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
let loop_start = Instant::now();
|
||||
|
||||
// Process commands (non-blocking)
|
||||
// Process commands
|
||||
while let Ok(cmd) = command_rx.try_recv() {
|
||||
match cmd {
|
||||
EngineCommand::SetMute(m) => {
|
||||
state.muted.store(m, Ordering::Relaxed);
|
||||
info!(muted = m, "mute toggled");
|
||||
}
|
||||
EngineCommand::SetSpeaker(s) => {
|
||||
state.speaker.store(s, Ordering::Relaxed);
|
||||
info!(speaker = s, "speaker toggled");
|
||||
}
|
||||
EngineCommand::ForceProfile(p) => {
|
||||
pipeline.force_profile(p);
|
||||
info!(?p, "profile forced");
|
||||
}
|
||||
EngineCommand::Stop => {
|
||||
info!("stop command received");
|
||||
state.running.store(false, Ordering::Release);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync AEC/AGC enabled flags from shared state.
|
||||
// Sync AEC/AGC
|
||||
let cur_aec = state.aec_enabled.load(Ordering::Relaxed);
|
||||
if cur_aec != prev_aec {
|
||||
pipeline.set_aec_enabled(cur_aec);
|
||||
@@ -247,22 +370,15 @@ impl WzpEngine {
|
||||
if captured >= FRAME_SAMPLES {
|
||||
let muted = state.muted.load(Ordering::Relaxed);
|
||||
if let Some(encoded) = pipeline.encode_frame(&capture_buf, muted) {
|
||||
// Send to network (best-effort)
|
||||
let _ = send_tx.try_send(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recv → Decode → Playout ---
|
||||
// Drain received packets from the network channel
|
||||
while let Ok(data) = recv_rx.try_recv() {
|
||||
recv_buf = data;
|
||||
// Deserialize the packet and feed to pipeline
|
||||
// For now, feed raw bytes — full MediaPacket deserialization
|
||||
// will be added with the transport integration
|
||||
let _ = &recv_buf; // suppress unused warning
|
||||
while let Ok(pkt) = recv_rx.try_recv() {
|
||||
pipeline.feed_packet(pkt);
|
||||
}
|
||||
|
||||
// Decode from jitter buffer
|
||||
if let Some(pcm) = pipeline.decode_frame() {
|
||||
audio.write_playout(&pcm);
|
||||
}
|
||||
@@ -278,108 +394,75 @@ impl WzpEngine {
|
||||
stats.quality_tier = pstats.quality_tier;
|
||||
}
|
||||
|
||||
// Sleep for remainder of the 20ms frame period
|
||||
let elapsed = loop_start.elapsed();
|
||||
if elapsed < frame_duration {
|
||||
std::thread::sleep(frame_duration - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
audio.stop();
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Closed;
|
||||
}
|
||||
info!("codec thread exited");
|
||||
})?;
|
||||
|
||||
self.codec_thread = Some(codec_thread);
|
||||
self.tokio_runtime = Some(runtime);
|
||||
self.call_start = Some(Instant::now());
|
||||
|
||||
info!("call started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the current call and clean up all resources.
|
||||
pub fn stop_call(&mut self) {
|
||||
if !self.state.running.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Signal stop
|
||||
self.state.running.store(false, Ordering::Release);
|
||||
let _ = self.state.command_tx.send(EngineCommand::Stop);
|
||||
|
||||
// Join codec thread
|
||||
if let Some(handle) = self.codec_thread.take() {
|
||||
if let Err(e) = handle.join() {
|
||||
warn!("codec thread panicked: {e:?}");
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
// Shut down tokio runtime
|
||||
if let Some(rt) = self.tokio_runtime.take() {
|
||||
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||
}
|
||||
|
||||
self.call_start = None;
|
||||
info!("call stopped");
|
||||
}
|
||||
|
||||
/// Set microphone mute state.
|
||||
pub fn set_mute(&self, muted: bool) {
|
||||
let _ = self.state.command_tx.send(EngineCommand::SetMute(muted));
|
||||
}
|
||||
|
||||
/// Set speaker (loudspeaker) mode.
|
||||
#[allow(unused)]
|
||||
pub fn set_speaker(&self, enabled: bool) {
|
||||
let _ = self
|
||||
.state
|
||||
.command_tx
|
||||
.send(EngineCommand::SetSpeaker(enabled));
|
||||
let _ = self.state.command_tx.send(EngineCommand::SetSpeaker(enabled));
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&self, enabled: bool) {
|
||||
self.state.aec_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Enable or disable automatic gain control.
|
||||
pub fn set_agc_enabled(&self, enabled: bool) {
|
||||
self.state.agc_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Force a specific quality profile (overrides adaptive logic).
|
||||
#[allow(unused)]
|
||||
pub fn force_profile(&self, profile: QualityProfile) {
|
||||
let _ = self
|
||||
.state
|
||||
.command_tx
|
||||
.send(EngineCommand::ForceProfile(profile));
|
||||
let _ = self.state.command_tx.send(EngineCommand::ForceProfile(profile));
|
||||
}
|
||||
|
||||
/// Get a snapshot of the current call statistics.
|
||||
pub fn get_stats(&self) -> CallStats {
|
||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
||||
// Update duration from wall clock
|
||||
if let Some(start) = self.call_start {
|
||||
stats.duration_secs = start.elapsed().as_secs_f64();
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
/// Check if a call is currently active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.running.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Destroy the engine, stopping any active call.
|
||||
pub fn destroy(mut self) {
|
||||
self.stop_call();
|
||||
info!("engine destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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_<package>_<Class>_<method>
|
||||
// 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");
|
||||
}));
|
||||
}
|
||||
|
||||
41
docs/android/README.md
Normal file
41
docs/android/README.md
Normal file
@@ -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.
|
||||
400
docs/android/architecture.md
Normal file
400
docs/android/architecture.md
Normal file
@@ -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<br/>Compose UI]
|
||||
CA --> VM
|
||||
VM --> UI
|
||||
end
|
||||
|
||||
subgraph "JNI Bridge"
|
||||
JB[jni_bridge.rs<br/>panic-safe FFI]
|
||||
end
|
||||
|
||||
subgraph "Rust Engine"
|
||||
ENG[WzpEngine<br/>Orchestrator]
|
||||
CT[Codec Thread<br/>20ms real-time loop]
|
||||
NET[Tokio Runtime<br/>2 async workers]
|
||||
PIPE[Pipeline<br/>Encode/Decode/FEC/Jitter]
|
||||
end
|
||||
|
||||
subgraph "C++ Audio"
|
||||
OBOE[Oboe Bridge<br/>Capture + Playout callbacks]
|
||||
RB[Ring Buffers<br/>Lock-free SPSC]
|
||||
end
|
||||
|
||||
subgraph "Network"
|
||||
QUIC[QUIC Connection<br/>quinn]
|
||||
RELAY[WZP Relay<br/>SFU Room]
|
||||
end
|
||||
|
||||
VM <-->|"JNI calls<br/>+ 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<br/>+ 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<br/>startCall / stopCall / getStats"]
|
||||
end
|
||||
|
||||
subgraph "Oboe Audio Thread (system)"
|
||||
AUD["Capture callback: mic → ring buf<br/>Playout callback: ring buf → speaker<br/>⚡ Highest priority, no allocations"]
|
||||
end
|
||||
|
||||
subgraph "Codec Thread (wzp-codec)"
|
||||
COD["20ms loop:<br/>1. Read capture ring buf<br/>2. AEC → AGC → Encode<br/>3. Send to network channel<br/>4. Recv from network channel<br/>5. FEC → Jitter → Decode<br/>6. Write playout ring buf<br/>⚡ Pinned to big core, RT priority"]
|
||||
end
|
||||
|
||||
subgraph "Tokio Runtime (2 workers)"
|
||||
NET_S["Send task:<br/>Channel → MediaPacket → QUIC datagram"]
|
||||
NET_R["Recv task:<br/>QUIC datagram → MediaPacket → Channel"]
|
||||
HS["Handshake:<br/>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<br/>Callback]
|
||||
OBOE_C -->|"ring_write()"| RB_C[Capture<br/>Ring Buffer]
|
||||
RB_C -->|"read_capture()"| AEC[Echo<br/>Canceller]
|
||||
AEC --> AGC[Auto Gain<br/>Control]
|
||||
AGC --> ENC[AdaptiveEncoder<br/>Opus 24k]
|
||||
ENC -->|"Vec u8"| FEC_E[RaptorQ<br/>FEC Encoder]
|
||||
FEC_E -->|"send_tx"| CHAN_S[Send Channel]
|
||||
end
|
||||
|
||||
subgraph "Network"
|
||||
CHAN_S --> PKT_S[MediaPacket<br/>Header + Payload]
|
||||
PKT_S -->|"QUIC DATAGRAM"| RELAY[Relay SFU]
|
||||
RELAY -->|"QUIC DATAGRAM"| PKT_R[MediaPacket<br/>Deserialize]
|
||||
PKT_R -->|"recv_tx"| CHAN_R[Recv Channel]
|
||||
end
|
||||
|
||||
subgraph "Playout Path"
|
||||
CHAN_R --> FEC_D[RaptorQ<br/>FEC Decoder]
|
||||
FEC_D --> JB[Jitter Buffer<br/>10-250 pkts]
|
||||
JB --> DEC[AdaptiveDecoder<br/>Opus 24k]
|
||||
DEC -->|"48kHz i16"| AEC_REF[AEC Far-End<br/>Reference]
|
||||
DEC -->|"write_playout()"| RB_P[Playout<br/>Ring Buffer]
|
||||
RB_P -->|"ring_read()"| OBOE_P[Oboe Playout<br/>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<br/>{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<br/>{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<br/>0.0.0.0:0 UDP]
|
||||
CONN[Connection to Relay<br/>SNI = room name]
|
||||
|
||||
subgraph "Unreliable Channel"
|
||||
DG_S[Send DATAGRAM<br/>MediaPacket serialized]
|
||||
DG_R[Recv DATAGRAM<br/>MediaPacket deserialized]
|
||||
end
|
||||
|
||||
subgraph "Reliable Channel"
|
||||
ST_S[Open bidi stream<br/>JSON length-prefixed<br/>SignalMessage]
|
||||
ST_R[Accept bidi stream<br/>JSON length-prefixed<br/>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<br/>QUIC conn] -->|MediaPacket| RELAY[Relay SFU]
|
||||
RELAY -->|MediaPacket| P2[Phone B<br/>QUIC conn]
|
||||
P2 -->|MediaPacket| RELAY
|
||||
RELAY -->|MediaPacket| P1
|
||||
end
|
||||
|
||||
Note1["Room name from QUIC TLS SNI<br/>No auth required<br/>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<br/>loss%, RTT, jitter] --> AQC[AdaptiveQualityController]
|
||||
|
||||
AQC -->|"loss<10%, RTT<400ms"| GOOD[GOOD<br/>Opus 24kbps<br/>FEC 20%<br/>20ms frames]
|
||||
AQC -->|"loss 10-40%<br/>RTT 400-600ms"| DEG[DEGRADED<br/>Opus 6kbps<br/>FEC 50%<br/>40ms frames]
|
||||
AQC -->|"loss>40%<br/>RTT>600ms"| CAT[CATASTROPHIC<br/>Codec2 1.2kbps<br/>FEC 100%<br/>40ms frames]
|
||||
|
||||
GOOD -->|"Hysteresis:<br/>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<br/>Types, traits, jitter,<br/>quality, session]
|
||||
CODEC[wzp-codec<br/>Opus, Codec2, AEC,<br/>AGC, resampling]
|
||||
FEC[wzp-fec<br/>RaptorQ fountain codes]
|
||||
CRYPTO[wzp-crypto<br/>Ed25519, X25519,<br/>ChaCha20-Poly1305]
|
||||
TRANSPORT[wzp-transport<br/>QUIC, datagrams,<br/>signaling streams]
|
||||
ANDROID[wzp-android<br/>Engine, JNI bridge,<br/>Oboe audio, pipeline]
|
||||
RELAY[wzp-relay<br/>SFU, rooms, auth,<br/>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 |
|
||||
155
docs/android/build-guide.md
Normal file
155
docs/android/build-guide.md
Normal file
@@ -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
|
||||
```
|
||||
214
docs/android/debugging.md
Normal file
214
docs/android/debugging.md
Normal file
@@ -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
|
||||
<!-- WRONG -->
|
||||
<application android:name=".WzpApplication">
|
||||
<activity android:name=".ui.call.CallActivity">
|
||||
|
||||
<!-- CORRECT -->
|
||||
<application android:name="com.wzp.WzpApplication">
|
||||
<activity android:name="com.wzp.ui.call.CallActivity">
|
||||
```
|
||||
|
||||
### 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<address_from_crash>
|
||||
```
|
||||
|
||||
## 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_<methodName>
|
||||
```
|
||||
|
||||
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 <host> <port>`)
|
||||
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)
|
||||
190
docs/android/maintenance.md
Normal file
190
docs/android/maintenance.md
Normal file
@@ -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 <featherchat-endpoint>`
|
||||
|
||||
### 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 |
|
||||
112
docs/android/roadmap.md
Normal file
112
docs/android/roadmap.md
Normal file
@@ -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.
|
||||
BIN
wzp-release.apk
BIN
wzp-release.apk
Binary file not shown.
Reference in New Issue
Block a user