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 4caa5e9..5e7eae2 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 @@ -109,10 +109,15 @@ class CallViewModel : ViewModel(), WzpCallback { private val _debugRecording = MutableStateFlow(false) val debugRecording: StateFlow = _debugRecording.asStateFlow() - // 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC) + // Quality profile index (matches JNI bridge profile_from_int) private val _codecChoice = MutableStateFlow(0) val codecChoice: StateFlow = _codecChoice.asStateFlow() + /** Key-change warning dialog state. */ + data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String) + private val _keyWarning = MutableStateFlow(null) + val keyWarning: StateFlow = _keyWarning.asStateFlow() + /** True when a call just ended and debug report can be sent. */ private val _debugReportAvailable = MutableStateFlow(false) val debugReportAvailable: StateFlow = _debugReportAvailable.asStateFlow() @@ -385,7 +390,35 @@ class CallViewModel : ViewModel(), WzpCallback { Log.i(TAG, "teardown: done") } + /** Accept the new server key and proceed with the call. */ + fun acceptNewFingerprint() { + val info = _keyWarning.value ?: return + _knownFingerprints.value = _knownFingerprints.value.toMutableMap().also { + it[info.address] = info.newFp + } + settings?.saveServerFingerprint(info.address, info.newFp) + _keyWarning.value = null + startCallInternal() + } + + fun dismissKeyWarning() { + _keyWarning.value = null + } + fun startCall() { + val serverEntry = _servers.value[_selectedServer.value] + // Check for key change before connecting + val ls = lockStatus(serverEntry.address) + if (ls == LockStatus.CHANGED) { + val known = _knownFingerprints.value[serverEntry.address] ?: "" + val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: "" + _keyWarning.value = KeyWarningInfo(serverEntry.address, known, current) + return + } + startCallInternal() + } + + private fun startCallInternal() { val serverEntry = _servers.value[_selectedServer.value] val room = _roomName.value Log.i(TAG, "startCall: server=${serverEntry.address} room=$room") 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 1774614..b4df9fe 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 @@ -45,6 +45,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -89,6 +90,50 @@ fun InCallScreen( val pingResults by viewModel.pingResults.collectAsState() var showManageRelays by remember { mutableStateOf(false) } + val keyWarning by viewModel.keyWarning.collectAsState() + + // Key-change warning dialog + keyWarning?.let { info -> + AlertDialog( + onDismissRequest = { viewModel.dismissKeyWarning() }, + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text("\u26A0\uFE0F", fontSize = 40.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Server Key Changed", fontWeight = FontWeight.Bold) + } + }, + text = { + Column { + Text( + "The relay's identity has changed since you last connected. " + + "This usually happens when the server was restarted.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + Text("Previously known", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(info.oldFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(8.dp)) + Text("New key", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(info.newFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + Button( + onClick = { viewModel.acceptNewFingerprint() }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFACC15)) + ) { + Text("Accept New Key", color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissKeyWarning() }) { + Text("Cancel") + } + } + ) + } // Ping once on launch, then every 5 minutes LaunchedEffect(Unit) { diff --git a/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt index ce5d32f..41e1d95 100644 --- a/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt @@ -50,6 +50,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.wzp.ui.call.CallViewModel import com.wzp.ui.call.ServerEntry @@ -245,31 +248,47 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(12.dp)) - // Codec selection - val codecNames = listOf("Opus 24k (Best)", "Opus 6k (Low BW)", "Codec2 1.2k (Minimal)") + // Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + val qualityLabels = listOf( + "Studio 64k", "Studio 48k", "Studio 32k", "Opus 24k", + "Opus 6k", "Codec2 1.2k", "Codec2 3.2k" + ) + // Map slider position to JNI profile int: + // 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Opus24k(0), + // 4=Opus6k(1), 5=Codec2_1.2k(2), 6=Codec2_3.2k(3) + val sliderToProfile = intArrayOf(6, 5, 4, 0, 1, 2, 3) + val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 0 to 3, 1 to 4, 2 to 5, 3 to 6) + val qualityColors = listOf( + Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635), + Color(0xFFFACC15), Color(0xFF991B1B), Color(0xFFE97320) + ) val currentCodec by viewModel.codecChoice.collectAsState() - Text("Encode Codec", style = MaterialTheme.typography.bodyMedium) + val sliderPos = profileToSlider[currentCodec] ?: 3 + Text("Quality", style = MaterialTheme.typography.bodyMedium) Text( text = "Decode always accepts all codecs", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) - codecNames.forEachIndexed { idx, name -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { viewModel.setCodecChoice(idx) } - .padding(vertical = 4.dp) - ) { - RadioButton( - selected = currentCodec == idx, - onClick = { viewModel.setCodecChoice(idx) } - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(name, style = MaterialTheme.typography.bodyMedium) - } + Text( + text = qualityLabels[sliderPos], + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = qualityColors[sliderPos] + ) + Slider( + value = sliderPos.toFloat(), + onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) }, + valueRange = 0f..6f, + steps = 5, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E)) + Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B)) } Spacer(modifier = Modifier.height(24.dp)) diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index 54785f6..18450be 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -615,6 +615,9 @@ async fn run_call( let switch_profile = match pkt.header.codec_id { CodecId::Opus24k => QualityProfile::GOOD, CodecId::Opus6k => QualityProfile::DEGRADED, + CodecId::Opus32k => QualityProfile::STUDIO_32K, + CodecId::Opus48k => QualityProfile::STUDIO_48K, + CodecId::Opus64k => QualityProfile::STUDIO_64K, CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC, CodecId::Codec2_3200 => QualityProfile { codec: CodecId::Codec2_3200, diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index 1f9848d..b599115 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -23,8 +23,18 @@ unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle { fn profile_from_int(value: jint) -> QualityProfile { match value { - 1 => QualityProfile::DEGRADED, - 2 => QualityProfile::CATASTROPHIC, + 0 => QualityProfile::GOOD, // Opus 24k + 1 => QualityProfile::DEGRADED, // Opus 6k + 2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k + 3 => QualityProfile { // Codec2 3.2k + codec: wzp_proto::CodecId::Codec2_3200, + fec_ratio: 0.5, + frame_duration_ms: 20, + frames_per_block: 5, + }, + 4 => QualityProfile::STUDIO_32K, // Opus 32k + 5 => QualityProfile::STUDIO_48K, // Opus 48k + 6 => QualityProfile::STUDIO_64K, // Opus 64k _ => QualityProfile::GOOD, } } diff --git a/crates/wzp-codec/src/opus_dec.rs b/crates/wzp-codec/src/opus_dec.rs index 36593af..c8b6cd4 100644 --- a/crates/wzp-codec/src/opus_dec.rs +++ b/crates/wzp-codec/src/opus_dec.rs @@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder { fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> { match profile.codec { - CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => { + c if c.is_opus() => { self.codec_id = profile.codec; self.frame_duration_ms = profile.frame_duration_ms; Ok(()) diff --git a/crates/wzp-codec/src/opus_enc.rs b/crates/wzp-codec/src/opus_enc.rs index 41534de..1a5dca1 100644 --- a/crates/wzp-codec/src/opus_enc.rs +++ b/crates/wzp-codec/src/opus_enc.rs @@ -100,7 +100,7 @@ impl AudioEncoder for OpusEncoder { fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> { match profile.codec { - CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => { + c if c.is_opus() => { self.codec_id = profile.codec; self.frame_duration_ms = profile.frame_duration_ms; self.apply_bitrate(profile.codec)?; diff --git a/crates/wzp-proto/src/codec_id.rs b/crates/wzp-proto/src/codec_id.rs index 2c09cc5..d90c3a0 100644 --- a/crates/wzp-proto/src/codec_id.rs +++ b/crates/wzp-proto/src/codec_id.rs @@ -18,6 +18,12 @@ pub enum CodecId { Codec2_1200 = 4, /// Comfort noise descriptor (silence suppression) ComfortNoise = 5, + /// Opus at 32kbps (studio low) + Opus32k = 6, + /// Opus at 48kbps (studio) + Opus48k = 7, + /// Opus at 64kbps (studio high) + Opus64k = 8, } impl CodecId { @@ -27,6 +33,9 @@ impl CodecId { Self::Opus24k => 24_000, Self::Opus16k => 16_000, Self::Opus6k => 6_000, + Self::Opus32k => 32_000, + Self::Opus48k => 48_000, + Self::Opus64k => 64_000, Self::Codec2_3200 => 3_200, Self::Codec2_1200 => 1_200, Self::ComfortNoise => 0, @@ -36,8 +45,7 @@ impl CodecId { /// Preferred frame duration in milliseconds. pub const fn frame_duration_ms(self) -> u8 { match self { - Self::Opus24k => 20, - Self::Opus16k => 20, + Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20, Self::Opus6k => 40, Self::Codec2_3200 => 20, Self::Codec2_1200 => 40, @@ -48,7 +56,8 @@ impl CodecId { /// Sample rate expected by this codec. pub const fn sample_rate_hz(self) -> u32 { match self { - Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000, + Self::Opus24k | Self::Opus16k | Self::Opus6k + | Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000, Self::Codec2_3200 | Self::Codec2_1200 => 8_000, Self::ComfortNoise => 48_000, } @@ -63,6 +72,9 @@ impl CodecId { 3 => Some(Self::Codec2_3200), 4 => Some(Self::Codec2_1200), 5 => Some(Self::ComfortNoise), + 6 => Some(Self::Opus32k), + 7 => Some(Self::Opus48k), + 8 => Some(Self::Opus64k), _ => None, } } @@ -71,6 +83,12 @@ impl CodecId { pub const fn to_wire(self) -> u8 { self as u8 } + + /// Returns true if this is an Opus variant. + pub const fn is_opus(self) -> bool { + matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k + | Self::Opus32k | Self::Opus48k | Self::Opus64k) + } } /// Describes the complete quality configuration for a call session. @@ -111,6 +129,30 @@ impl QualityProfile { frames_per_block: 8, }; + /// Studio low: Opus 32kbps, minimal FEC. + pub const STUDIO_32K: Self = Self { + codec: CodecId::Opus32k, + fec_ratio: 0.1, + frame_duration_ms: 20, + frames_per_block: 5, + }; + + /// Studio: Opus 48kbps, minimal FEC. + pub const STUDIO_48K: Self = Self { + codec: CodecId::Opus48k, + fec_ratio: 0.1, + frame_duration_ms: 20, + frames_per_block: 5, + }; + + /// Studio high: Opus 64kbps, minimal FEC. + pub const STUDIO_64K: Self = Self { + codec: CodecId::Opus64k, + fec_ratio: 0.1, + frame_duration_ms: 20, + frames_per_block: 5, + }; + /// Estimated total bandwidth in kbps including FEC overhead. pub fn total_bitrate_kbps(&self) -> f32 { let base = self.codec.bitrate_bps() as f32 / 1000.0;