refactor: separate SignalManager from WzpEngine for direct calling
SignalManager (NEW): - Dedicated Rust struct with its own QUIC connection to _signal - Separate JNI handle (nativeSignalConnect/GetState/PlaceCall/etc) - Kotlin wrapper polls state every 500ms via getState() JSON - Lives independently of WzpEngine — survives across calls - connect() blocks briefly on 8MB thread, then recv loop runs on dedicated thread WzpEngine (CLEANED): - Back to pure media-only role (audio, codec, FEC, jitter) - Removed start_signaling/place_call/answer_call methods - Removed signal_transport/signal_fingerprint from EngineState CallViewModel: - Two separate managers: signalManager (persistent) + engine (per-call) - Two separate polling loops: signalPollJob + statsJob - Auto-connect to media room when signal polling detects "setup" state - hangupDirectCall() ends media but keeps signal alive Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.wzp.engine
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Persistent signal connection for direct 1:1 calls.
|
||||
* Separate from WzpEngine — survives across calls.
|
||||
*
|
||||
* Lifecycle: connect() → [placeCall/answerCall] → destroy()
|
||||
*/
|
||||
class SignalManager {
|
||||
|
||||
private var handle: Long = 0L
|
||||
|
||||
val isConnected: Boolean get() = handle != 0L
|
||||
|
||||
/**
|
||||
* Connect to relay and register for direct calls.
|
||||
* MUST be called from a thread with sufficient stack (8MB).
|
||||
* Blocks briefly during QUIC connect + register, then returns.
|
||||
*/
|
||||
fun connect(relay: String, seedHex: String): Boolean {
|
||||
if (handle != 0L) return true // already connected
|
||||
handle = nativeSignalConnect(relay, seedHex)
|
||||
return handle != 0L
|
||||
}
|
||||
|
||||
/** Get current signal state as parsed object. Non-blocking. */
|
||||
fun getState(): SignalState {
|
||||
if (handle == 0L) return SignalState()
|
||||
val json = nativeSignalGetState(handle) ?: return SignalState()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
SignalState(
|
||||
status = obj.optString("status", "idle"),
|
||||
fingerprint = obj.optString("fingerprint", ""),
|
||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id"),
|
||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp"),
|
||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias"),
|
||||
callSetupRelay = if (obj.isNull("call_setup_relay")) null else obj.optString("call_setup_relay"),
|
||||
callSetupRoom = if (obj.isNull("call_setup_room")) null else obj.optString("call_setup_room"),
|
||||
callSetupId = if (obj.isNull("call_setup_id")) null else obj.optString("call_setup_id"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
SignalState()
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a direct call to a target fingerprint. */
|
||||
fun placeCall(targetFp: String): Int {
|
||||
if (handle == 0L) return -1
|
||||
return nativeSignalPlaceCall(handle, targetFp)
|
||||
}
|
||||
|
||||
/** Answer an incoming call. mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric */
|
||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||
if (handle == 0L) return -1
|
||||
return nativeSignalAnswerCall(handle, callId, mode)
|
||||
}
|
||||
|
||||
/** Send hangup signal. */
|
||||
fun hangup() {
|
||||
if (handle != 0L) nativeSignalHangup(handle)
|
||||
}
|
||||
|
||||
/** Destroy the signal manager. */
|
||||
fun destroy() {
|
||||
if (handle != 0L) {
|
||||
nativeSignalDestroy(handle)
|
||||
handle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
// JNI native methods
|
||||
private external fun nativeSignalConnect(relay: String, seed: String): Long
|
||||
private external fun nativeSignalGetState(handle: Long): String?
|
||||
private external fun nativeSignalPlaceCall(handle: Long, targetFp: String): Int
|
||||
private external fun nativeSignalAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||
private external fun nativeSignalHangup(handle: Long)
|
||||
private external fun nativeSignalDestroy(handle: Long)
|
||||
|
||||
companion object {
|
||||
init { System.loadLibrary("wzp_android") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Signal connection state. */
|
||||
data class SignalState(
|
||||
val status: String = "idle",
|
||||
val fingerprint: String = "",
|
||||
val incomingCallId: String? = null,
|
||||
val incomingCallerFp: String? = null,
|
||||
val incomingCallerAlias: String? = null,
|
||||
val callSetupRelay: String? = null,
|
||||
val callSetupRoom: String? = null,
|
||||
val callSetupId: String? = null,
|
||||
)
|
||||
@@ -141,9 +141,9 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _targetFingerprint = MutableStateFlow("")
|
||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
||||
|
||||
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
||||
private val _signalState = MutableStateFlow(0)
|
||||
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
||||
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */
|
||||
private val _signalState = MutableStateFlow("idle")
|
||||
val signalState: StateFlow<String> = _signalState.asStateFlow()
|
||||
|
||||
/** Incoming call info */
|
||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||
@@ -155,37 +155,65 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
||||
|
||||
/** Separate signal manager (persistent, survives calls) */
|
||||
private var signalManager: com.wzp.engine.SignalManager? = null
|
||||
private var signalPollJob: Job? = null
|
||||
|
||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
||||
|
||||
/** Register on relay for direct calls */
|
||||
fun registerForCalls() {
|
||||
if (engine == null) {
|
||||
engine = WzpEngine(this).also { it.init() }
|
||||
}
|
||||
val serverIdx = _selectedServer.value
|
||||
val serverList = _servers.value
|
||||
if (serverIdx >= serverList.size) return
|
||||
|
||||
val relay = serverList[serverIdx].address
|
||||
val seed = _seedHex.value
|
||||
val alias = _alias.value
|
||||
|
||||
// Start stats polling BEFORE blocking — startSignaling blocks the thread forever
|
||||
startStatsPolling()
|
||||
|
||||
// Use a Java Thread with 8MB stack — blocks forever in signal recv loop
|
||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||
|
||||
// Connect on a thread with 8MB stack (QUIC + TLS needs it)
|
||||
Thread(null, {
|
||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
||||
// Only reached if signaling disconnects
|
||||
val mgr = com.wzp.engine.SignalManager()
|
||||
val ok = mgr.connect(resolvedRelay, seed)
|
||||
viewModelScope.launch {
|
||||
if (result != 0) {
|
||||
_errorMessage.value = "Signal connection lost"
|
||||
if (ok) {
|
||||
signalManager = mgr
|
||||
startSignalPolling()
|
||||
} else {
|
||||
_errorMessage.value = "Failed to register on relay"
|
||||
}
|
||||
_signalState.value = 0
|
||||
}
|
||||
}, "wzp-register", 8 * 1024 * 1024).start()
|
||||
}, "wzp-signal-connect", 8 * 1024 * 1024).start()
|
||||
}
|
||||
|
||||
/** Poll signal manager state every 500ms */
|
||||
private fun startSignalPolling() {
|
||||
signalPollJob?.cancel()
|
||||
signalPollJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
val mgr = signalManager
|
||||
if (mgr != null && mgr.isConnected) {
|
||||
val state = mgr.getState()
|
||||
_signalState.value = state.status
|
||||
_incomingCallId.value = state.incomingCallId
|
||||
_incomingCallerFp.value = state.incomingCallerFp
|
||||
_incomingCallerAlias.value = state.incomingCallerAlias
|
||||
|
||||
// Auto-connect to media room when call is set up
|
||||
if (state.status == "setup" && state.callSetupRelay != null && state.callSetupRoom != null) {
|
||||
Log.i(TAG, "CallSetup: connecting to ${state.callSetupRelay} room ${state.callSetupRoom}")
|
||||
startCallInternal(state.callSetupRelay, state.callSetupRoom)
|
||||
}
|
||||
}
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSignalPolling() {
|
||||
signalPollJob?.cancel()
|
||||
signalPollJob = null
|
||||
}
|
||||
|
||||
/** Place a direct call to the target fingerprint */
|
||||
@@ -195,24 +223,28 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_errorMessage.value = "Enter a fingerprint to call"
|
||||
return
|
||||
}
|
||||
engine?.placeCall(target)
|
||||
_signalState.value = 6 // Ringing
|
||||
signalManager?.placeCall(target)
|
||||
}
|
||||
|
||||
/** Answer an incoming direct call */
|
||||
fun answerIncomingCall(mode: Int = 2) {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
engine?.answerCall(callId, mode)
|
||||
signalManager?.answerCall(callId, mode)
|
||||
}
|
||||
|
||||
/** Reject an incoming direct call */
|
||||
fun rejectIncomingCall() {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
engine?.answerCall(callId, 0) // 0 = Reject
|
||||
_signalState.value = 5 // Back to registered
|
||||
_incomingCallId.value = null
|
||||
_incomingCallerFp.value = null
|
||||
_incomingCallerAlias.value = null
|
||||
signalManager?.answerCall(callId, 0)
|
||||
}
|
||||
|
||||
/** Hang up direct call — media ends, signal stays alive */
|
||||
fun hangupDirectCall() {
|
||||
signalManager?.hangup()
|
||||
engine?.stopCall()
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -690,30 +722,10 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
val s = CallStats.fromJson(json)
|
||||
lastCallDuration = s.durationSecs
|
||||
_stats.value = s
|
||||
// Track signal state changes for direct calling
|
||||
if (s.state in 5..7) {
|
||||
_signalState.value = s.state
|
||||
// Don't update callState for signal-only states
|
||||
} else if (s.state != 0) {
|
||||
// Only update callState from media engine stats (not signal)
|
||||
if (s.state != 0) {
|
||||
_callState.value = s.state
|
||||
}
|
||||
// Incoming call detection
|
||||
if (s.state == 7) { // IncomingCall
|
||||
_incomingCallId.value = s.incomingCallId
|
||||
_incomingCallerFp.value = s.incomingCallerFp
|
||||
_incomingCallerAlias.value = s.incomingCallerAlias
|
||||
}
|
||||
// CallSetup: auto-connect to media room
|
||||
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
|
||||
// Format: "relay_addr|room_name"
|
||||
val parts = s.incomingCallId.split("|", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val mediaRelay = parts[0]
|
||||
val mediaRoom = parts[1]
|
||||
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
|
||||
startCallInternal(mediaRelay, mediaRoom)
|
||||
}
|
||||
}
|
||||
if (s.state == 2 && !audioStarted) {
|
||||
startAudio()
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ fun InCallScreen(
|
||||
|
||||
// Mode toggle: Room vs Direct Call
|
||||
val callMode by viewModel.callMode.collectAsState()
|
||||
val signalState by viewModel.signalState.collectAsState()
|
||||
val signalState by viewModel.signalState.collectAsState() // "idle"/"registered"/"ringing"/etc
|
||||
val targetFp by viewModel.targetFingerprint.collectAsState()
|
||||
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
||||
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
||||
@@ -309,7 +309,7 @@ fun InCallScreen(
|
||||
}
|
||||
} else {
|
||||
// ── Direct call mode ──
|
||||
if (signalState < 5) {
|
||||
if (signalState == "idle") {
|
||||
// Not registered yet
|
||||
SectionLabel("ALIAS")
|
||||
OutlinedTextField(
|
||||
@@ -333,7 +333,7 @@ fun InCallScreen(
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (signalState == 5) {
|
||||
} else if (signalState == "registered" || signalState == "incoming") {
|
||||
// Registered — show dial pad
|
||||
Text(
|
||||
"\u2705 Registered — waiting for calls",
|
||||
@@ -403,8 +403,7 @@ fun InCallScreen(
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (signalState == 6) {
|
||||
// Ringing
|
||||
} else if (signalState == "ringing") {
|
||||
Text(
|
||||
"\uD83D\uDD14 Ringing...",
|
||||
color = Yellow,
|
||||
@@ -412,11 +411,10 @@ fun InCallScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (signalState == 7) {
|
||||
// Incoming call (state 7 also handled above in registered view)
|
||||
} else if (signalState == "setup") {
|
||||
Text(
|
||||
"\uD83D\uDCDE Incoming call...",
|
||||
color = Green,
|
||||
"Connecting to call...",
|
||||
color = Accent,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
Reference in New Issue
Block a user