diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6c1bb21..0eea970 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,7 +3,7 @@
-
+
@@ -27,7 +27,7 @@
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 d5c7dde..40e93e3 100644
--- a/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
+++ b/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
@@ -11,6 +11,7 @@ import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import com.wzp.engine.WzpEngine
+import kotlin.math.pow
/**
* Audio pipeline that captures mic audio and plays received audio using
@@ -36,6 +37,12 @@ class AudioPipeline(private val context: Context) {
@Volatile
private var running = false
+ /** Playout (incoming voice) gain in dB. 0 = unity. */
+ @Volatile
+ var playoutGainDb: Float = 0f
+ /** Capture (mic) gain in dB. 0 = unity. */
+ @Volatile
+ var captureGainDb: Float = 0f
private var captureThread: Thread? = null
private var playoutThread: Thread? = null
@@ -45,14 +52,20 @@ class AudioPipeline(private val context: Context) {
captureThread = Thread({
runCapture(engine)
+ // Park thread forever — exiting triggers a libcrypto TLS destructor
+ // crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
+ parkThread()
}, "wzp-capture").apply {
+ isDaemon = true
priority = Thread.MAX_PRIORITY
start()
}
playoutThread = Thread({
runPlayout(engine)
+ parkThread()
}, "wzp-playout").apply {
+ isDaemon = true
priority = Thread.MAX_PRIORITY
start()
}
@@ -62,13 +75,28 @@ class AudioPipeline(private val context: Context) {
fun stop() {
running = false
- captureThread?.join(1000)
- playoutThread?.join(1000)
+ // Don't join — threads are parked as daemons to avoid native TLS crash
captureThread = null
playoutThread = null
Log.i(TAG, "audio pipeline stopped")
}
+ private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
+ if (db == 0f) return
+ val linear = 10f.pow(db / 20f)
+ for (i in 0 until count) {
+ pcm[i] = (pcm[i] * linear).toInt().coerceIn(-32000, 32000).toShort()
+ }
+ }
+
+ private fun parkThread() {
+ try {
+ Thread.sleep(Long.MAX_VALUE)
+ } catch (_: InterruptedException) {
+ // process exiting
+ }
+ }
+
private fun runCapture(engine: WzpEngine) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
@@ -107,6 +135,7 @@ class AudioPipeline(private val context: Context) {
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
+ applyGain(pcm, read, captureGainDb)
engine.writeAudio(pcm)
} else if (read < 0) {
Log.e(TAG, "AudioRecord.read error: $read")
@@ -157,6 +186,7 @@ class AudioPipeline(private val context: Context) {
while (running) {
val read = engine.readAudio(pcm)
if (read >= FRAME_SAMPLES) {
+ applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read)
} else {
// Not enough decoded audio — write silence to keep stream alive
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 72955f3..900113e 100644
--- a/android/app/src/main/java/com/wzp/engine/CallStats.kt
+++ b/android/app/src/main/java/com/wzp/engine/CallStats.kt
@@ -31,7 +31,7 @@ data class CallStats(
/** Frames recovered by FEC. */
val fecRecovered: Long = 0,
/** Current mic audio level (RMS, 0-32767). */
- val audioLevel: Int = 0
+ val audioLevel: Int = 0,
) {
/** Human-readable quality label. */
val qualityLabel: String
diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt
index 1d071b7..4966d4a 100644
--- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt
+++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt
@@ -66,6 +66,7 @@ class WzpEngine(private val callback: WzpCallback) {
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
}
+
/**
* Get current call statistics as a JSON string.
*
diff --git a/android/app/src/main/java/com/wzp/service/CallService.kt b/android/app/src/main/java/com/wzp/service/CallService.kt
index ea0021d..16b2323 100644
--- a/android/app/src/main/java/com/wzp/service/CallService.kt
+++ b/android/app/src/main/java/com/wzp/service/CallService.kt
@@ -41,6 +41,7 @@ class CallService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
+ onStopFromNotification?.invoke()
stopSelf()
return START_NOT_STICKY
}
@@ -151,6 +152,9 @@ class CallService : Service() {
private const val ACTION_STOP = "com.wzp.service.STOP"
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
+ /** Called when the user taps "End Call" in the notification. */
+ var onStopFromNotification: (() -> Unit)? = null
+
/** Start the foreground call service. */
fun start(context: Context) {
val intent = Intent(context, CallService::class.java)
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 3178644..b0623ad 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
@@ -2,9 +2,7 @@ package com.wzp.ui.call
import android.Manifest
import android.content.pm.PackageManager
-import android.net.wifi.WifiManager
import android.os.Bundle
-import android.os.PowerManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -23,16 +21,13 @@ import androidx.core.content.ContextCompat
/**
* Main activity hosting the in-call Compose UI.
*
- * Acquires a partial wake lock and WiFi lock during calls to prevent
- * audio from stopping when the screen turns off.
+ * Call lifecycle (wake lock, Wi-Fi lock, audio mode, notification)
+ * is managed by [com.wzp.service.CallService] foreground service.
*/
class CallActivity : ComponentActivity() {
private val viewModel: CallViewModel by viewModels()
- private var wakeLock: PowerManager.WakeLock? = null
- private var wifiLock: WifiManager.WifiLock? = null
-
private val audioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
@@ -45,7 +40,6 @@ class CallActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
viewModel.setContext(this)
- viewModel.setWakeLockCallbacks(::acquireWakeLocks, ::releaseWakeLocks)
setContent {
WzpTheme {
@@ -65,33 +59,8 @@ class CallActivity : ComponentActivity() {
}
}
- private fun acquireWakeLocks() {
- if (wakeLock == null) {
- val pm = getSystemService(POWER_SERVICE) as PowerManager
- wakeLock = pm.newWakeLock(
- PowerManager.PARTIAL_WAKE_LOCK,
- "wzp:call"
- ).apply { acquire() }
- }
- if (wifiLock == null) {
- val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
- wifiLock = wm.createWifiLock(
- WifiManager.WIFI_MODE_FULL_HIGH_PERF,
- "wzp:call"
- ).apply { acquire() }
- }
- }
-
- private fun releaseWakeLocks() {
- wakeLock?.let { if (it.isHeld) it.release() }
- wakeLock = null
- wifiLock?.let { if (it.isHeld) it.release() }
- wifiLock = null
- }
-
override fun onDestroy() {
super.onDestroy()
- releaseWakeLocks()
if (isFinishing) {
viewModel.stopCall()
}
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 f911f66..048b135 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
@@ -1,10 +1,13 @@
package com.wzp.ui.call
import android.content.Context
+import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline
+import com.wzp.audio.AudioRouteManager
import com.wzp.engine.CallStats
+import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Job
@@ -25,9 +28,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engine: WzpEngine? = null
private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null
+ private var audioRouteManager: AudioRouteManager? = null
private var audioStarted = false
- private var acquireWakeLocks: (() -> Unit)? = null
- private var releaseWakeLocks: (() -> Unit)? = null
+ private var appContext: Context? = null
private val _callState = MutableStateFlow(0)
val callState: StateFlow get() = _callState.asStateFlow()
@@ -59,9 +62,16 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _preferIPv6 = MutableStateFlow(false)
val preferIPv6: StateFlow = _preferIPv6.asStateFlow()
+ private val _playoutGainDb = MutableStateFlow(0f)
+ val playoutGainDb: StateFlow = _playoutGainDb.asStateFlow()
+
+ private val _captureGainDb = MutableStateFlow(0f)
+ val captureGainDb: StateFlow = _captureGainDb.asStateFlow()
+
private var statsJob: Job? = null
companion object {
+ private const val TAG = "WzpCall"
val DEFAULT_SERVERS = listOf(
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
@@ -70,14 +80,14 @@ class CallViewModel : ViewModel(), WzpCallback {
}
fun setContext(context: Context) {
+ val appCtx = context.applicationContext
+ appContext = appCtx
if (audioPipeline == null) {
- audioPipeline = AudioPipeline(context.applicationContext)
+ audioPipeline = AudioPipeline(appCtx)
+ }
+ if (audioRouteManager == null) {
+ audioRouteManager = AudioRouteManager(appCtx)
}
- }
-
- fun setWakeLockCallbacks(acquire: () -> Unit, release: () -> Unit) {
- acquireWakeLocks = acquire
- releaseWakeLocks = release
}
fun selectServer(index: Int) {
@@ -108,6 +118,16 @@ class CallViewModel : ViewModel(), WzpCallback {
fun setRoomName(name: String) { _roomName.value = name }
+ fun setPlayoutGainDb(db: Float) {
+ _playoutGainDb.value = db
+ audioPipeline?.playoutGainDb = db
+ }
+
+ fun setCaptureGainDb(db: Float) {
+ _captureGainDb.value = db
+ audioPipeline?.captureGainDb = db
+ }
+
/**
* Resolve DNS hostname to IP address on the Kotlin/Android side,
* since Rust's DNS resolution may not work on Android.
@@ -143,52 +163,74 @@ 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")
+ CallService.onStopFromNotification = null
+ stopAudio()
+ stopStatsPolling()
+ 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 (stopService) {
+ try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
+ }
+ Log.i(TAG, "teardown: done")
+ }
+
fun startCall() {
val serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value
+ Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
try {
- if (engine == null) {
- engine = WzpEngine(this)
- }
- if (!engineInitialized) {
- engine?.init()
- engineInitialized = true
- }
+ // Teardown previous call but don't stop the service (we're about to restart it)
+ teardown(stopService = false)
+
+ Log.i(TAG, "startCall: creating engine")
+ engine = WzpEngine(this)
+ engine!!.init()
+ engineInitialized = true
_callState.value = 1
_errorMessage.value = null
- acquireWakeLocks?.invoke()
+ try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
+ Log.w(TAG, "service start err: $e")
+ }
startStatsPolling()
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
val relay = resolveToIp(serverEntry.address)
+ Log.i(TAG, "startCall: resolved=$relay, calling engine.startCall")
val result = engine?.startCall(relay, room) ?: -1
+ Log.i(TAG, "startCall: engine returned $result")
+ // Only wire up notification callback after engine is running
+ CallService.onStopFromNotification = { stopCall() }
if (result != 0) {
_callState.value = 0
_errorMessage.value = "Failed to start call (code $result)"
- releaseWakeLocks?.invoke()
+ appContext?.let { CallService.stop(it) }
}
} catch (e: Exception) {
+ Log.e(TAG, "startCall IO error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
- releaseWakeLocks?.invoke()
+ appContext?.let { CallService.stop(it) }
}
}
} catch (e: Exception) {
+ Log.e(TAG, "startCall error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
- releaseWakeLocks?.invoke()
+ appContext?.let { CallService.stop(it) }
}
}
fun stopCall() {
- stopAudio()
- stopStatsPolling()
- try {
- engine?.stopCall()
- } catch (_: Exception) {}
- _callState.value = 0
- releaseWakeLocks?.invoke()
+ Log.i(TAG, "stopCall")
+ teardown()
}
fun toggleMute() {
@@ -200,7 +242,7 @@ class CallViewModel : ViewModel(), WzpCallback {
fun toggleSpeaker() {
val newSpeaker = !_isSpeaker.value
_isSpeaker.value = newSpeaker
- try { engine?.setSpeaker(newSpeaker) } catch (_: Exception) {}
+ audioRouteManager?.setSpeaker(newSpeaker)
}
fun clearError() { _errorMessage.value = null }
@@ -213,13 +255,24 @@ class CallViewModel : ViewModel(), WzpCallback {
private fun startAudio() {
if (audioStarted) return
val e = engine ?: return
- audioPipeline?.start(e)
+ val ctx = appContext ?: return
+ // Create a fresh pipeline each call to avoid stale threads
+ audioPipeline = AudioPipeline(ctx).also {
+ it.playoutGainDb = _playoutGainDb.value
+ it.captureGainDb = _captureGainDb.value
+ it.start(e)
+ }
+ audioRouteManager?.register()
audioStarted = true
}
private fun stopAudio() {
if (!audioStarted) return
audioPipeline?.stop()
+ audioPipeline = null
+ audioRouteManager?.unregister()
+ audioRouteManager?.setSpeaker(false)
+ _isSpeaker.value = false
audioStarted = false
}
@@ -230,6 +283,7 @@ class CallViewModel : ViewModel(), WzpCallback {
try {
val json = engine?.getStats() ?: "{}"
if (json.isNotEmpty()) {
+ Log.d(TAG, "raw: $json")
val s = CallStats.fromJson(json)
_stats.value = s
if (s.state != 0) {
@@ -252,14 +306,7 @@ class CallViewModel : ViewModel(), WzpCallback {
override fun onCleared() {
super.onCleared()
- stopAudio()
- stopStatsPolling()
- releaseWakeLocks?.invoke()
- try {
- engine?.stopCall()
- engine?.destroy()
- } catch (_: Exception) {}
- engine = null
- engineInitialized = false
+ Log.i(TAG, "onCleared")
+ teardown()
}
}
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 630f556..6b1a75e 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
@@ -28,6 +28,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -65,6 +66,8 @@ fun InCallScreen(
val selectedServer by viewModel.selectedServer.collectAsState()
val servers by viewModel.servers.collectAsState()
val preferIPv6 by viewModel.preferIPv6.collectAsState()
+ val playoutGainDb by viewModel.playoutGainDb.collectAsState()
+ val captureGainDb by viewModel.captureGainDb.collectAsState()
var showAddServerDialog by remember { mutableStateOf(false) }
@@ -229,7 +232,22 @@ fun InCallScreen(
AudioLevelBar(stats.audioLevel)
- Spacer(modifier = Modifier.height(48.dp))
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Gain sliders
+ GainSlider(
+ label = "Voice Volume",
+ gainDb = playoutGainDb,
+ onGainChange = { viewModel.setPlayoutGainDb(it) }
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ GainSlider(
+ label = "Mic Gain",
+ gainDb = captureGainDb,
+ onGainChange = { viewModel.setCaptureGainDb(it) }
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
ControlRow(
isMuted = isMuted,
@@ -406,6 +424,29 @@ private fun AudioLevelBar(audioLevel: Int) {
}
}
+@Composable
+private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxWidth(0.8f),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val sign = if (gainDb >= 0) "+" else ""
+ Text(
+ text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Slider(
+ value = gainDb,
+ onValueChange = { onGainChange(Math.round(it).toFloat()) },
+ valueRange = -20f..20f,
+ steps = 0,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
@Composable
private fun ControlRow(
isMuted: Boolean,
@@ -490,7 +531,7 @@ private fun StatsOverlay(stats: CallStats) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
- text = "Network Stats",
+ text = "Stats",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -508,10 +549,9 @@ private fun StatsOverlay(stats: CallStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
- StatItem("Enc", "${stats.framesEncoded}")
- StatItem("Dec", "${stats.framesDecoded}")
+ StatItem("Sent", "${stats.framesEncoded}")
+ StatItem("Recv", "${stats.framesDecoded}")
StatItem("FEC", "${stats.fecRecovered}")
- StatItem("Under", "${stats.underruns}")
}
}
}
diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs
index 9321cdb..5dce97d 100644
--- a/crates/wzp-android/src/engine.rs
+++ b/crates/wzp-android/src/engine.rs
@@ -162,7 +162,6 @@ impl WzpEngine {
if let Some(start) = self.call_start {
stats.duration_secs = start.elapsed().as_secs_f64();
}
- // Include current audio level
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
stats
}
@@ -506,15 +505,25 @@ async fn run_call(
}
};
- // Stats task
+ // Stats task — polls path quality + quinn RTT every 500ms
+ let transport_stats = transport.clone();
let stats_task = async {
loop {
if !state.running.load(Ordering::Relaxed) {
break;
}
+ // Feed quinn's QUIC-level RTT into our path monitor
+ let quic_rtt_ms = transport_stats.connection().stats().path.rtt.as_millis() as u32;
+ if quic_rtt_ms > 0 {
+ transport_stats.feed_rtt(quic_rtt_ms);
+ }
+ let pq = transport_stats.path_quality();
{
let mut stats = state.stats.lock().unwrap();
stats.frames_encoded = seq.load(Ordering::Relaxed) as u64;
+ stats.loss_pct = pq.loss_pct;
+ stats.rtt_ms = quic_rtt_ms;
+ stats.jitter_ms = pq.jitter_ms;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
diff --git a/crates/wzp-transport/src/path_monitor.rs b/crates/wzp-transport/src/path_monitor.rs
index 837565c..abbef6a 100644
--- a/crates/wzp-transport/src/path_monitor.rs
+++ b/crates/wzp-transport/src/path_monitor.rs
@@ -136,6 +136,11 @@ impl PathMonitor {
}
}
+ /// Get raw packet counts for debugging.
+ pub fn counts(&self) -> (u64, u64) {
+ (self.total_sent, self.total_received)
+ }
+
/// Estimate bandwidth in kbps from bytes received over time.
fn estimate_bandwidth_kbps(&self) -> u32 {
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
diff --git a/crates/wzp-transport/src/quic.rs b/crates/wzp-transport/src/quic.rs
index 0c3f1ed..68fddb2 100644
--- a/crates/wzp-transport/src/quic.rs
+++ b/crates/wzp-transport/src/quic.rs
@@ -33,6 +33,16 @@ impl QuinnTransport {
&self.connection
}
+ /// 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);
+ }
+
+ /// Get raw packet counts from path monitor (sent, received).
+ pub fn monitor_counts(&self) -> (u64, u64) {
+ self.path_monitor.lock().unwrap().counts()
+ }
+
/// Get the maximum datagram payload size, if datagrams are supported.
pub fn max_datagram_size(&self) -> Option {
datagram::max_datagram_payload(&self.connection)
diff --git a/wzp-release.apk b/wzp-release.apk
index 6fe05cb..ee9b5b7 100644
Binary files a/wzp-release.apk and b/wzp-release.apk differ