feat: wakelock for background calls, server selector UI

- Partial wake lock + WiFi high-perf lock during calls — audio
  continues when screen is off / phone is locked
- Server selector: toggle between LAN (172.16.81.175) and Pangolin
  (pangolin.manko.yoga) before connecting
- Room name editable in idle screen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-05 12:54:02 +00:00
parent bf91cf25bd
commit 2fa07286c3
4 changed files with 103 additions and 18 deletions

View File

@@ -2,7 +2,9 @@ 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
@@ -21,13 +23,16 @@ import androidx.core.content.ContextCompat
/**
* Main activity hosting the in-call Compose UI.
*
* Shows the call screen. Does NOT auto-start a call — the user must
* tap "Connect" in the UI.
* Acquires a partial wake lock and WiFi lock during calls to prevent
* audio from stopping when the screen turns off.
*/
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 ->
@@ -40,6 +45,7 @@ class CallActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
viewModel.setContext(this)
viewModel.setWakeLockCallbacks(::acquireWakeLocks, ::releaseWakeLocks)
setContent {
WzpTheme {
@@ -52,7 +58,6 @@ class CallActivity : ComponentActivity() {
}
}
// Request audio permission proactively but don't start a call
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
@@ -60,8 +65,33 @@ 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()
}

View File

@@ -21,6 +21,8 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null
private var audioStarted = false
private var acquireWakeLocks: (() -> Unit)? = null
private var releaseWakeLocks: (() -> Unit)? = null
private val _callState = MutableStateFlow(0)
val callState: StateFlow<Int> get() = _callState.asStateFlow()
@@ -43,24 +45,41 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
val roomName: StateFlow<String> = _roomName.asStateFlow()
private val _selectedServer = MutableStateFlow(0) // index into SERVERS
val selectedServer: StateFlow<Int> = _selectedServer.asStateFlow()
private var statsJob: Job? = null
companion object {
const val DEFAULT_RELAY = "pangolin.manko.yoga:4433"
val SERVERS = listOf(
"172.16.81.175:4433" to "LAN (172.16.81.175)",
"pangolin.manko.yoga:4433" to "Pangolin (remote)",
)
const val DEFAULT_ROOM = "android"
}
/** Must be called once with Activity context before startCall. */
fun setContext(context: Context) {
if (audioPipeline == null) {
audioPipeline = AudioPipeline(context.applicationContext)
}
}
fun startCall(
relayAddr: String = DEFAULT_RELAY,
room: String = _roomName.value
) {
fun setWakeLockCallbacks(acquire: () -> Unit, release: () -> Unit) {
acquireWakeLocks = acquire
releaseWakeLocks = release
}
fun selectServer(index: Int) {
if (index in SERVERS.indices) {
_selectedServer.value = index
}
}
fun setRoomName(name: String) { _roomName.value = name }
fun startCall() {
val relay = SERVERS[_selectedServer.value].first
val room = _roomName.value
try {
if (engine == null) {
engine = WzpEngine(this)
@@ -69,24 +88,28 @@ class CallViewModel : ViewModel(), WzpCallback {
engine?.init()
engineInitialized = true
}
_callState.value = 1 // Connecting
_callState.value = 1
acquireWakeLocks?.invoke()
startStatsPolling()
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
val result = engine?.startCall(relayAddr, room) ?: -1
val result = engine?.startCall(relay, room) ?: -1
if (result != 0) {
_callState.value = 0
_errorMessage.value = "Failed to start call (code $result)"
releaseWakeLocks?.invoke()
}
} catch (e: Exception) {
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
releaseWakeLocks?.invoke()
}
}
} catch (e: Exception) {
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
releaseWakeLocks?.invoke()
}
}
@@ -97,6 +120,7 @@ class CallViewModel : ViewModel(), WzpCallback {
engine?.stopCall()
} catch (_: Exception) {}
_callState.value = 0
releaseWakeLocks?.invoke()
}
fun toggleMute() {
@@ -113,8 +137,6 @@ class CallViewModel : ViewModel(), WzpCallback {
fun clearError() { _errorMessage.value = null }
fun setRoomName(name: String) { _roomName.value = name }
// WzpCallback
override fun onCallStateChanged(state: Int) { _callState.value = state }
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
@@ -142,11 +164,9 @@ class CallViewModel : ViewModel(), WzpCallback {
if (json.isNotEmpty()) {
val s = CallStats.fromJson(json)
_stats.value = s
// Sync call state from native engine stats
if (s.state != 0) {
_callState.value = s.state
}
// Start audio pipeline when call becomes active
if (s.state == 2 && !audioStarted) {
startAudio()
}
@@ -166,6 +186,7 @@ class CallViewModel : ViewModel(), WzpCallback {
super.onCleared()
stopAudio()
stopStatsPolling()
releaseWakeLocks?.invoke()
try {
engine?.stopCall()
engine?.destroy()

View File

@@ -50,6 +50,7 @@ fun InCallScreen(
val qualityTier by viewModel.qualityTier.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val roomName by viewModel.roomName.collectAsState()
val selectedServer by viewModel.selectedServer.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
@@ -80,11 +81,44 @@ fun InCallScreen(
// Idle — show connect button
Spacer(modifier = Modifier.height(48.dp))
// Server selector
Text(
text = "Relay: ${CallViewModel.DEFAULT_RELAY}",
style = MaterialTheme.typography.bodyMedium,
text = "Server",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CallViewModel.SERVERS.forEachIndexed { idx, (_, label) ->
val isSelected = selectedServer == idx
FilledTonalIconButton(
onClick = { viewModel.selectServer(idx) },
modifier = Modifier
.padding(horizontal = 4.dp)
.height(36.dp)
.width(140.dp),
shape = RoundedCornerShape(8.dp),
colors = if (isSelected) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = roomName,
@@ -94,7 +128,7 @@ fun InCallScreen(
modifier = Modifier.fillMaxWidth(0.6f)
)
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { viewModel.startCall() },