From 53f8bf8fff9c7eccc5cb9f6de2d0036db1081014 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Tue, 7 Apr 2026 19:11:29 +0400 Subject: [PATCH] feat: full quality tiers + slider UI + key-change warning on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Wire protocol: add Opus 32k/48k/64k (CodecId 6/7/8) + STUDIO profiles with is_opus() helper. Opus enc/dec accept all Opus variants. 2. JNI bridge: expand profile_from_int to 7 levels (0-6) mapping to GOOD, DEGRADED, CATASTROPHIC, Codec2_3200, STUDIO_32K/48K/64K. 3. Settings UI: replace radio buttons with Material3 Slider — 7 stops from Studio 64k (green) to Codec2 1.2k (dark red), matching desktop. 4. Key-change warning: AlertDialog on connect when server fingerprint has changed. Shows old vs new fingerprint, Accept New Key or Cancel. Accepting saves the new fingerprint and proceeds with the call. 5. Engine recv: handle studio codec IDs in auto-switch path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/wzp/ui/call/CallViewModel.kt | 35 +++++++++++- .../main/java/com/wzp/ui/call/InCallScreen.kt | 45 +++++++++++++++ .../com/wzp/ui/settings/SettingsScreen.kt | 55 +++++++++++++------ crates/wzp-android/src/engine.rs | 3 + crates/wzp-android/src/jni_bridge.rs | 14 ++++- crates/wzp-codec/src/opus_dec.rs | 2 +- crates/wzp-codec/src/opus_enc.rs | 2 +- crates/wzp-proto/src/codec_id.rs | 48 +++++++++++++++- 8 files changed, 178 insertions(+), 26 deletions(-) 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;