feat: Android engine + Kotlin API for direct 1:1 calling
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m47s

Rust engine:
- start_signaling(): persistent _signal connection, presence registration
- Signal recv loop: handles DirectCallOffer, CallRinging, CallSetup, Hangup
- New CallState variants: Registered, Ringing, IncomingCall
- Stats expose incoming_call_id, incoming_caller_fp, incoming_caller_alias, sas_code
- New EngineCommands: PlaceCall, AnswerCall, RejectCall

JNI bridge:
- nativeStartSignaling(relay, seed, token, alias)
- nativePlaceCall(targetFp)
- nativeAnswerCall(callId, mode)

Kotlin API (WzpEngine.kt):
- startSignaling(relay, seed, token, alias)
- placeCall(targetFingerprint)
- answerCall(callId, mode) — 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-09 06:02:48 +04:00
parent 6694aebfd9
commit 5d8e743cbf
5 changed files with 304 additions and 0 deletions

View File

@@ -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")

View File

@@ -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 },
}

View File

@@ -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);
}

View File

@@ -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 }
}
}

View File

@@ -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<RoomMember>,
/// SAS code for verbal verification (None if not in a call).
#[serde(skip_serializing_if = "Option::is_none")]
pub sas_code: Option<u32>,
/// Incoming call info (present when state == IncomingCall).
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_call_id: Option<String>,
/// Fingerprint of the caller (present when state == IncomingCall).
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_fp: Option<String>,
/// Alias of the caller (present when state == IncomingCall).
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_alias: Option<String>,
}
/// A room member entry, serialized into the stats JSON.