feat: ping-and-exit for server RTT, remove broken UDP ping
- Ping button: pings all servers via native QUIC, saves RTT + fingerprint to SharedPreferences, then exits process (System.exit) - On restart: loads saved ping results (no native .so loading needed) - Avoids jemalloc crash: native lib only loaded once per process lifetime - Removed broken UDP probe (QUIC servers don't respond to it) - SettingsRepository: savePingRtt/loadPingRtt for cached results - PingResult: added reachable field Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -185,4 +185,14 @@ class SettingsRepository(context: Context) {
|
|||||||
fun loadServerFingerprint(address: String): String? {
|
fun loadServerFingerprint(address: String): String? {
|
||||||
return prefs.getString("$TOFU_PREFIX$address", null)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,35 @@
|
|||||||
package com.wzp.net
|
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.
|
* Relay ping via native QUIC — requires loading the native .so.
|
||||||
* Sends a minimal packet to the relay and measures response time.
|
* After ping completes, the process must be restarted (System.exit)
|
||||||
* QUIC servers reply with Version Negotiation to unknown packets.
|
* 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 {
|
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(
|
data class PingResult(
|
||||||
val rttMs: Int,
|
val rttMs: Int,
|
||||||
val reachable: Boolean,
|
val reachable: Boolean,
|
||||||
|
val serverFingerprint: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping a relay server via UDP. Returns RTT in ms, or unreachable.
|
* Ping a relay via the native QUIC stack.
|
||||||
* Thread-safe, can be called from coroutine on Dispatchers.IO.
|
* WARNING: After calling this, the process must be restarted.
|
||||||
*/
|
*/
|
||||||
fun ping(address: String): PingResult {
|
fun ping(address: String): PingResult {
|
||||||
return try {
|
return try {
|
||||||
val parts = address.split(":")
|
val json = com.wzp.engine.WzpEngine.pingRelay(address) ?: return PingResult(0, false)
|
||||||
if (parts.size != 2) return PingResult(0, false)
|
val obj = org.json.JSONObject(json)
|
||||||
val host = parts[0]
|
PingResult(
|
||||||
val port = parts[1].toIntOrNull() ?: return PingResult(0, false)
|
rttMs = obj.getInt("rtt_ms"),
|
||||||
|
reachable = true,
|
||||||
val socket = DatagramSocket()
|
serverFingerprint = obj.optString("server_fingerprint", ""),
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "ping $address failed: ${e.message}")
|
|
||||||
PingResult(0, false)
|
PingResult(0, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ data class ServerEntry(val address: String, val label: String)
|
|||||||
|
|
||||||
data class PingResult(
|
data class PingResult(
|
||||||
val rttMs: Int,
|
val rttMs: Int,
|
||||||
val serverFingerprint: String,
|
val serverFingerprint: String = "",
|
||||||
|
val reachable: Boolean = rttMs > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||||
@@ -207,56 +208,61 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveSelectedServer(_selectedServer.value)
|
settings?.saveSelectedServer(_selectedServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pingJob: Job? = null
|
/** Load saved ping results from last ping-and-exit cycle. */
|
||||||
|
fun loadSavedPingResults() {
|
||||||
/** Start periodic ping every 5 seconds. Safe to call multiple times. */
|
val s = settings ?: return
|
||||||
fun startPeriodicPing() {
|
val results = mutableMapOf<String, PingResult>()
|
||||||
if (pingJob?.isActive == true) return
|
val known = mutableMapOf<String, String>()
|
||||||
pingJob = viewModelScope.launch {
|
_servers.value.forEach { server ->
|
||||||
while (isActive) {
|
val rtt = s.loadPingRtt(server.address)
|
||||||
pingAllServersOnce()
|
val fp = s.loadServerFingerprint(server.address)
|
||||||
delay(5000)
|
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() {
|
* Ping all servers via native QUIC, save results, then exit process.
|
||||||
pingJob?.cancel()
|
* On restart, saved results are loaded. This avoids the jemalloc crash
|
||||||
pingJob = null
|
* by ensuring the native .so is only loaded once per process lifetime.
|
||||||
}
|
*/
|
||||||
|
fun pingAndExit() {
|
||||||
/** Ping all servers once (pure Kotlin UDP, no JNI). */
|
|
||||||
fun pingAllServersOnce() {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val results = mutableMapOf<String, PingResult>()
|
val results = mutableMapOf<String, PingResult>()
|
||||||
_servers.value.forEach { server ->
|
_servers.value.forEach { server ->
|
||||||
val udpResult = withContext(Dispatchers.IO) {
|
val pr = withContext(Dispatchers.IO) {
|
||||||
com.wzp.net.RelayPinger.ping(server.address)
|
com.wzp.net.RelayPinger.ping(server.address)
|
||||||
}
|
}
|
||||||
results[server.address] = PingResult(
|
results[server.address] = PingResult(
|
||||||
rttMs = udpResult.rttMs,
|
rttMs = pr.rttMs,
|
||||||
serverFingerprint = "", // filled lazily on first real connection
|
serverFingerprint = pr.serverFingerprint,
|
||||||
)
|
)
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_pingResults.value = results
|
_pingResults.value = results
|
||||||
// Load saved TOFU fingerprints
|
// Exit process — next launch loads saved results, native .so reinits cleanly
|
||||||
val known = mutableMapOf<String, String>()
|
delay(300) // let UI update
|
||||||
_servers.value.forEach { server ->
|
System.exit(0)
|
||||||
settings?.loadServerFingerprint(server.address)?.let {
|
|
||||||
known[server.address] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_knownFingerprints.value = known
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get lock status for a server. */
|
/** Get lock status for a server. */
|
||||||
fun lockStatus(address: String): LockStatus {
|
fun lockStatus(address: String): LockStatus {
|
||||||
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
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
|
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
|
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,8 @@ fun InCallScreen(
|
|||||||
|
|
||||||
var showManageRelays by remember { mutableStateOf(false) }
|
var showManageRelays by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pure Kotlin UDP ping — no native .so loading, safe on Android 16 MTE
|
// Load saved ping results from last ping-and-exit cycle
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) { viewModel.loadSavedPingResults() }
|
||||||
viewModel.startPeriodicPing()
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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 ->
|
errorMessage?.let { err ->
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(text = err, color = Red, style = MaterialTheme.typography.bodySmall)
|
Text(text = err, color = Red, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|||||||
Reference in New Issue
Block a user