Compare commits
3 Commits
build/last
...
ba29d8354f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba29d8354f | ||
|
|
0908507a7a | ||
|
|
860c90394d |
@@ -7,8 +7,6 @@ on:
|
||||
- 'feat/*'
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore:
|
||||
- '.gitea/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/*'
|
||||
- 'feature/*'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
git remote add github git@github.com:manawenuz/wzp.git
|
||||
|
||||
# Push the current branch
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
||||
echo "Pushing tag: ${TAG}"
|
||||
git push github "refs/tags/${TAG}" --force
|
||||
else
|
||||
echo "Pushing branch: ${BRANCH}"
|
||||
git push github "HEAD:refs/heads/${BRANCH}" --force
|
||||
fi
|
||||
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -43,12 +43,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -3186,17 +3180,6 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12612be8f868a09c0ceae7113ff26afe79d81a24473a393cb9120ece162e86c0"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -4035,7 +4018,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-android",
|
||||
"tracing-subscriber",
|
||||
"wzp-codec",
|
||||
"wzp-crypto",
|
||||
|
||||
@@ -29,15 +29,5 @@
|
||||
android:name="com.wzp.service.CallService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -8,21 +8,10 @@ import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.wzp.engine.WzpEngine
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStreamWriter
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Audio pipeline that captures mic audio and plays received audio using
|
||||
@@ -54,38 +43,15 @@ class AudioPipeline(private val context: Context) {
|
||||
/** Capture (mic) gain in dB. 0 = unity. */
|
||||
@Volatile
|
||||
var captureGainDb: Float = 0f
|
||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||
var aecEnabled: Boolean = true
|
||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||
var debugRecording: Boolean = true
|
||||
private var captureThread: 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.
|
||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||
private var drainLatch: CountDownLatch? = null
|
||||
|
||||
private val debugDir: File by lazy {
|
||||
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
fun start(engine: WzpEngine) {
|
||||
if (running) return
|
||||
running = true
|
||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
||||
|
||||
captureThread = Thread({
|
||||
runCapture(engine)
|
||||
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
|
||||
// Park thread forever — exiting triggers a libcrypto TLS destructor
|
||||
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
|
||||
parkThread()
|
||||
@@ -97,7 +63,6 @@ class AudioPipeline(private val context: Context) {
|
||||
|
||||
playoutThread = Thread({
|
||||
runPlayout(engine)
|
||||
drainLatch?.countDown() // signal: playout loop exited
|
||||
parkThread()
|
||||
}, "wzp-playout").apply {
|
||||
isDaemon = true
|
||||
@@ -110,20 +75,10 @@ class AudioPipeline(private val context: Context) {
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
// Don't join threads — they are parked as daemons to avoid native TLS crash.
|
||||
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
|
||||
Log.i(TAG, "audio pipeline stopped (running=false)")
|
||||
}
|
||||
|
||||
/** Block until both audio threads have exited their loops (max 200ms).
|
||||
* After this returns, no more JNI calls to the engine will be made. */
|
||||
fun awaitDrain(): Boolean {
|
||||
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
||||
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
|
||||
// Don't join — threads are parked as daemons to avoid native TLS crash
|
||||
captureThread = null
|
||||
playoutThread = null
|
||||
drainLatch = null
|
||||
return ok
|
||||
Log.i(TAG, "audio pipeline stopped")
|
||||
}
|
||||
|
||||
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
|
||||
@@ -134,15 +89,6 @@ class AudioPipeline(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeRms(pcm: ShortArray, count: Int): Int {
|
||||
var sumSq = 0.0
|
||||
for (i in 0 until count) {
|
||||
val s = pcm[i].toDouble()
|
||||
sumSq += s * s
|
||||
}
|
||||
return sqrt(sumSq / count).toInt()
|
||||
}
|
||||
|
||||
private fun parkThread() {
|
||||
try {
|
||||
Thread.sleep(Long.MAX_VALUE)
|
||||
@@ -181,89 +127,25 @@ class AudioPipeline(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Attach hardware AEC if available and enabled in settings
|
||||
var aec: AcousticEchoCanceler? = null
|
||||
var ns: NoiseSuppressor? = null
|
||||
if (aecEnabled) {
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
try {
|
||||
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
|
||||
aec?.enabled = true
|
||||
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AEC init failed: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "AEC not available on this device")
|
||||
}
|
||||
|
||||
// Attach hardware noise suppressor if available
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
try {
|
||||
ns = NoiseSuppressor.create(recorder.audioSessionId)
|
||||
ns?.enabled = true
|
||||
Log.i(TAG, "NoiseSuppressor enabled")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "AEC disabled by user setting")
|
||||
}
|
||||
|
||||
recorder.startRecording()
|
||||
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")
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES)
|
||||
// Debug: PCM file + RMS CSV
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var frameIdx = 0L
|
||||
if (debugRecording) {
|
||||
try {
|
||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
|
||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
|
||||
rmsCsv.write("frame,time_ms,rms\n")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "debug recording init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (running) {
|
||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
applyGain(pcm, read, captureGainDb)
|
||||
// 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
|
||||
if (pcmOut != null) {
|
||||
byteConv.clear()
|
||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
||||
}
|
||||
if (rmsCsv != null) {
|
||||
val rms = computeRms(pcm, read)
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
||||
}
|
||||
frameIdx++
|
||||
engine.writeAudio(pcm)
|
||||
} else if (read < 0) {
|
||||
Log.e(TAG, "AudioRecord.read error: $read")
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pcmOut?.close()
|
||||
rmsCsv?.close()
|
||||
recorder.stop()
|
||||
aec?.release()
|
||||
ns?.release()
|
||||
recorder.release()
|
||||
Log.i(TAG, "capture stopped (frames=$frameIdx)")
|
||||
Log.i(TAG, "capture stopped")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,61 +181,24 @@ class AudioPipeline(private val context: Context) {
|
||||
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES)
|
||||
val silence = ShortArray(FRAME_SAMPLES)
|
||||
// Debug: PCM file + RMS CSV for playout
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var frameIdx = 0L
|
||||
if (debugRecording) {
|
||||
try {
|
||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
|
||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
|
||||
rmsCsv.write("frame,time_ms,rms\n")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "debug playout recording init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
val silence = ShortArray(FRAME_SAMPLES) // pre-allocated silence
|
||||
try {
|
||||
while (running) {
|
||||
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||
playoutDirectBuf.clear()
|
||||
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||
val read = engine.readAudio(pcm)
|
||||
if (read >= FRAME_SAMPLES) {
|
||||
playoutDirectBuf.rewind()
|
||||
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
||||
applyGain(pcm, read, playoutGainDb)
|
||||
track.write(pcm, 0, read)
|
||||
|
||||
// Debug: write raw PCM + RMS
|
||||
if (pcmOut != null) {
|
||||
byteConv.clear()
|
||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
||||
}
|
||||
if (rmsCsv != null) {
|
||||
val rms = computeRms(pcm, read)
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
||||
}
|
||||
frameIdx++
|
||||
} else {
|
||||
// Not enough decoded audio — write silence to keep stream alive
|
||||
track.write(silence, 0, FRAME_SAMPLES)
|
||||
// Log silence frames to RMS as 0
|
||||
if (rmsCsv != null) {
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,0\n")
|
||||
}
|
||||
frameIdx++
|
||||
// Sleep briefly to avoid busy-spinning
|
||||
Thread.sleep(5)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pcmOut?.close()
|
||||
rmsCsv?.close()
|
||||
track.stop()
|
||||
track.release()
|
||||
Log.i(TAG, "playout stopped (frames=$frameIdx)")
|
||||
Log.i(TAG, "playout stopped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ class SettingsRepository(context: Context) {
|
||||
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
|
||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||
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 ---
|
||||
@@ -115,11 +112,6 @@ class SettingsRepository(context: Context) {
|
||||
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
|
||||
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
|
||||
|
||||
// --- AEC ---
|
||||
|
||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||
|
||||
// --- Identity seed ---
|
||||
|
||||
/**
|
||||
@@ -140,43 +132,4 @@ class SettingsRepository(context: Context) {
|
||||
fun saveSeedHex(hex: String) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.wzp.debug
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Collects call debug data (audio recordings, logs, histograms, stats)
|
||||
* into a zip file for email sharing.
|
||||
*/
|
||||
class DebugReporter(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DebugReporter"
|
||||
private const val SAMPLE_RATE = 48000
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip with all debug data.
|
||||
* Returns the zip File on success, or null on failure.
|
||||
*/
|
||||
suspend fun collectZip(
|
||||
callDurationSecs: Double,
|
||||
finalStatsJson: String,
|
||||
aecEnabled: Boolean,
|
||||
alias: String,
|
||||
server: String,
|
||||
room: String
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
// 1. Call metadata
|
||||
val meta = buildString {
|
||||
appendLine("=== WZ Phone Debug Report ===")
|
||||
appendLine("Timestamp: $timestamp")
|
||||
appendLine("Alias: $alias")
|
||||
appendLine("Server: $server")
|
||||
appendLine("Room: $room")
|
||||
appendLine("Duration: ${"%.1f".format(callDurationSecs)}s")
|
||||
appendLine("AEC: ${if (aecEnabled) "ON" else "OFF"}")
|
||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||
appendLine()
|
||||
appendLine("=== Final Stats ===")
|
||||
appendLine(finalStatsJson)
|
||||
}
|
||||
addTextEntry(zos, "meta.txt", meta)
|
||||
|
||||
// 2. Logcat — WZP-related tags
|
||||
val logcat = collectLogcat()
|
||||
addTextEntry(zos, "logcat.txt", logcat)
|
||||
|
||||
// 3. Capture audio (mic) → WAV
|
||||
val captureRaw = File(debugDir, "capture.pcm")
|
||||
if (captureRaw.exists() && captureRaw.length() > 0) {
|
||||
addWavEntry(zos, "capture.wav", captureRaw)
|
||||
Log.i(TAG, "capture.pcm: ${captureRaw.length()} bytes -> WAV")
|
||||
}
|
||||
|
||||
// 4. Playout audio (speaker) → WAV
|
||||
val playoutRaw = File(debugDir, "playout.pcm")
|
||||
if (playoutRaw.exists() && playoutRaw.length() > 0) {
|
||||
addWavEntry(zos, "playout.wav", playoutRaw)
|
||||
Log.i(TAG, "playout.pcm: ${playoutRaw.length()} bytes -> WAV")
|
||||
}
|
||||
|
||||
// 5. RMS histogram CSV
|
||||
val captureHist = File(debugDir, "capture_rms.csv")
|
||||
if (captureHist.exists()) addFileEntry(zos, "capture_rms.csv", captureHist)
|
||||
val playoutHist = File(debugDir, "playout_rms.csv")
|
||||
if (playoutHist.exists()) addFileEntry(zos, "playout_rms.csv", playoutHist)
|
||||
}
|
||||
|
||||
Log.i(TAG, "zip created: ${zipFile.length()} bytes (${zipFile.length() / 1024}KB)")
|
||||
|
||||
// Clean up raw debug files (keep zip)
|
||||
debugDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
zipFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "debug report failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up any leftover debug files from a previous session. */
|
||||
fun prepareForCall() {
|
||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
||||
if (debugDir.exists()) {
|
||||
debugDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
debugDir.mkdirs()
|
||||
// Also clean up old zip files
|
||||
context.cacheDir.listFiles()?.filter { it.name.startsWith("wzp_debug_") }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun collectLogcat(): String {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(
|
||||
arrayOf(
|
||||
"logcat", "-d",
|
||||
"-t", "5000",
|
||||
"--format", "threadtime"
|
||||
)
|
||||
)
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output.lines()
|
||||
.filter { line ->
|
||||
line.contains("wzp", ignoreCase = true) ||
|
||||
line.contains("WzpEngine") ||
|
||||
line.contains("AudioPipeline") ||
|
||||
line.contains("WzpCall") ||
|
||||
line.contains("CallService") ||
|
||||
line.contains("AudioTrack") ||
|
||||
line.contains("AudioRecord") ||
|
||||
line.contains("AcousticEchoCanceler") ||
|
||||
line.contains("NoiseSuppressor") ||
|
||||
line.contains("FATAL") ||
|
||||
line.contains("ANR") ||
|
||||
line.contains("AudioFlinger") ||
|
||||
line.contains("DebugReporter") ||
|
||||
line.contains("QUIC") ||
|
||||
line.contains("quinn") ||
|
||||
line.contains("send task") ||
|
||||
line.contains("recv task") ||
|
||||
line.contains("send stats") ||
|
||||
line.contains("recv stats") ||
|
||||
line.contains("send_media") ||
|
||||
line.contains("FEC block") ||
|
||||
line.contains("recv gap") ||
|
||||
line.contains("frames_dropped") ||
|
||||
line.contains("opus")
|
||||
}
|
||||
.joinToString("\n")
|
||||
} catch (e: Exception) {
|
||||
"Failed to collect logcat: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun addWavEntry(zos: ZipOutputStream, name: String, pcmFile: File) {
|
||||
val dataSize = pcmFile.length().toInt()
|
||||
val byteRate = SAMPLE_RATE * 1 * 16 / 8
|
||||
val blockAlign = 1 * 16 / 8
|
||||
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
|
||||
// Write WAV header (44 bytes)
|
||||
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
|
||||
header.put("RIFF".toByteArray())
|
||||
header.putInt(36 + dataSize)
|
||||
header.put("WAVE".toByteArray())
|
||||
header.put("fmt ".toByteArray())
|
||||
header.putInt(16)
|
||||
header.putShort(1) // PCM
|
||||
header.putShort(1) // mono
|
||||
header.putInt(SAMPLE_RATE)
|
||||
header.putInt(byteRate)
|
||||
header.putShort(blockAlign.toShort())
|
||||
header.putShort(16) // bits per sample
|
||||
header.put("data".toByteArray())
|
||||
header.putInt(dataSize)
|
||||
zos.write(header.array())
|
||||
|
||||
// Stream PCM data directly (avoids loading entire file into memory)
|
||||
FileInputStream(pcmFile).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
private fun addTextEntry(zos: ZipOutputStream, name: String, content: String) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
zos.write(content.toByteArray())
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
private fun addFileEntry(zos: ZipOutputStream, name: String, file: File) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
FileInputStream(file).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ data class CallStats(
|
||||
val o = arr.getJSONObject(i)
|
||||
RoomMember(
|
||||
fingerprint = o.optString("fingerprint", ""),
|
||||
alias = if (o.isNull("alias")) null else o.optString("alias", null)
|
||||
alias = o.optString("alias", null)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,5 @@ data class RoomMember(
|
||||
) {
|
||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
||||
val displayName: String
|
||||
get() = alias?.takeIf { it.isNotBlank() }
|
||||
?: fingerprint.take(8).ifEmpty { "unknown" }
|
||||
get() = alias ?: fingerprint.take(8)
|
||||
}
|
||||
|
||||
@@ -117,26 +117,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
return nativeReadAudio(nativeHandle, pcm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
|
||||
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
|
||||
* Called from the AudioRecord capture thread.
|
||||
*/
|
||||
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
|
||||
* The buffer must be a direct ByteBuffer with native byte order.
|
||||
* Called from the AudioTrack playout thread.
|
||||
*/
|
||||
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
|
||||
}
|
||||
|
||||
// -- JNI native methods --------------------------------------------------
|
||||
|
||||
private external fun nativeInit(): Long
|
||||
@@ -150,23 +130,12 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
private external fun nativeForceProfile(handle: Long, profile: Int)
|
||||
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
|
||||
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
|
||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
||||
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
|
||||
companion object {
|
||||
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?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -23,12 +21,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.wzp.ui.settings.SettingsScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Main activity hosting the in-call Compose UI.
|
||||
@@ -38,10 +31,6 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class CallActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CallActivity"
|
||||
}
|
||||
|
||||
private val viewModel: CallViewModel by viewModels()
|
||||
|
||||
private val audioPermissionLauncher = registerForActivityResult(
|
||||
@@ -80,45 +69,6 @@ class CallActivity : ComponentActivity() {
|
||||
) {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// Watch for debug zip ready → launch email intent
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.debugZipReady.collect { zipFile ->
|
||||
if (zipFile != null && zipFile.exists()) {
|
||||
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
|
||||
launchEmailIntent(zipFile)
|
||||
viewModel.onDebugReportSent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchEmailIntent(zipFile: java.io.File) {
|
||||
try {
|
||||
val authority = "${applicationContext.packageName}.fileprovider"
|
||||
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
|
||||
val uri = FileProvider.getUriForFile(this, authority, zipFile)
|
||||
Log.i(TAG, "FileProvider URI: $uri")
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "message/rfc822"
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
|
||||
)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Send debug report"))
|
||||
Log.i(TAG, "email intent launched")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "email intent failed", e)
|
||||
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -7,12 +7,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.audio.AudioPipeline
|
||||
import com.wzp.audio.AudioRouteManager
|
||||
import com.wzp.data.SettingsRepository
|
||||
import com.wzp.debug.DebugReporter
|
||||
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
|
||||
@@ -20,22 +18,12 @@ 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
|
||||
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
|
||||
@@ -45,10 +33,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private var audioStarted = false
|
||||
private var appContext: Context? = null
|
||||
private var settings: SettingsRepository? = null
|
||||
private var debugReporter: DebugReporter? = null
|
||||
private var lastStatsJson: String = "{}"
|
||||
private var lastCallDuration: Double = 0.0
|
||||
private var lastCallServer: String = ""
|
||||
|
||||
private val _callState = MutableStateFlow(0)
|
||||
val callState: StateFlow<Int> get() = _callState.asStateFlow()
|
||||
@@ -80,16 +64,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _preferIPv6 = MutableStateFlow(false)
|
||||
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)
|
||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||
|
||||
@@ -102,21 +76,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _seedHex = MutableStateFlow("")
|
||||
val seedHex: StateFlow<String> = _seedHex.asStateFlow()
|
||||
|
||||
private val _aecEnabled = MutableStateFlow(true)
|
||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||
|
||||
/** True when a call just ended and debug report can be sent. */
|
||||
private val _debugReportAvailable = MutableStateFlow(false)
|
||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||
|
||||
/** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
|
||||
private val _debugReportStatus = MutableStateFlow<String?>(null)
|
||||
val debugReportStatus: StateFlow<String?> = _debugReportStatus.asStateFlow()
|
||||
|
||||
/** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
|
||||
private val _debugZipReady = MutableStateFlow<File?>(null)
|
||||
val debugZipReady: StateFlow<File?> = _debugZipReady.asStateFlow()
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
companion object {
|
||||
@@ -137,9 +96,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
if (audioRouteManager == null) {
|
||||
audioRouteManager = AudioRouteManager(appCtx)
|
||||
}
|
||||
if (debugReporter == null) {
|
||||
debugReporter = DebugReporter(appCtx)
|
||||
}
|
||||
if (settings == null) {
|
||||
settings = SettingsRepository(appCtx)
|
||||
loadSettings()
|
||||
@@ -158,8 +114,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_playoutGainDb.value = s.loadPlayoutGain()
|
||||
_captureGainDb.value = s.loadCaptureGain()
|
||||
_seedHex.value = s.getOrCreateSeedHex()
|
||||
_aecEnabled.value = s.loadAecEnabled()
|
||||
_recentRooms.value = s.loadRecentRooms()
|
||||
}
|
||||
|
||||
fun selectServer(index: Int) {
|
||||
@@ -195,59 +149,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/** Batch-apply servers and selection from Settings draft state. */
|
||||
fun applyServers(servers: List<ServerEntry>, selected: Int) {
|
||||
_servers.value = servers
|
||||
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
|
||||
settings?.saveServers(servers)
|
||||
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) {
|
||||
_roomName.value = name
|
||||
settings?.saveRoom(name)
|
||||
@@ -275,11 +176,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
settings?.saveSeedHex(hex)
|
||||
}
|
||||
|
||||
fun setAecEnabled(enabled: Boolean) {
|
||||
_aecEnabled.value = enabled
|
||||
settings?.saveAecEnabled(enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||
* since Rust's DNS resolution may not work on Android.
|
||||
@@ -318,28 +214,15 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
|
||||
private fun teardown(stopService: Boolean = true) {
|
||||
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
||||
val hadCall = audioStarted
|
||||
CallService.onStopFromNotification = null
|
||||
stopAudio() // sets running=false (non-blocking)
|
||||
stopAudio()
|
||||
stopStatsPolling()
|
||||
|
||||
// Wait for audio threads to exit their loops before destroying the engine.
|
||||
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
|
||||
val drained = audioPipeline?.awaitDrain() ?: true
|
||||
if (!drained) {
|
||||
Log.w(TAG, "teardown: audio threads did not drain in time")
|
||||
}
|
||||
audioPipeline = null
|
||||
|
||||
Log.i(TAG, "teardown: stopping engine")
|
||||
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
||||
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
_callState.value = 0
|
||||
if (hadCall) {
|
||||
_debugReportAvailable.value = true
|
||||
}
|
||||
if (stopService) {
|
||||
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
|
||||
}
|
||||
@@ -350,12 +233,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
val room = _roomName.value
|
||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
lastCallServer = serverEntry.address
|
||||
settings?.addRecentRoom(serverEntry.address, room)
|
||||
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
||||
debugReporter?.prepareForCall()
|
||||
try {
|
||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||
teardown(stopService = false)
|
||||
@@ -420,40 +297,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
fun clearError() { _errorMessage.value = null }
|
||||
|
||||
fun sendDebugReport() {
|
||||
val reporter = debugReporter ?: return
|
||||
_debugReportStatus.value = "Preparing debug report..."
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val zipFile = reporter.collectZip(
|
||||
callDurationSecs = lastCallDuration,
|
||||
finalStatsJson = lastStatsJson,
|
||||
aecEnabled = _aecEnabled.value,
|
||||
alias = _alias.value,
|
||||
server = lastCallServer,
|
||||
room = _roomName.value
|
||||
)
|
||||
if (zipFile != null) {
|
||||
_debugZipReady.value = zipFile
|
||||
_debugReportStatus.value = "ready"
|
||||
} else {
|
||||
_debugReportStatus.value = "Error: failed to create zip"
|
||||
}
|
||||
_debugReportAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by Activity after email intent is launched. */
|
||||
fun onDebugReportSent() {
|
||||
_debugZipReady.value = null
|
||||
_debugReportStatus.value = null
|
||||
}
|
||||
|
||||
fun dismissDebugReport() {
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
_debugZipReady.value = null
|
||||
}
|
||||
|
||||
// WzpCallback
|
||||
override fun onCallStateChanged(state: Int) { _callState.value = state }
|
||||
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
|
||||
@@ -467,7 +310,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
audioPipeline = AudioPipeline(ctx).also {
|
||||
it.playoutGainDb = _playoutGainDb.value
|
||||
it.captureGainDb = _captureGainDb.value
|
||||
it.aecEnabled = _aecEnabled.value
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
@@ -476,7 +318,8 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
private fun stopAudio() {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
|
||||
audioPipeline?.stop()
|
||||
audioPipeline = null
|
||||
audioRouteManager?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
@@ -491,9 +334,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
val json = engine?.getStats() ?: "{}"
|
||||
if (json.isNotEmpty()) {
|
||||
Log.d(TAG, "raw: $json")
|
||||
lastStatsJson = json
|
||||
val s = CallStats.fromJson(json)
|
||||
lastCallDuration = s.durationSecs
|
||||
_stats.value = s
|
||||
if (s.state != 0) {
|
||||
_callState.value = s.state
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -42,13 +43,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.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)
|
||||
@@ -70,8 +69,6 @@ fun InCallScreen(
|
||||
val preferIPv6 by viewModel.preferIPv6.collectAsState()
|
||||
val playoutGainDb by viewModel.playoutGainDb.collectAsState()
|
||||
val captureGainDb by viewModel.captureGainDb.collectAsState()
|
||||
val debugReportAvailable by viewModel.debugReportAvailable.collectAsState()
|
||||
val debugReportStatus by viewModel.debugReportStatus.collectAsState()
|
||||
|
||||
var showAddServerDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -119,31 +116,18 @@ 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(40.dp)
|
||||
.width(160.dp),
|
||||
.height(36.dp)
|
||||
.width(140.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = if (isSelected) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
@@ -154,28 +138,11 @@ fun InCallScreen(
|
||||
IconButtonDefaults.filledTonalIconButtonColors()
|
||||
}
|
||||
) {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = entry.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
// + Add button
|
||||
@@ -183,18 +150,13 @@ fun InCallScreen(
|
||||
onClick = { showAddServerDialog = true },
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.height(40.dp),
|
||||
.height(36.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(
|
||||
@@ -237,36 +199,6 @@ fun InCallScreen(
|
||||
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))
|
||||
|
||||
Button(
|
||||
@@ -296,17 +228,6 @@ fun InCallScreen(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
// Debug report card — shown after call ends
|
||||
if (debugReportAvailable || debugReportStatus != null) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
DebugReportCard(
|
||||
available = debugReportAvailable,
|
||||
status = debugReportStatus,
|
||||
onSend = { viewModel.sendDebugReport() },
|
||||
onDismiss = { viewModel.dismissDebugReport() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// In-call UI
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -318,44 +239,18 @@ fun InCallScreen(
|
||||
QualityIndicator(qualityTier, stats.qualityLabel)
|
||||
|
||||
if (stats.roomParticipantCount > 0) {
|
||||
// Dedup by fingerprint — same key = same person, even if
|
||||
// relay hasn't cleaned up stale entries yet.
|
||||
val unique = stats.roomParticipants
|
||||
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "${unique.size} in room",
|
||||
text = "${stats.roomParticipantCount} in room",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
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 = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.roomParticipants.forEach { member ->
|
||||
Text(
|
||||
text = member.displayName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,20 +438,15 @@ private fun AudioLevelBar(audioLevel: Int) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Box(
|
||||
LinearProgressIndicator(
|
||||
progress = level,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.6f)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(level)
|
||||
.height(6.dp)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,70 +598,3 @@ private fun StatItem(label: String, value: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugReportCard(
|
||||
available: Boolean,
|
||||
status: String?,
|
||||
onSend: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Debug Report",
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Email call recordings, logs & stats for analysis",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
status != null && status.startsWith("Error") -> {
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onSend) { Text("Retry") }
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
}
|
||||
}
|
||||
status != null && status != "ready" -> {
|
||||
// Preparing zip...
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
available -> {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onSend) {
|
||||
Text("Email Report")
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Skip")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -21,9 +21,9 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -36,12 +36,9 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -50,7 +47,6 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.wzp.ui.call.CallViewModel
|
||||
import com.wzp.ui.call.ServerEntry
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -59,39 +55,14 @@ fun SettingsScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Snapshot current values into local draft state
|
||||
val currentAlias by viewModel.alias.collectAsState()
|
||||
val currentSeedHex by viewModel.seedHex.collectAsState()
|
||||
val currentServers by viewModel.servers.collectAsState()
|
||||
val currentSelectedServer by viewModel.selectedServer.collectAsState()
|
||||
val currentRoomName by viewModel.roomName.collectAsState()
|
||||
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
|
||||
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
|
||||
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
|
||||
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
|
||||
|
||||
// Draft state — initialized from current values
|
||||
var draftAlias by remember { mutableStateOf(currentAlias) }
|
||||
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
|
||||
val draftServers = remember { currentServers.toMutableStateList() }
|
||||
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
|
||||
var draftRoomName by remember { mutableStateOf(currentRoomName) }
|
||||
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
|
||||
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
|
||||
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
|
||||
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
|
||||
|
||||
// Track if anything changed
|
||||
val hasChanges = draftAlias != currentAlias ||
|
||||
draftSeedHex != currentSeedHex ||
|
||||
draftServers.toList() != currentServers ||
|
||||
draftSelectedServer != currentSelectedServer ||
|
||||
draftRoomName != currentRoomName ||
|
||||
draftPreferIPv6 != currentPreferIPv6 ||
|
||||
draftPlayoutGain != currentPlayoutGain ||
|
||||
draftCaptureGain != currentCaptureGain ||
|
||||
draftAecEnabled != currentAecEnabled
|
||||
val servers by viewModel.servers.collectAsState()
|
||||
val selectedServer by viewModel.selectedServer.collectAsState()
|
||||
val roomName by viewModel.roomName.collectAsState()
|
||||
val preferIPv6 by viewModel.preferIPv6.collectAsState()
|
||||
val playoutGainDb by viewModel.playoutGainDb.collectAsState()
|
||||
val captureGainDb by viewModel.captureGainDb.collectAsState()
|
||||
val alias by viewModel.alias.collectAsState()
|
||||
val seedHex by viewModel.seedHex.collectAsState()
|
||||
|
||||
var showAddServerDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreKeyDialog by remember { mutableStateOf(false) }
|
||||
@@ -123,24 +94,8 @@ fun SettingsScreen(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// Save button — only enabled when changes exist
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setAlias(draftAlias)
|
||||
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
|
||||
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
|
||||
viewModel.setRoomName(draftRoomName)
|
||||
viewModel.setPreferIPv6(draftPreferIPv6)
|
||||
viewModel.setPlayoutGainDb(draftPlayoutGain)
|
||||
viewModel.setCaptureGainDb(draftCaptureGain)
|
||||
viewModel.setAecEnabled(draftAecEnabled)
|
||||
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
},
|
||||
enabled = hasChanges
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
// Balance the back button
|
||||
Spacer(modifier = Modifier.width(64.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
@@ -149,8 +104,8 @@ fun SettingsScreen(
|
||||
SectionHeader("Identity")
|
||||
|
||||
OutlinedTextField(
|
||||
value = draftAlias,
|
||||
onValueChange = { draftAlias = it },
|
||||
value = alias,
|
||||
onValueChange = { viewModel.setAlias(it) },
|
||||
label = { Text("Display Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -158,30 +113,20 @@ fun SettingsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Fingerprint display with identicon
|
||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||
// Fingerprint display
|
||||
val fingerprint = if (seedHex.length >= 16) seedHex.take(16).uppercase() else "Not generated"
|
||||
Text(
|
||||
text = "Fingerprint",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
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(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = fingerprint.chunked(4).joinToString(" "),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -189,7 +134,7 @@ fun SettingsScreen(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", seedHex))
|
||||
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Text("Copy Key")
|
||||
@@ -208,39 +153,16 @@ fun SettingsScreen(
|
||||
|
||||
GainSlider(
|
||||
label = "Voice Volume",
|
||||
gainDb = draftPlayoutGain,
|
||||
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
|
||||
gainDb = playoutGainDb,
|
||||
onGainChange = { viewModel.setPlayoutGainDb(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
GainSlider(
|
||||
label = "Mic Gain",
|
||||
gainDb = draftCaptureGain,
|
||||
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
|
||||
gainDb = captureGainDb,
|
||||
onGainChange = { viewModel.setCaptureGainDb(it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Echo Cancellation (AEC)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Disable if audio sounds distorted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = draftAecEnabled,
|
||||
onCheckedChange = { draftAecEnabled = it }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -253,11 +175,11 @@ fun SettingsScreen(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
draftServers.forEachIndexed { idx, entry ->
|
||||
val isSelected = draftSelectedServer == idx
|
||||
servers.forEachIndexed { idx, entry ->
|
||||
val isSelected = selectedServer == idx
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
FilledTonalIconButton(
|
||||
onClick = { draftSelectedServer = idx },
|
||||
onClick = { viewModel.selectServer(idx) },
|
||||
modifier = Modifier
|
||||
.padding(end = 2.dp)
|
||||
.height(36.dp)
|
||||
@@ -281,12 +203,7 @@ fun SettingsScreen(
|
||||
// Show remove button for non-default servers
|
||||
if (idx >= 2) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
draftServers.removeAt(idx)
|
||||
if (draftSelectedServer >= draftServers.size) {
|
||||
draftSelectedServer = 0
|
||||
}
|
||||
},
|
||||
onClick = { viewModel.removeServer(idx) },
|
||||
modifier = Modifier.height(36.dp)
|
||||
) {
|
||||
Text("X", color = MaterialTheme.colorScheme.error)
|
||||
@@ -307,7 +224,7 @@ fun SettingsScreen(
|
||||
// Show selected server address
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
|
||||
text = "Default: ${servers.getOrNull(selectedServer)?.address ?: "none"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -329,8 +246,8 @@ fun SettingsScreen(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = draftPreferIPv6,
|
||||
onCheckedChange = { draftPreferIPv6 = it }
|
||||
checked = preferIPv6,
|
||||
onCheckedChange = { viewModel.setPreferIPv6(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -342,8 +259,8 @@ fun SettingsScreen(
|
||||
SectionHeader("Room")
|
||||
|
||||
OutlinedTextField(
|
||||
value = draftRoomName,
|
||||
onValueChange = { draftRoomName = it },
|
||||
value = roomName,
|
||||
onValueChange = { viewModel.setRoomName(it) },
|
||||
label = { Text("Default Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -357,7 +274,7 @@ fun SettingsScreen(
|
||||
AddServerDialog(
|
||||
onDismiss = { showAddServerDialog = false },
|
||||
onAdd = { host, port, label ->
|
||||
draftServers.add(ServerEntry("$host:$port", label))
|
||||
viewModel.addServer("$host:$port", label)
|
||||
showAddServerDialog = false
|
||||
}
|
||||
)
|
||||
@@ -367,9 +284,9 @@ fun SettingsScreen(
|
||||
RestoreKeyDialog(
|
||||
onDismiss = { showRestoreKeyDialog = false },
|
||||
onRestore = { hex ->
|
||||
draftSeedHex = hex
|
||||
viewModel.restoreSeed(hex)
|
||||
showRestoreKeyDialog = false
|
||||
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Key restored", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -399,7 +316,7 @@ private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Un
|
||||
)
|
||||
Slider(
|
||||
value = gainDb,
|
||||
onValueChange = onGainChange,
|
||||
onValueChange = { onGainChange(Math.round(it).toFloat()) },
|
||||
valueRange = -20f..20f,
|
||||
steps = 0,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="debug" path="." />
|
||||
</paths>
|
||||
@@ -17,7 +17,7 @@ wzp-crypto = { workspace = true }
|
||||
wzp-transport = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
tracing-subscriber = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
@@ -28,7 +28,6 @@ libc = "0.2"
|
||||
jni = { version = "0.21", default-features = false }
|
||||
rand = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
tracing-android = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
@@ -1,128 +1,91 @@
|
||||
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||
//! Lock-free SPSC ring buffers for audio PCM transfer between
|
||||
//! Kotlin AudioRecord/AudioTrack threads and the Rust engine.
|
||||
//!
|
||||
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||
//!
|
||||
//! On overflow (writer laps the reader), the writer simply overwrites
|
||||
//! old buffer data. The reader detects the lap via `available() >
|
||||
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
||||
//!
|
||||
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
||||
//! These use a simple spin-free design: the producer writes and advances
|
||||
//! a write cursor, the consumer reads and advances a read cursor.
|
||||
//! Both cursors are atomic so no mutex is needed.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
|
||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
||||
const RING_CAPACITY: usize = 16384; // 2^14
|
||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
/// Ring buffer capacity in i16 samples.
|
||||
/// 960 samples * 10 frames = ~200ms of audio at 48kHz mono.
|
||||
const RING_CAPACITY: usize = 960 * 10;
|
||||
|
||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16]>,
|
||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
write_pos: AtomicUsize,
|
||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||
read_pos: AtomicUsize,
|
||||
/// Incremented by reader when it detects it was lapped (overflow).
|
||||
overflow_count: AtomicU64,
|
||||
/// Incremented by reader when ring is empty (underrun).
|
||||
underrun_count: AtomicU64,
|
||||
}
|
||||
|
||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||
// SAFETY: AudioRing is designed for SPSC — one thread writes, one reads.
|
||||
// The atomics ensure visibility. The buffer itself is never accessed
|
||||
// from the same index by both threads simultaneously because the
|
||||
// producer only writes to positions between write_pos and read_pos,
|
||||
// and the consumer only reads from positions between read_pos and write_pos.
|
||||
unsafe impl Send for AudioRing {}
|
||||
unsafe impl Sync for AudioRing {}
|
||||
|
||||
impl AudioRing {
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||
buf: Box::new([0i16; RING_CAPACITY]),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
underrun_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read (clamped to capacity).
|
||||
/// Number of samples available to read.
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||
let r = self.read_pos.load(Ordering::Acquire);
|
||||
w.wrapping_sub(r)
|
||||
}
|
||||
|
||||
/// Number of samples that can be written without overwriting unread data.
|
||||
/// Number of samples that can be written without overwriting.
|
||||
pub fn free_space(&self) -> usize {
|
||||
RING_CAPACITY.saturating_sub(self.available())
|
||||
RING_CAPACITY - self.available()
|
||||
}
|
||||
|
||||
/// Write samples into the ring. Returns number of samples written.
|
||||
///
|
||||
/// If the ring is full, old data is silently overwritten. The reader
|
||||
/// will detect the lap and self-correct. The writer NEVER touches
|
||||
/// `read_pos` — this is the key invariant that prevents cursor desync.
|
||||
/// Drops oldest samples if the ring is full.
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
|
||||
for i in 0..count {
|
||||
let idx = (w + i) % RING_CAPACITY;
|
||||
// SAFETY: We're the only writer, and the reader won't read
|
||||
// past read_pos which we haven't advanced past yet.
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||
*ptr.add(idx) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
|
||||
// If we overwrote unread data, advance read_pos
|
||||
if self.available() > RING_CAPACITY {
|
||||
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
|
||||
self.read_pos.store(new_read, Ordering::Release);
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||
///
|
||||
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
||||
/// forward to the oldest valid data. This is safe because only the
|
||||
/// reader thread writes `read_pos`.
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||
|
||||
let mut avail = w.wrapping_sub(r);
|
||||
|
||||
// Lap detection: writer has overwritten our unread data.
|
||||
// Snap read_pos forward to oldest valid data in the buffer.
|
||||
if avail > RING_CAPACITY {
|
||||
r = w.wrapping_sub(RING_CAPACITY);
|
||||
avail = RING_CAPACITY;
|
||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let avail = self.available();
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
if w == r {
|
||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
for i in 0..count {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
let idx = (r + i) % RING_CAPACITY;
|
||||
out[i] = unsafe { *self.buf.as_ptr().add(idx) };
|
||||
}
|
||||
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of overflow events (reader was lapped by writer).
|
||||
pub fn overflow_count(&self) -> u64 {
|
||||
self.overflow_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Number of underrun events (reader found empty buffer).
|
||||
pub fn underrun_count(&self) -> u64 {
|
||||
self.underrun_count.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +67,6 @@ pub(crate) struct EngineState {
|
||||
pub playout_ring: AudioRing,
|
||||
/// Current audio level (RMS) for UI display, updated by capture path.
|
||||
pub audio_level_rms: AtomicU32,
|
||||
/// QUIC transport handle — stored so stop_call() can close it immediately,
|
||||
/// triggering relay-side leave + RoomUpdate broadcast.
|
||||
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
|
||||
}
|
||||
|
||||
pub struct WzpEngine {
|
||||
@@ -90,7 +87,6 @@ impl WzpEngine {
|
||||
capture_ring: AudioRing::new(),
|
||||
playout_ring: AudioRing::new(),
|
||||
audio_level_rms: AtomicU32::new(0),
|
||||
quic_transport: Mutex::new(None),
|
||||
});
|
||||
Self {
|
||||
state,
|
||||
@@ -148,25 +144,12 @@ impl WzpEngine {
|
||||
}
|
||||
|
||||
pub fn stop_call(&mut self) {
|
||||
info!("stop_call: setting running=false");
|
||||
self.state.running.store(false, Ordering::Release);
|
||||
// Close QUIC connection — this wakes up all blocked recv/send futures
|
||||
// inside block_on(run_call(...)) on the JNI thread. run_call will then
|
||||
// wait up to 500ms for the peer to acknowledge the close before returning.
|
||||
if let Some(transport) = self.state.quic_transport.lock().unwrap().take() {
|
||||
info!("stop_call: closing QUIC connection");
|
||||
transport.close_now();
|
||||
}
|
||||
let _ = self.state.command_tx.send(EngineCommand::Stop);
|
||||
// Note: the runtime is still blocked in block_on(run_call(...)) on the
|
||||
// start_call thread. Once run_call exits (triggered by running=false +
|
||||
// connection close above), block_on returns and stores the runtime in
|
||||
// self.tokio_runtime. We don't need to shut it down here.
|
||||
if let Some(rt) = self.tokio_runtime.take() {
|
||||
rt.shutdown_timeout(std::time::Duration::from_millis(100));
|
||||
rt.shutdown_background();
|
||||
}
|
||||
self.call_start = None;
|
||||
info!("stop_call: done");
|
||||
}
|
||||
|
||||
pub fn set_mute(&self, muted: bool) {
|
||||
@@ -183,9 +166,6 @@ impl WzpEngine {
|
||||
stats.duration_secs = start.elapsed().as_secs_f64();
|
||||
}
|
||||
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
|
||||
stats.playout_overflows = self.state.playout_ring.overflow_count();
|
||||
stats.playout_underruns = self.state.playout_ring.underrun_count();
|
||||
stats.capture_overflows = self.state.capture_ring.overflow_count();
|
||||
stats
|
||||
}
|
||||
|
||||
@@ -243,9 +223,6 @@ async fn run_call(
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Store transport handle so stop_call() can close the connection immediately
|
||||
*state.quic_transport.lock().unwrap() = Some(transport.clone());
|
||||
|
||||
// Crypto handshake
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(identity_seed);
|
||||
let ephemeral_pub = kx.generate_ephemeral();
|
||||
@@ -324,24 +301,8 @@ async fn run_call(
|
||||
let mut block_id: u8 = 0;
|
||||
|
||||
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
||||
//
|
||||
// IMPORTANT: send_media() uses quinn's send_datagram() which is
|
||||
// synchronous and returns Err(Blocked) when the congestion window
|
||||
// is full. We MUST NOT break on send errors — that would kill the
|
||||
// entire call. Instead we drop the packet and keep going.
|
||||
let send_task = async {
|
||||
info!("send task started (Opus + RaptorQ FEC)");
|
||||
let mut send_errors: u64 = 0;
|
||||
let mut last_send_error_log = Instant::now();
|
||||
let mut last_stats_log = Instant::now();
|
||||
let mut frames_sent: u64 = 0;
|
||||
let mut frames_dropped: u64 = 0;
|
||||
// Per-step timing accumulators (reset every stats log)
|
||||
let mut t_agc_us: u64 = 0;
|
||||
let mut t_opus_us: u64 = 0;
|
||||
let mut t_fec_us: u64 = 0;
|
||||
let mut t_send_us: u64 = 0;
|
||||
let mut t_frames: u64 = 0;
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
@@ -358,19 +319,10 @@ async fn run_call(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mute: zero out the buffer so Opus encodes silence.
|
||||
// We still read from the ring to prevent it from filling up.
|
||||
if state.muted.load(Ordering::Relaxed) {
|
||||
capture_buf.fill(0);
|
||||
}
|
||||
|
||||
// AGC: normalize capture volume before encoding
|
||||
let t0 = Instant::now();
|
||||
capture_agc.process_frame(&mut capture_buf);
|
||||
t_agc_us += t0.elapsed().as_micros() as u64;
|
||||
|
||||
// Opus encode
|
||||
let t0 = Instant::now();
|
||||
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -378,7 +330,6 @@ async fn run_call(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
t_opus_us += t0.elapsed().as_micros() as u64;
|
||||
let encoded = &encode_buf[..encoded_len];
|
||||
|
||||
// Build source packet
|
||||
@@ -403,30 +354,13 @@ async fn run_call(
|
||||
quality_report: None,
|
||||
};
|
||||
|
||||
// Send source packet — drop on error, never break
|
||||
let t0 = Instant::now();
|
||||
// Send source packet
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Log first few errors, then throttle to once per second
|
||||
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
|
||||
warn!(
|
||||
seq = s,
|
||||
send_errors,
|
||||
frames_dropped,
|
||||
"send_media error (dropping packet): {e}"
|
||||
);
|
||||
last_send_error_log = Instant::now();
|
||||
}
|
||||
// Don't feed to FEC either — the source is lost
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
continue;
|
||||
error!("send error: {e}");
|
||||
break;
|
||||
}
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
frames_sent += 1;
|
||||
|
||||
// Feed encoded frame to FEC encoder
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
@@ -458,11 +392,9 @@ async fn run_call(
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
if let Err(e) = transport.send_media(&repair_pkt).await {
|
||||
error!("send repair error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
@@ -483,32 +415,11 @@ async fn run_call(
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
}
|
||||
t_fec_us += t0.elapsed().as_micros() as u64;
|
||||
t_frames += 1;
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_stats_log.elapsed().as_secs() >= 5 {
|
||||
let avg = |total: u64| if t_frames > 0 { total / t_frames } else { 0 };
|
||||
info!(
|
||||
seq = s,
|
||||
block_id,
|
||||
frames_sent,
|
||||
frames_dropped,
|
||||
send_errors,
|
||||
ring_avail = state.capture_ring.available(),
|
||||
capture_overflows = state.capture_ring.overflow_count(),
|
||||
avg_agc_us = avg(t_agc_us),
|
||||
avg_opus_us = avg(t_opus_us),
|
||||
avg_fec_us = avg(t_fec_us),
|
||||
avg_send_us = avg(t_send_us),
|
||||
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
|
||||
"send stats"
|
||||
);
|
||||
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
|
||||
last_stats_log = Instant::now();
|
||||
if s % 500 == 0 {
|
||||
info!(seq = s, block_id, frame_in_block, "sending");
|
||||
}
|
||||
}
|
||||
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
||||
};
|
||||
|
||||
// Pre-allocate decode buffer
|
||||
@@ -518,10 +429,6 @@ async fn run_call(
|
||||
let recv_task = async {
|
||||
let mut frames_decoded: u64 = 0;
|
||||
let mut fec_recovered: u64 = 0;
|
||||
let mut recv_errors: u64 = 0;
|
||||
let mut last_recv_instant = Instant::now();
|
||||
let mut max_recv_gap_ms: u64 = 0;
|
||||
let mut last_stats_log = Instant::now();
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
@@ -529,21 +436,6 @@ async fn run_call(
|
||||
}
|
||||
match transport_recv.recv_media().await {
|
||||
Ok(Some(pkt)) => {
|
||||
// Track recv gaps — large gaps indicate network or relay issues
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
if recv_gap_ms > 500 {
|
||||
warn!(
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
is_repair = pkt.header.is_repair,
|
||||
"large recv gap — possible network stall"
|
||||
);
|
||||
}
|
||||
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let pkt_block = pkt.header.fec_block;
|
||||
let pkt_symbol = pkt.header.fec_symbol;
|
||||
@@ -560,6 +452,7 @@ async fn run_call(
|
||||
if !is_repair {
|
||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||
Ok(samples) => {
|
||||
// AGC on playout — normalizes received audio volume
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
frames_decoded += 1;
|
||||
@@ -574,8 +467,13 @@ async fn run_call(
|
||||
}
|
||||
}
|
||||
|
||||
// Try FEC recovery
|
||||
// Try FEC recovery for this block
|
||||
// (useful when source packets were lost but repair arrived)
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
// FEC recovered the block — any previously missing frames
|
||||
// are now available. In a full jitter buffer implementation,
|
||||
// we'd insert recovered frames at the right position.
|
||||
// For now, log recovery for telemetry.
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
@@ -592,47 +490,24 @@ async fn run_call(
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
}
|
||||
|
||||
if frames_decoded == 1 || frames_decoded % 500 == 0 {
|
||||
info!(frames_decoded, fec_recovered, "recv stats");
|
||||
}
|
||||
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_decoded = frames_decoded;
|
||||
stats.fec_recovered = fec_recovered;
|
||||
drop(stats);
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_stats_log.elapsed().as_secs() >= 5 {
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
playout_overflows = state.playout_ring.overflow_count(),
|
||||
playout_underruns = state.playout_ring.underrun_count(),
|
||||
"recv stats"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
last_stats_log = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
|
||||
info!("relay disconnected");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
recv_errors += 1;
|
||||
// Transient errors: log and keep going
|
||||
let msg = e.to_string();
|
||||
if msg.contains("closed") || msg.contains("reset") {
|
||||
error!(recv_errors, "recv fatal: {e}");
|
||||
break;
|
||||
}
|
||||
// Non-fatal: log throttled
|
||||
if recv_errors <= 3 || recv_errors % 50 == 0 {
|
||||
warn!(recv_errors, "recv error (continuing): {e}");
|
||||
}
|
||||
error!("recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
|
||||
};
|
||||
|
||||
// Stats task — polls path quality + quinn RTT every 500ms
|
||||
@@ -694,22 +569,12 @@ async fn run_call(
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => info!("send task ended"),
|
||||
_ = recv_task => info!("recv task ended"),
|
||||
_ = stats_task => info!("stats task ended"),
|
||||
_ = signal_task => info!("signal task ended"),
|
||||
_ = send_task => {}
|
||||
_ = recv_task => {}
|
||||
_ = stats_task => {}
|
||||
_ = signal_task => {}
|
||||
}
|
||||
|
||||
// Send CONNECTION_CLOSE and wait up to 500ms for the peer to acknowledge.
|
||||
// This ensures the relay sees the close even if the first packet is lost.
|
||||
info!("closing QUIC connection...");
|
||||
transport.close_now();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
transport.connection().closed(),
|
||||
).await {
|
||||
Ok(_) => info!("QUIC connection closed cleanly"),
|
||||
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
|
||||
}
|
||||
transport.close().await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
|
||||
|
||||
use std::panic;
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
@@ -29,41 +28,12 @@ fn profile_from_int(value: jint) -> QualityProfile {
|
||||
}
|
||||
}
|
||||
|
||||
static INIT_LOGGING: Once = Once::new();
|
||||
|
||||
/// Initialize tracing → Android logcat (tag "wzp_android").
|
||||
/// Safe to call multiple times — only the first call takes effect.
|
||||
fn init_logging() {
|
||||
INIT_LOGGING.call_once(|| {
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jlong {
|
||||
let result = panic::catch_unwind(|| {
|
||||
init_logging();
|
||||
let handle = Box::new(EngineHandle {
|
||||
engine: WzpEngine::new(),
|
||||
});
|
||||
@@ -223,6 +193,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
|
||||
return 0;
|
||||
}
|
||||
let mut buf = vec![0i16; len];
|
||||
// GetShortArrayRegion copies Java array into our buffer
|
||||
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
|
||||
return 0;
|
||||
}
|
||||
@@ -256,56 +227,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
|
||||
/// The ByteBuffer must contain little-endian i16 samples.
|
||||
/// Called from the AudioRecord capture thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
buffer: jni::objects::JByteBuffer,
|
||||
sample_count: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || sample_count <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
|
||||
};
|
||||
h.engine.write_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
|
||||
/// The ByteBuffer will be filled with little-endian i16 samples.
|
||||
/// Called from the AudioTrack playout thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
buffer: jni::objects::JByteBuffer,
|
||||
max_samples: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || max_samples <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
|
||||
};
|
||||
h.engine.read_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||
_env: JNIEnv,
|
||||
@@ -317,79 +238,3 @@ 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::<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())
|
||||
}
|
||||
|
||||
@@ -51,12 +51,6 @@ pub struct CallStats {
|
||||
pub underruns: u64,
|
||||
/// Frames recovered by FEC.
|
||||
pub fec_recovered: u64,
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
pub playout_underruns: u64,
|
||||
/// Capture ring overflow count.
|
||||
pub capture_overflows: u64,
|
||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||
pub audio_level: u32,
|
||||
/// Number of participants in the room (from last RoomUpdate).
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||
//!
|
||||
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
|
||||
//! thread that owns the stream. The public API exposes only `Send + Sync`
|
||||
//! channel handles.
|
||||
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
||||
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
@@ -16,6 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
|
||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||
pub const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
@@ -23,22 +23,24 @@ pub const FRAME_SAMPLES: usize = 960;
|
||||
// AudioCapture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Captures microphone input and yields 960-sample PCM frames.
|
||||
/// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
|
||||
///
|
||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||
pub struct AudioCapture {
|
||||
rx: mpsc::Receiver<Vec<i16>>,
|
||||
ring: Arc<AudioRing>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioCapture {
|
||||
/// Create and start capturing from the default input device at 48 kHz mono.
|
||||
pub fn start() -> Result<Self, anyhow::Error> {
|
||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||
let ring = Arc::new(AudioRing::new());
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_clone = running.clone();
|
||||
|
||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
|
||||
let ring_cb = ring.clone();
|
||||
let running_clone = running.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-audio-capture".into())
|
||||
@@ -54,21 +56,17 @@ impl AudioCapture {
|
||||
let config = StreamConfig {
|
||||
channels: 1,
|
||||
sample_rate: SampleRate(48_000),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
buffer_size: cpal::BufferSize::Fixed(FRAME_SAMPLES as u32),
|
||||
};
|
||||
|
||||
let use_f32 = !supports_i16_input(&device)?;
|
||||
|
||||
let buf = Arc::new(std::sync::Mutex::new(
|
||||
Vec::<i16>::with_capacity(FRAME_SAMPLES),
|
||||
));
|
||||
let err_cb = |e: cpal::StreamError| {
|
||||
warn!("input stream error: {e}");
|
||||
};
|
||||
|
||||
let stream = if use_f32 {
|
||||
let buf = buf.clone();
|
||||
let tx = tx.clone();
|
||||
let ring = ring_cb.clone();
|
||||
let running = running_clone.clone();
|
||||
device.build_input_stream(
|
||||
&config,
|
||||
@@ -76,21 +74,22 @@ impl AudioCapture {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut lock = buf.lock().unwrap();
|
||||
for &s in data {
|
||||
lock.push(f32_to_i16(s));
|
||||
if lock.len() == FRAME_SAMPLES {
|
||||
let frame = lock.drain(..).collect();
|
||||
let _ = tx.try_send(frame);
|
||||
// Batch convert f32 → i16, then write entire slice to ring.
|
||||
// Stack alloc for typical callback sizes (≤ 960 samples).
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
||||
let n = chunk.len();
|
||||
for i in 0..n {
|
||||
tmp[i] = f32_to_i16(chunk[i]);
|
||||
}
|
||||
ring.write(&tmp[..n]);
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let buf = buf.clone();
|
||||
let tx = tx.clone();
|
||||
let ring = ring_cb.clone();
|
||||
let running = running_clone.clone();
|
||||
device.build_input_stream(
|
||||
&config,
|
||||
@@ -98,14 +97,7 @@ impl AudioCapture {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut lock = buf.lock().unwrap();
|
||||
for &s in data {
|
||||
lock.push(s);
|
||||
if lock.len() == FRAME_SAMPLES {
|
||||
let frame = lock.drain(..).collect();
|
||||
let _ = tx.try_send(frame);
|
||||
}
|
||||
}
|
||||
ring.write(data);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
@@ -114,7 +106,6 @@ impl AudioCapture {
|
||||
|
||||
stream.play().context("failed to start input stream")?;
|
||||
|
||||
// Signal success to the caller before parking.
|
||||
let _ = init_tx.send(Ok(()));
|
||||
|
||||
// Keep stream alive until stopped.
|
||||
@@ -135,15 +126,12 @@ impl AudioCapture {
|
||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { rx, running })
|
||||
Ok(Self { ring, running })
|
||||
}
|
||||
|
||||
/// Read the next frame of 960 PCM samples (blocking until available).
|
||||
///
|
||||
/// Returns `None` when the stream has been stopped or the channel is
|
||||
/// disconnected.
|
||||
pub fn read_frame(&self) -> Option<Vec<i16>> {
|
||||
self.rx.recv().ok()
|
||||
/// Get a reference to the capture ring buffer for direct polling.
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
/// Stop capturing.
|
||||
@@ -152,26 +140,34 @@ impl AudioCapture {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioCapture {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioPlayback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Plays PCM frames through the default output device at 48 kHz mono.
|
||||
/// Plays PCM through the default output device, reading from a lock-free ring buffer.
|
||||
///
|
||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||
pub struct AudioPlayback {
|
||||
tx: mpsc::SyncSender<Vec<i16>>,
|
||||
ring: Arc<AudioRing>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
/// Create and start playback on the default output device at 48 kHz mono.
|
||||
pub fn start() -> Result<Self, anyhow::Error> {
|
||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||
let ring = Arc::new(AudioRing::new());
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_clone = running.clone();
|
||||
|
||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
|
||||
let ring_cb = ring.clone();
|
||||
let running_clone = running.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-audio-playback".into())
|
||||
@@ -187,67 +183,45 @@ impl AudioPlayback {
|
||||
let config = StreamConfig {
|
||||
channels: 1,
|
||||
sample_rate: SampleRate(48_000),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
buffer_size: cpal::BufferSize::Fixed(FRAME_SAMPLES as u32),
|
||||
};
|
||||
|
||||
let use_f32 = !supports_i16_output(&device)?;
|
||||
|
||||
// Shared ring of samples the cpal callback drains from.
|
||||
let ring = Arc::new(std::sync::Mutex::new(
|
||||
std::collections::VecDeque::<i16>::with_capacity(FRAME_SAMPLES * 8),
|
||||
));
|
||||
|
||||
// Background drainer: moves frames from the mpsc channel into the ring.
|
||||
{
|
||||
let ring = ring.clone();
|
||||
let running = running_clone.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-playback-drain".into())
|
||||
.spawn(move || {
|
||||
while running.load(Ordering::Relaxed) {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(frame) => {
|
||||
let mut lock = ring.lock().unwrap();
|
||||
lock.extend(frame);
|
||||
while lock.len() > FRAME_SAMPLES * 16 {
|
||||
lock.pop_front();
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
let err_cb = |e: cpal::StreamError| {
|
||||
warn!("output stream error: {e}");
|
||||
};
|
||||
|
||||
let stream = if use_f32 {
|
||||
let ring = ring.clone();
|
||||
let ring = ring_cb.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut lock = ring.lock().unwrap();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = match lock.pop_front() {
|
||||
Some(s) => i16_to_f32(s),
|
||||
None => 0.0,
|
||||
};
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in data.chunks_mut(FRAME_SAMPLES) {
|
||||
let n = chunk.len();
|
||||
let read = ring.read(&mut tmp[..n]);
|
||||
for i in 0..read {
|
||||
chunk[i] = i16_to_f32(tmp[i]);
|
||||
}
|
||||
// Fill remainder with silence if ring underran
|
||||
for i in read..n {
|
||||
chunk[i] = 0.0;
|
||||
}
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let ring = ring.clone();
|
||||
let ring = ring_cb.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut lock = ring.lock().unwrap();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = lock.pop_front().unwrap_or(0);
|
||||
let read = ring.read(data);
|
||||
// Fill remainder with silence if ring underran
|
||||
for sample in &mut data[read..] {
|
||||
*sample = 0;
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
@@ -257,7 +231,6 @@ impl AudioPlayback {
|
||||
|
||||
stream.play().context("failed to start output stream")?;
|
||||
|
||||
// Signal success to the caller before parking.
|
||||
let _ = init_tx.send(Ok(()));
|
||||
|
||||
// Keep stream alive until stopped.
|
||||
@@ -278,12 +251,12 @@ impl AudioPlayback {
|
||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { tx, running })
|
||||
Ok(Self { ring, running })
|
||||
}
|
||||
|
||||
/// Write a frame of PCM samples for playback.
|
||||
pub fn write_frame(&self, pcm: &[i16]) {
|
||||
let _ = self.tx.try_send(pcm.to_vec());
|
||||
/// Get a reference to the playout ring buffer for direct writing.
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
/// Stop playback.
|
||||
@@ -292,11 +265,16 @@ impl AudioPlayback {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayback {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if the input device supports i16 at 48 kHz mono.
|
||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
let supported = device
|
||||
.supported_input_configs()
|
||||
@@ -313,7 +291,6 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if the output device supports i16 at 48 kHz mono.
|
||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
let supported = device
|
||||
.supported_output_configs()
|
||||
|
||||
89
crates/wzp-client/src/audio_ring.rs
Normal file
89
crates/wzp-client/src/audio_ring.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Lock-free SPSC ring buffer for audio PCM transfer between
|
||||
//! CPAL audio callbacks and the Rust engine.
|
||||
//!
|
||||
//! Identical design to wzp-android's audio_ring: the producer writes and
|
||||
//! advances a write cursor, the consumer reads and advances a read cursor.
|
||||
//! Both cursors are atomic — no mutex, no blocking on the audio thread.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Ring buffer capacity in i16 samples.
|
||||
/// 960 samples * 10 frames = ~200ms of audio at 48kHz mono.
|
||||
const RING_CAPACITY: usize = 960 * 10;
|
||||
|
||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
write_pos: AtomicUsize,
|
||||
read_pos: AtomicUsize,
|
||||
}
|
||||
|
||||
// SAFETY: AudioRing is designed for SPSC — one thread writes, one reads.
|
||||
// The atomics ensure visibility. The buffer itself is never accessed
|
||||
// from the same index by both threads simultaneously because the
|
||||
// producer only writes to positions between write_pos and read_pos,
|
||||
// and the consumer only reads from positions between read_pos and write_pos.
|
||||
unsafe impl Send for AudioRing {}
|
||||
unsafe impl Sync for AudioRing {}
|
||||
|
||||
impl AudioRing {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: Box::new([0i16; RING_CAPACITY]),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read.
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Acquire);
|
||||
w.wrapping_sub(r)
|
||||
}
|
||||
|
||||
/// Write samples into the ring. Returns number of samples written.
|
||||
/// Drops oldest samples if the ring is full.
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
|
||||
for i in 0..count {
|
||||
let idx = (w + i) % RING_CAPACITY;
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add(idx) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos
|
||||
.store(w.wrapping_add(count), Ordering::Release);
|
||||
|
||||
// If we overwrote unread data, advance read_pos
|
||||
if self.available() > RING_CAPACITY {
|
||||
let new_read = self
|
||||
.write_pos
|
||||
.load(Ordering::Relaxed)
|
||||
.wrapping_sub(RING_CAPACITY);
|
||||
self.read_pos.store(new_read, Ordering::Release);
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let avail = self.available();
|
||||
let count = out.len().min(avail);
|
||||
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
for i in 0..count {
|
||||
let idx = (r + i) % RING_CAPACITY;
|
||||
out[i] = unsafe { *self.buf.as_ptr().add(idx) };
|
||||
}
|
||||
|
||||
self.read_pos
|
||||
.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,22 @@ struct CliArgs {
|
||||
seed_hex: Option<String>,
|
||||
mnemonic: Option<String>,
|
||||
room: Option<String>,
|
||||
raw_room: bool,
|
||||
alias: Option<String>,
|
||||
token: Option<String>,
|
||||
_metrics_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Default identity file path: ~/.wzp/identity
|
||||
fn default_identity_path() -> std::path::PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
std::path::PathBuf::from(home).join(".wzp").join("identity")
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
/// Resolve the identity seed from --seed, --mnemonic, or generate a new one.
|
||||
/// Resolve the identity seed from --seed, --mnemonic, or persistent file.
|
||||
///
|
||||
/// Priority: --seed > --mnemonic > ~/.wzp/identity > generate + save.
|
||||
pub fn resolve_seed(&self) -> wzp_crypto::Seed {
|
||||
if let Some(ref hex_str) = self.seed_hex {
|
||||
let seed = wzp_crypto::Seed::from_hex(hex_str).expect("invalid --seed hex");
|
||||
@@ -65,10 +75,30 @@ impl CliArgs {
|
||||
info!(fingerprint = %fp, "identity from --mnemonic");
|
||||
seed
|
||||
} else {
|
||||
let path = default_identity_path();
|
||||
// Try loading existing identity
|
||||
if path.exists() {
|
||||
if let Ok(hex_str) = std::fs::read_to_string(&path) {
|
||||
let hex_str = hex_str.trim();
|
||||
if let Ok(seed) = wzp_crypto::Seed::from_hex(hex_str) {
|
||||
let id = seed.derive_identity();
|
||||
let fp = id.public_identity().fingerprint;
|
||||
info!(fingerprint = %fp, path = %path.display(), "loaded persistent identity");
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generate new and save
|
||||
let seed = wzp_crypto::Seed::generate();
|
||||
let id = seed.derive_identity();
|
||||
let fp = id.public_identity().fingerprint;
|
||||
info!(fingerprint = %fp, "generated ephemeral identity");
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
// Encode seed as hex manually (avoid dep on `hex` crate in binary)
|
||||
let hex_str: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
std::fs::write(&path, hex_str).ok();
|
||||
info!(fingerprint = %fp, path = %path.display(), "generated and saved new identity");
|
||||
seed
|
||||
}
|
||||
}
|
||||
@@ -86,6 +116,8 @@ fn parse_args() -> CliArgs {
|
||||
let mut seed_hex = None;
|
||||
let mut mnemonic = None;
|
||||
let mut room = None;
|
||||
let mut raw_room = false;
|
||||
let mut alias = None;
|
||||
let mut token = None;
|
||||
let mut metrics_file = None;
|
||||
let mut relay_str = None;
|
||||
@@ -130,6 +162,11 @@ fn parse_args() -> CliArgs {
|
||||
i += 1;
|
||||
room = Some(args.get(i).expect("--room requires a name").to_string());
|
||||
}
|
||||
"--raw-room" => raw_room = true,
|
||||
"--alias" => {
|
||||
i += 1;
|
||||
alias = Some(args.get(i).expect("--alias requires a name").to_string());
|
||||
}
|
||||
"--token" => {
|
||||
i += 1;
|
||||
token = Some(args.get(i).expect("--token requires a value").to_string());
|
||||
@@ -183,10 +220,13 @@ fn parse_args() -> CliArgs {
|
||||
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
||||
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
||||
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
|
||||
eprintln!(" --raw-room Send room name as-is (no hash, for Android compat)");
|
||||
eprintln!(" --alias <name> Display name shown to other participants");
|
||||
eprintln!(" --token <token> featherChat bearer token for relay auth");
|
||||
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
|
||||
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
||||
eprintln!();
|
||||
eprintln!("Identity is auto-saved to ~/.wzp/identity on first run.");
|
||||
eprintln!("Default relay: 127.0.0.1:4433");
|
||||
std::process::exit(0);
|
||||
}
|
||||
@@ -219,6 +259,8 @@ fn parse_args() -> CliArgs {
|
||||
seed_hex,
|
||||
mnemonic,
|
||||
room,
|
||||
raw_room,
|
||||
alias,
|
||||
token,
|
||||
_metrics_file: metrics_file,
|
||||
}
|
||||
@@ -250,8 +292,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
"WarzonePhone client"
|
||||
);
|
||||
|
||||
// Hash room name for SNI privacy (or "default" if none specified)
|
||||
// Compute SNI from room name.
|
||||
// --raw-room sends the name as-is (for Android compat — Android doesn't hash).
|
||||
// Default behaviour hashes for privacy.
|
||||
let sni = match &cli.room {
|
||||
Some(name) if cli.raw_room => {
|
||||
info!(room = %name, "using raw room name as SNI (no hash)");
|
||||
name.clone()
|
||||
}
|
||||
Some(name) => {
|
||||
let hashed = wzp_crypto::hash_room_name(name);
|
||||
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
||||
@@ -287,7 +335,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
None, // alias — desktop client doesn't set one yet
|
||||
cli.alias.as_deref(),
|
||||
).await?;
|
||||
info!("crypto handshake complete");
|
||||
|
||||
@@ -549,78 +597,225 @@ async fn run_file_mode(
|
||||
}
|
||||
|
||||
/// Live mode: capture from mic, encode, send; receive, decode, play.
|
||||
///
|
||||
/// Architecture (mirrors wzp-android/engine.rs):
|
||||
/// CPAL capture callback → AudioRing → send task (5ms poll) → QUIC
|
||||
/// QUIC → recv task → jitter buffer → decode tick (20ms) → AudioRing → CPAL playback callback
|
||||
///
|
||||
/// All lock-free: CPAL callbacks use atomic ring buffers, no Mutex on the audio path.
|
||||
#[cfg(feature = "audio")]
|
||||
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
|
||||
async fn run_live(
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::sync::Arc as StdArc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
||||
use wzp_client::call::JitterTelemetry;
|
||||
|
||||
let capture = AudioCapture::start()?;
|
||||
let playback = AudioPlayback::start()?;
|
||||
info!("Audio I/O started — press Ctrl+C to stop");
|
||||
info!("audio I/O started (lock-free ring buffers) — press Ctrl+C to stop");
|
||||
|
||||
let capture_ring = capture.ring().clone();
|
||||
let playout_ring = playback.ring().clone();
|
||||
|
||||
let running = StdArc::new(AtomicBool::new(true));
|
||||
|
||||
// --- Signal handler: set running=false on first Ctrl+C, force-quit on second ---
|
||||
let signal_running = running.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
eprintln!(); // newline after ^C
|
||||
info!("Ctrl+C received, shutting down...");
|
||||
signal_running.store(false, Ordering::SeqCst);
|
||||
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
eprintln!("\nForce quit");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let config = CallConfig::default();
|
||||
|
||||
// --- Send task: poll capture ring → encode → send via async ---
|
||||
let send_transport = transport.clone();
|
||||
let rt_handle = tokio::runtime::Handle::current();
|
||||
let send_handle = std::thread::Builder::new()
|
||||
.name("wzp-send-loop".into())
|
||||
.spawn(move || {
|
||||
let config = CallConfig::default();
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
loop {
|
||||
let frame = match capture.read_frame() {
|
||||
Some(f) => f,
|
||||
None => break,
|
||||
};
|
||||
let packets = match encoder.encode_frame(&frame) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!("encode error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for pkt in &packets {
|
||||
if let Err(e) = rt_handle.block_on(send_transport.send_media(pkt)) {
|
||||
error!("send error: {e}");
|
||||
return;
|
||||
}
|
||||
let send_running = running.clone();
|
||||
let send_task = async move {
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut frames_sent: u64 = 0;
|
||||
|
||||
loop {
|
||||
if !send_running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let avail = capture_ring.available();
|
||||
if avail < FRAME_SAMPLES {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let read = capture_ring.read(&mut capture_buf);
|
||||
if read < FRAME_SAMPLES {
|
||||
continue;
|
||||
}
|
||||
|
||||
let packets = match encoder.encode_frame(&capture_buf) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!("encode error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for pkt in &packets {
|
||||
if let Err(e) = send_transport.send_media(pkt).await {
|
||||
error!("send error: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
frames_sent += 1;
|
||||
if frames_sent == 1 || frames_sent % 500 == 0 {
|
||||
info!(frames_sent, "send progress");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Recv task: receive packets → ingest into jitter buffer ---
|
||||
// Uses timeout so it can check the running flag and exit on Ctrl+C.
|
||||
let recv_transport = transport.clone();
|
||||
let recv_handle = tokio::spawn(async move {
|
||||
let config = CallConfig::default();
|
||||
let mut decoder = CallDecoder::new(&config);
|
||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let recv_running = running.clone();
|
||||
let config = CallConfig::default();
|
||||
let decoder = StdArc::new(tokio::sync::Mutex::new(CallDecoder::new(&config)));
|
||||
let decoder_recv = decoder.clone();
|
||||
|
||||
let recv_task = async move {
|
||||
let mut packets_received: u64 = 0;
|
||||
loop {
|
||||
match recv_transport.recv_media().await {
|
||||
Ok(Some(pkt)) => {
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
// Only decode for source packets (1 source = 1 audio frame).
|
||||
// Repair packets feed the FEC decoder but don't produce audio.
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
playback.write_frame(&pcm_buf);
|
||||
}
|
||||
if !recv_running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
// Timeout so we can check running flag periodically
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
recv_transport.recv_media(),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
let mut dec = decoder_recv.lock().await;
|
||||
dec.ingest(pkt);
|
||||
packets_received += 1;
|
||||
if packets_received == 1 || packets_received % 500 == 0 {
|
||||
info!(packets_received, depth = dec.stats().current_depth, "recv progress");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(Ok(None)) => {
|
||||
info!("connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(Err(e)) => {
|
||||
error!("recv error: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // timeout — loop and check running flag
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tokio::signal::ctrl_c().await?;
|
||||
info!("Shutting down...");
|
||||
// --- Playout tick: decode from jitter buffer at steady 20ms intervals ---
|
||||
let playout_running = running.clone();
|
||||
let decoder_playout = decoder.clone();
|
||||
let playout_task = async move {
|
||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut telemetry = JitterTelemetry::new(5);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if !playout_running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
recv_handle.abort();
|
||||
drop(send_handle);
|
||||
transport.close().await?;
|
||||
info!("done");
|
||||
let mut dec = decoder_playout.lock().await;
|
||||
|
||||
// Drain ready frames from jitter buffer into playout ring.
|
||||
let mut decoded_this_tick = 0;
|
||||
while let Some(n) = dec.decode_next(&mut pcm_buf) {
|
||||
playout_ring.write(&pcm_buf[..n]);
|
||||
decoded_this_tick += 1;
|
||||
if decoded_this_tick >= 2 {
|
||||
break; // Don't drain too aggressively in one tick
|
||||
}
|
||||
}
|
||||
|
||||
telemetry.maybe_log(dec.stats());
|
||||
}
|
||||
};
|
||||
|
||||
// --- Signal task: listen for RoomUpdate and display presence ---
|
||||
let signal_transport = transport.clone();
|
||||
let signal_running = running.clone();
|
||||
let signal_task = async move {
|
||||
loop {
|
||||
if !signal_running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
signal_transport.recv_signal(),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate { count, participants }))) => {
|
||||
info!(count, "room update");
|
||||
for p in &participants {
|
||||
let name = p
|
||||
.alias
|
||||
.as_deref()
|
||||
.unwrap_or("(no alias)");
|
||||
let fp = if p.fingerprint.is_empty() {
|
||||
"(no fingerprint)"
|
||||
} else {
|
||||
&p.fingerprint
|
||||
};
|
||||
info!(" participant: {name} [{fp}]");
|
||||
}
|
||||
}
|
||||
Ok(Ok(Some(msg))) => {
|
||||
info!("signal: {:?}", std::mem::discriminant(&msg));
|
||||
}
|
||||
Ok(Ok(None)) => {
|
||||
info!("signal stream closed");
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // timeout — loop and check running flag
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Run all tasks, exit when any finishes (or running flag cleared by Ctrl+C) ---
|
||||
tokio::select! {
|
||||
_ = send_task => info!("send task ended"),
|
||||
_ = recv_task => info!("recv task ended"),
|
||||
_ = playout_task => info!("playout task ended"),
|
||||
_ = signal_task => info!("signal task ended"),
|
||||
}
|
||||
|
||||
running.store(false, Ordering::SeqCst);
|
||||
capture.stop();
|
||||
playback.stop();
|
||||
|
||||
// Give transport 2s to close gracefully, then bail
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), transport.close()).await {
|
||||
Ok(Ok(())) => info!("done"),
|
||||
Ok(Err(e)) => info!("close error (non-fatal): {e}"),
|
||||
Err(_) => info!("close timed out, exiting anyway"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SetAlias { .. } => CallSignalType::Offer, // reuse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +126,6 @@ mod tests {
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![QualityProfile::GOOD],
|
||||
alias: None,
|
||||
};
|
||||
|
||||
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||
@@ -143,7 +143,6 @@ mod tests {
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
alias: None,
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
pub mod audio_io;
|
||||
#[cfg(feature = "audio")]
|
||||
pub mod audio_ring;
|
||||
pub mod bench;
|
||||
pub mod call;
|
||||
pub mod drift_test;
|
||||
|
||||
@@ -656,6 +656,11 @@ pub enum SignalMessage {
|
||||
/// List of participants currently in the room.
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
/// Set or update the client's display name.
|
||||
/// Sent by client after joining; relay updates the participant entry and
|
||||
/// re-broadcasts a RoomUpdate to all participants.
|
||||
SetAlias { alias: String },
|
||||
}
|
||||
|
||||
/// A participant entry in a RoomUpdate message.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::packet::TrunkFrame;
|
||||
use wzp_proto::MediaTransport;
|
||||
@@ -141,6 +141,17 @@ impl Room {
|
||||
self.participants.iter().map(|p| p.sender.clone()).collect()
|
||||
}
|
||||
|
||||
/// Update a participant's alias. Returns true if the participant was found.
|
||||
fn set_alias(&mut self, id: ParticipantId, alias: String) -> bool {
|
||||
if let Some(p) = self.participants.iter_mut().find(|p| p.id == id) {
|
||||
info!(participant = id, %alias, "alias updated");
|
||||
p.alias = Some(alias);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.participants.is_empty()
|
||||
}
|
||||
@@ -255,6 +266,26 @@ impl RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a participant's alias and return a RoomUpdate + senders for broadcasting.
|
||||
pub fn set_alias(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
participant_id: ParticipantId,
|
||||
alias: String,
|
||||
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
if room.set_alias(participant_id, alias) {
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
return Some((update, senders));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get senders for all OTHER participants in a room.
|
||||
pub fn others(
|
||||
&self,
|
||||
@@ -374,141 +405,111 @@ async fn run_participant_plain(
|
||||
session_id: &str,
|
||||
) {
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
let mut last_recv_instant = std::time::Instant::now();
|
||||
let mut max_recv_gap_ms = 0u64;
|
||||
let mut max_forward_ms = 0u64;
|
||||
let mut send_errors = 0u64;
|
||||
let mut last_log_instant = std::time::Instant::now();
|
||||
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
%addr,
|
||||
session = session_id,
|
||||
"forwarding loop started (plain)"
|
||||
);
|
||||
|
||||
loop {
|
||||
let recv_start = std::time::Instant::now();
|
||||
let pkt = match transport.recv_media().await {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
|
||||
} else {
|
||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||
// Media forwarding task
|
||||
let media_room_mgr = room_mgr.clone();
|
||||
let media_room_name = room_name.clone();
|
||||
let media_transport = transport.clone();
|
||||
let media_metrics = metrics.clone();
|
||||
let media_session_id = session_id.to_string();
|
||||
let media_task = async move {
|
||||
let mut packets_forwarded = 0u64;
|
||||
loop {
|
||||
let pkt = match media_transport.recv_media().await {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
||||
info!(%addr, participant = participant_id, "connection closed: {e}");
|
||||
} else {
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
media_metrics.update_session_quality(&media_session_id, report);
|
||||
}
|
||||
};
|
||||
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = std::time::Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
// Log if recv gap is suspiciously large (>200ms = missed ~10 packets)
|
||||
if recv_gap_ms > 200 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
"large recv gap"
|
||||
);
|
||||
}
|
||||
let others = {
|
||||
let mgr = media_room_mgr.lock().await;
|
||||
mgr.others(&media_room_name, participant_id)
|
||||
};
|
||||
|
||||
// Update per-session quality metrics if a quality report is present
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
// Get current list of other participants
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
lock_ms,
|
||||
"slow room_mgr lock"
|
||||
);
|
||||
}
|
||||
|
||||
// Forward to all others
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
ParticipantSender::Quic(t) => {
|
||||
if let Err(e) = t.send_media(&pkt).await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
peer = %t.connection().remote_address(),
|
||||
total_send_errors = send_errors,
|
||||
"send_media error: {e}"
|
||||
);
|
||||
}
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
ParticipantSender::Quic(t) => {
|
||||
let _ = t.send_media(&pkt).await;
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
media_metrics.packets_forwarded.inc_by(fan_out);
|
||||
media_metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
if packets_forwarded % 500 == 0 {
|
||||
let room_size = {
|
||||
let mgr = media_room_mgr.lock().await;
|
||||
mgr.room_size(&media_room_name)
|
||||
};
|
||||
info!(
|
||||
room = %media_room_name,
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
"participant stats"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Signal handling task — processes SetAlias and other in-call signals
|
||||
let signal_room_mgr = room_mgr.clone();
|
||||
let signal_room_name = room_name.clone();
|
||||
let signal_transport = transport.clone();
|
||||
let signal_task = async move {
|
||||
loop {
|
||||
match signal_transport.recv_signal().await {
|
||||
Ok(Some(wzp_proto::SignalMessage::SetAlias { alias })) => {
|
||||
info!(%addr, participant = participant_id, %alias, "SetAlias received");
|
||||
let mut mgr = signal_room_mgr.lock().await;
|
||||
if let Some((update, senders)) =
|
||||
mgr.set_alias(&signal_room_name, participant_id, alias)
|
||||
{
|
||||
drop(mgr);
|
||||
broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
}
|
||||
Ok(Some(wzp_proto::SignalMessage::Hangup { .. })) => {
|
||||
info!(%addr, participant = participant_id, "hangup received");
|
||||
break;
|
||||
}
|
||||
Ok(Some(msg)) => {
|
||||
info!(%addr, participant = participant_id, "signal: {:?}", std::mem::discriminant(&msg));
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
warn!(%addr, participant = participant_id, "signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
if fwd_ms > max_forward_ms {
|
||||
max_forward_ms = fwd_ms;
|
||||
}
|
||||
if fwd_ms > 50 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
fwd_ms,
|
||||
fan_out = others.len(),
|
||||
"slow forward"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
|
||||
// Periodic stats log every 5 seconds
|
||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
};
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
fan_out,
|
||||
max_recv_gap_ms,
|
||||
max_forward_ms,
|
||||
send_errors,
|
||||
"participant stats"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
max_forward_ms = 0;
|
||||
last_log_instant = std::time::Instant::now();
|
||||
}
|
||||
// Run both in parallel — exit when either finishes (disconnection)
|
||||
tokio::select! {
|
||||
_ = media_task => {}
|
||||
_ = signal_task => {}
|
||||
}
|
||||
|
||||
// Clean up — leave room and broadcast update to remaining participants
|
||||
@@ -532,19 +533,6 @@ async fn run_participant_trunked(
|
||||
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
let mut last_recv_instant = std::time::Instant::now();
|
||||
let mut max_recv_gap_ms = 0u64;
|
||||
let mut max_forward_ms = 0u64;
|
||||
let mut send_errors = 0u64;
|
||||
let mut last_log_instant = std::time::Instant::now();
|
||||
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
%addr,
|
||||
session = session_id,
|
||||
"forwarding loop started (trunked)"
|
||||
);
|
||||
|
||||
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
|
||||
// transport (stable for the Arc's lifetime). We use the remote address
|
||||
@@ -566,50 +554,24 @@ async fn run_participant_trunked(
|
||||
let pkt = match result {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = std::time::Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
if recv_gap_ms > 200 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
"large recv gap (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
lock_ms,
|
||||
"slow room_mgr lock (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
@@ -619,44 +581,21 @@ async fn run_participant_trunked(
|
||||
.entry(peer_addr)
|
||||
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
||||
if let Err(e) = fwd.send(&pkt).await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
peer = %peer_addr,
|
||||
total_send_errors = send_errors,
|
||||
"trunked send error: {e}"
|
||||
);
|
||||
}
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients bypass trunking — send raw payload directly
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
if fwd_ms > max_forward_ms {
|
||||
max_forward_ms = fwd_ms;
|
||||
}
|
||||
if fwd_ms > 50 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
fwd_ms,
|
||||
fan_out = others.len(),
|
||||
"slow forward (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||
if packets_forwarded % 500 == 0 {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
@@ -666,30 +605,15 @@ async fn run_participant_trunked(
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
fan_out,
|
||||
max_recv_gap_ms,
|
||||
max_forward_ms,
|
||||
send_errors,
|
||||
"participant stats (trunked)"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
max_forward_ms = 0;
|
||||
last_log_instant = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
_ = flush_interval.tick() => {
|
||||
for fwd in forwarders.values_mut() {
|
||||
if let Err(e) = fwd.flush().await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
total_send_errors = send_errors,
|
||||
"trunk flush error: {e}"
|
||||
);
|
||||
}
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,6 @@ impl QuinnTransport {
|
||||
&self.connection
|
||||
}
|
||||
|
||||
/// Close the QUIC connection immediately (synchronous, no async needed).
|
||||
/// The relay will detect the close and remove this participant from the room.
|
||||
pub fn close_now(&self) {
|
||||
self.connection.close(quinn::VarInt::from_u32(0), b"hangup");
|
||||
}
|
||||
|
||||
/// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor.
|
||||
pub fn feed_rtt(&self, rtt_ms: u32) {
|
||||
self.path_monitor.lock().unwrap().observe_rtt(rtt_ms);
|
||||
|
||||
@@ -272,7 +272,7 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
||||
// Crypto handshake with relay
|
||||
let handshake_start = std::time::Instant::now();
|
||||
let bridge_seed = wzp_crypto::Seed::generate();
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0, None).await {
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await {
|
||||
Ok(_session) => {
|
||||
let elapsed = handshake_start.elapsed().as_secs_f64();
|
||||
state.metrics.handshake_latency.observe(elapsed);
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# Incident Report: SIGBUS in ART GC During Audio Thread JNI Calls
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** High — app crash (SIGBUS) mid-call
|
||||
**Status:** Root-caused, fix proposed
|
||||
**Affects:** Android 16 (API 36) devices with concurrent mark-compact GC
|
||||
|
||||
## Summary
|
||||
|
||||
The app crashes with SIGBUS (signal 7, BUS_ADRERR) during an active call. The crash occurs in ART's garbage collector or JIT compiler, NOT in our Rust native code or AudioRing buffer. Both `wzp-capture` and `wzp-playout` Kotlin threads are affected.
|
||||
|
||||
## Crash Details
|
||||
|
||||
### Crash 1: wzp-capture (18:42, after 476s of call)
|
||||
|
||||
```
|
||||
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x720009be38
|
||||
tid 19697 (wzp-capture), pid 17885 (com.wzp.phone)
|
||||
```
|
||||
|
||||
**Backtrace:**
|
||||
```
|
||||
#00 art::StackVisitor::WalkStack
|
||||
#01 art::Thread::VisitRoots
|
||||
#02 art::gc::collector::MarkCompact::ThreadFlipVisitor::Run
|
||||
#03 art::Thread::EnsureFlipFunctionStarted
|
||||
#04 CheckJNI::ReleasePrimitiveArrayElements ← JNI boundary
|
||||
#05 android_media_AudioRecord_readInArray ← AudioRecord.read()
|
||||
#09 com.wzp.audio.AudioPipeline.runCapture
|
||||
```
|
||||
|
||||
**Root cause:** ART's concurrent mark-compact GC (`MarkCompact::ThreadFlipVisitor`) is flipping thread roots while the capture thread is in the middle of a JNI call (`AudioRecord.read()`). The GC's `EnsureFlipFunctionStarted` triggers a stack walk that hits an invalid address.
|
||||
|
||||
### Crash 2: wzp-playout (19:17, mid-call)
|
||||
|
||||
```
|
||||
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x225eb98
|
||||
tid 32574 (wzp-playout), pid 32479 (com.wzp.phone)
|
||||
```
|
||||
|
||||
**Backtrace:**
|
||||
```
|
||||
#00 com.wzp.audio.AudioPipeline.runPlayout ← JIT-compiled code
|
||||
#01 art_quick_osr_stub ← On-Stack Replacement
|
||||
#02 art::jit::Jit::MaybeDoOnStackReplacement
|
||||
#03-#04 art::interpreter::ExecuteSwitchImplCpp
|
||||
```
|
||||
|
||||
**Root cause:** ART's JIT compiler performed On-Stack Replacement (OSR) on the hot playout loop. The OSR stub references a code address (`0x225eb98`) that is no longer valid — likely because the GC moved the compiled code in memory during concurrent compaction.
|
||||
|
||||
## Why This Happens
|
||||
|
||||
Android 16 introduced a new **concurrent mark-compact GC** (CMC) that moves objects in memory while other threads are running. This is safe for normal Java code because ART uses read barriers. But our audio threads have specific properties that stress this:
|
||||
|
||||
1. **`Thread.MAX_PRIORITY`** — audio threads run at the highest priority, starving the GC thread of CPU time. The GC may not complete its thread-flip before the audio thread resumes.
|
||||
|
||||
2. **Tight JNI loops** — `runCapture()` and `runPlayout()` loop every 20ms calling `AudioRecord.read()` / `AudioTrack.write()` via JNI. Each JNI transition is a GC safepoint, but the thread spends most of its time in native code where the GC can't flip it.
|
||||
|
||||
3. **Long-running JIT-compiled code** — the hot loop gets JIT-compiled and may undergo OSR. If the GC compacts memory while OSR is in progress, the stub can reference stale addresses.
|
||||
|
||||
4. **Daemon threads that never exit** — our threads are parked with `Thread.sleep(Long.MAX_VALUE)` after the call ends (to avoid the libcrypto TLS destructor crash). These zombie threads accumulate GC root scan work.
|
||||
|
||||
## Evidence This Is Not Our Bug
|
||||
|
||||
| Component | Evidence |
|
||||
|-----------|---------|
|
||||
| **AudioRing** | Not in any backtrace. All crash frames are in `libart.so` (ART runtime) |
|
||||
| **Rust native code** | `libwzp_android.so` not in any crash frame |
|
||||
| **JNI bridge** | Crash happens during `ReleasePrimitiveArrayElements` (ART internal), not during our JNI calls |
|
||||
| **Timing** | Crashes after 476s and mid-call — not during init or teardown |
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Option A: Disable concurrent GC compaction for audio threads (recommended)
|
||||
|
||||
Use `dalvik.vm.gctype` or per-thread GC pinning to prevent the mark-compact collector from moving objects referenced by audio threads.
|
||||
|
||||
**Not directly controllable from app code.** But we can reduce GC pressure:
|
||||
|
||||
### Option B: Reduce JNI transitions in audio threads
|
||||
|
||||
Instead of calling `engine.writeAudio(pcm)` / `engine.readAudio(pcm)` via JNI on every 20ms frame, batch multiple frames or use `DirectByteBuffer` to share memory without JNI array copies.
|
||||
|
||||
**Implementation:**
|
||||
- Allocate a `DirectByteBuffer` in Kotlin, share the pointer with Rust via JNI
|
||||
- Audio threads write/read directly to the buffer (no JNI call per frame)
|
||||
- Rust reads/writes from the same memory region
|
||||
- Reduces JNI transitions from 100/sec to 0/sec per audio direction
|
||||
|
||||
### Option C: Use Android's Oboe (AAudio) natively from Rust
|
||||
|
||||
Skip the Kotlin AudioRecord/AudioTrack entirely. Use Oboe (which we already have as a dependency in `wzp-android/Cargo.toml`) to create native audio streams directly from Rust. The audio callbacks run in native code with no JNI, no GC interaction, no ART.
|
||||
|
||||
This is how the project was originally designed (see `audio_android.rs` with Oboe references) before switching to Kotlin AudioRecord for simplicity.
|
||||
|
||||
**Pros:** Eliminates the entire JNI audio path. No GC interaction. Lower latency.
|
||||
**Cons:** Requires rewriting `AudioPipeline.kt` into Rust. Oboe setup is more complex.
|
||||
|
||||
### Option D: Pin audio thread objects to prevent GC movement
|
||||
|
||||
Use JNI `GetPrimitiveArrayCritical` instead of `GetShortArrayRegion` to pin the array in memory during the operation. This prevents the GC from moving the array while we're using it.
|
||||
|
||||
**Implementation:** Change `nativeWriteAudio` / `nativeReadAudio` JNI functions to use critical sections.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Short term: Option B** (DirectByteBuffer) — reduces JNI transitions without major refactoring.
|
||||
|
||||
**Long term: Option C** (Oboe from Rust) — eliminates the problem entirely. This is the architecturally correct solution and matches the original design intent.
|
||||
|
||||
## Data Files
|
||||
|
||||
- Logcat from Nothing A059 (Android 16, API 36)
|
||||
- Two crashes in the same session: 18:42 (capture, after 476s) and 19:17 (playout)
|
||||
- Both SIGBUS/BUS_ADRERR, both in ART internal frames
|
||||
@@ -1,175 +0,0 @@
|
||||
# Incident Report: Native Crash in Capture Thread — Use-After-Free on Engine Handle
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** Critical — app crash (SIGSEGV) on call hangup
|
||||
**Status:** Root-caused, fix pending
|
||||
**Affects:** Android client only
|
||||
|
||||
## Summary
|
||||
|
||||
The app crashes with a native SIGSEGV during or shortly after call hangup. The crash occurs in JIT-compiled code inside `AudioPipeline.runCapture()`. The root cause is a use-after-free: the capture thread calls `engine.writeAudio()` via JNI after the engine's native handle has been freed by `teardown()` on the ViewModel thread.
|
||||
|
||||
## Crash Stacktrace
|
||||
|
||||
```
|
||||
04-06 13:05:42.707 F DEBUG: #09 pc 000000000250696c /memfd:jit-cache (deleted) (com.wzp.audio.AudioPipeline.runCapture+3228)
|
||||
04-06 13:05:42.707 F DEBUG: #14 pc 0000000000005270 <anonymous:730900d000> (com.wzp.audio.AudioPipeline.start$lambda$0+0)
|
||||
04-06 13:05:42.708 F DEBUG: #19 pc 00000000000044cc <anonymous:730900d000> (com.wzp.audio.AudioPipeline.$r8$lambda$0rYcivupwvyN4SgBXhsroKmTlo8+0)
|
||||
04-06 13:05:42.708 F DEBUG: #24 pc 00000000000042e4 <anonymous:730900d000> (com.wzp.audio.AudioPipeline$$ExternalSyntheticLambda0.run+0)
|
||||
```
|
||||
|
||||
This is a tombstone (signal crash), not a Java exception. The `F DEBUG` tag indicates a native crash handler (debuggerd) captured the signal.
|
||||
|
||||
## Root Cause
|
||||
|
||||
### The Race Condition
|
||||
|
||||
Two threads operate on the engine concurrently without synchronization:
|
||||
|
||||
**Thread 1: `wzp-capture` (AudioRecord thread, MAX_PRIORITY)**
|
||||
```kotlin
|
||||
// AudioPipeline.runCapture() — runs in a tight loop
|
||||
while (running) {
|
||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
engine.writeAudio(pcm) // <-- JNI call to native engine
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Thread 2: ViewModel/UI thread (normal priority)**
|
||||
```kotlin
|
||||
// CallViewModel.teardown()
|
||||
stopAudio() // sets AudioPipeline.running = false
|
||||
engine?.stopCall() // tells Rust to stop
|
||||
engine?.destroy() // frees native memory, sets nativeHandle = 0L
|
||||
engine = null
|
||||
```
|
||||
|
||||
### The Kotlin Guard is Insufficient
|
||||
|
||||
`WzpEngine.writeAudio()` has a guard:
|
||||
```kotlin
|
||||
fun writeAudio(pcm: ShortArray): Int {
|
||||
if (nativeHandle == 0L) return 0 // check
|
||||
return nativeWriteAudio(nativeHandle, pcm) // use
|
||||
}
|
||||
```
|
||||
|
||||
This is a **TOCTOU (time-of-check/time-of-use) race**:
|
||||
1. Capture thread checks `nativeHandle != 0L` → true
|
||||
2. ViewModel thread calls `destroy()`, which calls `nativeDestroy(handle)` then sets `nativeHandle = 0L`
|
||||
3. Capture thread calls `nativeWriteAudio(handle, pcm)` with the now-freed handle
|
||||
4. The JNI function dereferences `handle` as a pointer → **SIGSEGV**
|
||||
|
||||
The same race exists for `readAudio()` on the `wzp-playout` thread.
|
||||
|
||||
### Why `stopAudio()` Doesn't Prevent This
|
||||
|
||||
`AudioPipeline.stop()` sets `running = false` but does **NOT join or wait** for the threads:
|
||||
```kotlin
|
||||
fun stop() {
|
||||
running = false
|
||||
// Don't join — threads are parked as daemons to avoid native TLS crash
|
||||
captureThread = null
|
||||
playoutThread = null
|
||||
}
|
||||
```
|
||||
|
||||
The threads are intentionally not joined because of a separate bug: exiting a JNI-calling thread triggers a `SIGSEGV in OPENSSL_free` due to libcrypto TLS destructors on Android. The threads instead "park" with `Thread.sleep(Long.MAX_VALUE)` after the loop exits.
|
||||
|
||||
But the problem is the **window between `running = false` and the thread actually checking it**. The capture thread may be blocked in `recorder.read()` (which blocks for 20ms per frame) or in the middle of `engine.writeAudio()` when `destroy()` is called.
|
||||
|
||||
### Timeline of the Crash
|
||||
|
||||
```
|
||||
T=0ms ViewModel: stopAudio() → sets running=false
|
||||
T=0ms ViewModel: stopStatsPolling()
|
||||
T=0ms ViewModel: engine.stopCall() — Rust stops internal tasks
|
||||
T=1ms ViewModel: engine.destroy() — frees native memory
|
||||
↑ nativeHandle = 0L
|
||||
|
||||
T=0-20ms Capture thread: still in recorder.read() or writeAudio()
|
||||
→ if in writeAudio(), the nativeHandle check passed BEFORE destroy()
|
||||
→ JNI dereferences freed pointer → SIGSEGV
|
||||
```
|
||||
|
||||
## Affected Code
|
||||
|
||||
### Files with the race
|
||||
|
||||
| File | Line(s) | Issue |
|
||||
|------|---------|-------|
|
||||
| `android/.../WzpEngine.kt` | 107-108, 116-117 | TOCTOU on `nativeHandle` in `writeAudio()` / `readAudio()` |
|
||||
| `android/.../CallViewModel.kt` | 257-262 | `stopAudio()` + `destroy()` without waiting for audio threads to quiesce |
|
||||
| `android/.../AudioPipeline.kt` | 80-82 | `stop()` doesn't synchronize with running threads |
|
||||
|
||||
### Files with the thread parking workaround
|
||||
|
||||
| File | Line(s) | Context |
|
||||
|------|---------|---------|
|
||||
| `android/.../AudioPipeline.kt` | 57-58, 69-70 | Threads parked after loop exit to avoid libcrypto TLS crash |
|
||||
| `android/.../AudioPipeline.kt` | 96-101 | `parkThread()` — `Thread.sleep(Long.MAX_VALUE)` |
|
||||
|
||||
## Constraints for the Fix
|
||||
|
||||
1. **Cannot join audio threads** — joining triggers a separate SIGSEGV in `OPENSSL_free` when the thread's TLS destructors fire (documented in `AudioPipeline.kt` comments). The parking workaround must be preserved.
|
||||
|
||||
2. **Must guarantee no JNI calls after `destroy()`** — the native handle is a raw pointer; any dereference after free is undefined behavior.
|
||||
|
||||
3. **Must not add blocking waits on the UI thread** — `teardown()` runs on the ViewModel thread which must remain responsive.
|
||||
|
||||
4. **The `@Volatile running` flag is necessary but not sufficient** — it prevents new loop iterations but doesn't help with in-flight JNI calls.
|
||||
|
||||
5. **Both `writeAudio` and `readAudio` have the same race** — the fix must cover both the capture and playout paths.
|
||||
|
||||
## Reproduction
|
||||
|
||||
The crash is timing-dependent. It's most likely to occur when:
|
||||
- The capture thread is in the middle of a `writeAudio()` JNI call when `destroy()` is called
|
||||
- More likely on slower devices or under CPU pressure (GC, thermal throttling)
|
||||
- Can happen on every hangup, but only crashes ~10-30% of the time due to the timing window
|
||||
|
||||
## Analysis of Possible Fix Approaches
|
||||
|
||||
### Approach A: Add a synchronization gate in the JNI bridge
|
||||
|
||||
Use a `ReentrantReadWriteLock` or `AtomicBoolean` in `WzpEngine.kt`:
|
||||
- Audio threads acquire a read lock / check the flag before JNI calls
|
||||
- `destroy()` acquires a write lock / sets the flag and waits for in-flight calls to drain
|
||||
|
||||
**Pro:** Clean, solves the race directly.
|
||||
**Con:** Adding a lock to the audio hot path (every 20ms). `ReentrantReadWriteLock` is not lock-free. However, the read-lock path is uncontended 99.99% of the time (write-lock only during destroy), so contention is negligible.
|
||||
|
||||
### Approach B: Defer `destroy()` until audio threads have stopped
|
||||
|
||||
Instead of calling `destroy()` in `teardown()`, set a flag and have the audio threads call `destroy()` after they exit the loop (before parking).
|
||||
|
||||
**Pro:** No locks on hot path.
|
||||
**Con:** Complex lifecycle — which thread calls destroy? What if both threads race to destroy? Need a `CountDownLatch` or similar.
|
||||
|
||||
### Approach C: Make the JNI handle atomically invalidated
|
||||
|
||||
Use `AtomicLong` for `nativeHandle` and use `compareAndExchange` in `destroy()` + `getAndCheck` pattern in audio calls.
|
||||
|
||||
**Pro:** Lock-free.
|
||||
**Con:** Still has a TOCTOU window — the thread can load the handle, then it gets CAS'd to 0, then the thread uses the stale handle. Doesn't fully solve the race without combining with a reference count or epoch.
|
||||
|
||||
### Approach D: Introduce a destroy latch
|
||||
|
||||
Add a `CountDownLatch(1)` that audio threads wait on before parking. `teardown()` sets `running=false`, then `await`s the latch (with timeout), then calls `destroy()`. Each audio thread counts down the latch after exiting the loop.
|
||||
|
||||
Actually this needs a `CountDownLatch(2)` — one for each thread (capture + playout).
|
||||
|
||||
**Pro:** Guarantees no in-flight JNI calls at destroy time. No locks on hot path.
|
||||
**Con:** `teardown()` blocks for up to one frame duration (~20ms) waiting for threads to exit their loops. Acceptable for a hangup path.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Approach D (destroy latch)** is the cleanest. The 20ms worst-case wait is imperceptible on the hangup path, and it provides a hard guarantee that no JNI calls are in flight when `destroy()` runs. Combined with the existing `running` volatile flag, the audio threads exit their loops within one frame and count down the latch.
|
||||
|
||||
If the latch times out (e.g., AudioRecord.read() is stuck), `destroy()` proceeds anyway — the `panic::catch_unwind` in the JNI bridge will catch the invalid access as a panic rather than a SIGSEGV (though this is best-effort; a true SIGSEGV from freed memory is not catchable).
|
||||
|
||||
## Data Files
|
||||
|
||||
The crash was captured from the Nothing A059 device at 13:05:42 on 2026-04-06. The tombstone is in the device's `/data/tombstones/` directory. The logcat output shows the crash frames.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Incident Report: Playout Ring Buffer Cursor Desync — Bidirectional Audio Loss
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** Critical — causes 10-16 seconds of complete bidirectional silence mid-call
|
||||
**Status:** Root-caused, fix pending
|
||||
**Affects:** All clients using `AudioRing` (Android, potentially desktop)
|
||||
|
||||
## Summary
|
||||
|
||||
Both participants in a call experience simultaneous, prolonged audio silence (10-16 seconds) despite the QUIC transport, relay, and Rust codec pipeline all functioning normally. The root cause is a cursor desynchronization in the lock-free SPSC ring buffer (`AudioRing`) that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
|
||||
|
||||
## How We Know It's the Ring Buffer
|
||||
|
||||
### Evidence that eliminates other components
|
||||
|
||||
| Component | Evidence it's healthy | Source |
|
||||
|-----------|----------------------|--------|
|
||||
| **QUIC send path** | `frames_dropped=0, send_errors=0` on both clients | Engine send stats log |
|
||||
| **QUIC recv path** | `max_recv_gap_ms=82, recv_errors=0` — no gaps >82ms | Engine recv stats log |
|
||||
| **Relay forwarding** | `max_forward_ms=0, send_errors=0` in previous relay-instrumented test | Relay debug logging |
|
||||
| **Opus codec** | `frames_decoded=2442` over 51.9s = 47 frames/sec (correct for 20ms) | Final stats JSON |
|
||||
| **FEC** | `fec_recovered=4870` — FEC working normally | Final stats JSON |
|
||||
| **Audio capture** | Pixel 6 capture has 0% silence; Nothing has gaps but those are expected mic pauses | capture_rms.csv |
|
||||
|
||||
### Evidence pointing to the ring buffer
|
||||
|
||||
1. **Both clients go silent at the exact same wall-clock moment (26.66s into call)** — rules out per-device issues; the common factor is the relay, but the relay was proven healthy in prior tests.
|
||||
|
||||
2. **`playout_avail=8640` at stats dump time** — the playout ring reports 8640 samples available (180ms, nearly full at the 9600 capacity). The recv task believes it has successfully written data into the ring. But the AudioTrack playout thread is reading silence (RMS=0 for 12+ seconds).
|
||||
|
||||
3. **Recv task continued receiving packets with no gaps** — `max_recv_gap_ms=82` across the entire call. The decoded audio was written to the ring continuously.
|
||||
|
||||
4. **Silence starts and ends cleanly** — the transition from audio → silence happens within a single 20ms frame (frame 1332: rms=101, frame 1333: rms=0). This is not network degradation (which shows gradual quality loss). It's a discrete state change — the reader suddenly stops seeing data.
|
||||
|
||||
5. **Recovery is also discrete** — at ~38.8s (Sharp Hawk) and ~42.7s (Pixel 6), audio snaps back with high-energy frames (rms=3296+). Not a gradual reconnection.
|
||||
|
||||
## The Ring Buffer Code
|
||||
|
||||
**File:** `crates/wzp-android/src/audio_ring.rs`
|
||||
|
||||
```rust
|
||||
const RING_CAPACITY: usize = 960 * 10; // 9600 samples = 200ms at 48kHz
|
||||
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
write_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
|
||||
read_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
|
||||
}
|
||||
```
|
||||
|
||||
### `available()` — how many samples can be read
|
||||
```rust
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Acquire);
|
||||
w.wrapping_sub(r) // relies on usize wrapping arithmetic
|
||||
}
|
||||
```
|
||||
|
||||
### `write()` — producer (Rust recv task thread, inside tokio block_on)
|
||||
```rust
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
// ... write samples at (w + i) % RING_CAPACITY ...
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
|
||||
// If we overwrote unread data, advance read_pos
|
||||
if self.available() > RING_CAPACITY {
|
||||
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
|
||||
self.read_pos.store(new_read, Ordering::Release);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `read()` — consumer (Kotlin AudioTrack JVM thread, via JNI)
|
||||
```rust
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let avail = self.available();
|
||||
let count = out.len().min(avail);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
// ... read samples at (r + i) % RING_CAPACITY ...
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
```
|
||||
|
||||
## Suspected Failure Modes
|
||||
|
||||
### 1. Writer advances `read_pos` while reader is mid-read (data race)
|
||||
|
||||
The `write()` method at lines 68-72 modifies `read_pos` from the writer thread when it detects overflow. But the `read()` method on the consumer thread also modifies `read_pos`. This violates the SPSC contract — `read_pos` is supposed to be owned by the consumer.
|
||||
|
||||
**Scenario:**
|
||||
1. Reader loads `read_pos = R` (line 82)
|
||||
2. Writer detects overflow, stores `read_pos = R'` (line 71) where `R' > R`
|
||||
3. Reader finishes reading, stores `read_pos = R + count` (line 88) — **overwrites** the writer's `R'` with a stale, smaller value
|
||||
|
||||
After step 3, the ring's `read_pos` has gone backwards. Now `available()` returns `write_pos.wrapping_sub(old_read_pos)` which is larger than `RING_CAPACITY`. Every subsequent `write()` call hits the overflow branch and keeps advancing `read_pos`, but the reader keeps overwriting it back. The ring is in a corrupted state where the reader and writer are fighting over `read_pos`.
|
||||
|
||||
### 2. `wrapping_sub` returns astronomically large values
|
||||
|
||||
`available()` uses `w.wrapping_sub(r)`. On a 64-bit platform, if due to the race above `r > w`, `wrapping_sub` returns `usize::MAX - (r - w) + 1` — an enormous number. The `read()` method caps this with `out.len().min(avail)` so it reads `out.len()` samples. But those samples are from indices calculated as `(r + i) % RING_CAPACITY` which wraps correctly. The samples read would be whatever was in the buffer at those positions — potentially stale/old data, or zeros from initialization.
|
||||
|
||||
However, the playout RMS CSV shows clean zeros (RMS=0), not garbage. This suggests the ring is returning the zeroed-out initial buffer contents, meaning `read_pos` has jumped far ahead of `write_pos`, pointing to memory that was never written to (or was written long ago and has since been zeroed by the overflow advance logic).
|
||||
|
||||
### 3. Why silence lasts exactly 12-16 seconds
|
||||
|
||||
After the desync, each `write()` call (every 20ms when a packet is decoded) enters the overflow branch and resets `read_pos`. But the reader immediately overwrites it back in its next `read()` call. This tug-of-war continues until one of two things happens:
|
||||
- The cursors happen to realign through wrapping arithmetic
|
||||
- A timing coincidence where the writer's store to `read_pos` happens to "win" the race
|
||||
|
||||
The 12-16 second duration is non-deterministic and depends on exact thread scheduling and cursor values.
|
||||
|
||||
## Reproduction Pattern
|
||||
|
||||
The bug manifests after roughly 25-30 seconds of a call. This timing is suspicious:
|
||||
- At 48kHz mono, 20ms frames = 50 frames/sec
|
||||
- Each decoded frame writes 960 samples to the ring
|
||||
- After 25 seconds: `write_pos ≈ 25 * 50 * 960 = 1,200,000`
|
||||
- The ring capacity is 9600, so `write_pos` has wrapped around `RING_CAPACITY` about 125 times
|
||||
|
||||
The wrapping of the monotonic cursors past certain thresholds, combined with the reader/writer `read_pos` race, likely triggers the desync at this scale.
|
||||
|
||||
## Data Files
|
||||
|
||||
All data from two independent test sessions (3 calls total) is in `/workspace/wzp/debug/`:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `wzp_debug_20260406_120546.zip` | Sharp Hawk (Nothing A059) — 51.9s call |
|
||||
| `wzp_debug_20260406_120549.zip` | Bright Viper (Pixel 6) — 51.9s call |
|
||||
| `wzp_debug_20260406_111733.zip` | Sharp Hawk — earlier 72.0s call, same pattern |
|
||||
| `wzp_debug_20260406_111735.zip` | Bright Viper — earlier 72.0s call, same pattern |
|
||||
| `wzp_debug_20260406_105858.zip` | First session (pre-logging fix), 39.8s call |
|
||||
| `wzp_debug_20260406_105900.zip` | First session, 39.7s call |
|
||||
|
||||
### Key fields in each zip
|
||||
|
||||
- `meta.txt` — device, duration, final stats JSON
|
||||
- `playout_rms.csv` — per-frame (20ms) RMS of AudioTrack output; silence = RMS 0
|
||||
- `capture_rms.csv` — per-frame RMS of AudioRecord input
|
||||
- `logcat.txt` — Android logcat filtered to WZP + audio tags
|
||||
|
||||
### How to reproduce the analysis
|
||||
|
||||
```python
|
||||
import csv
|
||||
with open("playout_rms.csv") as f:
|
||||
for row in csv.DictReader(f):
|
||||
if int(row['rms']) == 0 and int(row['time_ms']) > 2000:
|
||||
print(f"SILENCE at {row['time_ms']}ms")
|
||||
```
|
||||
|
||||
## Affected Code
|
||||
|
||||
- `crates/wzp-android/src/audio_ring.rs` — the `AudioRing` struct, specifically the `write()` method's overflow handling that mutates `read_pos` from the producer thread
|
||||
- Any client using `AudioRing` for playout (currently only Android; desktop uses `cpal` directly)
|
||||
|
||||
## Constraints for the Fix
|
||||
|
||||
1. Must remain lock-free — AudioTrack thread runs at `Thread.MAX_PRIORITY` and cannot block
|
||||
2. Must handle overflow gracefully — if the reader falls behind, old audio should be dropped, not cause a desync
|
||||
3. The writer (Rust recv task) and reader (Kotlin AudioTrack via JNI) run on different threads with different scheduling priorities
|
||||
4. Ring capacity is 200ms which is tight — any fix must not increase latency significantly
|
||||
5. The `write_pos` and `read_pos` are the only synchronization mechanism (no mutex, no condvar)
|
||||
@@ -1,123 +0,0 @@
|
||||
# Incident Report: Send Task Fatal Exit on QUIC Congestion
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** High — causes complete audio loss mid-call
|
||||
**Status:** Fixed in Android client, **pending fix in desktop client and web client**
|
||||
|
||||
## Summary
|
||||
|
||||
A QUIC congestion event causes `send_datagram()` to return `Err(Blocked)`. The send task treats this as a fatal error and exits, which kills the entire call via `tokio::select!`. Audio becomes one-way (recv still works briefly) then dies completely.
|
||||
|
||||
## Root Cause
|
||||
|
||||
In the engine's send loop (`run_call` function), `transport.send_media()` errors were handled with `break`:
|
||||
|
||||
```rust
|
||||
// BEFORE (broken)
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
error!("send error: {e}");
|
||||
break; // <-- kills send task, which kills everything
|
||||
}
|
||||
```
|
||||
|
||||
Quinn's `send_datagram()` is synchronous and returns `Err(SendDatagramError::Blocked)` when the QUIC congestion window is full. This is a **transient condition** — the window opens again once ACKs arrive. But the `break` kills the send task, and since all tasks run under `tokio::select!`, the recv task, stats task, and signal task all die too.
|
||||
|
||||
### Why it manifests as "intermittent disconnections"
|
||||
|
||||
- Mobile networks have brief congestion spikes (cell tower handoff, WiFi interference)
|
||||
- A single spike fills the QUIC congestion window
|
||||
- One `Blocked` error → send task exits → `select!` cancels recv → complete silence
|
||||
- The QUIC connection stays open (no error logged), so stats polling continues showing stale data
|
||||
- From the user's perspective: audio drops for 5-20 seconds then "maybe comes back" (it doesn't — they're hearing cached playout ring drain)
|
||||
|
||||
### Evidence from debug reports
|
||||
|
||||
**Relay logs** confirmed the relay was healthy:
|
||||
- `max_forward_ms=0` — relay forwards instantly
|
||||
- `send_errors=0` — no relay-side failures
|
||||
- The relay saw `large recv gap` warnings on participant 1 (Nothing A059): 722ms → 814ms → 1778ms → 3500ms → 6091ms — the client progressively stopped sending
|
||||
|
||||
**Client stats** confirmed:
|
||||
- `frames_encoded` kept incrementing (Opus encoder running)
|
||||
- `frames_decoded` froze at a fixed value (recv task died)
|
||||
- `fec_recovered` froze simultaneously
|
||||
- RTT, loss, jitter all frozen (stats task died)
|
||||
|
||||
## Fix Applied
|
||||
|
||||
### Android client (`crates/wzp-android/src/engine.rs`)
|
||||
|
||||
```rust
|
||||
// AFTER (fixed)
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
|
||||
warn!(seq = s, send_errors, frames_dropped,
|
||||
"send_media error (dropping packet): {e}");
|
||||
last_send_error_log = Instant::now();
|
||||
}
|
||||
continue; // <-- drop packet, keep going
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern applied to FEC repair packet sends.
|
||||
|
||||
Recv task also hardened: transient errors (non-closed/reset) are now logged and survived rather than causing exit.
|
||||
|
||||
Added periodic health logging to both tasks (5-second intervals):
|
||||
- Send: `frames_sent`, `frames_dropped`, `send_errors`, `ring_avail`
|
||||
- Recv: `frames_decoded`, `fec_recovered`, `recv_errors`, `max_recv_gap_ms`, `playout_avail`
|
||||
|
||||
### Relay (`crates/wzp-relay/src/room.rs`)
|
||||
|
||||
Added debug logging to both plain and trunked forwarding loops:
|
||||
- Per-recv gap tracking (warns on >200ms gaps)
|
||||
- Room manager lock contention tracking (warns on >10ms)
|
||||
- Forward latency tracking (warns on >50ms)
|
||||
- Send error counting with peer identification
|
||||
- 5-second periodic stats with all above metrics
|
||||
|
||||
## Affected Clients — FIX REQUIRED
|
||||
|
||||
### Desktop client (`crates/wzp-client/src/cli.rs`)
|
||||
|
||||
**Lines 345-348:**
|
||||
```rust
|
||||
if let Err(e) = transport.send_media(pkt).await {
|
||||
error!("send error: {e}");
|
||||
break; // <-- SAME BUG
|
||||
}
|
||||
```
|
||||
|
||||
**Lines 431-434:**
|
||||
```rust
|
||||
if let Err(e) = send_transport.send_media(pkt).await {
|
||||
error!("send error: {e}");
|
||||
return; // <-- SAME BUG
|
||||
}
|
||||
```
|
||||
|
||||
Both need the same continue-on-error pattern.
|
||||
|
||||
### Web client (`crates/wzp-web/src/main.rs`)
|
||||
|
||||
Needs audit — WebSocket transport may have different error semantics but same pattern should be checked.
|
||||
|
||||
## Testing
|
||||
|
||||
After fix, a congestion event will:
|
||||
1. Log warnings with packet counts: `send_media error (dropping packet): Blocked`
|
||||
2. Drop affected packets (brief audio glitch — ~20-100ms)
|
||||
3. Resume normal sending once congestion window opens
|
||||
4. FEC on the receiver side will recover most dropped packets
|
||||
5. Call continues uninterrupted
|
||||
|
||||
## Timeline
|
||||
|
||||
- 10:37 — First crash observed (LinearProgressIndicator compose bug masked investigation)
|
||||
- 10:58 — Debug reports collected, decoded stall pattern identified
|
||||
- 11:16 — Relay debug logging deployed, confirmed relay is clean
|
||||
- 11:17 — Second debug reports collected, send gaps correlated with relay recv gaps
|
||||
- 11:30 — Root cause identified: `break` on `send_media` error in send task
|
||||
- 11:45 — Fix applied and deployed
|
||||
@@ -1,394 +0,0 @@
|
||||
# Fix: AudioRing SPSC Buffer Cursor Desync
|
||||
|
||||
## Problem
|
||||
|
||||
A critical bug causes 10-16 seconds of bidirectional audio silence mid-call (~25-30s in). Both participants go silent at the exact same moment. The QUIC transport, relay, Opus codec, and FEC are all healthy — the bug is in the lock-free ring buffer that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
|
||||
|
||||
**Root cause:** `AudioRing::write()` modifies `read_pos` from the producer thread during overflow handling (lines 68-72 of `audio_ring.rs`). This violates the SPSC invariant — only the consumer should own `read_pos`. When both threads write to `read_pos`, a race corrupts the cursor state, causing the reader to see an empty or stale buffer for 12-16 seconds.
|
||||
|
||||
**Full forensics:** `debug/INCIDENT-2026-04-06-playout-ring-desync.md`
|
||||
|
||||
---
|
||||
|
||||
## Solution: Reader-Detects-Lap Architecture
|
||||
|
||||
The writer NEVER touches `read_pos`. On overflow, the writer simply overwrites old buffer data and advances `write_pos`. The reader detects it was lapped and self-corrects by snapping its own `read_pos` forward.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Rewrite `AudioRing`
|
||||
|
||||
**File:** `crates/wzp-android/src/audio_ring.rs`
|
||||
|
||||
Replace the entire implementation with:
|
||||
|
||||
**Constants:**
|
||||
```rust
|
||||
/// Ring buffer capacity — must be a power of 2 for bitmask indexing.
|
||||
/// 16384 samples = 341.3ms at 48kHz mono. Provides 70% more headroom
|
||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
||||
const RING_CAPACITY: usize = 16384; // 2^14
|
||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
```
|
||||
|
||||
**Struct:**
|
||||
```rust
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
write_pos: AtomicUsize, // monotonically increasing, ONLY written by producer
|
||||
read_pos: AtomicUsize, // monotonically increasing, ONLY written by consumer
|
||||
overflow_count: AtomicU64, // incremented by reader when it detects a lap
|
||||
underrun_count: AtomicU64, // incremented by reader when ring is empty
|
||||
}
|
||||
```
|
||||
|
||||
**`write()` — producer. Does NOT touch `read_pos`:**
|
||||
```rust
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
|
||||
for i in 0..count {
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
```
|
||||
|
||||
**`read()` — consumer. Detects lap, self-corrects:**
|
||||
```rust
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||
|
||||
let mut avail = w.wrapping_sub(r);
|
||||
|
||||
// Lap detection: writer has overwritten our unread data.
|
||||
// Snap read_pos forward to oldest valid data in the buffer.
|
||||
// Safe because we (the reader) are the sole owner of read_pos.
|
||||
if avail > RING_CAPACITY {
|
||||
r = w.wrapping_sub(RING_CAPACITY);
|
||||
avail = RING_CAPACITY;
|
||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
if w == r {
|
||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
for i in 0..count {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
}
|
||||
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
```
|
||||
|
||||
**`available()` — clamped for external callers:**
|
||||
```rust
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||
}
|
||||
```
|
||||
|
||||
**`free_space()` — keep for API compat:**
|
||||
```rust
|
||||
pub fn free_space(&self) -> usize {
|
||||
RING_CAPACITY.saturating_sub(self.available())
|
||||
}
|
||||
```
|
||||
|
||||
**Diagnostic accessors:**
|
||||
```rust
|
||||
pub fn overflow_count(&self) -> u64 {
|
||||
self.overflow_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn underrun_count(&self) -> u64 {
|
||||
self.underrun_count.load(Ordering::Relaxed)
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor:**
|
||||
```rust
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: Box::new([0i16; RING_CAPACITY]),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
underrun_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Imports to add:** `use std::sync::atomic::AtomicU64;`
|
||||
|
||||
**Safety comment update:**
|
||||
```rust
|
||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Add counter fields to `CallStats`
|
||||
|
||||
**File:** `crates/wzp-android/src/stats.rs`
|
||||
|
||||
Add three fields to the `CallStats` struct (after `fec_recovered`):
|
||||
|
||||
```rust
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
pub playout_underruns: u64,
|
||||
/// Capture ring overflow count.
|
||||
pub capture_overflows: u64,
|
||||
```
|
||||
|
||||
These derive `Default` (= 0) automatically via the existing `#[derive(Default)]`.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Wire ring diagnostics into engine stats + logging
|
||||
|
||||
**File:** `crates/wzp-android/src/engine.rs`
|
||||
|
||||
**3a.** In `get_stats()` (~line 181), populate the new fields:
|
||||
|
||||
```rust
|
||||
stats.playout_overflows = self.state.playout_ring.overflow_count();
|
||||
stats.playout_underruns = self.state.playout_ring.underrun_count();
|
||||
stats.capture_overflows = self.state.capture_ring.overflow_count();
|
||||
```
|
||||
|
||||
**3b.** In the recv task periodic stats log, add ring health:
|
||||
|
||||
```rust
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
playout_overflows = state.playout_ring.overflow_count(),
|
||||
playout_underruns = state.playout_ring.underrun_count(),
|
||||
"recv stats"
|
||||
);
|
||||
```
|
||||
|
||||
**3c.** In the send task periodic stats log, add capture ring health:
|
||||
|
||||
```rust
|
||||
info!(
|
||||
seq = s,
|
||||
block_id,
|
||||
frames_sent,
|
||||
frames_dropped,
|
||||
send_errors,
|
||||
ring_avail = state.capture_ring.available(),
|
||||
capture_overflows = state.capture_ring.overflow_count(),
|
||||
"send stats"
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Parse new stats in Kotlin
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/engine/CallStats.kt`
|
||||
|
||||
Add fields to the data class:
|
||||
|
||||
```kotlin
|
||||
val playoutOverflows: Long = 0,
|
||||
val playoutUnderruns: Long = 0,
|
||||
val captureOverflows: Long = 0,
|
||||
```
|
||||
|
||||
Add parsing in `fromJson()`:
|
||||
|
||||
```kotlin
|
||||
playoutOverflows = obj.optLong("playout_overflows", 0),
|
||||
playoutUnderruns = obj.optLong("playout_underruns", 0),
|
||||
captureOverflows = obj.optLong("capture_overflows", 0),
|
||||
```
|
||||
|
||||
No UI changes needed — these fields will appear in debug report JSON automatically.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Unit tests
|
||||
|
||||
**File:** `crates/wzp-android/src/audio_ring.rs` — add `#[cfg(test)] mod tests`
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capacity_is_power_of_two() {
|
||||
assert!(RING_CAPACITY.is_power_of_two());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_write_read() {
|
||||
let ring = AudioRing::new();
|
||||
let input: Vec<i16> = (0..960).map(|i| i as i16).collect();
|
||||
ring.write(&input);
|
||||
assert_eq!(ring.available(), 960);
|
||||
|
||||
let mut output = vec![0i16; 960];
|
||||
let read = ring.read(&mut output);
|
||||
assert_eq!(read, 960);
|
||||
assert_eq!(output, input);
|
||||
assert_eq!(ring.available(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraparound() {
|
||||
let ring = AudioRing::new();
|
||||
let frame = vec![42i16; 960];
|
||||
// Write enough to wrap the buffer multiple times
|
||||
for _ in 0..20 {
|
||||
ring.write(&frame);
|
||||
let mut out = vec![0i16; 960];
|
||||
ring.read(&mut out);
|
||||
assert!(out.iter().all(|&s| s == 42));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_detected_by_reader() {
|
||||
let ring = AudioRing::new();
|
||||
// Write more than RING_CAPACITY without reading
|
||||
let big = vec![7i16; RING_CAPACITY + 960];
|
||||
ring.write(&big[..RING_CAPACITY]);
|
||||
ring.write(&big[RING_CAPACITY..]);
|
||||
|
||||
// Reader should detect lap
|
||||
let mut out = vec![0i16; 960];
|
||||
let read = ring.read(&mut out);
|
||||
assert!(read > 0);
|
||||
assert_eq!(ring.overflow_count(), 1);
|
||||
// Data should be from the most recent writes
|
||||
assert!(out.iter().all(|&s| s == 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_never_modifies_read_pos() {
|
||||
let ring = AudioRing::new();
|
||||
// Read pos should stay at 0 until read() is called
|
||||
let data = vec![1i16; RING_CAPACITY + 960];
|
||||
ring.write(&data);
|
||||
// read_pos is private, but we can check available() > CAPACITY
|
||||
// which proves write() didn't advance read_pos
|
||||
let w = ring.write_pos.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let r = ring.read_pos.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(r, 0, "write() must not modify read_pos");
|
||||
assert!(w.wrapping_sub(r) > RING_CAPACITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underrun_counted() {
|
||||
let ring = AudioRing::new();
|
||||
let mut out = vec![0i16; 960];
|
||||
let read = ring.read(&mut out);
|
||||
assert_eq!(read, 0);
|
||||
assert_eq!(ring.underrun_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_recovery_reads_recent_data() {
|
||||
let ring = AudioRing::new();
|
||||
// Fill with old data
|
||||
let old = vec![1i16; RING_CAPACITY];
|
||||
ring.write(&old);
|
||||
// Overwrite with new data (lapping the reader)
|
||||
let new_data = vec![99i16; 960];
|
||||
ring.write(&new_data);
|
||||
|
||||
// Reader should snap forward and get recent data
|
||||
let mut out = vec![0i16; RING_CAPACITY];
|
||||
let read = ring.read(&mut out);
|
||||
assert_eq!(read, RING_CAPACITY);
|
||||
// The last 960 samples should be 99
|
||||
assert!(out[RING_CAPACITY - 960..].iter().all(|&s| s == 99));
|
||||
assert_eq!(ring.overflow_count(), 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Ordering Reference
|
||||
|
||||
| Operation | Ordering | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| `write_pos.store` in `write()` | Release | Buffer writes visible before cursor advances |
|
||||
| `write_pos.load` in `read()` | Acquire | Pairs with Release above — sees all buffer writes |
|
||||
| `write_pos.load` in `write()` | Relaxed | Writer is sole owner of write_pos |
|
||||
| `read_pos.load` in `read()` | Relaxed | Reader is sole owner of read_pos |
|
||||
| `read_pos.store` in `read()` | Release | Makes available() consistent from any thread |
|
||||
| `read_pos.load` in `available()` | Relaxed | Informational only, slight staleness OK |
|
||||
| All counters | Relaxed | Diagnostic only |
|
||||
|
||||
---
|
||||
|
||||
## Capacity Tradeoff
|
||||
|
||||
| Capacity | Duration | Memory | Verdict |
|
||||
|----------|----------|--------|---------|
|
||||
| 8192 (2^13) | 170ms | 16KB | Less than current 200ms — risky |
|
||||
| **16384 (2^14)** | **341ms** | **32KB** | **70% more headroom, bitmask indexing** |
|
||||
| 32768 (2^15) | 682ms | 64KB | Excessive latency on overflow recovery |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo test -p wzp-android` — new unit tests pass
|
||||
2. `cargo ndk -t arm64-v8a build --release -p wzp-android` — ARM cross-compile succeeds
|
||||
3. Build APK, install on both test devices (Nothing A059 + Pixel 6)
|
||||
4. 2+ minute call — verify no audio gaps
|
||||
5. Check debug report JSON: `playout_overflows` should be 0 or very small
|
||||
6. Check logcat `wzp_android` tag: send/recv stats show healthy ring state
|
||||
7. Stress test: play music through one device speaker while on call — forces high ring throughput
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `crates/wzp-android/src/audio_ring.rs` | Complete rewrite — the core fix |
|
||||
| `crates/wzp-android/src/stats.rs` | Add 3 counter fields |
|
||||
| `crates/wzp-android/src/engine.rs` | Wire counters into get_stats() + periodic logs |
|
||||
| `android/app/src/main/java/com/wzp/engine/CallStats.kt` | Parse 3 new JSON fields |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `AudioPipeline.kt` — calls `readAudio()`/`writeAudio()` unchanged; ring fix is transparent
|
||||
- `jni_bridge.rs` — JNI bridge passes through unchanged
|
||||
- `audio_android.rs` — separate Oboe-based ring, currently unused, different design
|
||||
- Relay code — relay is confirmed healthy
|
||||
- Desktop client — uses `Mutex + mpsc`, not `AudioRing`
|
||||
@@ -1,149 +0,0 @@
|
||||
# Fix: Capture/Playout Thread Use-After-Free on Hangup
|
||||
|
||||
## Problem
|
||||
|
||||
App crashes (SIGSEGV) when hanging up a call. The capture thread (`wzp-capture`) calls `engine.writeAudio()` via JNI after `teardown()` has freed the native engine handle. Same race exists for the playout thread's `readAudio()`.
|
||||
|
||||
**Root cause:** TOCTOU race between the `nativeHandle == 0L` check in `WzpEngine.writeAudio()`/`readAudio()` and `destroy()` freeing the native memory on the ViewModel thread. Audio threads can't be joined (libcrypto TLS destructor crash), so there's no synchronization between `stopAudio()` and `destroy()`.
|
||||
|
||||
**Full forensics:** `debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md`
|
||||
|
||||
---
|
||||
|
||||
## Solution: Destroy Latch
|
||||
|
||||
Add a `CountDownLatch(2)` that both audio threads count down after exiting their loops. `teardown()` awaits the latch (with timeout) before calling `destroy()`, guaranteeing no in-flight JNI calls.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add a drain latch to `AudioPipeline`
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/audio/AudioPipeline.kt`
|
||||
|
||||
Add a `CountDownLatch` field:
|
||||
|
||||
```kotlin
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AudioPipeline(private val context: Context) {
|
||||
// ... existing fields ...
|
||||
|
||||
/** Latch counted down by each audio thread after exiting its loop.
|
||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||
private var drainLatch: CountDownLatch? = null
|
||||
```
|
||||
|
||||
In `start()`, create the latch before spawning threads:
|
||||
|
||||
```kotlin
|
||||
fun start(engine: WzpEngine) {
|
||||
if (running) return
|
||||
running = true
|
||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
||||
|
||||
captureThread = Thread({
|
||||
runCapture(engine)
|
||||
drainLatch?.countDown() // signal: capture loop exited
|
||||
parkThread()
|
||||
}, "wzp-capture").apply { ... }
|
||||
|
||||
playoutThread = Thread({
|
||||
runPlayout(engine)
|
||||
drainLatch?.countDown() // signal: playout loop exited
|
||||
parkThread()
|
||||
}, "wzp-playout").apply { ... }
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Add `awaitDrain()` — called by ViewModel before `destroy()`:
|
||||
|
||||
```kotlin
|
||||
/** Block until both audio threads have exited their loops (max 200ms).
|
||||
* After this returns, no more JNI calls to the engine will be made. */
|
||||
fun awaitDrain(): Boolean {
|
||||
return drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
||||
}
|
||||
```
|
||||
|
||||
`stop()` remains unchanged (non-blocking, sets `running = false`).
|
||||
|
||||
### Step 2: Update `CallViewModel.teardown()` to await drain
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
|
||||
|
||||
Change teardown to wait for audio threads before destroying:
|
||||
|
||||
```kotlin
|
||||
private fun teardown(stopService: Boolean = true) {
|
||||
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
||||
val hadCall = audioStarted
|
||||
CallService.onStopFromNotification = null
|
||||
stopAudio() // sets running=false (non-blocking)
|
||||
stopStatsPolling()
|
||||
|
||||
// Wait for audio threads to exit their loops before destroying the engine.
|
||||
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
|
||||
val drained = audioPipeline?.awaitDrain() ?: true
|
||||
if (!drained) {
|
||||
Log.w(TAG, "teardown: audio threads did not drain in time")
|
||||
}
|
||||
audioPipeline = null
|
||||
|
||||
Log.i(TAG, "teardown: stopping engine")
|
||||
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
||||
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
**Key change:** `awaitDrain()` is called AFTER `stopAudio()` (which sets `running=false`) but BEFORE `engine?.destroy()`. The latch guarantees both threads have exited their `while(running)` loops and will never call `writeAudio`/`readAudio` again.
|
||||
|
||||
Also move `audioPipeline = null` to after `awaitDrain()` to keep the reference alive for the latch call.
|
||||
|
||||
### Step 3: Move `stopAudio()` pipeline nulling
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
|
||||
|
||||
In `stopAudio()`, do NOT null out the pipeline — let `teardown()` handle it after drain:
|
||||
|
||||
```kotlin
|
||||
private fun stopAudio() {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false
|
||||
// DON'T null audioPipeline here — teardown() needs it for awaitDrain()
|
||||
audioRouteManager?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
audioStarted = false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `android/.../audio/AudioPipeline.kt` | Add `CountDownLatch`, `countDown()` in threads, `awaitDrain()` method |
|
||||
| `android/.../ui/call/CallViewModel.kt` | `teardown()` calls `awaitDrain()` before `destroy()`; `stopAudio()` doesn't null pipeline |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `WzpEngine.kt` — the `nativeHandle == 0L` guard stays as defense-in-depth
|
||||
- `jni_bridge.rs` — `panic::catch_unwind` stays as last resort
|
||||
- `AudioPipeline.stop()` — remains non-blocking
|
||||
- Thread parking — still needed to avoid libcrypto TLS crash
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build APK, install on test device
|
||||
2. Make a call, hang up — verify no crash in logcat (`adb logcat -s AndroidRuntime:E DEBUG:F`)
|
||||
3. Rapid call/hangup/call/hangup cycles — stress the teardown path
|
||||
4. Check logcat for `teardown: audio threads did not drain in time` — should never appear under normal conditions
|
||||
5. Verify debug report still works after hangup (latch doesn't interfere with report collection)
|
||||
@@ -1,376 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build WarzonePhone Android APK using a temporary Hetzner Cloud VPS.
|
||||
# Creates a VM, builds both debug and release APKs, downloads them, destroys the VM.
|
||||
#
|
||||
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-android-cloud.sh Full build (create → build → download → destroy)
|
||||
# ./scripts/build-android-cloud.sh --prepare Create VM and install deps only
|
||||
# ./scripts/build-android-cloud.sh --build Build on existing VM
|
||||
# ./scripts/build-android-cloud.sh --transfer Download APKs from VM
|
||||
# ./scripts/build-android-cloud.sh --destroy Delete the VM
|
||||
# ./scripts/build-android-cloud.sh --all prepare + build + transfer (VM persists)
|
||||
# ./scripts/build-android-cloud.sh --upload Re-upload source to existing VM
|
||||
#
|
||||
# Environment variables (all optional):
|
||||
# WZP_BRANCH Branch to build (default: feat/android-voip-client)
|
||||
# WZP_SERVER_TYPE Hetzner server type (default: cx32 — 4 vCPU, 8GB RAM)
|
||||
# WZP_KEEP_VM Set to 1 to skip destroy on full build
|
||||
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="${WZP_SERVER_TYPE:-cx33}"
|
||||
IMAGE="ubuntu-24.04"
|
||||
SERVER_NAME="wzp-android-builder"
|
||||
REMOTE_USER="root"
|
||||
OUTPUT_DIR="target/android-apk"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
|
||||
KEEP_VM="${WZP_KEEP_VM:-0}"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"
|
||||
|
||||
# NDK 26.1 — NDK 27 crashes scudo on Android 16 MTE devices
|
||||
NDK_VERSION="26.1.10909125"
|
||||
ANDROID_API="34"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||
die() { err "$@"; do_destroy_quiet; exit 1; }
|
||||
|
||||
get_vm_ip() {
|
||||
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "No VM found. Run --prepare first."
|
||||
ssh $SSH_OPTS -A -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
scp_down() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "No VM found."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2"
|
||||
}
|
||||
|
||||
do_destroy_quiet() {
|
||||
local name
|
||||
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -n "$name" ]; then
|
||||
echo ""
|
||||
err "Cleaning up — destroying VM $name"
|
||||
hcloud server delete "$name" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --prepare: Create VM, install all build dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_prepare() {
|
||||
# Check if VM already exists
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -n "$existing" ]; then
|
||||
log "VM already exists: $existing — reusing"
|
||||
do_upload
|
||||
return
|
||||
fi
|
||||
|
||||
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create \
|
||||
--name "$SERVER_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location fsn1 \
|
||||
--quiet \
|
||||
|| die "Failed to create VM"
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "VM created but no IP found"
|
||||
echo " VM: $SERVER_NAME @ $ip"
|
||||
|
||||
# Wait for SSH
|
||||
log "Waiting for SSH..."
|
||||
local ok=0
|
||||
for i in $(seq 1 30); do
|
||||
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$ok" -eq 1 ] || die "SSH timeout after 60s"
|
||||
|
||||
# System packages
|
||||
log "Installing system packages (cmake, JDK 17, build tools)..."
|
||||
ssh_cmd "export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get update -qq && \
|
||||
apt-get install -y -qq \
|
||||
build-essential cmake curl git libssl-dev pkg-config \
|
||||
unzip wget zip openjdk-17-jdk-headless \
|
||||
> /dev/null 2>&1" \
|
||||
|| die "Failed to install system packages"
|
||||
|
||||
# Verify cmake version (must be <= 3.30)
|
||||
local cmake_ver
|
||||
cmake_ver=$(ssh_cmd "cmake --version | head -1")
|
||||
echo " cmake: $cmake_ver"
|
||||
echo " java: $(ssh_cmd "java -version 2>&1 | head -1")"
|
||||
|
||||
# Rust
|
||||
log "Installing Rust toolchain..."
|
||||
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" \
|
||||
|| die "Failed to install Rust"
|
||||
ssh_cmd "source \$HOME/.cargo/env && rustup target add aarch64-linux-android > /dev/null 2>&1"
|
||||
ssh_cmd "source \$HOME/.cargo/env && cargo install cargo-ndk > /dev/null 2>&1" \
|
||||
|| die "Failed to install cargo-ndk"
|
||||
echo " rust: $(ssh_cmd "source \$HOME/.cargo/env && rustc --version")"
|
||||
|
||||
# Android SDK + NDK
|
||||
log "Installing Android SDK + NDK $NDK_VERSION..."
|
||||
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||
mkdir -p \$HOME/android-sdk/cmdline-tools && \
|
||||
cd /tmp && \
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip && \
|
||||
unzip -qo cmdtools.zip -d \$HOME/android-sdk/cmdline-tools && \
|
||||
mv \$HOME/android-sdk/cmdline-tools/cmdline-tools \$HOME/android-sdk/cmdline-tools/latest 2>/dev/null; \
|
||||
yes | \$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1; \
|
||||
\$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \
|
||||
'platforms;android-${ANDROID_API}' \
|
||||
'build-tools;${ANDROID_API}.0.0' \
|
||||
'ndk;${NDK_VERSION}' \
|
||||
'platform-tools' \
|
||||
2>&1 | grep -v '^\[' > /dev/null" \
|
||||
|| die "Failed to install Android SDK/NDK"
|
||||
|
||||
ssh_cmd "[ -d \$HOME/android-sdk/ndk/$NDK_VERSION ]" \
|
||||
|| die "NDK not found after install"
|
||||
echo " NDK: $NDK_VERSION"
|
||||
|
||||
# Upload source
|
||||
do_upload
|
||||
|
||||
log "VM ready!"
|
||||
echo " IP: $ip"
|
||||
echo " SSH: ssh -A -i $SSH_KEY_PATH root@$ip"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --upload: Upload source code to VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_upload() {
|
||||
log "Uploading source code (rsync)..."
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "No VM found."
|
||||
rsync -az --delete \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='.claude' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='dist' \
|
||||
--exclude='desktop/src-tauri/gen' \
|
||||
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
|
||||
"$PROJECT_DIR/" "$REMOTE_USER@$ip:/root/wzp-build/"
|
||||
echo " Source uploaded."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --build: Build native .so + debug & release APKs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_build() {
|
||||
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
|
||||
ssh_cmd "source \$HOME/.cargo/env && \
|
||||
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||
export ANDROID_NDK_HOME=\$ANDROID_HOME/ndk/$NDK_VERSION && \
|
||||
export ANDROID_NDK=\$ANDROID_NDK_HOME && \
|
||||
cd /root/wzp-build && \
|
||||
cargo ndk -t arm64-v8a \
|
||||
-o android/app/src/main/jniLibs \
|
||||
build --release -p wzp-android 2>&1" | tail -5 \
|
||||
|| die "Rust native build failed"
|
||||
|
||||
ssh_cmd "[ -f /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ]" \
|
||||
|| die "libwzp_android.so not found after build"
|
||||
|
||||
local so_size
|
||||
so_size=$(ssh_cmd "du -h /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1")
|
||||
echo " .so: $so_size"
|
||||
|
||||
# Generate debug keystore if missing
|
||||
ssh_cmd "[ -f /root/wzp-build/android/keystore/wzp-debug.jks ] || \
|
||||
(mkdir -p /root/wzp-build/android/keystore && \
|
||||
keytool -genkey -v \
|
||||
-keystore /root/wzp-build/android/keystore/wzp-debug.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias wzp-debug -storepass android -keypass android \
|
||||
-dname 'CN=WZP Debug' > /dev/null 2>&1)"
|
||||
|
||||
# Build debug APK
|
||||
log "Building debug APK..."
|
||||
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||
cd /root/wzp-build/android && \
|
||||
chmod +x ./gradlew && \
|
||||
./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1" | tail -3 \
|
||||
|| die "Debug APK build failed"
|
||||
|
||||
# Build release APK (uses debug keystore for now)
|
||||
log "Building release APK..."
|
||||
# Copy debug keystore as release keystore (same password in build.gradle)
|
||||
ssh_cmd "cp /root/wzp-build/android/keystore/wzp-debug.jks /root/wzp-build/android/keystore/wzp-release.jks 2>/dev/null; true"
|
||||
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||
cd /root/wzp-build/android && \
|
||||
./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1" | tail -3 \
|
||||
|| echo " (release APK failed — debug APK still available)"
|
||||
|
||||
log "Build complete!"
|
||||
ssh_cmd "find /root/wzp-build/android -name '*.apk' -path '*/outputs/apk/*' -exec ls -lh {} \;"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --transfer: Download APKs to local machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_transfer() {
|
||||
log "Downloading APKs..."
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
|
||||
# Debug APK
|
||||
local debug_apk
|
||||
debug_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-debug*.apk' -path '*/outputs/apk/*' | head -1")
|
||||
if [ -n "$debug_apk" ]; then
|
||||
scp_down "$debug_apk" "$OUTPUT_DIR/wzp-debug.apk"
|
||||
echo " debug: $OUTPUT_DIR/wzp-debug.apk ($(du -h "$OUTPUT_DIR/wzp-debug.apk" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Release APK
|
||||
local release_apk
|
||||
release_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-release*.apk' -path '*/outputs/apk/*' | head -1" || true)
|
||||
if [ -n "$release_apk" ]; then
|
||||
scp_down "$release_apk" "$OUTPUT_DIR/wzp-release.apk"
|
||||
echo " release: $OUTPUT_DIR/wzp-release.apk ($(du -h "$OUTPUT_DIR/wzp-release.apk" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Also copy the .so for inspection
|
||||
scp_down "/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" "$OUTPUT_DIR/libwzp_android.so"
|
||||
echo " .so: $OUTPUT_DIR/libwzp_android.so"
|
||||
|
||||
log "Transfer complete!"
|
||||
echo ""
|
||||
echo " Install debug: adb install -r $OUTPUT_DIR/wzp-debug.apk"
|
||||
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && echo " Install release: adb install -r $OUTPUT_DIR/wzp-release.apk"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --destroy: Delete the VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_destroy() {
|
||||
local name
|
||||
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -z "$name" ]; then
|
||||
echo "No VM to destroy."
|
||||
return
|
||||
fi
|
||||
log "Deleting VM: $name"
|
||||
hcloud server delete "$name"
|
||||
echo " Done."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full build: create → build → transfer → destroy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_full() {
|
||||
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
|
||||
|
||||
do_prepare
|
||||
|
||||
# Disable trap during build — release APK failure is non-fatal
|
||||
trap - ERR
|
||||
do_build
|
||||
do_transfer
|
||||
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
|
||||
|
||||
if [ "$KEEP_VM" = "1" ]; then
|
||||
log "VM kept alive (WZP_KEEP_VM=1). Destroy with: $0 --destroy"
|
||||
else
|
||||
do_destroy
|
||||
fi
|
||||
|
||||
log "All done!"
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────────┐"
|
||||
echo " │ Debug APK: $OUTPUT_DIR/wzp-debug.apk"
|
||||
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && \
|
||||
echo " │ Release APK: $OUTPUT_DIR/wzp-release.apk"
|
||||
echo " │"
|
||||
echo " │ Install: adb install -r $OUTPUT_DIR/wzp-debug.apk"
|
||||
echo " └──────────────────────────────────────────────────┘"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--prepare) do_prepare ;;
|
||||
--build) do_build ;;
|
||||
--transfer) do_transfer ;;
|
||||
--destroy) do_destroy ;;
|
||||
--upload) do_upload ;;
|
||||
--all)
|
||||
do_prepare
|
||||
do_build
|
||||
do_transfer
|
||||
log "VM still running. Destroy with: $0 --destroy"
|
||||
;;
|
||||
"")
|
||||
do_full
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--prepare|--build|--transfer|--destroy|--all|--upload]"
|
||||
echo ""
|
||||
echo " (no args) Full build: create VM → build → download → destroy VM"
|
||||
echo " --prepare Create VM and install deps"
|
||||
echo " --build Build on existing VM"
|
||||
echo " --transfer Download APKs from VM"
|
||||
echo " --destroy Delete the VM"
|
||||
echo " --all prepare + build + transfer (VM persists)"
|
||||
echo " --upload Re-upload source to existing VM"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " WZP_BRANCH=$BRANCH"
|
||||
echo " WZP_SERVER_TYPE=$SERVER_TYPE"
|
||||
echo " WZP_KEEP_VM=$KEEP_VM (set to 1 to skip auto-destroy)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,240 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# WZ Phone — Android APK build script for Debian 12 (Bookworm)
|
||||
#
|
||||
# Sets up a complete build environment from scratch and produces a debug APK.
|
||||
# Idempotent — safe to run multiple times (skips already-installed components).
|
||||
#
|
||||
# Tested on: Debian 12 x86_64, cross-compiling to aarch64-linux-android
|
||||
#
|
||||
# Why these specific versions:
|
||||
#
|
||||
# cmake 3.25-3.28 (system package from apt)
|
||||
# cmake 3.25 (Debian 12) and 3.28 (Ubuntu 24.04) both work.
|
||||
# cmake 3.31+ has armv7/aarch64 flag conflicts in Android-Determine.cmake.
|
||||
# cmake 4.x drops cmake_minimum_required < 3.5.
|
||||
# Do NOT use pip cmake — it bundles its own modules with different bugs.
|
||||
# CRITICAL: must set ANDROID_NDK=$ANDROID_NDK_HOME (cmake checks ANDROID_NDK).
|
||||
#
|
||||
# NDK 26.1.10909125 (r26b)
|
||||
# NDK 27+ ships a newer libc++_shared.so with different scudo allocator
|
||||
# defaults. On Android 16 devices with MTE (Memory Tagging Extension)
|
||||
# enabled (e.g. Nothing A059), NDK 27's scudo crashes during malloc/calloc.
|
||||
# NDK 26.1 is the last stable version for these devices.
|
||||
# Matches build.gradle.kts: ndkVersion = "26.1.10909125"
|
||||
#
|
||||
# JDK 17 (openjdk-17-jdk-headless)
|
||||
# Gradle 8.5 + AGP 8.2.0 officially support JDK 17.
|
||||
# JDK 21 works for compilation but has Gradle daemon compat issues.
|
||||
#
|
||||
# Rust stable (currently 1.94.1)
|
||||
# Edition 2024, MSRV 1.85. Stable channel is fine.
|
||||
#
|
||||
# ANDROID_NDK=$ANDROID_NDK_HOME (BOTH must be set)
|
||||
# cmake's Android platform module checks ANDROID_NDK (no _HOME suffix).
|
||||
# cargo-ndk sets ANDROID_NDK_HOME. Both must point to the same path.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x scripts/build-android.sh
|
||||
# ./scripts/build-android.sh # build from current tree
|
||||
# WZP_CLONE=1 ./scripts/build-android.sh # clone fresh from git
|
||||
# WZP_COMMIT=2092245 ./scripts/build-android.sh # pin to specific commit
|
||||
#
|
||||
# Environment variables (all optional):
|
||||
# WZP_CLONE Set to 1 to clone from git instead of using current dir
|
||||
# WZP_REPO Git clone URL (default: ssh://git@git.manko.yoga:222/manawenuz/wz-phone)
|
||||
# WZP_BRANCH Branch to checkout (default: feat/android-voip-client)
|
||||
# WZP_COMMIT Commit to pin to (default: HEAD)
|
||||
# WZP_WORKDIR Build directory (default: /tmp/wzp-build)
|
||||
# ANDROID_API SDK platform level (default: 34)
|
||||
# NDK_VERSION NDK version string (default: 26.1.10909125)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
CLONE="${WZP_CLONE:-0}"
|
||||
REPO="${WZP_REPO:-ssh://git@git.manko.yoga:222/manawenuz/wz-phone}"
|
||||
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
|
||||
COMMIT="${WZP_COMMIT:-}"
|
||||
WORKDIR="${WZP_WORKDIR:-/tmp/wzp-build}"
|
||||
ANDROID_API="${ANDROID_API:-34}"
|
||||
NDK_VERSION="${NDK_VERSION:-26.1.10909125}"
|
||||
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
|
||||
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
# cmake checks ANDROID_NDK (not _HOME) — both must be set
|
||||
ANDROID_NDK="$ANDROID_NDK_HOME"
|
||||
JAVA_HOME="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)"
|
||||
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||
|
||||
export ANDROID_HOME ANDROID_NDK_HOME ANDROID_NDK JAVA_HOME
|
||||
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: System packages (cmake 3.25, JDK 17, make, git, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Installing system packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
build-essential \
|
||||
cmake \
|
||||
curl \
|
||||
git \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
unzip \
|
||||
wget \
|
||||
zip \
|
||||
openjdk-17-jdk-headless \
|
||||
2>/dev/null
|
||||
|
||||
# Verify critical versions
|
||||
log "Verifying build environment"
|
||||
echo " cmake: $(cmake --version | head -1)"
|
||||
echo " java: $(java -version 2>&1 | head -1)"
|
||||
echo " make: $(make --version | head -1)"
|
||||
|
||||
CMAKE_MAJOR=$(cmake --version | head -1 | grep -oP '\d+' | head -1)
|
||||
CMAKE_MINOR=$(cmake --version | head -1 | grep -oP '\d+' | sed -n '2p')
|
||||
if [ "$CMAKE_MAJOR" -gt 3 ] || { [ "$CMAKE_MAJOR" -eq 3 ] && [ "$CMAKE_MINOR" -gt 30 ]; }; then
|
||||
err "cmake $(cmake --version | head -1) is too new! Need cmake <= 3.28.x. cmake 3.31+ has Android cross-compilation bugs."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Rust toolchain
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Setting up Rust toolchain"
|
||||
if ! command -v rustup &>/dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
rustup default stable
|
||||
rustup target add aarch64-linux-android
|
||||
echo " rustc: $(rustc --version)"
|
||||
echo " cargo: $(cargo --version)"
|
||||
|
||||
if ! command -v cargo-ndk &>/dev/null; then
|
||||
log "Installing cargo-ndk"
|
||||
cargo install cargo-ndk
|
||||
fi
|
||||
echo " ndk: $(cargo ndk --version)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: Android SDK + NDK 26.1
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Setting up Android SDK + NDK $NDK_VERSION"
|
||||
if [ ! -f "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
log "Downloading Android command-line tools"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
TMPZIP=$(mktemp /tmp/cmdline-tools-XXXXX.zip)
|
||||
wget -q -O "$TMPZIP" "$CMDLINE_TOOLS_URL"
|
||||
unzip -qo "$TMPZIP" -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" 2>/dev/null || true
|
||||
rm -f "$TMPZIP"
|
||||
fi
|
||||
|
||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
|
||||
if [ ! -d "$ANDROID_NDK_HOME" ]; then
|
||||
log "Installing NDK $NDK_VERSION (this takes a few minutes)"
|
||||
sdkmanager --install \
|
||||
"platforms;android-${ANDROID_API}" \
|
||||
"build-tools;${ANDROID_API}.0.0" \
|
||||
"ndk;${NDK_VERSION}" \
|
||||
"platform-tools" \
|
||||
2>&1 | grep -v "^\[" || true
|
||||
fi
|
||||
|
||||
[ -d "$ANDROID_NDK_HOME" ] || err "NDK not found at $ANDROID_NDK_HOME"
|
||||
echo " NDK: $ANDROID_NDK_HOME"
|
||||
echo " SDK: $ANDROID_HOME"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4: Source code
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$CLONE" = "1" ]; then
|
||||
log "Cloning $REPO (branch: $BRANCH)"
|
||||
if [ -d "$WORKDIR/.git" ]; then
|
||||
cd "$WORKDIR"
|
||||
git fetch origin
|
||||
else
|
||||
rm -rf "$WORKDIR"
|
||||
git clone --branch "$BRANCH" --recurse-submodules "$REPO" "$WORKDIR"
|
||||
cd "$WORKDIR"
|
||||
fi
|
||||
git checkout "$BRANCH"
|
||||
git pull origin "$BRANCH" || true
|
||||
git submodule update --init --recursive
|
||||
|
||||
if [ -n "$COMMIT" ]; then
|
||||
log "Pinning to commit $COMMIT"
|
||||
git checkout "$COMMIT"
|
||||
fi
|
||||
else
|
||||
# Use current directory (assume we're in the repo root)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORKDIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$WORKDIR"
|
||||
[ -f "Cargo.toml" ] || err "Not in repo root. Run from repo root or set WZP_CLONE=1"
|
||||
fi
|
||||
|
||||
echo " HEAD: $(git log --oneline -1)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: Build native Rust library (.so)
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Building Rust native library (arm64-v8a, release)"
|
||||
cargo ndk -t arm64-v8a \
|
||||
-o "$WORKDIR/android/app/src/main/jniLibs" \
|
||||
build --release -p wzp-android
|
||||
|
||||
SO="$WORKDIR/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so"
|
||||
[ -f "$SO" ] || err ".so not found at $SO"
|
||||
echo " Built: $SO ($(du -h "$SO" | cut -f1))"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 6: Generate debug keystore (if missing)
|
||||
# ---------------------------------------------------------------------------
|
||||
KEYSTORE="$WORKDIR/android/keystore/wzp-debug.jks"
|
||||
if [ ! -f "$KEYSTORE" ]; then
|
||||
log "Generating debug keystore"
|
||||
mkdir -p "$(dirname "$KEYSTORE")"
|
||||
keytool -genkey -v \
|
||||
-keystore "$KEYSTORE" \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias wzp-debug \
|
||||
-storepass android -keypass android \
|
||||
-dname "CN=WZP Debug" 2>&1 | tail -1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 7: Build Android APK
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Building APK (debug)"
|
||||
cd "$WORKDIR/android"
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleDebug --no-daemon --warning-mode=none
|
||||
|
||||
APK=$(find . -name "app-debug*.apk" -path "*/outputs/apk/*" | head -1)
|
||||
[ -n "$APK" ] || err "APK not found"
|
||||
APK_ABS="$(cd "$(dirname "$APK")" && pwd)/$(basename "$APK")"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Done
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Build complete!"
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────────────────┐"
|
||||
echo " │ APK: $APK_ABS"
|
||||
echo " │ Size: $(du -h "$APK_ABS" | cut -f1)"
|
||||
echo " │ SHA256: $(sha256sum "$APK_ABS" | cut -d' ' -f1)"
|
||||
echo " └──────────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
echo " Install: adb install -r $APK_ABS"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user