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 421a3d5..5a4cef5 100644 --- a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt +++ b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt @@ -29,6 +29,7 @@ class SettingsRepository(context: Context) { private const val KEY_IDENTITY_SEED = "identity_seed_hex" private const val KEY_AEC_ENABLED = "aec_enabled" private const val KEY_RECENT_ROOMS = "recent_rooms" + private const val TOFU_PREFIX = "tofu_" } // --- Servers --- @@ -168,4 +169,14 @@ class SettingsRepository(context: Context) { fun clearRecentRooms() { 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) + } } diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt index 651f3d8..6b9e864 100644 --- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -158,6 +158,15 @@ class WzpEngine(private val callback: WzpCallback) { init { 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? } } 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 4ff1819..3520938 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 @@ -12,6 +12,7 @@ import com.wzp.engine.CallStats import com.wzp.service.CallService import com.wzp.engine.WzpCallback import com.wzp.engine.WzpEngine +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject import java.io.File import java.net.Inet4Address import java.net.Inet6Address @@ -26,6 +29,13 @@ import java.net.InetAddress 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 { private var engine: WzpEngine? = null @@ -73,6 +83,13 @@ class CallViewModel : ViewModel(), WzpCallback { private val _recentRooms = MutableStateFlow>(emptyList()) val recentRooms: StateFlow> = _recentRooms.asStateFlow() + /** Ping results keyed by server address. */ + private val _pingResults = MutableStateFlow>(emptyMap()) + val pingResults: StateFlow> = _pingResults.asStateFlow() + + /** Known server fingerprints (TOFU). */ + private val _knownFingerprints = MutableStateFlow>(emptyMap()) + private val _playoutGainDb = MutableStateFlow(0f) val playoutGainDb: StateFlow = _playoutGainDb.asStateFlow() @@ -186,6 +203,51 @@ class CallViewModel : ViewModel(), WzpCallback { settings?.saveSelectedServer(_selectedServer.value) } + /** Ping all servers in background, update results. */ + fun pingAllServers() { + 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 + } + } + } + _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) { _roomName.value = name settings?.saveRoom(name) 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 8356965..f51821b 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 @@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wzp.engine.CallStats +import com.wzp.ui.call.LockStatus import kotlin.math.roundToInt @OptIn(ExperimentalLayoutApi::class) @@ -118,18 +119,31 @@ fun InCallScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) + val pingResults by viewModel.pingResults.collectAsState() + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { servers.forEachIndexed { idx, entry -> 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( onClick = { viewModel.selectServer(idx) }, modifier = Modifier .padding(2.dp) - .height(36.dp) - .width(140.dp), + .height(40.dp) + .width(160.dp), shape = RoundedCornerShape(8.dp), colors = if (isSelected) { IconButtonDefaults.filledTonalIconButtonColors( @@ -140,11 +154,28 @@ fun InCallScreen( IconButtonDefaults.filledTonalIconButtonColors() } ) { - Text( - text = entry.label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1 - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (lockIcon.isNotEmpty()) { + Text(text = lockIcon, fontSize = 12.sp) + 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 @@ -152,13 +183,18 @@ fun InCallScreen( onClick = { showAddServerDialog = true }, modifier = Modifier .padding(2.dp) - .height(36.dp), + .height(40.dp), shape = RoundedCornerShape(8.dp) ) { Text("+", style = MaterialTheme.typography.labelMedium) } } + // Ping button + TextButton(onClick = { viewModel.pingAllServers() }) { + Text("Ping All", style = MaterialTheme.typography.labelSmall) + } + // IPv4/IPv6 preference Spacer(modifier = Modifier.height(8.dp)) Row( diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index 75e3bca..3ddc11e 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -317,3 +317,79 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy( 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::>().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()) +}