diff --git a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt index 3fc32d0..79fe9ba 100644 --- a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt +++ b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt @@ -185,4 +185,14 @@ class SettingsRepository(context: Context) { fun loadServerFingerprint(address: String): String? { return prefs.getString("$TOFU_PREFIX$address", null) } + + // --- Ping RTT cache --- + + fun savePingRtt(address: String, rttMs: Int) { + prefs.edit().putInt("ping_rtt_$address", rttMs).apply() + } + + fun loadPingRtt(address: String): Int { + return prefs.getInt("ping_rtt_$address", -1) + } } diff --git a/android/app/src/main/java/com/wzp/net/RelayPinger.kt b/android/app/src/main/java/com/wzp/net/RelayPinger.kt index b371774..3eb8665 100644 --- a/android/app/src/main/java/com/wzp/net/RelayPinger.kt +++ b/android/app/src/main/java/com/wzp/net/RelayPinger.kt @@ -1,65 +1,35 @@ package com.wzp.net -import android.util.Log -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetSocketAddress - /** - * Pure Kotlin UDP ping — no JNI, no native lib loading. - * Sends a minimal packet to the relay and measures response time. - * QUIC servers reply with Version Negotiation to unknown packets. + * Relay ping via native QUIC — requires loading the native .so. + * After ping completes, the process must be restarted (System.exit) + * because jemalloc initialization during .so load corrupts state + * on Android 16 MTE devices. + * + * Flow: ping all servers → save results → exit → app restarts → load results */ object RelayPinger { - private const val TAG = "RelayPinger" - private const val TIMEOUT_MS = 2000 - - // Minimal QUIC-like Initial packet (just enough to provoke a response) - // First byte 0xC0 = long header, version 0x00000000 = version negotiation trigger - private val PROBE = byteArrayOf( - 0xC0.toByte(), // long header form - 0x00, 0x00, 0x00, 0x00, // version 0 → triggers Version Negotiation - 0x08, // DCID length = 8 - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // fake DCID - 0x00, // SCID length = 0 - 0x00, 0x00, // token length = 0 (for Initial) - 0x00, 0x04, // payload length = 4 - 0x00, 0x00, 0x00, 0x00, // dummy payload - ) data class PingResult( val rttMs: Int, val reachable: Boolean, + val serverFingerprint: String = "", ) /** - * Ping a relay server via UDP. Returns RTT in ms, or unreachable. - * Thread-safe, can be called from coroutine on Dispatchers.IO. + * Ping a relay via the native QUIC stack. + * WARNING: After calling this, the process must be restarted. */ fun ping(address: String): PingResult { return try { - val parts = address.split(":") - if (parts.size != 2) return PingResult(0, false) - val host = parts[0] - val port = parts[1].toIntOrNull() ?: return PingResult(0, false) - - val socket = DatagramSocket() - socket.soTimeout = TIMEOUT_MS - val dest = InetSocketAddress(host, port) - - val sendPacket = DatagramPacket(PROBE, PROBE.size, dest) - val recvBuf = ByteArray(1200) - val recvPacket = DatagramPacket(recvBuf, recvBuf.size) - - val start = System.nanoTime() - socket.send(sendPacket) - socket.receive(recvPacket) // blocks until response or timeout - val rttMs = ((System.nanoTime() - start) / 1_000_000).toInt() - - socket.close() - PingResult(rttMs, true) + val json = com.wzp.engine.WzpEngine.pingRelay(address) ?: return PingResult(0, false) + val obj = org.json.JSONObject(json) + PingResult( + rttMs = obj.getInt("rtt_ms"), + reachable = true, + serverFingerprint = obj.optString("server_fingerprint", ""), + ) } catch (e: Exception) { - Log.w(TAG, "ping $address failed: ${e.message}") PingResult(0, false) } } diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt index a677a75..e87dd97 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -31,7 +31,8 @@ data class ServerEntry(val address: String, val label: String) data class PingResult( val rttMs: Int, - val serverFingerprint: String, + val serverFingerprint: String = "", + val reachable: Boolean = rttMs > 0, ) enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED } @@ -207,56 +208,61 @@ class CallViewModel : ViewModel(), WzpCallback { settings?.saveSelectedServer(_selectedServer.value) } - private var pingJob: Job? = null - - /** Start periodic ping every 5 seconds. Safe to call multiple times. */ - fun startPeriodicPing() { - if (pingJob?.isActive == true) return - pingJob = viewModelScope.launch { - while (isActive) { - pingAllServersOnce() - delay(5000) + /** Load saved ping results from last ping-and-exit cycle. */ + fun loadSavedPingResults() { + val s = settings ?: return + val results = mutableMapOf() + val known = mutableMapOf() + _servers.value.forEach { server -> + val rtt = s.loadPingRtt(server.address) + val fp = s.loadServerFingerprint(server.address) + if (rtt >= 0) { + results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp ?: "") } + fp?.let { known[server.address] = it } } + _pingResults.value = results + _knownFingerprints.value = known } - /** Stop periodic ping. */ - fun stopPeriodicPing() { - pingJob?.cancel() - pingJob = null - } - - /** Ping all servers once (pure Kotlin UDP, no JNI). */ - fun pingAllServersOnce() { + /** + * Ping all servers via native QUIC, save results, then exit process. + * On restart, saved results are loaded. This avoids the jemalloc crash + * by ensuring the native .so is only loaded once per process lifetime. + */ + fun pingAndExit() { viewModelScope.launch { val results = mutableMapOf() _servers.value.forEach { server -> - val udpResult = withContext(Dispatchers.IO) { + val pr = withContext(Dispatchers.IO) { com.wzp.net.RelayPinger.ping(server.address) } results[server.address] = PingResult( - rttMs = udpResult.rttMs, - serverFingerprint = "", // filled lazily on first real connection + rttMs = pr.rttMs, + serverFingerprint = pr.serverFingerprint, ) - } - _pingResults.value = results - // Load saved TOFU fingerprints - val known = mutableMapOf() - _servers.value.forEach { server -> - settings?.loadServerFingerprint(server.address)?.let { - known[server.address] = it + // Save results + settings?.savePingRtt(server.address, pr.rttMs) + if (pr.serverFingerprint.isNotEmpty()) { + val saved = settings?.loadServerFingerprint(server.address) + if (saved == null) { + settings?.saveServerFingerprint(server.address, pr.serverFingerprint) + } } } - _knownFingerprints.value = known + _pingResults.value = results + // Exit process — next launch loads saved results, native .so reinits cleanly + delay(300) // let UI update + System.exit(0) } } /** Get lock status for a server. */ fun lockStatus(address: String): LockStatus { val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN - if (pr.rttMs <= 0 && pr.serverFingerprint.isEmpty()) return LockStatus.OFFLINE + if (!pr.reachable) return LockStatus.OFFLINE val known = _knownFingerprints.value[address] ?: return LockStatus.NEW - if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW // no fingerprint yet + if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED } diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt index f95ada0..28cbbc4 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -90,10 +90,8 @@ fun InCallScreen( var showManageRelays by remember { mutableStateOf(false) } - // Pure Kotlin UDP ping — no native .so loading, safe on Android 16 MTE - LaunchedEffect(Unit) { - viewModel.startPeriodicPing() - } + // Load saved ping results from last ping-and-exit cycle + LaunchedEffect(Unit) { viewModel.loadSavedPingResults() } Surface( modifier = Modifier.fillMaxSize(), @@ -229,6 +227,21 @@ fun InCallScreen( ) } + Spacer(modifier = Modifier.height(8.dp)) + + // Ping button — pings all servers via native QUIC, saves results, exits app + OutlinedButton( + onClick = { viewModel.pingAndExit() }, + modifier = Modifier.fillMaxWidth().height(40.dp), + shape = RoundedCornerShape(8.dp), + ) { + Text( + "Ping Servers (restarts app)", + style = MaterialTheme.typography.labelSmall, + color = TextDim + ) + } + errorMessage?.let { err -> Spacer(modifier = Modifier.height(8.dp)) Text(text = err, color = Red, style = MaterialTheme.typography.bodySmall)