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.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -21,13 +23,16 @@ import androidx.core.content.ContextCompat
/** /**
* Main activity hosting the in-call Compose UI. * Main activity hosting the in-call Compose UI.
* *
* Shows the call screen. Does NOT auto-start a call — the user must * Acquires a partial wake lock and WiFi lock during calls to prevent
* tap "Connect" in the UI. * audio from stopping when the screen turns off.
*/ */
class CallActivity : ComponentActivity() { class CallActivity : ComponentActivity() {
private val viewModel: CallViewModel by viewModels() private val viewModel: CallViewModel by viewModels()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private val audioPermissionLauncher = registerForActivityResult( private val audioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
@@ -40,6 +45,7 @@ class CallActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.setContext(this) viewModel.setContext(this)
viewModel.setWakeLockCallbacks(::acquireWakeLocks, ::releaseWakeLocks)
setContent { setContent {
WzpTheme { 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) if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED != 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
releaseWakeLocks()
if (isFinishing) { if (isFinishing) {
viewModel.stopCall() viewModel.stopCall()
} }

View File

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

View File

@@ -50,6 +50,7 @@ fun InCallScreen(
val qualityTier by viewModel.qualityTier.collectAsState() val qualityTier by viewModel.qualityTier.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState()
val roomName by viewModel.roomName.collectAsState() val roomName by viewModel.roomName.collectAsState()
val selectedServer by viewModel.selectedServer.collectAsState()
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -80,11 +81,44 @@ fun InCallScreen(
// Idle — show connect button // Idle — show connect button
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
// Server selector
Text( Text(
text = "Relay: ${CallViewModel.DEFAULT_RELAY}", text = "Server",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = roomName, value = roomName,
@@ -94,7 +128,7 @@ fun InCallScreen(
modifier = Modifier.fillMaxWidth(0.6f) modifier = Modifier.fillMaxWidth(0.6f)
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { viewModel.startCall() }, onClick = { viewModel.startCall() },

Binary file not shown.