feat: full quality tiers + slider UI + key-change warning on Android
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:
@@ -109,10 +109,15 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _debugRecording = MutableStateFlow(false)
|
||||
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)
|
||||
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. */
|
||||
private val _debugReportAvailable = MutableStateFlow(false)
|
||||
val debugReportAvailable: StateFlow<Boolean> = _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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user