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.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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
BIN
wzp-release.apk
BIN
wzp-release.apk
Binary file not shown.
Reference in New Issue
Block a user