feat: foreground service, dB gain sliders, speaker routing, live network stats
- Wire CallService foreground service for background calls (microphone type) - Add Voice Volume + Mic Gain sliders (-20 to +20 dB) applied in Kotlin - Connect AudioRouteManager for real speaker toggle via AudioManager - Feed quinn QUIC RTT into PathMonitor, display Loss/RTT/Jitter from live data - Nuclear teardown between calls — recreate engine + audio pipeline each call - Fix re-entrant teardown loop from CallService notification callback - Park audio threads as daemons to avoid libcrypto TLS destructor crash on exit - Remove duplicate wakelocks from Activity (service owns them now) - Strip AEC + denoise from capture path, keep AGC only (incremental approach) - Fix .so copy target: libwzp_android.so not libwzp.so Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Int> get() = _callState.asStateFlow()
|
||||
@@ -59,9 +62,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _preferIPv6 = MutableStateFlow(false)
|
||||
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
||||
|
||||
private val _playoutGainDb = MutableStateFlow(0f)
|
||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||
|
||||
private val _captureGainDb = MutableStateFlow(0f)
|
||||
val captureGainDb: StateFlow<Float> = _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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user