Compare commits
20 Commits
main
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c17317ea | ||
|
|
5799d18aee | ||
|
|
46c9ee1be3 | ||
|
|
b53eae9192 | ||
|
|
a3f54566d4 | ||
|
|
76e9fe5e43 | ||
|
|
b0a89d4f39 | ||
|
|
abc96e8887 | ||
|
|
3a6ae61f8d | ||
|
|
4c536d256b | ||
|
|
b0ec9ff4ab | ||
|
|
5855533a39 | ||
|
|
ed09c2e8cc | ||
|
|
f44306cc17 | ||
|
|
0b821585ab | ||
|
|
faec332a8c | ||
|
|
fe9ae276dc | ||
|
|
4fbf6770c4 | ||
|
|
30a893a73f | ||
|
|
d46f3b1deb |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4370,6 +4370,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.7.9",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
|
|||||||
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,
|
||||||
|
)
|
||||||
@@ -159,6 +159,18 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): 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 nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init { System.loadLibrary("wzp_android") }
|
||||||
|
|
||||||
|
/** Get the identity fingerprint for a seed hex. No engine needed. */
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeGetFingerprint(seedHex: String): String?
|
||||||
|
|
||||||
|
/** Compute the full identity fingerprint (xxxx:xxxx:...) from a seed hex string. */
|
||||||
|
@JvmStatic
|
||||||
|
fun getFingerprint(seedHex: String): String = nativeGetFingerprint(seedHex) ?: ""
|
||||||
|
}
|
||||||
private external fun nativePingRelay(handle: Long, relay: String): String?
|
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 nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
||||||
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
||||||
@@ -208,11 +220,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
return nativeAnswerCall(nativeHandle, callId, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
init {
|
|
||||||
System.loadLibrary("wzp_android")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _targetFingerprint = MutableStateFlow("")
|
private val _targetFingerprint = MutableStateFlow("")
|
||||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
||||||
|
|
||||||
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */
|
||||||
private val _signalState = MutableStateFlow(0)
|
private val _signalState = MutableStateFlow("idle")
|
||||||
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
val signalState: StateFlow<String> = _signalState.asStateFlow()
|
||||||
|
|
||||||
/** Incoming call info */
|
/** Incoming call info */
|
||||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||||
@@ -155,34 +155,82 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
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 setCallMode(mode: Int) { _callMode.value = mode }
|
||||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
||||||
|
|
||||||
/** Register on relay for direct calls */
|
/** Register on relay for direct calls */
|
||||||
fun registerForCalls() {
|
fun registerForCalls() {
|
||||||
if (engine == null) {
|
|
||||||
engine = WzpEngine(this).also { it.init() }
|
|
||||||
}
|
|
||||||
val serverIdx = _selectedServer.value
|
val serverIdx = _selectedServer.value
|
||||||
val serverList = _servers.value
|
val serverList = _servers.value
|
||||||
if (serverIdx >= serverList.size) return
|
if (serverIdx >= serverList.size) return
|
||||||
|
|
||||||
val relay = serverList[serverIdx].address
|
val relay = serverList[serverIdx].address
|
||||||
val seed = _seedHex.value
|
var seed = _seedHex.value
|
||||||
val alias = _alias.value
|
// Generate seed if empty (fresh install or cleared storage)
|
||||||
|
if (seed.isEmpty()) {
|
||||||
|
val newSeed = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
|
||||||
|
seed = newSeed.joinToString("") { "%02x".format(it) }
|
||||||
|
_seedHex.value = seed
|
||||||
|
settings?.saveSeedHex(seed)
|
||||||
|
Log.i(TAG, "generated new identity seed")
|
||||||
|
}
|
||||||
|
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
// nativeSignalConnect has JNI overhead — must be on a thread with enough stack.
|
||||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
// Dispatchers.IO threads overflow. Use explicit Java Thread.
|
||||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
Thread(null, {
|
||||||
if (result == 0) {
|
try {
|
||||||
_signalState.value = 5 // Registered
|
val mgr = com.wzp.engine.SignalManager()
|
||||||
startStatsPolling()
|
val ok = mgr.connect(resolvedRelay, seed)
|
||||||
} else {
|
viewModelScope.launch {
|
||||||
_errorMessage.value = "Failed to register on relay"
|
if (ok) {
|
||||||
|
signalManager = mgr
|
||||||
|
startSignalPolling()
|
||||||
|
} else {
|
||||||
|
_errorMessage.value = "Failed to register on relay"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_errorMessage.value = "Register error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "wzp-signal-init", 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 */
|
/** Place a direct call to the target fingerprint */
|
||||||
fun placeDirectCall() {
|
fun placeDirectCall() {
|
||||||
val target = _targetFingerprint.value.trim()
|
val target = _targetFingerprint.value.trim()
|
||||||
@@ -190,24 +238,28 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_errorMessage.value = "Enter a fingerprint to call"
|
_errorMessage.value = "Enter a fingerprint to call"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
engine?.placeCall(target)
|
signalManager?.placeCall(target)
|
||||||
_signalState.value = 6 // Ringing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Answer an incoming direct call */
|
/** Answer an incoming direct call */
|
||||||
fun answerIncomingCall(mode: Int = 2) {
|
fun answerIncomingCall(mode: Int = 2) {
|
||||||
val callId = _incomingCallId.value ?: return
|
val callId = _incomingCallId.value ?: return
|
||||||
engine?.answerCall(callId, mode)
|
signalManager?.answerCall(callId, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reject an incoming direct call */
|
/** Reject an incoming direct call */
|
||||||
fun rejectIncomingCall() {
|
fun rejectIncomingCall() {
|
||||||
val callId = _incomingCallId.value ?: return
|
val callId = _incomingCallId.value ?: return
|
||||||
engine?.answerCall(callId, 0) // 0 = Reject
|
signalManager?.answerCall(callId, 0)
|
||||||
_signalState.value = 5 // Back to registered
|
}
|
||||||
_incomingCallId.value = null
|
|
||||||
_incomingCallerFp.value = null
|
/** Hang up direct call — media ends, signal stays alive */
|
||||||
_incomingCallerAlias.value = null
|
fun hangupDirectCall() {
|
||||||
|
signalManager?.hangup()
|
||||||
|
engine?.stopCall()
|
||||||
|
engine?.destroy()
|
||||||
|
engine = null
|
||||||
|
engineInitialized = false
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -685,30 +737,10 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
val s = CallStats.fromJson(json)
|
val s = CallStats.fromJson(json)
|
||||||
lastCallDuration = s.durationSecs
|
lastCallDuration = s.durationSecs
|
||||||
_stats.value = s
|
_stats.value = s
|
||||||
|
// Only update callState from media engine stats (not signal)
|
||||||
if (s.state != 0) {
|
if (s.state != 0) {
|
||||||
_callState.value = s.state
|
_callState.value = s.state
|
||||||
}
|
}
|
||||||
// Track signal state changes for direct calling
|
|
||||||
if (s.state in 5..7) {
|
|
||||||
_signalState.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) {
|
if (s.state == 2 && !audioStarted) {
|
||||||
startAudio()
|
startAudio()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.wzp.ui.call
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -166,7 +165,7 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "ENCRYPTED VOICE",
|
text = "ENCRYPTED VOICE \u2022 direct-call-v1",
|
||||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
|
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
|
||||||
color = TextDim
|
color = TextDim
|
||||||
)
|
)
|
||||||
@@ -220,7 +219,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
// Mode toggle: Room vs Direct Call
|
// Mode toggle: Room vs Direct Call
|
||||||
val callMode by viewModel.callMode.collectAsState()
|
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 targetFp by viewModel.targetFingerprint.collectAsState()
|
||||||
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
||||||
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
||||||
@@ -310,7 +309,7 @@ fun InCallScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ── Direct call mode ──
|
// ── Direct call mode ──
|
||||||
if (signalState < 5) {
|
if (signalState == "idle") {
|
||||||
// Not registered yet
|
// Not registered yet
|
||||||
SectionLabel("ALIAS")
|
SectionLabel("ALIAS")
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -334,7 +333,7 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (signalState == 5) {
|
} else if (signalState == "registered" || signalState == "incoming") {
|
||||||
// Registered — show dial pad
|
// Registered — show dial pad
|
||||||
Text(
|
Text(
|
||||||
"\u2705 Registered — waiting for calls",
|
"\u2705 Registered — waiting for calls",
|
||||||
@@ -404,8 +403,7 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (signalState == 6) {
|
} else if (signalState == "ringing") {
|
||||||
// Ringing
|
|
||||||
Text(
|
Text(
|
||||||
"\uD83D\uDD14 Ringing...",
|
"\uD83D\uDD14 Ringing...",
|
||||||
color = Yellow,
|
color = Yellow,
|
||||||
@@ -413,11 +411,10 @@ fun InCallScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
} else if (signalState == 7) {
|
} else if (signalState == "setup") {
|
||||||
// Incoming call (state 7 also handled above in registered view)
|
|
||||||
Text(
|
Text(
|
||||||
"\uD83D\uDCDE Incoming call...",
|
"Connecting to call...",
|
||||||
color = Green,
|
color = Accent,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -432,14 +429,16 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
// Identity
|
// Identity — compute real fingerprint from seed
|
||||||
val fp = if (seedHex.length >= 16) seedHex.take(16) else ""
|
val fullFp = remember(seedHex) {
|
||||||
|
if (seedHex.length >= 64) com.wzp.engine.WzpEngine.getFingerprint(seedHex) else ""
|
||||||
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (fp.isNotEmpty()) {
|
if (fullFp.isNotEmpty()) {
|
||||||
Identicon(fingerprint = seedHex, size = 28.dp)
|
Identicon(fingerprint = fullFp, size = 28.dp)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
CopyableFingerprint(
|
CopyableFingerprint(
|
||||||
fingerprint = fp.chunked(4).joinToString(":"),
|
fingerprint = fullFp,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
color = TextDim
|
color = TextDim
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ impl WzpEngine {
|
|||||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
||||||
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
||||||
let addr: SocketAddr = address.parse()?;
|
let addr: SocketAddr = address.parse()?;
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -245,154 +244,7 @@ impl WzpEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start persistent signaling connection for direct calls.
|
/// Start persistent signaling connection for direct calls.
|
||||||
/// Spawns a background task that maintains the `_signal` connection.
|
// Signal methods (start_signaling, place_call, answer_call) moved to signal_mgr.rs
|
||||||
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) {
|
pub fn set_mute(&self, muted: bool) {
|
||||||
self.state.muted.store(muted, Ordering::Relaxed);
|
self.state.muted.store(muted, Ordering::Relaxed);
|
||||||
@@ -456,7 +308,6 @@ async fn run_call(
|
|||||||
alias: Option<&str>,
|
alias: Option<&str>,
|
||||||
state: Arc<EngineState>,
|
state: Arc<EngineState>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
|||||||
) -> jlong {
|
) -> jlong {
|
||||||
let result = panic::catch_unwind(|| {
|
let result = panic::catch_unwind(|| {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
// Install rustls crypto provider ONCE on the main thread.
|
||||||
|
// Must not be called per-thread — conflicts with Android's system libcrypto.so TLS keys.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let handle = Box::new(EngineHandle {
|
let handle = Box::new(EngineHandle {
|
||||||
engine: WzpEngine::new(),
|
engine: WzpEngine::new(),
|
||||||
});
|
});
|
||||||
@@ -360,88 +363,149 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
|||||||
.unwrap_or(JObject::null().into_raw())
|
.unwrap_or(JObject::null().into_raw())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the identity fingerprint for a seed hex string.
|
||||||
|
/// Returns the full fingerprint (xxxx:xxxx:...) or empty string on error.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetFingerprint<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
seed_hex_j: JString,
|
||||||
|
) -> jstring {
|
||||||
|
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let fp = if seed_hex.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
match wzp_crypto::Seed::from_hex(&seed_hex) {
|
||||||
|
Ok(seed) => {
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
id.public_identity().fingerprint.to_string()
|
||||||
|
}
|
||||||
|
Err(_) => String::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
env.new_string(&fp)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Direct calling JNI functions ──
|
// ── Direct calling JNI functions ──
|
||||||
|
|
||||||
/// Start persistent signaling connection to relay for direct calls.
|
// ── SignalManager JNI functions ──
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
|
/// Opaque handle for SignalManager (separate from EngineHandle).
|
||||||
|
struct SignalHandle {
|
||||||
|
mgr: crate::signal_mgr::SignalManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn signal_ref(handle: jlong) -> &'static SignalHandle {
|
||||||
|
unsafe { &*(handle as *const SignalHandle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to relay for signaling. Returns handle (jlong) or 0 on error.
|
||||||
|
/// Blocks up to 10s waiting for the internal signal thread to connect.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalConnect<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
relay_j: JString,
|
||||||
relay_addr_j: JString,
|
seed_j: JString,
|
||||||
seed_hex_j: JString,
|
) -> jlong {
|
||||||
token_j: JString,
|
info!("nativeSignalConnect: entered");
|
||||||
alias_j: JString,
|
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||||
) -> jint {
|
let seed: String = env.get_string(&seed_j).map(|s| s.into()).unwrap_or_default();
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
info!(relay = %relay, seed_len = seed.len(), "nativeSignalConnect: parsed strings");
|
||||||
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(
|
// start() spawns an internal thread (connect+register+recv, ONE runtime, never dropped).
|
||||||
&relay_addr,
|
// Blocks up to 10s waiting for the connect+register to complete.
|
||||||
&seed_hex,
|
match crate::signal_mgr::SignalManager::start(&relay, &seed) {
|
||||||
if token.is_empty() { None } else { Some(&token) },
|
Ok(mgr) => {
|
||||||
if alias.is_empty() { None } else { Some(&alias) },
|
let handle = Box::new(SignalHandle { mgr });
|
||||||
)
|
Box::into_raw(handle) as jlong
|
||||||
}));
|
}
|
||||||
|
Err(e) => {
|
||||||
match result {
|
error!("signal connect failed: {e}");
|
||||||
Ok(Ok(())) => 0,
|
0
|
||||||
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
|
}
|
||||||
Err(_) => { error!("start_signaling panicked"); -1 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Place a direct call to a target fingerprint.
|
/// Get signal state as JSON string.
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalGetState<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
target_fp_j: JString,
|
) -> jstring {
|
||||||
) -> jint {
|
if handle == 0 { return JObject::null().into_raw(); }
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
let h = signal_ref(handle);
|
||||||
let h = unsafe { handle_ref(handle) };
|
let json = h.mgr.get_state_json();
|
||||||
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
|
env.new_string(&json)
|
||||||
h.engine.place_call(&target)
|
.map(|s| s.into_raw())
|
||||||
}));
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
match result {
|
/// Place a direct call.
|
||||||
Ok(Ok(())) => 0,
|
#[unsafe(no_mangle)]
|
||||||
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalPlaceCall<'a>(
|
||||||
Err(_) => { error!("place_call panicked"); -1 }
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
target_j: JString,
|
||||||
|
) -> jint {
|
||||||
|
if handle == 0 { return -1; }
|
||||||
|
let h = signal_ref(handle);
|
||||||
|
let target: String = env.get_string(&target_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
match h.mgr.place_call(&target) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(e) => { error!("place_call: {e}"); -1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Answer an incoming direct call.
|
/// Answer an incoming call.
|
||||||
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalAnswerCall<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
call_id_j: JString,
|
call_id_j: JString,
|
||||||
mode: jint,
|
mode: jint,
|
||||||
) -> jint {
|
) -> jint {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
if handle == 0 { return -1; }
|
||||||
let h = unsafe { handle_ref(handle) };
|
let h = signal_ref(handle);
|
||||||
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
||||||
let accept_mode = match mode {
|
let accept_mode = match mode {
|
||||||
0 => wzp_proto::CallAcceptMode::Reject,
|
0 => wzp_proto::CallAcceptMode::Reject,
|
||||||
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
||||||
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||||
};
|
};
|
||||||
h.engine.answer_call(&call_id, accept_mode)
|
match h.mgr.answer_call(&call_id, accept_mode) {
|
||||||
}));
|
Ok(()) => 0,
|
||||||
|
Err(e) => { error!("answer_call: {e}"); -1 }
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("answer_call panicked"); -1 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send hangup signal.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalHangup(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 { return; }
|
||||||
|
let h = signal_ref(handle);
|
||||||
|
h.mgr.hangup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy the signal manager and free resources.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalDestroy(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 { return; }
|
||||||
|
let h = signal_ref(handle);
|
||||||
|
h.mgr.stop();
|
||||||
|
// Reclaim the Box
|
||||||
|
let _ = unsafe { Box::from_raw(handle as *mut SignalHandle) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ pub mod audio_ring;
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod signal_mgr;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod jni_bridge;
|
pub mod jni_bridge;
|
||||||
|
|||||||
288
crates/wzp-android/src/signal_mgr.rs
Normal file
288
crates/wzp-android/src/signal_mgr.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! Persistent signal connection manager for direct 1:1 calls.
|
||||||
|
//!
|
||||||
|
//! Separate from the media engine — survives across calls.
|
||||||
|
//! Connects to relay via `_signal` SNI, registers presence,
|
||||||
|
//! and handles call signaling (offer/answer/setup/hangup).
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
|
|
||||||
|
/// Signal connection status.
|
||||||
|
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||||
|
pub struct SignalState {
|
||||||
|
pub status: String, // "idle", "registered", "ringing", "incoming", "setup"
|
||||||
|
pub fingerprint: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_call_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_caller_fp: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_caller_alias: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub call_setup_relay: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub call_setup_room: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub call_setup_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages a persistent `_signal` QUIC connection to a relay.
|
||||||
|
pub struct SignalManager {
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
state: Arc<Mutex<SignalState>>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalManager {
|
||||||
|
/// Create SignalManager and start connect+register+recv on a background thread.
|
||||||
|
/// Returns immediately. The internal thread runs forever.
|
||||||
|
/// CRITICAL: tokio runtime must never be dropped on Android (libcrypto TLS conflict).
|
||||||
|
pub fn start(relay_addr: &str, seed_hex: &str) -> Result<Self, anyhow::Error> {
|
||||||
|
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 state = Arc::new(Mutex::new(SignalState {
|
||||||
|
status: "connecting".into(),
|
||||||
|
fingerprint: fp.clone(),
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
// Channel to receive transport after connect succeeds
|
||||||
|
let (transport_tx, transport_rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
let bg_state = Arc::clone(&state);
|
||||||
|
let bg_running = Arc::clone(&running);
|
||||||
|
let ret_state = Arc::clone(&state);
|
||||||
|
let ret_running = Arc::clone(&running);
|
||||||
|
|
||||||
|
// ONE thread, ONE runtime, NEVER dropped.
|
||||||
|
// Connect + register + recv loop all happen here.
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-signal".into())
|
||||||
|
.stack_size(4 * 1024 * 1024)
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio runtime");
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
info!(fingerprint = %fp, relay = %addr, "signal: connecting");
|
||||||
|
|
||||||
|
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}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
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}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
// Register
|
||||||
|
if let Err(e) = transport.send_signal(&SignalMessage::RegisterPresence {
|
||||||
|
identity_pub, signature: vec![], alias: None,
|
||||||
|
}).await {
|
||||||
|
error!("signal register: {e}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
|
||||||
|
info!(fingerprint = %fp, "signal: registered");
|
||||||
|
bg_state.lock().unwrap().status = "registered".into();
|
||||||
|
// Send transport to caller
|
||||||
|
let _ = transport_tx.send(transport.clone());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
error!("signal registration failed: {other:?}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recv loop — runs forever
|
||||||
|
loop {
|
||||||
|
if !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 s = state.lock().unwrap();
|
||||||
|
s.status = "ringing".into();
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
||||||
|
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "incoming".into();
|
||||||
|
s.incoming_call_id = Some(call_id);
|
||||||
|
s.incoming_caller_fp = Some(caller_fingerprint);
|
||||||
|
s.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");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "setup".into();
|
||||||
|
s.call_setup_relay = Some(relay_addr);
|
||||||
|
s.call_setup_room = Some(room);
|
||||||
|
s.call_setup_id = Some(call_id);
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||||
|
info!(reason = ?reason, "signal: hangup");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "registered".into();
|
||||||
|
s.incoming_call_id = None;
|
||||||
|
s.incoming_caller_fp = None;
|
||||||
|
s.incoming_caller_alias = None;
|
||||||
|
s.call_setup_relay = None;
|
||||||
|
s.call_setup_room = None;
|
||||||
|
s.call_setup_id = None;
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
info!("signal: connection closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("signal recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
}); // block_on
|
||||||
|
|
||||||
|
// Runtime intentionally NOT dropped — lives until thread exits.
|
||||||
|
// This prevents ring/libcrypto TLS cleanup conflict on Android.
|
||||||
|
// The thread is parked here forever (block_on returned = connection lost).
|
||||||
|
std::thread::park();
|
||||||
|
})?; // thread spawn
|
||||||
|
|
||||||
|
// Wait for transport (up to 10s)
|
||||||
|
let transport = transport_rx.recv_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.map_err(|_| anyhow::anyhow!("signal connect timeout — check relay address"))?;
|
||||||
|
|
||||||
|
Ok(Self { transport, state: ret_state, running: ret_running })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current state (non-blocking).
|
||||||
|
pub fn get_state(&self) -> SignalState {
|
||||||
|
self.state.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get state as JSON string.
|
||||||
|
pub fn get_state_json(&self) -> String {
|
||||||
|
serde_json::to_string(&self.get_state()).unwrap_or_else(|_| "{}".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a direct call.
|
||||||
|
pub fn place_call(&self, target_fp: &str) -> Result<(), anyhow::Error> {
|
||||||
|
let fp = self.state.lock().unwrap().fingerprint.clone();
|
||||||
|
let target = target_fp.to_string();
|
||||||
|
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||||
|
let transport = self.transport.clone();
|
||||||
|
|
||||||
|
// Send on a small thread (async send needs a runtime)
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-call-send".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all().build().expect("rt");
|
||||||
|
rt.block_on(async {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||||
|
caller_fingerprint: fp,
|
||||||
|
caller_alias: None,
|
||||||
|
target_fingerprint: target,
|
||||||
|
call_id,
|
||||||
|
identity_pub: [0u8; 32],
|
||||||
|
ephemeral_pub: [0u8; 32],
|
||||||
|
signature: vec![],
|
||||||
|
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||||
|
}).await;
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an incoming call.
|
||||||
|
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
|
||||||
|
let call_id = call_id.to_string();
|
||||||
|
let transport = self.transport.clone();
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-answer-send".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all().build().expect("rt");
|
||||||
|
rt.block_on(async {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||||
|
call_id,
|
||||||
|
accept_mode: mode,
|
||||||
|
identity_pub: None,
|
||||||
|
ephemeral_pub: None,
|
||||||
|
signature: None,
|
||||||
|
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||||
|
}).await;
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send hangup.
|
||||||
|
pub fn hangup(&self) {
|
||||||
|
let transport = self.transport.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all().build().expect("rt");
|
||||||
|
rt.block_on(async {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
}).await;
|
||||||
|
});
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "registered".into();
|
||||||
|
s.incoming_call_id = None;
|
||||||
|
s.incoming_caller_fp = None;
|
||||||
|
s.incoming_caller_alias = None;
|
||||||
|
s.call_setup_relay = None;
|
||||||
|
s.call_setup_room = None;
|
||||||
|
s.call_setup_id = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the signal connection.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Release);
|
||||||
|
self.transport.connection().close(0u32.into(), b"shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
docs/PRD-desktop-direct-calling.md
Normal file
22
docs/PRD-desktop-direct-calling.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# PRD: Desktop Direct Calling — Backport SignalManager
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The desktop Tauri app has the direct calling UI (Room/Direct Call toggle, Register, Call buttons) but the backend uses inline async code in `main.rs` instead of a proper `SignalManager`. This needs to be backported from the Android refactor.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. **Create `signal_mgr.rs` for desktop** — same pattern as Android, or reuse the crate directly
|
||||||
|
2. **Wire into Tauri commands** — `register_signal` should use `SignalManager::connect()` + `run_recv_loop()` on a dedicated thread
|
||||||
|
3. **State polling** — `get_signal_status` should call `SignalManager::get_state_json()`
|
||||||
|
4. **place_call / answer_call** — delegate to SignalManager methods
|
||||||
|
5. **Merge android branch into desktop branch** — resolve the 37 desktop-only + 90 android-only commit divergence
|
||||||
|
6. **Test** — Android calls Desktop, Desktop calls Android
|
||||||
|
|
||||||
|
## UI Fixes
|
||||||
|
|
||||||
|
1. **Default alias** — generate random name on first start (like Android does)
|
||||||
|
2. **Default room** — change from "android" to "general"
|
||||||
|
3. **Fingerprint display** — ensure full `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx` format (not truncated)
|
||||||
|
4. **Deregister button** — ability to disconnect signal channel
|
||||||
|
5. **Call state reset** — after hangup, return to "Registered" state, not stuck on "Ringing"
|
||||||
@@ -106,7 +106,7 @@ ls -lh android/app/src/main/jniLibs/arm64-v8a/
|
|||||||
|
|
||||||
echo ">>> APK build..."
|
echo ">>> APK build..."
|
||||||
cd android && chmod +x gradlew
|
cd android && chmod +x gradlew
|
||||||
./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -3
|
./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -50
|
||||||
echo "APK_BUILT"
|
echo "APK_BUILT"
|
||||||
'
|
'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user