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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send task runs in this task
|
||||
while let Some(encoded) = send_rx.recv().await {
|
||||
let seq = seq_c.fetch_add(1, Ordering::Relaxed);
|
||||
let ts = ts_c.fetch_add(20, Ordering::Relaxed);
|
||||
let packet = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq,
|
||||
timestamp: ts,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(encoded),
|
||||
quality_report: None,
|
||||
};
|
||||
if let Err(e) = transport.send_media(&packet).await {
|
||||
error!("send_media error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
recv_handle.abort();
|
||||
transport.close().await.ok();
|
||||
});
|
||||
|
||||
let recv_tx_clone = _recv_tx.clone();
|
||||
runtime.spawn(async move {
|
||||
// Network recv task: reads from transport, writes to recv_rx
|
||||
// This will be implemented when wzp-transport Android support is added
|
||||
let _tx = recv_tx_clone;
|
||||
// TODO: recv from wzp-transport and forward
|
||||
});
|
||||
|
||||
// Take the command receiver (it can only be taken once)
|
||||
// Take the command receiver
|
||||
let command_rx = self
|
||||
.state
|
||||
.command_rx
|
||||
@@ -157,11 +299,9 @@ impl WzpEngine {
|
||||
let codec_thread = std::thread::Builder::new()
|
||||
.name("wzp-codec".into())
|
||||
.spawn(move || {
|
||||
// Pin to big cores and set RT priority on Android
|
||||
crate::audio_android::pin_to_big_core();
|
||||
crate::audio_android::set_realtime_priority();
|
||||
|
||||
// Create audio backend
|
||||
let mut audio = OboeBackend::new();
|
||||
if let Err(e) = audio.start() {
|
||||
error!("failed to start audio: {e}");
|
||||
@@ -169,7 +309,6 @@ impl WzpEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pipeline
|
||||
let mut pipeline = match Pipeline::new(profile) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -181,52 +320,36 @@ impl WzpEngine {
|
||||
};
|
||||
|
||||
state.running.store(true, Ordering::Release);
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
info!("codec thread started");
|
||||
|
||||
// Track the last-applied AEC/AGC state so we only call
|
||||
// set_*_enabled when the value actually changes.
|
||||
let mut prev_aec = true;
|
||||
let mut prev_agc = true;
|
||||
|
||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||
#[allow(unused_assignments)]
|
||||
let mut recv_buf: Vec<u8> = Vec::new();
|
||||
|
||||
// Main codec loop: 20ms per iteration
|
||||
let frame_duration = std::time::Duration::from_millis(20);
|
||||
let mut recv_rx = recv_rx;
|
||||
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
let loop_start = Instant::now();
|
||||
|
||||
// Process commands (non-blocking)
|
||||
// Process commands
|
||||
while let Ok(cmd) = command_rx.try_recv() {
|
||||
match cmd {
|
||||
EngineCommand::SetMute(m) => {
|
||||
state.muted.store(m, Ordering::Relaxed);
|
||||
info!(muted = m, "mute toggled");
|
||||
}
|
||||
EngineCommand::SetSpeaker(s) => {
|
||||
state.speaker.store(s, Ordering::Relaxed);
|
||||
info!(speaker = s, "speaker toggled");
|
||||
}
|
||||
EngineCommand::ForceProfile(p) => {
|
||||
pipeline.force_profile(p);
|
||||
info!(?p, "profile forced");
|
||||
}
|
||||
EngineCommand::Stop => {
|
||||
info!("stop command received");
|
||||
state.running.store(false, Ordering::Release);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync AEC/AGC enabled flags from shared state.
|
||||
// Sync AEC/AGC
|
||||
let cur_aec = state.aec_enabled.load(Ordering::Relaxed);
|
||||
if cur_aec != prev_aec {
|
||||
pipeline.set_aec_enabled(cur_aec);
|
||||
@@ -247,22 +370,15 @@ impl WzpEngine {
|
||||
if captured >= FRAME_SAMPLES {
|
||||
let muted = state.muted.load(Ordering::Relaxed);
|
||||
if let Some(encoded) = pipeline.encode_frame(&capture_buf, muted) {
|
||||
// Send to network (best-effort)
|
||||
let _ = send_tx.try_send(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recv → Decode → Playout ---
|
||||
// Drain received packets from the network channel
|
||||
while let Ok(data) = recv_rx.try_recv() {
|
||||
recv_buf = data;
|
||||
// Deserialize the packet and feed to pipeline
|
||||
// For now, feed raw bytes — full MediaPacket deserialization
|
||||
// will be added with the transport integration
|
||||
let _ = &recv_buf; // suppress unused warning
|
||||
while let Ok(pkt) = recv_rx.try_recv() {
|
||||
pipeline.feed_packet(pkt);
|
||||
}
|
||||
|
||||
// Decode from jitter buffer
|
||||
if let Some(pcm) = pipeline.decode_frame() {
|
||||
audio.write_playout(&pcm);
|
||||
}
|
||||
@@ -278,108 +394,75 @@ impl WzpEngine {
|
||||
stats.quality_tier = pstats.quality_tier;
|
||||
}
|
||||
|
||||
// Sleep for remainder of the 20ms frame period
|
||||
let elapsed = loop_start.elapsed();
|
||||
if elapsed < frame_duration {
|
||||
std::thread::sleep(frame_duration - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
audio.stop();
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Closed;
|
||||
}
|
||||
info!("codec thread exited");
|
||||
})?;
|
||||
|
||||
self.codec_thread = Some(codec_thread);
|
||||
self.tokio_runtime = Some(runtime);
|
||||
self.call_start = Some(Instant::now());
|
||||
|
||||
info!("call started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the current call and clean up all resources.
|
||||
pub fn stop_call(&mut self) {
|
||||
if !self.state.running.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Signal stop
|
||||
self.state.running.store(false, Ordering::Release);
|
||||
let _ = self.state.command_tx.send(EngineCommand::Stop);
|
||||
|
||||
// Join codec thread
|
||||
if let Some(handle) = self.codec_thread.take() {
|
||||
if let Err(e) = handle.join() {
|
||||
warn!("codec thread panicked: {e:?}");
|
||||
}
|
||||
let _ = handle.join();
|
||||
}
|
||||
|
||||
// Shut down tokio runtime
|
||||
if let Some(rt) = self.tokio_runtime.take() {
|
||||
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||
}
|
||||
|
||||
self.call_start = None;
|
||||
info!("call stopped");
|
||||
}
|
||||
|
||||
/// Set microphone mute state.
|
||||
pub fn set_mute(&self, muted: bool) {
|
||||
let _ = self.state.command_tx.send(EngineCommand::SetMute(muted));
|
||||
}
|
||||
|
||||
/// Set speaker (loudspeaker) mode.
|
||||
#[allow(unused)]
|
||||
pub fn set_speaker(&self, enabled: bool) {
|
||||
let _ = self
|
||||
.state
|
||||
.command_tx
|
||||
.send(EngineCommand::SetSpeaker(enabled));
|
||||
let _ = self.state.command_tx.send(EngineCommand::SetSpeaker(enabled));
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&self, enabled: bool) {
|
||||
self.state.aec_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Enable or disable automatic gain control.
|
||||
pub fn set_agc_enabled(&self, enabled: bool) {
|
||||
self.state.agc_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Force a specific quality profile (overrides adaptive logic).
|
||||
#[allow(unused)]
|
||||
pub fn force_profile(&self, profile: QualityProfile) {
|
||||
let _ = self
|
||||
.state
|
||||
.command_tx
|
||||
.send(EngineCommand::ForceProfile(profile));
|
||||
let _ = self.state.command_tx.send(EngineCommand::ForceProfile(profile));
|
||||
}
|
||||
|
||||
/// Get a snapshot of the current call statistics.
|
||||
pub fn get_stats(&self) -> CallStats {
|
||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
||||
// Update duration from wall clock
|
||||
if let Some(start) = self.call_start {
|
||||
stats.duration_secs = start.elapsed().as_secs_f64();
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
/// Check if a call is currently active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.running.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Destroy the engine, stopping any active call.
|
||||
pub fn destroy(mut self) {
|
||||
self.stop_call();
|
||||
info!("engine destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user