diff --git a/android/app/src/main/java/com/wzp/net/RelayPinger.kt b/android/app/src/main/java/com/wzp/net/RelayPinger.kt new file mode 100644 index 0000000..b371774 --- /dev/null +++ b/android/app/src/main/java/com/wzp/net/RelayPinger.kt @@ -0,0 +1,66 @@ +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. + */ +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, + ) + + /** + * Ping a relay server via UDP. Returns RTT in ms, or unreachable. + * Thread-safe, can be called from coroutine on Dispatchers.IO. + */ + 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) + } 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 3520938..7772f93 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 @@ -203,38 +203,46 @@ class CallViewModel : ViewModel(), WzpCallback { settings?.saveSelectedServer(_selectedServer.value) } - /** Ping all servers in background, update results. */ - fun pingAllServers() { + 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) + } + } + } + + /** Stop periodic ping. */ + fun stopPeriodicPing() { + pingJob?.cancel() + pingJob = null + } + + /** Ping all servers once (pure Kotlin UDP, no JNI). */ + fun pingAllServersOnce() { viewModelScope.launch { val results = mutableMapOf() - val known = mutableMapOf() _servers.value.forEach { server -> - val pr = withContext(Dispatchers.IO) { - try { - val json = WzpEngine.pingRelay(server.address) ?: return@withContext null - val obj = JSONObject(json) - PingResult( - rttMs = obj.getInt("rtt_ms"), - serverFingerprint = obj.optString("server_fingerprint", ""), - ) - } catch (e: Exception) { - Log.w(TAG, "ping ${server.address} failed: ${e.message}") - null - } - } - if (pr != null) { - results[server.address] = pr - // TOFU: save fingerprint on first contact - if (pr.serverFingerprint.isNotEmpty()) { - val saved = settings?.loadServerFingerprint(server.address) - if (saved == null) { - settings?.saveServerFingerprint(server.address, pr.serverFingerprint) - } - known[server.address] = saved ?: pr.serverFingerprint - } + val udpResult = withContext(Dispatchers.IO) { + com.wzp.net.RelayPinger.ping(server.address) } + results[server.address] = PingResult( + rttMs = udpResult.rttMs, + serverFingerprint = "", // filled lazily on first real connection + ) } _pingResults.value = results + // Load saved TOFU fingerprints + val known = mutableMapOf() + _servers.value.forEach { server -> + settings?.loadServerFingerprint(server.address)?.let { + known[server.address] = it + } + } _knownFingerprints.value = known } } @@ -242,9 +250,9 @@ class CallViewModel : ViewModel(), WzpCallback { /** Get lock status for a server. */ fun lockStatus(address: String): LockStatus { val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN - val known = _knownFingerprints.value[address] - if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW - if (known == null) return LockStatus.NEW + if (pr.rttMs <= 0 && pr.serverFingerprint.isEmpty()) return LockStatus.OFFLINE + val known = _knownFingerprints.value[address] ?: return LockStatus.NEW + if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW // no fingerprint yet 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 0cfb675..f95ada0 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,8 +90,10 @@ fun InCallScreen( var showManageRelays by remember { mutableStateOf(false) } - // Don't auto-ping — loading the native .so triggers jemalloc init - // which crashes on Android 16 MTE. Let user click "Ping All" manually. + // Pure Kotlin UDP ping — no native .so loading, safe on Android 16 MTE + LaunchedEffect(Unit) { + viewModel.startPeriodicPing() + } Surface( modifier = Modifier.fillMaxSize(),