diff --git a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt index aa2c4f0..3178644 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt @@ -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() } diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt index c7cfd84..ce634e7 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -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 get() = _callState.asStateFlow() @@ -43,24 +45,41 @@ class CallViewModel : ViewModel(), WzpCallback { private val _roomName = MutableStateFlow(DEFAULT_ROOM) val roomName: StateFlow = _roomName.asStateFlow() + private val _selectedServer = MutableStateFlow(0) // index into SERVERS + val selectedServer: StateFlow = _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() diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt index b29c9f3..453275e 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -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() }, diff --git a/wzp-release.apk b/wzp-release.apk index 2c75792..1635871 100644 Binary files a/wzp-release.apk and b/wzp-release.apk differ