6 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
Siavash Sameni
a39b074d6e fix: DirectByteBuffer as class field — survives ART JIT OSR
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
Previous attempt allocated DirectByteBuffer as local variables inside
runCapture/runPlayout. ART's JIT On-Stack Replacement nulled them
when recompiling the hot loop mid-execution.

Fix: allocate as class fields on AudioPipeline (captureDirectBuf,
playoutDirectBuf). Object fields live on the heap, immune to OSR
stack frame replacement.

Eliminates JNI array copies (GetShortArrayRegion/SetShortArrayRegion)
from the audio hot path, preventing ART GC SIGBUS crashes on
Android 16 with concurrent mark-compact GC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:22:54 +04:00
Siavash Sameni
9cab6e2347 ci: skip build on CI-only file changes
Add paths-ignore for .gitea/** so build.yml doesn't waste runner time
when only workflow files are modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:13:29 +04:00
Siavash Sameni
5e93cb74f2 fix: filter tracing to INFO for wzp crates, WARN for jni crate
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 4m7s
The jni crate emits VERBOSE logs for every JNI method lookup (~10 lines
per call, 100+ calls/sec on audio threads). This floods logcat, consumes
CPU, and triggers system kills. Filter to only show INFO+ for our crates
and WARN+ for everything else.

Also fix build script: clean full Rust target to ensure libc++_shared.so
is always copied by cargo-ndk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:37:29 +04:00
Siavash Sameni
b56b4a759c revert: use ShortArray audio path (DirectByteBuffer causes null ptr crash)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
DirectByteBuffer.clear() crashes with null pointer in ART's JIT OSR
compiled code on Android 16. Revert AudioPipeline to use the original
ShortArray writeAudio/readAudio path.

The DirectByteBuffer JNI functions remain in WzpEngine.kt and
jni_bridge.rs for future use once the OSR issue is resolved.

The original SIGBUS from ART GC is rare (~1 crash per 8 min call)
and doesn't warrant the DirectByteBuffer approach until we can
allocate the buffer as a class field outside the hot loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:17:15 +04:00
11 changed files with 493 additions and 38 deletions

View File

@@ -7,6 +7,8 @@ on:
- 'feat/*' - 'feat/*'
tags: tags:
- 'v*' - 'v*'
paths-ignore:
- '.gitea/**'
workflow_dispatch: workflow_dispatch:
env: env:

View File

@@ -60,6 +60,16 @@ class AudioPipeline(private val context: Context) {
var debugRecording: Boolean = true var debugRecording: Boolean = true
private var captureThread: Thread? = null private var captureThread: Thread? = null
private var playoutThread: Thread? = null private var playoutThread: Thread? = null
// DirectByteBuffers for zero-copy JNI audio transfer.
// Allocated as class fields (NOT locals) because ART's JIT OSR
// can null local variables when it replaces the stack frame mid-loop.
// These survive OSR because they're on the heap.
private val captureDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
private val playoutDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
/** Latch counted down by each audio thread after exiting its loop. /** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */ * stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null private var drainLatch: CountDownLatch? = null
@@ -205,8 +215,6 @@ class AudioPipeline(private val context: Context) {
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}") Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
val pcm = ShortArray(FRAME_SAMPLES) val pcm = ShortArray(FRAME_SAMPLES)
// DirectByteBuffer for zero-copy JNI (avoids ART GC SIGBUS on Android 16)
val directBuf = ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
// Debug: PCM file + RMS CSV // Debug: PCM file + RMS CSV
var pcmOut: BufferedOutputStream? = null var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null var rmsCsv: OutputStreamWriter? = null
@@ -226,10 +234,10 @@ class AudioPipeline(private val context: Context) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES) val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) { if (read > 0) {
applyGain(pcm, read, captureGainDb) applyGain(pcm, read, captureGainDb)
// Zero-copy write via DirectByteBuffer (no GC array interaction) // Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
directBuf.clear() captureDirectBuf.clear()
directBuf.asShortBuffer().put(pcm, 0, read) captureDirectBuf.asShortBuffer().put(pcm, 0, read)
engine.writeAudioDirect(directBuf, read) engine.writeAudioDirect(captureDirectBuf, read)
// Debug: write raw PCM + RMS // Debug: write raw PCM + RMS
if (pcmOut != null) { if (pcmOut != null) {
@@ -292,8 +300,6 @@ class AudioPipeline(private val context: Context) {
val pcm = ShortArray(FRAME_SAMPLES) val pcm = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES)
// DirectByteBuffer for zero-copy JNI (avoids ART GC SIGBUS on Android 16)
val directBuf = ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
// Debug: PCM file + RMS CSV for playout // Debug: PCM file + RMS CSV for playout
var pcmOut: BufferedOutputStream? = null var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null var rmsCsv: OutputStreamWriter? = null
@@ -310,14 +316,12 @@ class AudioPipeline(private val context: Context) {
} }
try { try {
while (running) { while (running) {
// Zero-copy read via DirectByteBuffer // Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
directBuf.clear() playoutDirectBuf.clear()
val read = engine.readAudioDirect(directBuf, FRAME_SAMPLES) val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
if (read > 0) {
directBuf.rewind()
directBuf.asShortBuffer().get(pcm, 0, read)
}
if (read >= FRAME_SAMPLES) { if (read >= FRAME_SAMPLES) {
playoutDirectBuf.rewind()
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
applyGain(pcm, read, playoutGainDb) applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read) track.write(pcm, 0, read)

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()
} }
) { ) {
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
@@ -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 ->
Text( Row(
text = member.displayName, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(vertical = 2.dp)
color = MaterialTheme.colorScheme.onSurfaceVariant ) {
) com.wzp.ui.components.Identicon(
fingerprint = member.fingerprint.ifEmpty { member.displayName },
size = 28.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = member.displayName,
style = MaterialTheme.typography.labelSmall,
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,
style = MaterialTheme.typography.bodyMedium.copy( modifier = Modifier.padding(vertical = 4.dp)
fontFamily = FontFamily.Monospace ) {
), com.wzp.ui.components.Identicon(
color = MaterialTheme.colorScheme.onSurface 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(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@@ -17,7 +17,7 @@ wzp-crypto = { workspace = true }
wzp-transport = { workspace = true } wzp-transport = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] }
bytes = { workspace = true } bytes = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = "1" serde_json = "1"

View File

@@ -41,8 +41,17 @@ fn init_logging() {
let _ = std::panic::catch_unwind(|| { let _ = std::panic::catch_unwind(|| {
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if let Ok(layer) = tracing_android::layer("wzp_android") { if let Ok(layer) = tracing_android::layer("wzp_android") {
let _ = tracing_subscriber::registry().with(layer).try_init(); // Filter: INFO for our crates, WARN for everything else.
// The jni crate emits VERBOSE logs for every method lookup
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
// and causes the system to kill the app.
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
let _ = tracing_subscriber::registry()
.with(layer)
.with(filter)
.try_init();
} }
}); });
}); });
@@ -308,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())
}

View File

@@ -196,6 +196,12 @@ do_upload() {
do_build() { do_build() {
log "Building Rust native library (arm64-v8a, release)..." log "Building Rust native library (arm64-v8a, release)..."
# Clean Rust release target to force full rebuild.
# cargo-ndk only copies libc++_shared.so when it actually links — a partial
# clean that skips relinking leaves libc++_shared.so missing from jniLibs.
ssh_cmd "rm -rf /root/wzp-build/target/aarch64-linux-android/release \
/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a"
# ANDROID_NDK must be set (not just ANDROID_NDK_HOME) — cmake checks it # ANDROID_NDK must be set (not just ANDROID_NDK_HOME) — cmake checks it
ssh_cmd "source \$HOME/.cargo/env && \ ssh_cmd "source \$HOME/.cargo/env && \
export ANDROID_HOME=\$HOME/android-sdk && \ export ANDROID_HOME=\$HOME/android-sdk && \