diff --git a/Cargo.lock b/Cargo.lock
index 93c24d2..9cefa21 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -64,6 +64,12 @@ 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"
@@ -5821,6 +5827,17 @@ 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"
@@ -7099,6 +7116,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
+ "tracing-android",
"tracing-subscriber",
"wzp-codec",
"wzp-crypto",
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0eea970..166014a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -29,5 +29,15 @@
android:name="com.wzp.service.CallService"
android:foregroundServiceType="microphone"
android:exported="false" />
+
+
+
+
diff --git a/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt b/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
index 40e93e3..7126f66 100644
--- a/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
+++ b/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
@@ -8,10 +8,19 @@ 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 kotlin.math.pow
+import kotlin.math.sqrt
/**
* Audio pipeline that captures mic audio and plays received audio using
@@ -43,9 +52,17 @@ 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
+ private val debugDir: File by lazy {
+ File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
+ }
+
fun start(engine: WzpEngine) {
if (running) return
running = true
@@ -89,6 +106,15 @@ 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)
@@ -127,25 +153,86 @@ 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")
+ Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
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)
engine.writeAudio(pcm)
+
+ // 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 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")
+ Log.i(TAG, "capture stopped (frames=$frameIdx)")
}
}
@@ -181,24 +268,57 @@ 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) // pre-allocated silence
+ 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}")
+ }
+ }
try {
while (running) {
val read = engine.readAudio(pcm)
if (read >= FRAME_SAMPLES) {
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)
- // Sleep briefly to avoid busy-spinning
+ // 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++
Thread.sleep(5)
}
}
} finally {
+ pcmOut?.close()
+ rmsCsv?.close()
track.stop()
track.release()
- Log.i(TAG, "playout stopped")
+ Log.i(TAG, "playout stopped (frames=$frameIdx)")
}
}
}
diff --git a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt
index 2d2162c..28c41e9 100644
--- a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt
+++ b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt
@@ -27,6 +27,7 @@ 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"
}
// --- Servers ---
@@ -112,6 +113,11 @@ 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 ---
/**
diff --git a/android/app/src/main/java/com/wzp/debug/DebugReporter.kt b/android/app/src/main/java/com/wzp/debug/DebugReporter.kt
new file mode 100644
index 0000000..02d6e25
--- /dev/null
+++ b/android/app/src/main/java/com/wzp/debug/DebugReporter.kt
@@ -0,0 +1,198 @@
+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()
+ }
+}
diff --git a/android/app/src/main/java/com/wzp/engine/CallStats.kt b/android/app/src/main/java/com/wzp/engine/CallStats.kt
index d4e4f41..17ac4cb 100644
--- a/android/app/src/main/java/com/wzp/engine/CallStats.kt
+++ b/android/app/src/main/java/com/wzp/engine/CallStats.kt
@@ -54,7 +54,7 @@ data class CallStats(
val o = arr.getJSONObject(i)
RoomMember(
fingerprint = o.optString("fingerprint", ""),
- alias = o.optString("alias", null)
+ alias = if (o.isNull("alias")) null else o.optString("alias", null)
)
}
}
@@ -92,5 +92,6 @@ data class RoomMember(
) {
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
val displayName: String
- get() = alias ?: fingerprint.take(8)
+ get() = alias?.takeIf { it.isNotBlank() }
+ ?: fingerprint.take(8).ifEmpty { "unknown" }
}
diff --git a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
index a2e46a9..f651ae2 100644
--- a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
+++ b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
@@ -1,8 +1,10 @@
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
@@ -21,7 +23,12 @@ 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.
@@ -31,6 +38,10 @@ import com.wzp.ui.settings.SettingsScreen
*/
class CallActivity : ComponentActivity() {
+ companion object {
+ private const val TAG = "CallActivity"
+ }
+
private val viewModel: CallViewModel by viewModels()
private val audioPermissionLauncher = registerForActivityResult(
@@ -69,6 +80,45 @@ 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() {
diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
index e2b28f2..30bd7e4 100644
--- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
+++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
@@ -7,6 +7,7 @@ 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
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
@@ -33,6 +35,10 @@ 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 get() = _callState.asStateFlow()
@@ -76,6 +82,21 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _seedHex = MutableStateFlow("")
val seedHex: StateFlow = _seedHex.asStateFlow()
+ private val _aecEnabled = MutableStateFlow(true)
+ val aecEnabled: StateFlow = _aecEnabled.asStateFlow()
+
+ /** True when a call just ended and debug report can be sent. */
+ private val _debugReportAvailable = MutableStateFlow(false)
+ val debugReportAvailable: StateFlow = _debugReportAvailable.asStateFlow()
+
+ /** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
+ private val _debugReportStatus = MutableStateFlow(null)
+ val debugReportStatus: StateFlow = _debugReportStatus.asStateFlow()
+
+ /** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
+ private val _debugZipReady = MutableStateFlow(null)
+ val debugZipReady: StateFlow = _debugZipReady.asStateFlow()
+
private var statsJob: Job? = null
companion object {
@@ -96,6 +117,9 @@ class CallViewModel : ViewModel(), WzpCallback {
if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx)
}
+ if (debugReporter == null) {
+ debugReporter = DebugReporter(appCtx)
+ }
if (settings == null) {
settings = SettingsRepository(appCtx)
loadSettings()
@@ -114,6 +138,7 @@ class CallViewModel : ViewModel(), WzpCallback {
_playoutGainDb.value = s.loadPlayoutGain()
_captureGainDb.value = s.loadCaptureGain()
_seedHex.value = s.getOrCreateSeedHex()
+ _aecEnabled.value = s.loadAecEnabled()
}
fun selectServer(index: Int) {
@@ -149,6 +174,14 @@ class CallViewModel : ViewModel(), WzpCallback {
}
}
+ /** Batch-apply servers and selection from Settings draft state. */
+ fun applyServers(servers: List, selected: Int) {
+ _servers.value = servers
+ _selectedServer.value = selected.coerceIn(0, servers.lastIndex)
+ settings?.saveServers(servers)
+ settings?.saveSelectedServer(_selectedServer.value)
+ }
+
fun setRoomName(name: String) {
_roomName.value = name
settings?.saveRoom(name)
@@ -176,6 +209,11 @@ 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.
@@ -214,6 +252,7 @@ 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()
stopStatsPolling()
@@ -223,6 +262,9 @@ class CallViewModel : ViewModel(), WzpCallback {
engine = null
engineInitialized = false
_callState.value = 0
+ if (hadCall) {
+ _debugReportAvailable.value = true
+ }
if (stopService) {
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
}
@@ -233,6 +275,10 @@ 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
+ debugReporter?.prepareForCall()
try {
// Teardown previous call but don't stop the service (we're about to restart it)
teardown(stopService = false)
@@ -297,6 +343,40 @@ 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 }
@@ -310,6 +390,7 @@ 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()
@@ -334,7 +415,9 @@ 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
diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
index 66311e3..0bf6260 100644
--- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
+++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
@@ -24,7 +24,6 @@ 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
@@ -69,6 +68,8 @@ 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) }
@@ -228,6 +229,17 @@ 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))
@@ -239,13 +251,17 @@ 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 = "${stats.roomParticipantCount} in room",
+ text = "${unique.size} in room",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- stats.roomParticipants.forEach { member ->
+ unique.forEach { member ->
Text(
text = member.displayName,
style = MaterialTheme.typography.labelSmall,
@@ -438,15 +454,20 @@ private fun AudioLevelBar(audioLevel: Int) {
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
- LinearProgressIndicator(
- progress = level,
+ Box(
modifier = Modifier
.fillMaxWidth(0.6f)
.height(6.dp)
- .clip(RoundedCornerShape(3.dp)),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.surfaceVariant,
- )
+ .clip(RoundedCornerShape(3.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(level)
+ .height(6.dp)
+ .background(MaterialTheme.colorScheme.primary)
+ )
+ }
}
}
@@ -598,3 +619,70 @@ 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")
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
index 769c2ec..6a083c2 100644
--- a/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
+++ b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
@@ -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,9 +36,12 @@ 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
@@ -47,6 +50,7 @@ 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
@@ -55,14 +59,39 @@ fun SettingsScreen(
onBack: () -> Unit
) {
val context = LocalContext.current
- 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()
+
+ // 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
var showAddServerDialog by remember { mutableStateOf(false) }
var showRestoreKeyDialog by remember { mutableStateOf(false) }
@@ -94,8 +123,24 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.weight(1f))
- // Balance the back button
- Spacer(modifier = Modifier.width(64.dp))
+ // 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")
+ }
}
Spacer(modifier = Modifier.height(24.dp))
@@ -104,8 +149,8 @@ fun SettingsScreen(
SectionHeader("Identity")
OutlinedTextField(
- value = alias,
- onValueChange = { viewModel.setAlias(it) },
+ value = draftAlias,
+ onValueChange = { draftAlias = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
@@ -114,7 +159,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display
- val fingerprint = if (seedHex.length >= 16) seedHex.take(16).uppercase() else "Not generated"
+ val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text(
text = "Fingerprint",
style = MaterialTheme.typography.labelSmall,
@@ -134,7 +179,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", seedHex))
+ clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
}) {
Text("Copy Key")
@@ -153,16 +198,39 @@ fun SettingsScreen(
GainSlider(
label = "Voice Volume",
- gainDb = playoutGainDb,
- onGainChange = { viewModel.setPlayoutGainDb(it) }
+ gainDb = draftPlayoutGain,
+ onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(4.dp))
GainSlider(
label = "Mic Gain",
- gainDb = captureGainDb,
- onGainChange = { viewModel.setCaptureGainDb(it) }
+ gainDb = draftCaptureGain,
+ onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
)
+ 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))
@@ -175,11 +243,11 @@ fun SettingsScreen(
horizontalArrangement = Arrangement.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
- servers.forEachIndexed { idx, entry ->
- val isSelected = selectedServer == idx
+ draftServers.forEachIndexed { idx, entry ->
+ val isSelected = draftSelectedServer == idx
Row(verticalAlignment = Alignment.CenterVertically) {
FilledTonalIconButton(
- onClick = { viewModel.selectServer(idx) },
+ onClick = { draftSelectedServer = idx },
modifier = Modifier
.padding(end = 2.dp)
.height(36.dp)
@@ -203,7 +271,12 @@ fun SettingsScreen(
// Show remove button for non-default servers
if (idx >= 2) {
TextButton(
- onClick = { viewModel.removeServer(idx) },
+ onClick = {
+ draftServers.removeAt(idx)
+ if (draftSelectedServer >= draftServers.size) {
+ draftSelectedServer = 0
+ }
+ },
modifier = Modifier.height(36.dp)
) {
Text("X", color = MaterialTheme.colorScheme.error)
@@ -224,7 +297,7 @@ fun SettingsScreen(
// Show selected server address
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "Default: ${servers.getOrNull(selectedServer)?.address ?: "none"}",
+ text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -246,8 +319,8 @@ fun SettingsScreen(
modifier = Modifier.weight(1f)
)
Switch(
- checked = preferIPv6,
- onCheckedChange = { viewModel.setPreferIPv6(it) }
+ checked = draftPreferIPv6,
+ onCheckedChange = { draftPreferIPv6 = it }
)
}
@@ -259,8 +332,8 @@ fun SettingsScreen(
SectionHeader("Room")
OutlinedTextField(
- value = roomName,
- onValueChange = { viewModel.setRoomName(it) },
+ value = draftRoomName,
+ onValueChange = { draftRoomName = it },
label = { Text("Default Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
@@ -274,7 +347,7 @@ fun SettingsScreen(
AddServerDialog(
onDismiss = { showAddServerDialog = false },
onAdd = { host, port, label ->
- viewModel.addServer("$host:$port", label)
+ draftServers.add(ServerEntry("$host:$port", label))
showAddServerDialog = false
}
)
@@ -284,9 +357,9 @@ fun SettingsScreen(
RestoreKeyDialog(
onDismiss = { showRestoreKeyDialog = false },
onRestore = { hex ->
- viewModel.restoreSeed(hex)
+ draftSeedHex = hex
showRestoreKeyDialog = false
- Toast.makeText(context, "Key restored", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
}
)
}
@@ -316,7 +389,7 @@ private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Un
)
Slider(
value = gainDb,
- onValueChange = { onGainChange(Math.round(it).toFloat()) },
+ onValueChange = onGainChange,
valueRange = -20f..20f,
steps = 0,
modifier = Modifier.fillMaxWidth()
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..45fce9e
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/crates/wzp-android/Cargo.toml b/crates/wzp-android/Cargo.toml
index 3fcd32b..c13e3f2 100644
--- a/crates/wzp-android/Cargo.toml
+++ b/crates/wzp-android/Cargo.toml
@@ -28,6 +28,7 @@ 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"
diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs
index 08ec63e..ea20fb6 100644
--- a/crates/wzp-android/src/engine.rs
+++ b/crates/wzp-android/src/engine.rs
@@ -67,6 +67,9 @@ 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