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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user