Load audio and network settings on IO and document persistent change ownership

This commit is contained in:
evgeny committed 2026-10-29 13:35:41 +01:00
1 parent 30648086b4
commit 013ff93406
3 files changed
+40 -7

No files matched your search

@@ -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 }
}
@@ -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<AudioSettingsSnapshot?>(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) }
@@ -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