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>
226 lines
8.2 KiB
Kotlin
226 lines
8.2 KiB
Kotlin
package com.wzp.engine
|
|
|
|
/**
|
|
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
|
|
*
|
|
* Lifecycle:
|
|
* 1. Construct with a [WzpCallback]
|
|
* 2. Call [init] to create the native engine
|
|
* 3. Call [startCall] to begin a VoIP session
|
|
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
|
|
* 5. Call [stopCall] to end the session
|
|
* 6. Call [destroy] when the engine is no longer needed
|
|
*
|
|
* Thread safety: all methods must be called from the same thread (typically main).
|
|
*/
|
|
class WzpEngine(private val callback: WzpCallback) {
|
|
|
|
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
|
|
private var nativeHandle: Long = 0L
|
|
|
|
/** Whether the engine has been initialised. */
|
|
val isInitialized: Boolean get() = nativeHandle != 0L
|
|
|
|
/** Create the native engine. Must be called before any other method. */
|
|
fun init() {
|
|
check(nativeHandle == 0L) { "Engine already initialized" }
|
|
nativeHandle = nativeInit()
|
|
check(nativeHandle != 0L) { "Native engine creation failed" }
|
|
}
|
|
|
|
/**
|
|
* Start a call.
|
|
*
|
|
* @param relayAddr relay server address (host:port)
|
|
* @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)
|
|
* @param alias display name sent to relay for room participant list
|
|
* @return 0 on success, negative error code on failure
|
|
*/
|
|
/**
|
|
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
|
|
*/
|
|
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
|
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
|
if (result == 0) {
|
|
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
|
} else {
|
|
callback.onError(result, "Failed to start call")
|
|
}
|
|
return result
|
|
}
|
|
|
|
/** Stop the active call. Safe to call when no call is active. */
|
|
@Synchronized
|
|
fun stopCall() {
|
|
if (nativeHandle != 0L) {
|
|
nativeStopCall(nativeHandle)
|
|
callback.onCallStateChanged(CallStateConstants.CLOSED)
|
|
}
|
|
}
|
|
|
|
/** Mute or unmute the microphone. */
|
|
fun setMute(muted: Boolean) {
|
|
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
|
|
}
|
|
|
|
/** Enable or disable loudspeaker mode. */
|
|
fun setSpeaker(speaker: Boolean) {
|
|
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
|
|
}
|
|
|
|
|
|
/**
|
|
* Get current call statistics as a JSON string.
|
|
*
|
|
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
|
*/
|
|
@Synchronized
|
|
fun getStats(): String {
|
|
if (nativeHandle == 0L) return "{}"
|
|
return try {
|
|
nativeGetStats(nativeHandle) ?: "{}"
|
|
} catch (_: Exception) {
|
|
"{}"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force a quality profile, overriding adaptive selection.
|
|
*
|
|
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
|
|
*/
|
|
fun forceProfile(profile: Int) {
|
|
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
|
}
|
|
|
|
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
|
@Synchronized
|
|
fun destroy() {
|
|
if (nativeHandle != 0L) {
|
|
nativeDestroy(nativeHandle)
|
|
nativeHandle = 0L
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write captured PCM samples into the engine's capture ring buffer.
|
|
* Called from the AudioRecord capture thread.
|
|
*/
|
|
fun writeAudio(pcm: ShortArray): Int {
|
|
if (nativeHandle == 0L) return 0
|
|
return nativeWriteAudio(nativeHandle, pcm)
|
|
}
|
|
|
|
/**
|
|
* Read decoded PCM samples from the engine's playout ring buffer.
|
|
* Called from the AudioTrack playout thread.
|
|
*/
|
|
fun readAudio(pcm: ShortArray): Int {
|
|
if (nativeHandle == 0L) return 0
|
|
return nativeReadAudio(nativeHandle, pcm)
|
|
}
|
|
|
|
/**
|
|
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
|
|
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
|
|
* Called from the AudioRecord capture thread.
|
|
*/
|
|
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
|
|
if (nativeHandle == 0L) return 0
|
|
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
|
|
}
|
|
|
|
/**
|
|
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
|
|
* The buffer must be a direct ByteBuffer with native byte order.
|
|
* Called from the AudioTrack playout thread.
|
|
*/
|
|
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
|
|
if (nativeHandle == 0L) return 0
|
|
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
|
|
}
|
|
|
|
// -- JNI native methods --------------------------------------------------
|
|
|
|
private external fun nativeInit(): Long
|
|
private external fun nativeStartCall(
|
|
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
|
|
): Int
|
|
private external fun nativeStopCall(handle: Long)
|
|
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
|
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
|
|
private external fun nativeGetStats(handle: Long): String?
|
|
private external fun nativeForceProfile(handle: Long, profile: Int)
|
|
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
|
|
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
|
|
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
|
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.
|
|
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
|
*/
|
|
fun pingRelay(address: String): String? {
|
|
if (nativeHandle == 0L) return null
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
|
object CallStateConstants {
|
|
const val IDLE = 0
|
|
const val CONNECTING = 1
|
|
const val ACTIVE = 2
|
|
const val RECONNECTING = 3
|
|
const val CLOSED = 4
|
|
}
|