diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index d57f2cd9..3436e1f6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -475,7 +475,7 @@ class ConfigProvider(private val context: Context) { fun getVoiceSettings(): VoiceSettings = runBlocking { val prefs = context.dataStore.data.first() val defaults = VoiceSettings() - val settings = VoiceSettings( + val raw = VoiceSettings( preset = prefs[chatOpusCodecPreset] ?: defaults.preset, compressorEnabled = (prefs[chatCompressorEnabled] ?: 1) != 0, maxGainDb = prefs[chatCompressorMaxGainDb] ?: defaults.maxGainDb, @@ -484,6 +484,10 @@ class ConfigProvider(private val context: Context) { riseRate = prefs[chatCompressorRiseRate] ?: defaults.riseRate, targetLevelDb = prefs[chatCompressorTargetLevelDb] ?: defaults.targetLevelDb ) + val settings = raw.copy(preset = raw.preset.coerceIn(0, 2), maxGainDb = raw.maxGainDb.coerceIn(0, 30), + lookbackMs = raw.lookbackMs.coerceIn(50, 500), lookaheadMs = raw.lookaheadMs.coerceIn(20, 500), + riseRate = raw.riseRate.coerceIn(2, 10), targetLevelDb = raw.targetLevelDb.coerceIn(-40, 0)) + if (raw != settings) LogManager.addLog("WARN", "AudioSettings", "Invalid saved voice settings normalized: $raw -> $settings") LogManager.addLog("INFO", "AudioSettings", "Loaded: $settings") settings } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 30d89965..54c48ad7 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -76,6 +76,12 @@ import java.io.IOException private val LEVELS = listOf("DISABLED", "NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE") private val PROTOCOLS = listOf("tcp", "udp") +private fun parseDebugPreferences(text: String): Map = text.split(',').mapNotNull { entry -> + val pair = entry.trim().split('=', limit = 2) + if (pair.size == 2 && pair[0].isNotBlank() && pair[1].uppercase() in LEVELS) pair[0].trim() to pair[1].uppercase() + else { if (entry.isNotBlank()) LogManager.addLog("WARN", "CFG", "Malformed debug category preference ignored"); null } +}.toMap() + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {}) { @@ -88,7 +94,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { var loadAttempt by remember { mutableIntStateOf(0) } LaunchedEffect(loadAttempt) { loadFailed = false - try { withContext(Dispatchers.IO) { provider.allValues.first() }; loaded = true } + try { changes.awaitIdle(); withContext(Dispatchers.IO) { provider.allValues.first() }; loaded = true } catch (e: CancellationException) { throw e } catch (e: Exception) { loadFailed = true; LogManager.addLog("ERROR", "Settings", "Settings load failed: $e") } } @@ -163,14 +169,18 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(stringResource(name), maxLines = 1) }) } } - when (tabIndex) { + key(tabIndex) { + var tabReady by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { changes.awaitIdle(); tabReady = true } + if (tabReady) when (tabIndex) { 0 -> GeneralSettingsTab(onDirty = { dirty = it }) 1 -> AudioSettingsTab(vm) 2 -> StorageSettingsTab() 3 -> RadioSettingsTab() - 4 -> StreamSettingsTab(vm) + 4 -> StreamSettingsTab() 5 -> DeveloperSettingsTab(vm, onDirty = { dirty = it }, onExit) 6 -> BluetoothSettingsTab() + } else CircularProgressIndicator(Modifier.padding(16.dp)) } } } @@ -178,7 +188,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { /* Параметры RS сохраняются для следующей эпохи трансляции. */ @Composable -private fun StreamSettingsTab(vm: ChatViewModel) { +private fun StreamSettingsTab() { val provider = ChatApplication.instance.configProvider var subblocks by rememberSaveable { mutableStateOf(provider.getInt("chat.stream_subblocks").toString()) } var redundant by rememberSaveable { mutableStateOf(provider.getInt("chat.stream_rs_redundant").toString()) } @@ -240,8 +250,11 @@ private fun GeneralSettingsTab(onDirty: (Boolean) -> Unit) { var localBroadcastsEnabled by remember { mutableStateOf(provider.getInt("chat.local_broadcasts_enabled") != 0) } LaunchedEffect(Unit) { - nodeName = provider.getString("node.name") ?: provider.getDeviceName() - LogManager.addLog("INFO", "SETTINGS", "General settings opened") + try { + nodeName = provider.getString("node.name") ?: provider.getDeviceName() + LogManager.addLog("INFO", "SETTINGS", "General settings opened") + } catch (e: CancellationException) { throw e } + catch (e: Exception) { provider.changes.reportFailure("load general settings", e) } } LazyColumn( @@ -369,11 +382,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, standbyMinSleepSec = provider.getInt("chat.standby_min_sleep_sec").toString() standbyKeepaliveMs = provider.getInt("chat.standby_keepalive_ms").toString() val stored = provider.getString("debug.categories") ?: "" - catLevels = stored.split(',').mapNotNull { entry -> - val pair = entry.trim().split('=', limit = 2) - if (pair.size == 2 && pair[0].isNotBlank() && pair[1].uppercase() in LEVELS) pair[0].trim() to pair[1].uppercase() - else { if (entry.isNotBlank()) LogManager.addLog("WARN", "CFG", "Malformed debug category preference ignored"); null } - }.toMap() + catLevels = parseDebugPreferences(stored) try { servers = provider.loadServersFromConfig().await() serversLoaded = true } catch (e: CancellationException) { throw e @@ -425,10 +434,10 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, } } val setCatLevel: (String, String) -> Unit = { cat, lvl -> - val updated = catLevels.toMutableMap() - if (lvl == "") updated.remove(cat) else updated[cat] = lvl - val csv = updated.entries.joinToString(",") { "${it.key}=${it.value}" } provider.changes.submit("debug category") { + val updated = parseDebugPreferences(provider.getString("debug.categories").orEmpty()).toMutableMap() + if (lvl.isEmpty()) updated.remove(cat) else updated[cat] = lvl + val csv = updated.entries.joinToString(",") { "${it.key}=${it.value}" } provider.setValue("debug.categories", csv); catLevels = updated NativeLib.setDebugLevel(cat, lvl.ifEmpty { "NONE" }) } @@ -815,10 +824,14 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, item(key = "exit-spacer") { Spacer(Modifier.height(8.dp)) } item(key = "exit") { Button(onClick = { - provider.changes.submit("stop service and exit") { + val request = provider.changes.submit("stop service and exit") { provider.setServiceEnabled(false) ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) - onExit() + } + scope.launch { + try { request.await(); onExit() } + catch (e: CancellationException) { throw e } + catch (e: Exception) { provider.changes.reportFailure("exit after service stop", e) } } }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { @@ -881,7 +894,7 @@ internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = } LaunchedEffect(resumed) { if (!resumed) { level = null; vadProbability = null; vadGain = null; return@LaunchedEffect } - while (true) { + try { while (true) { val status = NativeLib.radioInputStatus() active = engine.isActive() micPermission = MicrophonePermission.granted() @@ -891,6 +904,10 @@ internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = vadCompressorEnabled = status[6] != 0f blocked = status[2] != 0f delay(100) + } } catch (e: CancellationException) { throw e } + catch (e: Exception) { + level = null; vadProbability = null; vadGain = null + provider.changes.reportFailure("radio meter", e) } } if (!compact) Text(stringResource(R.string.radio_automatic), style = MaterialTheme.typography.titleMedium)