2 Commits

Author SHA1 Message Date
Siavash Sameni
264ef9c4d4 feat: relay ping with RTT, server TOFU, lock icons (Phase 2 backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
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>
2026-04-06 22:43:53 +04:00
Siavash Sameni
a9adb5cfd7 feat: identicons, tap-to-copy fingerprint, recent rooms (Phase 1 backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
Backport from desktop client to Android:

Identicons:
- New Identicon.kt composable: deterministic 5x5 symmetric Canvas pattern
  from fingerprint hash (same algorithm as desktop identicon.ts)
- Participant list shows identicon + name + tappable fingerprint
- Settings page shows identicon next to fingerprint

CopyableFingerprint:
- Tap any fingerprint text to copy to clipboard with Toast feedback
- Used in participant list and settings page

Recent rooms:
- SettingsRepository: persists last 5 (relay, room) pairs
- CallViewModel: saves on startCall, exposes as StateFlow
- InCallScreen: clickable chips that fill room + select matching server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:37:46 +04:00
7 changed files with 455 additions and 21 deletions

View File

@@ -28,6 +28,8 @@ class SettingsRepository(context: Context) {
private const val KEY_PREFER_IPV6 = "prefer_ipv6" private const val KEY_PREFER_IPV6 = "prefer_ipv6"
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 TOFU_PREFIX = "tofu_"
} }
// --- Servers --- // --- Servers ---
@@ -138,4 +140,43 @@ class SettingsRepository(context: Context) {
fun saveSeedHex(hex: String) { fun saveSeedHex(hex: String) {
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply() prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
} }
// --- Recent rooms ---
data class RecentRoom(val relay: String, val room: String)
fun addRecentRoom(relay: String, room: String) {
val rooms = loadRecentRooms().toMutableList()
rooms.removeAll { it.relay == relay && it.room == room }
rooms.add(0, RecentRoom(relay, room))
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
val arr = JSONArray()
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
}
fun loadRecentRooms(): List<RecentRoom> {
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RecentRoom(o.getString("relay"), o.getString("room"))
}
} catch (_: Exception) { emptyList() }
}
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)
}
} }

View File

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

View File

@@ -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
@@ -70,6 +80,16 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _preferIPv6 = MutableStateFlow(false) private val _preferIPv6 = MutableStateFlow(false)
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow() val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
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()
@@ -139,6 +159,7 @@ class CallViewModel : ViewModel(), WzpCallback {
_captureGainDb.value = s.loadCaptureGain() _captureGainDb.value = s.loadCaptureGain()
_seedHex.value = s.getOrCreateSeedHex() _seedHex.value = s.getOrCreateSeedHex()
_aecEnabled.value = s.loadAecEnabled() _aecEnabled.value = s.loadAecEnabled()
_recentRooms.value = s.loadRecentRooms()
} }
fun selectServer(index: Int) { fun selectServer(index: Int) {
@@ -182,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)
@@ -287,6 +353,8 @@ class CallViewModel : ViewModel(), WzpCallback {
_debugReportAvailable.value = false _debugReportAvailable.value = false
_debugReportStatus.value = null _debugReportStatus.value = null
lastCallServer = serverEntry.address lastCallServer = serverEntry.address
settings?.addRecentRoom(serverEntry.address, room)
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
debugReporter?.prepareForCall() debugReporter?.prepareForCall()
try { try {
// Teardown previous call but don't stop the service (we're about to restart it) // Teardown previous call but don't stop the service (we're about to restart it)

View File

@@ -42,11 +42,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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)
@@ -117,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(
@@ -139,11 +154,28 @@ fun InCallScreen(
IconButtonDefaults.filledTonalIconButtonColors() IconButtonDefaults.filledTonalIconButtonColors()
} }
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (lockIcon.isNotEmpty()) {
Text(text = lockIcon, fontSize = 12.sp)
Spacer(modifier = Modifier.width(4.dp))
}
Text( Text(
text = entry.label, text = entry.label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1 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
@@ -151,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(
@@ -200,6 +237,36 @@ fun InCallScreen(
modifier = Modifier.fillMaxWidth(0.6f) modifier = Modifier.fillMaxWidth(0.6f)
) )
// Recent rooms
val recentRooms by viewModel.recentRooms.collectAsState()
if (recentRooms.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
recentRooms.forEach { recent ->
Surface(
onClick = {
viewModel.setRoomName(recent.room)
// Select matching server
val idx = servers.indexOfFirst { it.address == recent.relay }
if (idx >= 0) viewModel.selectServer(idx)
},
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.padding(2.dp)
) {
Text(
text = recent.room,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
@@ -262,11 +329,33 @@ fun InCallScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
unique.forEach { member -> unique.forEach { member ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp)
) {
com.wzp.ui.components.Identicon(
fingerprint = member.fingerprint.ifEmpty { member.displayName },
size = 28.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text( Text(
text = member.displayName, text = member.displayName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
if (member.fingerprint.isNotEmpty()) {
com.wzp.ui.components.CopyableFingerprint(
fingerprint = member.fingerprint.take(16),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
} }
} }

View File

@@ -0,0 +1,141 @@
package com.wzp.ui.components
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.min
/**
* Deterministic identicon — generates a unique 5x5 symmetric pattern
* from a hex fingerprint string. Identical algorithm to the desktop
* TypeScript implementation in identicon.ts.
*/
@Composable
fun Identicon(
fingerprint: String,
size: Dp = 36.dp,
clickToCopy: Boolean = true,
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
val bytes = hashBytes(fingerprint)
val (bg, fg) = deriveColors(bytes)
val grid = buildGrid(bytes)
Canvas(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(size * 0.12f))
.then(
if (clickToCopy && fingerprint.isNotEmpty()) {
Modifier.clickable {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
}
} else Modifier
)
) {
val cellW = this.size.width / 5f
val cellH = this.size.height / 5f
// Background
drawRect(color = bg, size = this.size)
// Foreground cells
for (y in 0 until 5) {
for (x in 0 until 5) {
if (grid[y][x]) {
drawRect(
color = fg,
topLeft = Offset(x * cellW, y * cellH),
size = Size(cellW, cellH),
)
}
}
}
}
}
/**
* Fingerprint text that copies to clipboard on tap.
*/
@Composable
fun CopyableFingerprint(
fingerprint: String,
modifier: Modifier = Modifier,
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color: Color = Color.Unspecified,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
androidx.compose.material3.Text(
text = fingerprint,
style = style,
color = color,
modifier = modifier.clickable {
if (fingerprint.isNotEmpty()) {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
}
}
)
}
// --- Internal helpers (matching desktop identicon.ts) ---
private fun hashBytes(hex: String): List<Int> {
val clean = hex.filter { it.isLetterOrDigit() }
val bytes = mutableListOf<Int>()
var i = 0
while (i + 1 < clean.length) {
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
bytes.add(b)
i += 2
}
// Pad to at least 16 bytes
while (bytes.size < 16) bytes.add(0)
return bytes
}
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
val hue1 = bytes[0] * 360f / 256f
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
val bg = hslToColor(hue1, 0.65f, 0.35f)
val fg = hslToColor(hue2, 0.70f, 0.55f)
return bg to fg
}
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
return (0 until 5).map { y ->
val left = (0 until 3).map { x ->
val idx = 2 + y * 3 + x
bytes[idx % bytes.size] > 128
}
// Mirror: col3 = col1, col4 = col0
listOf(left[0], left[1], left[2], left[1], left[0])
}
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val k = { n: Float -> (n + h / 30f) % 12f }
val a = s * min(l, 1f - l)
val f = { n: Float ->
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
}
return Color(f(0f), f(8f), f(4f))
}

View File

@@ -158,20 +158,30 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display // Fingerprint display with identicon
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated" val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text( Text(
text = "Fingerprint", text = "Fingerprint",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Row(
text = fingerprint.chunked(4).joinToString(" "), verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
com.wzp.ui.components.Identicon(
fingerprint = draftSeedHex,
size = 40.dp,
)
Spacer(modifier = Modifier.width(12.dp))
com.wzp.ui.components.CopyableFingerprint(
fingerprint = fingerprint.chunked(4).joinToString(" "),
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
), ),
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) )
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

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