feat: relay ping with RTT, server TOFU, lock icons (Phase 2 backport)
Rust JNI:
- nativePingRelay: QUIC connect with 3s timeout, returns RTT + server
certificate fingerprint as JSON. Static method, no engine needed.
Kotlin:
- WzpEngine.pingRelay() static wrapper
- SettingsRepository: TOFU fingerprint persistence (tofu_{address} keys)
- CallViewModel: pingAllServers() coroutine, lockStatus() helper,
PingResult/LockStatus data types
- InCallScreen: server chips show lock icon + RTT color (green/yellow),
"Ping All" button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ class SettingsRepository(context: Context) {
|
|||||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||||
|
private const val TOFU_PREFIX = "tofu_"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Servers ---
|
// --- Servers ---
|
||||||
@@ -168,4 +169,14 @@ class SettingsRepository(context: Context) {
|
|||||||
fun clearRecentRooms() {
|
fun clearRecentRooms() {
|
||||||
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Server fingerprint TOFU ---
|
||||||
|
|
||||||
|
fun saveServerFingerprint(address: String, fingerprint: String) {
|
||||||
|
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadServerFingerprint(address: String): String? {
|
||||||
|
return prefs.getString("$TOFU_PREFIX$address", null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,15 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
init {
|
init {
|
||||||
System.loadLibrary("wzp_android")
|
System.loadLibrary("wzp_android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping a relay server. Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}`
|
||||||
|
* or null if unreachable. Does not require an engine instance.
|
||||||
|
*/
|
||||||
|
fun pingRelay(address: String): String? = nativePingRelay(address)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativePingRelay(relay: String): String?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.wzp.engine.CallStats
|
|||||||
import com.wzp.service.CallService
|
import com.wzp.service.CallService
|
||||||
import com.wzp.engine.WzpCallback
|
import com.wzp.engine.WzpCallback
|
||||||
import com.wzp.engine.WzpEngine
|
import com.wzp.engine.WzpEngine
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
@@ -26,6 +29,13 @@ import java.net.InetAddress
|
|||||||
|
|
||||||
data class ServerEntry(val address: String, val label: String)
|
data class ServerEntry(val address: String, val label: String)
|
||||||
|
|
||||||
|
data class PingResult(
|
||||||
|
val rttMs: Int,
|
||||||
|
val serverFingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||||
|
|
||||||
class CallViewModel : ViewModel(), WzpCallback {
|
class CallViewModel : ViewModel(), WzpCallback {
|
||||||
|
|
||||||
private var engine: WzpEngine? = null
|
private var engine: WzpEngine? = null
|
||||||
@@ -73,6 +83,13 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
||||||
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
||||||
|
|
||||||
|
/** Ping results keyed by server address. */
|
||||||
|
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
|
||||||
|
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
|
||||||
|
|
||||||
|
/** Known server fingerprints (TOFU). */
|
||||||
|
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
|
||||||
private val _playoutGainDb = MutableStateFlow(0f)
|
private val _playoutGainDb = MutableStateFlow(0f)
|
||||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||||
|
|
||||||
@@ -186,6 +203,51 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveSelectedServer(_selectedServer.value)
|
settings?.saveSelectedServer(_selectedServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ping all servers in background, update results. */
|
||||||
|
fun pingAllServers() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pingResults.value = results
|
||||||
|
_knownFingerprints.value = known
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||||
|
}
|
||||||
|
|
||||||
fun setRoomName(name: String) {
|
fun setRoomName(name: String) {
|
||||||
_roomName.value = name
|
_roomName.value = name
|
||||||
settings?.saveRoom(name)
|
settings?.saveRoom(name)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.wzp.engine.CallStats
|
import com.wzp.engine.CallStats
|
||||||
|
import com.wzp.ui.call.LockStatus
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -118,18 +119,31 @@ fun InCallScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
val pingResults by viewModel.pingResults.collectAsState()
|
||||||
|
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
servers.forEachIndexed { idx, entry ->
|
servers.forEachIndexed { idx, entry ->
|
||||||
val isSelected = selectedServer == idx
|
val isSelected = selectedServer == idx
|
||||||
|
val ping = pingResults[entry.address]
|
||||||
|
val lockStatus = viewModel.lockStatus(entry.address)
|
||||||
|
val lockIcon = when (lockStatus) {
|
||||||
|
LockStatus.VERIFIED -> "\uD83D\uDD12" // 🔒
|
||||||
|
LockStatus.NEW -> "\uD83D\uDD13" // 🔓
|
||||||
|
LockStatus.CHANGED -> "⚠\uFE0F" // ⚠️
|
||||||
|
LockStatus.OFFLINE -> "\uD83D\uDD34" // 🔴
|
||||||
|
LockStatus.UNKNOWN -> ""
|
||||||
|
}
|
||||||
|
val rttText = ping?.let { "${it.rttMs}ms" } ?: ""
|
||||||
|
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = { viewModel.selectServer(idx) },
|
onClick = { viewModel.selectServer(idx) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.height(36.dp)
|
.height(40.dp)
|
||||||
.width(140.dp),
|
.width(160.dp),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors = if (isSelected) {
|
colors = if (isSelected) {
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
@@ -140,11 +154,28 @@ fun InCallScreen(
|
|||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
IconButtonDefaults.filledTonalIconButtonColors()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = entry.label,
|
if (lockIcon.isNotEmpty()) {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Text(text = lockIcon, fontSize = 12.sp)
|
||||||
maxLines = 1
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
)
|
}
|
||||||
|
Text(
|
||||||
|
text = entry.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
if (rttText.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = rttText,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||||
|
color = when {
|
||||||
|
(ping?.rttMs ?: 0) > 200 -> Color(0xFFFACC15) // yellow
|
||||||
|
else -> Color(0xFF4ADE80) // green
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// + Add button
|
// + Add button
|
||||||
@@ -152,13 +183,18 @@ fun InCallScreen(
|
|||||||
onClick = { showAddServerDialog = true },
|
onClick = { showAddServerDialog = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.height(36.dp),
|
.height(40.dp),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Text("+", style = MaterialTheme.typography.labelMedium)
|
Text("+", style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ping button
|
||||||
|
TextButton(onClick = { viewModel.pingAllServers() }) {
|
||||||
|
Text("Ping All", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
|
||||||
// IPv4/IPv6 preference
|
// IPv4/IPv6 preference
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -317,3 +317,79 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
|||||||
drop(h);
|
drop(h);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ping a relay server — returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||||
|
/// Does NOT require an engine handle — creates a temporary QUIC connection.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
relay_j: JString,
|
||||||
|
) -> jstring {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let addr: std::net::SocketAddr = match relay.parse() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(conn)) => {
|
||||||
|
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||||
|
let server_fp = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| {
|
||||||
|
id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok()
|
||||||
|
})
|
||||||
|
.and_then(|certs| {
|
||||||
|
certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut h);
|
||||||
|
format!("{:016x}", h.finish())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
conn.close(0u32.into(), b"ping");
|
||||||
|
Some(format!(
|
||||||
|
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
|
||||||
|
rtt_ms, server_fp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
let json = match result {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return JObject::null().into_raw(),
|
||||||
|
};
|
||||||
|
env.new_string(&json)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user