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