feat: pure Kotlin UDP ping — periodic every 5s, no JNI crash
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m35s

Replace WzpEngine.pingRelay() (JNI, loads native .so, crashes jemalloc
on Android 16 MTE) with pure Kotlin DatagramSocket UDP probe.

- RelayPinger: sends QUIC Version Negotiation trigger packet, measures
  RTT from response. No native lib, no JNI, zero crash risk.
- Periodic: pings all servers every 5 seconds via coroutine
- Server fingerprint: filled lazily on first real QUIC connection
  (TOFU still works, just delayed)
- Lock status: OFFLINE when ping fails, NEW until first connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-07 08:57:27 +04:00
parent 97bcc79f9b
commit d09e21965e
3 changed files with 107 additions and 31 deletions

View File

@@ -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)
}
}
}

View File

@@ -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<String, PingResult>()
val known = mutableMapOf<String, String>()
_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<String, String>()
_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
}

View File

@@ -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(),