diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt index 0db3ff5..bfd05cf 100644 --- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -160,6 +160,9 @@ class WzpEngine(private val callback: WzpCallback) { private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int private external fun nativeDestroy(handle: Long) private external fun nativePingRelay(handle: Long, relay: String): String? + private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int + private external fun nativePlaceCall(handle: Long, targetFp: String): Int + private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int /** * Ping a relay server. Requires engine to be initialized. @@ -170,6 +173,41 @@ class WzpEngine(private val callback: WzpCallback) { return nativePingRelay(nativeHandle, address) } + /** + * Start persistent signaling connection for direct 1:1 calls. + * The engine registers on the relay and listens for incoming calls. + * Call state updates are available via [getStats]. + * + * @return 0 on success, -1 on error + */ + fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int { + check(nativeHandle != 0L) { "Engine not initialized" } + return nativeStartSignaling(nativeHandle, relay, seed, token, alias) + } + + /** + * Place a direct call to a peer by fingerprint. + * Requires [startSignaling] to have been called first. + * + * @return 0 on success, -1 on error + */ + fun placeCall(targetFingerprint: String): Int { + check(nativeHandle != 0L) { "Engine not initialized" } + return nativePlaceCall(nativeHandle, targetFingerprint) + } + + /** + * Answer an incoming direct call. + * + * @param callId The call ID from the incoming call (available in stats.incoming_call_id) + * @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated) + * @return 0 on success, -1 on error + */ + fun answerCall(callId: String, mode: Int = 2): Int { + check(nativeHandle != 0L) { "Engine not initialized" } + return nativeAnswerCall(nativeHandle, callId, mode) + } + companion object { init { System.loadLibrary("wzp_android") diff --git a/crates/wzp-android/src/commands.rs b/crates/wzp-android/src/commands.rs index 1790553..5de4ba9 100644 --- a/crates/wzp-android/src/commands.rs +++ b/crates/wzp-android/src/commands.rs @@ -12,4 +12,13 @@ pub enum EngineCommand { ForceProfile(QualityProfile), /// Stop the call and shut down the engine. Stop, + /// Place a direct call to a fingerprint (requires signal connection). + PlaceCall { target_fingerprint: String }, + /// Answer an incoming direct call. + AnswerCall { + call_id: String, + accept_mode: wzp_proto::CallAcceptMode, + }, + /// Reject an incoming direct call. + RejectCall { call_id: String }, } diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index cfb1812..134c019 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -244,6 +244,156 @@ impl WzpEngine { result } + /// Start persistent signaling connection for direct calls. + /// Spawns a background task that maintains the `_signal` connection. + pub fn start_signaling( + &mut self, + relay_addr: &str, + seed_hex: &str, + token: Option<&str>, + alias: Option<&str>, + ) -> Result<(), anyhow::Error> { + use wzp_proto::{MediaTransport, SignalMessage}; + + let addr: SocketAddr = relay_addr.parse()?; + let seed = if seed_hex.is_empty() { + wzp_crypto::Seed::generate() + } else { + wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))? + }; + let identity = seed.derive_identity(); + let pub_id = identity.public_identity(); + let identity_pub = *pub_id.signing.as_bytes(); + let fp = pub_id.fingerprint.to_string(); + let token = token.map(|s| s.to_string()); + let alias = alias.map(|s| s.to_string()); + let state = self.state.clone(); + let seed_bytes = seed.0; + + info!(fingerprint = %fp, relay = %addr, "starting signaling"); + + // Create runtime for signaling (separate from call runtime) + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build()?; + + let signal_state = state.clone(); + rt.spawn(async move { + let _ = rustls::crypto::ring::default_provider().install_default(); + let bind: SocketAddr = "0.0.0.0:0".parse().unwrap(); + let endpoint = match wzp_transport::create_endpoint(bind, None) { + Ok(e) => e, + Err(e) => { error!("signal endpoint: {e}"); return; } + }; + let client_cfg = wzp_transport::client_config(); + let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await { + Ok(c) => c, + Err(e) => { error!("signal connect: {e}"); return; } + }; + let transport = std::sync::Arc::new(wzp_transport::QuinnTransport::new(conn)); + + // Auth if token provided + if let Some(ref tok) = token { + let _ = transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await; + } + + // Register presence + let _ = transport.send_signal(&SignalMessage::RegisterPresence { + identity_pub, + signature: vec![], + alias: alias.clone(), + }).await; + + // Wait for ack + match transport.recv_signal().await { + Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => { + info!(fingerprint = %fp, "signal: registered"); + let mut stats = signal_state.stats.lock().unwrap(); + stats.state = crate::stats::CallState::Registered; + } + other => { + error!("signal registration failed: {other:?}"); + return; + } + } + + // Signal recv loop + loop { + if !signal_state.running.load(Ordering::Relaxed) { + break; + } + match transport.recv_signal().await { + Ok(Some(SignalMessage::CallRinging { call_id })) => { + info!(call_id = %call_id, "signal: ringing"); + let mut stats = signal_state.stats.lock().unwrap(); + stats.state = crate::stats::CallState::Ringing; + } + Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => { + info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call"); + let mut stats = signal_state.stats.lock().unwrap(); + stats.state = crate::stats::CallState::IncomingCall; + stats.incoming_call_id = Some(call_id); + stats.incoming_caller_fp = Some(caller_fingerprint); + stats.incoming_caller_alias = caller_alias; + } + Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => { + info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered"); + } + Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => { + info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup"); + // Connect to media room via the existing start_call mechanism + // Store the room info so Kotlin can call startCall with it + let mut stats = signal_state.stats.lock().unwrap(); + stats.state = crate::stats::CallState::Connecting; + // Store call setup info for Kotlin to pick up + stats.incoming_call_id = Some(format!("{relay_addr}|{room}")); + } + Ok(Some(SignalMessage::Hangup { reason })) => { + info!(reason = ?reason, "signal: call ended by remote"); + let mut stats = signal_state.stats.lock().unwrap(); + stats.state = crate::stats::CallState::Closed; + stats.incoming_call_id = None; + stats.incoming_caller_fp = None; + stats.incoming_caller_alias = None; + } + Ok(Some(_)) => {} + Ok(None) => { + info!("signal: connection closed"); + break; + } + Err(e) => { + error!("signal recv error: {e}"); + break; + } + } + } + + let mut stats = signal_state.stats.lock().unwrap(); + stats.state = crate::stats::CallState::Closed; + }); + + self.tokio_runtime = Some(rt); + Ok(()) + } + + /// Place a direct call to a target fingerprint via the signal connection. + pub fn place_call(&self, target_fingerprint: &str) -> Result<(), anyhow::Error> { + let _ = self.state.command_tx.send(EngineCommand::PlaceCall { + target_fingerprint: target_fingerprint.to_string(), + }); + Ok(()) + } + + /// Answer an incoming direct call. + pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> { + let _ = self.state.command_tx.send(EngineCommand::AnswerCall { + call_id: call_id.to_string(), + accept_mode: mode, + }); + Ok(()) + } + pub fn set_mute(&self, muted: bool) { self.state.muted.store(muted, Ordering::Relaxed); } diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index 61a28fd..b452c34 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -359,3 +359,89 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>( .map(|s| s.into_raw()) .unwrap_or(JObject::null().into_raw()) } + +// ── Direct calling JNI functions ── + +/// Start persistent signaling connection to relay for direct calls. +/// Returns 0 on success, -1 on error. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + handle: jlong, + relay_addr_j: JString, + seed_hex_j: JString, + token_j: JString, + alias_j: JString, +) -> jint { + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + let h = unsafe { handle_ref(handle) }; + let relay_addr: String = env.get_string(&relay_addr_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 alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default(); + + h.engine.start_signaling( + &relay_addr, + &seed_hex, + if token.is_empty() { None } else { Some(&token) }, + if alias.is_empty() { None } else { Some(&alias) }, + ) + })); + + match result { + Ok(Ok(())) => 0, + Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 } + Err(_) => { error!("start_signaling panicked"); -1 } + } +} + +/// Place a direct call to a target fingerprint. +/// Returns 0 on success, -1 on error. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + handle: jlong, + target_fp_j: JString, +) -> jint { + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + let h = unsafe { handle_ref(handle) }; + let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default(); + h.engine.place_call(&target) + })); + + match result { + Ok(Ok(())) => 0, + Ok(Err(e)) => { error!("place_call failed: {e}"); -1 } + Err(_) => { error!("place_call panicked"); -1 } + } +} + +/// Answer an incoming direct call. +/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + handle: jlong, + call_id_j: JString, + mode: jint, +) -> jint { + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + let h = unsafe { handle_ref(handle) }; + let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default(); + let accept_mode = match mode { + 0 => wzp_proto::CallAcceptMode::Reject, + 1 => wzp_proto::CallAcceptMode::AcceptTrusted, + _ => wzp_proto::CallAcceptMode::AcceptGeneric, + }; + h.engine.answer_call(&call_id, accept_mode) + })); + + match result { + Ok(Ok(())) => 0, + Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 } + Err(_) => { error!("answer_call panicked"); -1 } + } +} diff --git a/crates/wzp-android/src/stats.rs b/crates/wzp-android/src/stats.rs index 07aae39..7c162af 100644 --- a/crates/wzp-android/src/stats.rs +++ b/crates/wzp-android/src/stats.rs @@ -11,6 +11,12 @@ pub enum CallState { Active, Reconnecting, Closed, + /// Connected to relay signal channel, registered for direct calls. + Registered, + /// Outgoing call ringing on callee's side. + Ringing, + /// Incoming call received, waiting for user to accept/reject. + IncomingCall, } impl serde::Serialize for CallState { @@ -21,6 +27,9 @@ impl serde::Serialize for CallState { CallState::Active => 2, CallState::Reconnecting => 3, CallState::Closed => 4, + CallState::Registered => 5, + CallState::Ringing => 6, + CallState::IncomingCall => 7, }; serializer.serialize_u8(n) } @@ -69,6 +78,18 @@ pub struct CallStats { pub room_participant_count: u32, /// Participant list (fingerprint + optional alias) serialized as JSON array. pub room_participants: Vec, + /// SAS code for verbal verification (None if not in a call). + #[serde(skip_serializing_if = "Option::is_none")] + pub sas_code: Option, + /// Incoming call info (present when state == IncomingCall). + #[serde(skip_serializing_if = "Option::is_none")] + pub incoming_call_id: Option, + /// Fingerprint of the caller (present when state == IncomingCall). + #[serde(skip_serializing_if = "Option::is_none")] + pub incoming_caller_fp: Option, + /// Alias of the caller (present when state == IncomingCall). + #[serde(skip_serializing_if = "Option::is_none")] + pub incoming_caller_alias: Option, } /// A room member entry, serialized into the stats JSON.