From 013ff934065c3d9890fc720980165e2a93150367 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 11:28:08 +0100 Subject: [PATCH] Load audio and network settings on IO and document persistent change ownership --- .../com/utun/chat/data/SettingsOperations.kt | 4 +-- .../utun/chat/ui/screens/SettingsScreen.kt | 34 ++++++++++++++++--- tools/chatgui-android/doc/ARCHITECTURE.md | 9 +++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/SettingsOperations.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/SettingsOperations.kt index 86e8d560..2de4d91a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/SettingsOperations.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/SettingsOperations.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -/** Очередь Application на main: сохранить выбор, затем применить runtime без зависимости от UI scope. +/** Очередь Application на main: сохранить выбор, затем отправить runtime-обновление без зависимости от UI scope. * submit принимает неизменный снимок аргументов caller; отмена await не отменяет запись. * awaitIdle ставит барьер после принятых изменений перед формированием restart-конфига. */ class SettingsOperations(private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), @@ -28,7 +28,7 @@ class SettingsOperations(private val scope: CoroutineScope = CoroutineScope(Supe mutex.withLock { try { operation() - log("INFO", "Setting applied name=$name") + log("INFO", "Settings operation completed name=$name") } catch (e: CancellationException) { throw e } catch (e: Exception) { reportFailure(name, e); throw e } } 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 0cef3e89..64eb760f 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 @@ -362,7 +362,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, listenPort = provider.getInt("node.listen_port").toString() controlPort = provider.getInt("control.port").toString() controlBindIp = provider.getString("control.bind_ip") ?: "127.0.0.1" - val allIface = listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() + val allIface = listOf("lo" to "127.0.0.1") + withContext(Dispatchers.IO) { provider.getAvailableInterfaces() } ctrlBindLabel = allIface.firstOrNull { it.second == controlBindIp }?.let { "${it.first} (${it.second})" } ?: "lo (127.0.0.1)" debugLevel = provider.getString("debug.console_level") ?: "info" logFile = provider.getString("debug.log_file") ?: "utun.log" @@ -1177,10 +1177,34 @@ private fun RadioSettingsTab() { /* ── Audio Settings Tab ── */ +private data class AudioSettingsSnapshot(val voice: VoiceSettings, val bitrate: CallVideoBitrateRange, + val jitterMs: Int, val sound: Boolean) + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AudioSettingsTab(vm: ChatViewModel) { val provider = ChatApplication.instance.configProvider + var snapshot by remember { mutableStateOf(null) } + var loadFailed by remember { mutableStateOf(false) } + var loadAttempt by remember { mutableIntStateOf(0) } + LaunchedEffect(loadAttempt) { + loadFailed = false + try { + snapshot = withContext(Dispatchers.IO) { AudioSettingsSnapshot(provider.getVoiceSettings(), provider.getCallVideoBitrateRange(), + provider.getInt("chat.call_jitter_max_reserve_ms").coerceIn(500, 2000), provider.getSoundEnabled()) } + } catch (e: CancellationException) { throw e } + catch (e: Exception) { loadFailed = true; provider.changes.reportFailure("load audio settings", e) } + } + val settingsSnapshot = snapshot + if (settingsSnapshot == null) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (loadFailed) { + Text(stringResource(R.string.settings_update_failed), color = MaterialTheme.colorScheme.error) + Button(onClick = { loadAttempt++ }) { Text(stringResource(R.string.retry)) } + } else CircularProgressIndicator() + } + return + } /* Mic test state */ var micTesting by remember { mutableStateOf(false) } @@ -1192,7 +1216,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) { } /* Notification sound */ - var soundEnabled by remember { mutableStateOf(provider.getSoundEnabled()) } + var soundEnabled by remember { mutableStateOf(settingsSnapshot.sound) } LaunchedEffect(micTesting) { try { while (micTesting && vm.audioRecorder.isTesting) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) } } @@ -1203,12 +1227,12 @@ private fun AudioSettingsTab(vm: ChatViewModel) { /* Codec preset */ val presets = listOf(stringResource(R.string.codec_low), stringResource(R.string.codec_standard), stringResource(R.string.codec_high)) - val savedVoiceSettings = remember { provider.getVoiceSettings() } + val savedVoiceSettings = settingsSnapshot.voice var preset by remember { mutableStateOf(savedVoiceSettings.preset) } var presetExpanded by remember { mutableStateOf(false) } - var jitterReserveMs by remember { mutableStateOf(provider.getInt("chat.call_jitter_max_reserve_ms")) } - val savedVideoRange = remember { provider.getCallVideoBitrateRange() } + var jitterReserveMs by remember { mutableStateOf(settingsSnapshot.jitterMs) } + val savedVideoRange = settingsSnapshot.bitrate var videoMinimum by rememberSaveable { mutableIntStateOf(savedVideoRange.minimum) } var videoMaximum by rememberSaveable { mutableIntStateOf(savedVideoRange.maximum) } var videoRangeSaving by remember { mutableStateOf(false) } diff --git a/tools/chatgui-android/doc/ARCHITECTURE.md b/tools/chatgui-android/doc/ARCHITECTURE.md index bb97393a..17c0dc94 100644 --- a/tools/chatgui-android/doc/ARCHITECTURE.md +++ b/tools/chatgui-android/doc/ARCHITECTURE.md @@ -292,6 +292,15 @@ Metadata читаются в IO через отдельный владелец r к прежним значениям при выходе. VideoFilePlayerTest/VideoFileMetadataTest проверяют эти контракты на управляемых SDK-границах; аппаратное декодирование в JVM не выполняется. +ConfigProvider владеет SettingsOperations: изменения с неизменным снимком аргументов +принимаются на main, последовательно сохраняются и затем отправляют runtime-команды. +Закрытие/пересоздание Settings не отменяет уже принятые записи. Back перед restart и +открытие вкладки ждут очередь; ошибка записи остаётся видимой и не отправляет live-обновление. +Ошибка Flow чтения сохраняет последнее значение и повторяет подписку с backoff. +Первоначальный отказ чтения не открывает редактор с defaults; тяжёлые audio/network чтения идут через IO. +Тест микрофона принадлежит своей вкладке: dispose запрашивает асинхронный cleanup, +поздняя выдача разрешения не запускает захват после ухода UI. Очистка теста не отменяет запись сообщения. + TelecomCallManager на main регистрирует self-managed account и connections по call ID; lookup разрешён workers. Поздний/дублирующий Connection не заменяет живого владельца и не возвращается framework как новый активный вызов. Ошибки markDisconnected не прерывают destroy. Неудавшийся destroy сохраняет отдельного retiring owner