Compare commits
6 Commits
5e93cb74f2
...
build/last
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e0d8d212 | ||
|
|
4e0356ef37 | ||
|
|
264ef9c4d4 | ||
|
|
a9adb5cfd7 | ||
|
|
a39b074d6e | ||
|
|
9cab6e2347 |
@@ -7,6 +7,8 @@ on:
|
|||||||
- 'feat/*'
|
- 'feat/*'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '.gitea/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -224,7 +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)
|
||||||
engine.writeAudio(pcm)
|
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
||||||
|
captureDirectBuf.clear()
|
||||||
|
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
||||||
|
engine.writeAudioDirect(captureDirectBuf, read)
|
||||||
|
|
||||||
// Debug: write raw PCM + RMS
|
// Debug: write raw PCM + RMS
|
||||||
if (pcmOut != null) {
|
if (pcmOut != null) {
|
||||||
@@ -303,8 +316,12 @@ class AudioPipeline(private val context: Context) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
while (running) {
|
while (running) {
|
||||||
val read = engine.readAudio(pcm)
|
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||||
|
playoutDirectBuf.clear()
|
||||||
|
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal 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))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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, features = ["env-filter"] }
|
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -28,7 +27,9 @@ libc = "0.2"
|
|||||||
jni = { version = "0.21", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||||
tracing-android = "0.2"
|
android_logger = "0.14"
|
||||||
|
log = "0.4"
|
||||||
|
tracing-log = "0.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cc = "1"
|
cc = "1"
|
||||||
|
|||||||
@@ -35,24 +35,17 @@ static INIT_LOGGING: Once = Once::new();
|
|||||||
/// Safe to call multiple times — only the first call takes effect.
|
/// Safe to call multiple times — only the first call takes effect.
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
INIT_LOGGING.call_once(|| {
|
INIT_LOGGING.call_once(|| {
|
||||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
// Use android_logger directly — tracing_subscriber::registry() allocates
|
||||||
// tracing_subscriber::registry() can crash on some Android
|
// a sharded_slab which causes SIGSEGV on Android 16 MTE devices.
|
||||||
// devices if scudo malloc fails during early initialization.
|
// android_logger is lightweight and doesn't trigger scudo crashes.
|
||||||
let _ = std::panic::catch_unwind(|| {
|
let _ = std::panic::catch_unwind(|| {
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
android_logger::init_once(
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
android_logger::Config::default()
|
||||||
use tracing_subscriber::EnvFilter;
|
.with_max_level(log::LevelFilter::Info)
|
||||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
.with_tag("wzp"),
|
||||||
// Filter: INFO for our crates, WARN for everything else.
|
);
|
||||||
// The jni crate emits VERBOSE logs for every method lookup
|
// Bridge tracing → log so our tracing::info! macros work
|
||||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
let _ = tracing_log::LogTracer::init();
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -317,3 +310,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