feat: full quality tiers + slider UI + key-change warning on Android
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 1m52s

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-07 19:11:29 +04:00
parent b3cdad0c75
commit 53f8bf8fff
8 changed files with 178 additions and 26 deletions

View File

@@ -109,10 +109,15 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _debugRecording = MutableStateFlow(false) private val _debugRecording = MutableStateFlow(false)
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow() val debugRecording: StateFlow<Boolean> = _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) private val _codecChoice = MutableStateFlow(0)
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow() val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
/** Key-change warning dialog state. */
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
/** True when a call just ended and debug report can be sent. */ /** True when a call just ended and debug report can be sent. */
private val _debugReportAvailable = MutableStateFlow(false) private val _debugReportAvailable = MutableStateFlow(false)
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow() val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
@@ -385,7 +390,35 @@ class CallViewModel : ViewModel(), WzpCallback {
Log.i(TAG, "teardown: done") 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() { 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 serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value val room = _roomName.value
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room") Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -89,6 +90,50 @@ fun InCallScreen(
val pingResults by viewModel.pingResults.collectAsState() val pingResults by viewModel.pingResults.collectAsState()
var showManageRelays by remember { mutableStateOf(false) } 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 // Ping once on launch, then every 5 minutes
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View File

@@ -50,6 +50,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.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 androidx.compose.ui.unit.dp
import com.wzp.ui.call.CallViewModel import com.wzp.ui.call.CallViewModel
import com.wzp.ui.call.ServerEntry import com.wzp.ui.call.ServerEntry
@@ -245,31 +248,47 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Codec selection // Quality selection — slider from best (studio 64k) to worst (codec2 1.2k)
val codecNames = listOf("Opus 24k (Best)", "Opus 6k (Low BW)", "Codec2 1.2k (Minimal)") 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() 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(
text = "Decode always accepts all codecs", text = "Decode always accepts all codecs",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
codecNames.forEachIndexed { idx, name -> Text(
Row( text = qualityLabels[sliderPos],
verticalAlignment = Alignment.CenterVertically, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier color = qualityColors[sliderPos]
.fillMaxWidth()
.clickable { viewModel.setCodecChoice(idx) }
.padding(vertical = 4.dp)
) {
RadioButton(
selected = currentCodec == idx,
onClick = { viewModel.setCodecChoice(idx) }
) )
Spacer(modifier = Modifier.width(8.dp)) Slider(
Text(name, style = MaterialTheme.typography.bodyMedium) 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)) Spacer(modifier = Modifier.height(24.dp))

View File

@@ -615,6 +615,9 @@ async fn run_call(
let switch_profile = match pkt.header.codec_id { let switch_profile = match pkt.header.codec_id {
CodecId::Opus24k => QualityProfile::GOOD, CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus6k => QualityProfile::DEGRADED, 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_1200 => QualityProfile::CATASTROPHIC,
CodecId::Codec2_3200 => QualityProfile { CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200, codec: CodecId::Codec2_3200,

View File

@@ -23,8 +23,18 @@ unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
fn profile_from_int(value: jint) -> QualityProfile { fn profile_from_int(value: jint) -> QualityProfile {
match value { match value {
1 => QualityProfile::DEGRADED, 0 => QualityProfile::GOOD, // Opus 24k
2 => QualityProfile::CATASTROPHIC, 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, _ => QualityProfile::GOOD,
} }
} }

View File

@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> { fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec { match profile.codec {
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => { c if c.is_opus() => {
self.codec_id = profile.codec; self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms; self.frame_duration_ms = profile.frame_duration_ms;
Ok(()) Ok(())

View File

@@ -100,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> { fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec { match profile.codec {
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => { c if c.is_opus() => {
self.codec_id = profile.codec; self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms; self.frame_duration_ms = profile.frame_duration_ms;
self.apply_bitrate(profile.codec)?; self.apply_bitrate(profile.codec)?;

View File

@@ -18,6 +18,12 @@ pub enum CodecId {
Codec2_1200 = 4, Codec2_1200 = 4,
/// Comfort noise descriptor (silence suppression) /// Comfort noise descriptor (silence suppression)
ComfortNoise = 5, ComfortNoise = 5,
/// Opus at 32kbps (studio low)
Opus32k = 6,
/// Opus at 48kbps (studio)
Opus48k = 7,
/// Opus at 64kbps (studio high)
Opus64k = 8,
} }
impl CodecId { impl CodecId {
@@ -27,6 +33,9 @@ impl CodecId {
Self::Opus24k => 24_000, Self::Opus24k => 24_000,
Self::Opus16k => 16_000, Self::Opus16k => 16_000,
Self::Opus6k => 6_000, Self::Opus6k => 6_000,
Self::Opus32k => 32_000,
Self::Opus48k => 48_000,
Self::Opus64k => 64_000,
Self::Codec2_3200 => 3_200, Self::Codec2_3200 => 3_200,
Self::Codec2_1200 => 1_200, Self::Codec2_1200 => 1_200,
Self::ComfortNoise => 0, Self::ComfortNoise => 0,
@@ -36,8 +45,7 @@ impl CodecId {
/// Preferred frame duration in milliseconds. /// Preferred frame duration in milliseconds.
pub const fn frame_duration_ms(self) -> u8 { pub const fn frame_duration_ms(self) -> u8 {
match self { match self {
Self::Opus24k => 20, Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
Self::Opus16k => 20,
Self::Opus6k => 40, Self::Opus6k => 40,
Self::Codec2_3200 => 20, Self::Codec2_3200 => 20,
Self::Codec2_1200 => 40, Self::Codec2_1200 => 40,
@@ -48,7 +56,8 @@ impl CodecId {
/// Sample rate expected by this codec. /// Sample rate expected by this codec.
pub const fn sample_rate_hz(self) -> u32 { pub const fn sample_rate_hz(self) -> u32 {
match self { 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::Codec2_3200 | Self::Codec2_1200 => 8_000,
Self::ComfortNoise => 48_000, Self::ComfortNoise => 48_000,
} }
@@ -63,6 +72,9 @@ impl CodecId {
3 => Some(Self::Codec2_3200), 3 => Some(Self::Codec2_3200),
4 => Some(Self::Codec2_1200), 4 => Some(Self::Codec2_1200),
5 => Some(Self::ComfortNoise), 5 => Some(Self::ComfortNoise),
6 => Some(Self::Opus32k),
7 => Some(Self::Opus48k),
8 => Some(Self::Opus64k),
_ => None, _ => None,
} }
} }
@@ -71,6 +83,12 @@ impl CodecId {
pub const fn to_wire(self) -> u8 { pub const fn to_wire(self) -> u8 {
self as 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. /// Describes the complete quality configuration for a call session.
@@ -111,6 +129,30 @@ impl QualityProfile {
frames_per_block: 8, 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. /// Estimated total bandwidth in kbps including FEC overhead.
pub fn total_bitrate_kbps(&self) -> f32 { pub fn total_bitrate_kbps(&self) -> f32 {
let base = self.codec.bitrate_bps() as f32 / 1000.0; let base = self.codec.bitrate_bps() as f32 / 1000.0;