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 ce634e7..f911f66 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 @@ -14,6 +14,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress + +data class ServerEntry(val address: String, val label: String) class CallViewModel : ViewModel(), WzpCallback { @@ -45,15 +50,21 @@ class CallViewModel : ViewModel(), WzpCallback { private val _roomName = MutableStateFlow(DEFAULT_ROOM) val roomName: StateFlow = _roomName.asStateFlow() - private val _selectedServer = MutableStateFlow(0) // index into SERVERS + private val _selectedServer = MutableStateFlow(0) val selectedServer: StateFlow = _selectedServer.asStateFlow() + private val _servers = MutableStateFlow(DEFAULT_SERVERS.toList()) + val servers: StateFlow> = _servers.asStateFlow() + + private val _preferIPv6 = MutableStateFlow(false) + val preferIPv6: StateFlow = _preferIPv6.asStateFlow() + private var statsJob: Job? = null companion object { - val SERVERS = listOf( - "172.16.81.175:4433" to "LAN (172.16.81.175)", - "pangolin.manko.yoga:4433" to "Pangolin (remote)", + val DEFAULT_SERVERS = listOf( + ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"), + ServerEntry("193.180.213.68:4433", "Pangolin (IP)"), ) const val DEFAULT_ROOM = "android" } @@ -70,15 +81,70 @@ class CallViewModel : ViewModel(), WzpCallback { } fun selectServer(index: Int) { - if (index in SERVERS.indices) { + if (index in _servers.value.indices) { _selectedServer.value = index } } + fun setPreferIPv6(prefer: Boolean) { _preferIPv6.value = prefer } + + fun addServer(hostPort: String, label: String) { + val current = _servers.value.toMutableList() + current.add(ServerEntry(hostPort, label)) + _servers.value = current + } + + fun removeServer(index: Int) { + if (index < DEFAULT_SERVERS.size) return // don't remove built-in servers + val current = _servers.value.toMutableList() + if (index in current.indices) { + current.removeAt(index) + _servers.value = current + if (_selectedServer.value >= current.size) { + _selectedServer.value = 0 + } + } + } + fun setRoomName(name: String) { _roomName.value = name } + /** + * Resolve DNS hostname to IP address on the Kotlin/Android side, + * since Rust's DNS resolution may not work on Android. + * Returns "ip:port" string. + */ + private fun resolveToIp(hostPort: String): String { + val parts = hostPort.split(":") + if (parts.size != 2) return hostPort + val host = parts[0] + val port = parts[1] + + // Already an IP address — return as-is + if (host.matches(Regex("""\d+\.\d+\.\d+\.\d+"""))) return hostPort + if (host.contains(":")) return hostPort // IPv6 literal + + return try { + val addresses = InetAddress.getAllByName(host) + val preferV6 = _preferIPv6.value + val picked = if (preferV6) { + addresses.firstOrNull { it is Inet6Address } ?: addresses.firstOrNull { it is Inet4Address } + } else { + addresses.firstOrNull { it is Inet4Address } ?: addresses.firstOrNull { it is Inet6Address } + } + if (picked != null) { + val ip = picked.hostAddress ?: host + val formatted = if (picked is Inet6Address) "[$ip]:$port" else "$ip:$port" + formatted + } else { + hostPort + } + } catch (_: Exception) { + hostPort // resolution failed — pass through and let Rust try + } + } + fun startCall() { - val relay = SERVERS[_selectedServer.value].first + val serverEntry = _servers.value[_selectedServer.value] val room = _roomName.value try { if (engine == null) { @@ -89,11 +155,13 @@ class CallViewModel : ViewModel(), WzpCallback { engineInitialized = true } _callState.value = 1 + _errorMessage.value = null acquireWakeLocks?.invoke() startStatsPolling() viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { try { + val relay = resolveToIp(serverEntry.address) val result = engine?.startCall(relay, room) ?: -1 if (result != 0) { _callState.value = 0 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 453275e..630f556 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 @@ -4,6 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,8 +14,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledIconButton @@ -21,12 +26,18 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,6 +49,7 @@ import androidx.compose.ui.unit.sp import com.wzp.engine.CallStats import kotlin.math.roundToInt +@OptIn(ExperimentalLayoutApi::class) @Composable fun InCallScreen( viewModel: CallViewModel, @@ -51,6 +63,10 @@ fun InCallScreen( val errorMessage by viewModel.errorMessage.collectAsState() val roomName by viewModel.roomName.collectAsState() val selectedServer by viewModel.selectedServer.collectAsState() + val servers by viewModel.servers.collectAsState() + val preferIPv6 by viewModel.preferIPv6.collectAsState() + + var showAddServerDialog by remember { mutableStateOf(false) } Surface( modifier = Modifier.fillMaxSize(), @@ -59,12 +75,12 @@ fun InCallScreen( Column( modifier = Modifier .fillMaxSize() - .padding(24.dp), + .padding(24.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(48.dp)) - // App title Text( text = "WZ Phone", style = MaterialTheme.typography.headlineMedium.copy( @@ -78,8 +94,7 @@ fun InCallScreen( CallStateLabel(callState) if (callState == 0) { - // Idle — show connect button - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(32.dp)) // Server selector Text( @@ -88,16 +103,16 @@ fun InCallScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) - Row( + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - CallViewModel.SERVERS.forEachIndexed { idx, (_, label) -> + servers.forEachIndexed { idx, entry -> val isSelected = selectedServer == idx FilledTonalIconButton( onClick = { viewModel.selectServer(idx) }, modifier = Modifier - .padding(horizontal = 4.dp) + .padding(2.dp) .height(36.dp) .width(140.dp), shape = RoundedCornerShape(8.dp), @@ -111,14 +126,57 @@ fun InCallScreen( } ) { Text( - text = label, + text = entry.label, style = MaterialTheme.typography.labelSmall, maxLines = 1 ) } } + // + Add button + OutlinedButton( + onClick = { showAddServerDialog = true }, + modifier = Modifier + .padding(2.dp) + .height(36.dp), + shape = RoundedCornerShape(8.dp) + ) { + Text("+", style = MaterialTheme.typography.labelMedium) + } } + // IPv4/IPv6 preference + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "IPv4", + style = MaterialTheme.typography.labelSmall, + color = if (!preferIPv6) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Switch( + checked = preferIPv6, + onCheckedChange = { viewModel.setPreferIPv6(it) }, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Text( + text = "IPv6", + style = MaterialTheme.typography.labelSmall, + color = if (preferIPv6) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Selected server address + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = servers.getOrNull(selectedServer)?.address ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = roomName, @@ -149,7 +207,6 @@ fun InCallScreen( ) } - // Show error if any errorMessage?.let { err -> Spacer(modifier = Modifier.height(16.dp)) Text( @@ -172,7 +229,7 @@ fun InCallScreen( AudioLevelBar(stats.audioLevel) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(48.dp)) ControlRow( isMuted = isMuted, @@ -181,7 +238,6 @@ fun InCallScreen( onToggleSpeaker = viewModel::toggleSpeaker, onHangUp = { viewModel.stopCall() - // Don't finish activity — go back to idle } ) @@ -193,6 +249,71 @@ fun InCallScreen( } } } + + if (showAddServerDialog) { + AddServerDialog( + onDismiss = { showAddServerDialog = false }, + onAdd = { host, port, label -> + viewModel.addServer("$host:$port", label) + showAddServerDialog = false + } + ) + } +} + +@Composable +private fun AddServerDialog( + onDismiss: () -> Unit, + onAdd: (host: String, port: String, label: String) -> Unit +) { + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf("4433") } + var label by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Server") }, + text = { + Column { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host (IP or domain)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = port, + onValueChange = { port = it }, + label = { Text("Port") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text("Label (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (host.isNotBlank()) { + val displayLabel = label.ifBlank { host } + onAdd(host.trim(), port.trim(), displayLabel) + } + } + ) { Text("Add") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) } @Composable @@ -261,8 +382,6 @@ private fun QualityIndicator(tier: Int, label: String) { @Composable private fun AudioLevelBar(audioLevel: Int) { - // audioLevel is RMS of i16 samples (0-32767). - // Map to 0.0-1.0 with a log-ish curve for better visual feel. val level = if (audioLevel > 0) { (audioLevel.toFloat() / 8000f).coerceIn(0.02f, 1f) } else { diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index 6f41d6e..9321cdb 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -15,6 +15,7 @@ use std::time::Instant; use bytes::Bytes; use tracing::{error, info, warn}; +use wzp_codec::agc::AutoGainControl; use wzp_codec::opus_dec::OpusDecoder; use wzp_codec::opus_enc::OpusEncoder; use wzp_crypto::{KeyExchange, WarzoneKeyExchange}; @@ -275,10 +276,14 @@ async fn run_call( let mut fec_enc = wzp_fec::create_encoder(&profile); let mut fec_dec = wzp_fec::create_decoder(&profile); + // AGC: normalize volume on both capture and playout paths + let mut capture_agc = AutoGainControl::new(); + let mut playout_agc = AutoGainControl::new(); + info!( fec_ratio = profile.fec_ratio, frames_per_block = profile.frames_per_block, - "codec + FEC initialized (48kHz mono, 20ms frames, RaptorQ)" + "codec + FEC + AGC initialized (48kHz mono, 20ms frames)" ); let seq = AtomicU16::new(0); @@ -310,6 +315,9 @@ async fn run_call( continue; } + // AGC: normalize capture volume before encoding + capture_agc.process_frame(&mut capture_buf); + // Opus encode let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) { Ok(n) => n, @@ -440,12 +448,15 @@ async fn run_call( if !is_repair { match decoder.decode(&pkt.payload, &mut decode_buf) { Ok(samples) => { + // AGC on playout — normalizes received audio volume + playout_agc.process_frame(&mut decode_buf[..samples]); state.playout_ring.write(&decode_buf[..samples]); frames_decoded += 1; } Err(e) => { warn!("opus decode error: {e}"); if let Ok(samples) = decoder.decode_lost(&mut decode_buf) { + playout_agc.process_frame(&mut decode_buf[..samples]); state.playout_ring.write(&decode_buf[..samples]); } } diff --git a/wzp-release.apk b/wzp-release.apk index 1635871..6fe05cb 100644 Binary files a/wzp-release.apk and b/wzp-release.apk differ