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 03a9fae3..4c43446c 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 @@ -92,6 +92,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { catch (e: CancellationException) { throw e } catch (e: Exception) { loadFailed = true; LogManager.addLog("ERROR", "Settings", "Settings load failed: $e") } } + BackHandler(enabled = !loaded, onBack = onBack) if (!loaded) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { TextButton(onClick = onBack) { Text(stringResource(R.string.back)) } @@ -338,10 +339,13 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, fun saveUdpLogSettings() { val settings = UdpLogSettings(logUdpEnabled, logUdpIp, logUdpPort.toIntOrNull() ?: 0) - provider.saveUdpLogSettings(settings) - NativeLib.setUdpLogTarget(if (settings.active) settings.ip else "", if (settings.active) settings.port else 0) - LogManager.addLog(if (settings.enabled && !settings.active) "WARN" else "INFO", "UDPLog", - "Network logging enabled=${settings.enabled} active=${settings.active} target=${settings.ip}:${settings.port}") + val request = provider.saveUdpLogSettings(settings) + provider.changes.submit("network logging") { + request.await() + NativeLib.setUdpLogTarget(if (settings.active) settings.ip else "", if (settings.active) settings.port else 0) + LogManager.addLog(if (settings.enabled && !settings.active) "WARN" else "INFO", "UDPLog", + "Network logging enabled=${settings.enabled} active=${settings.active} target=${settings.ip}:${settings.port}") + } } LaunchedEffect(Unit) { @@ -354,22 +358,29 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, logFile = provider.getString("debug.log_file") ?: "utun.log" val udpLog = try { provider.getUdpLogSettings() } catch (e: CancellationException) { throw e } - catch (e: Exception) { LogManager.addLog("ERROR", "UDPLog", "Settings load failed; network logging disabled: $e"); UdpLogSettings() } - logUdpEnabled = udpLog.enabled; logUdpIp = udpLog.ip; logUdpPort = udpLog.port.toString() - logUdpLoaded = true - LogManager.addLog("INFO", "UDPLog", "Settings loaded enabled=${udpLog.enabled} active=${udpLog.active}") + catch (e: Exception) { provider.changes.reportFailure("load network logging", e); null } + if (udpLog != null) { + logUdpEnabled = udpLog.enabled; logUdpIp = udpLog.ip; logUdpPort = udpLog.port.toString() + logUdpLoaded = true + LogManager.addLog("INFO", "UDPLog", "Settings loaded enabled=${udpLog.enabled} active=${udpLog.active}") + } standbyActiveSec = provider.getInt("chat.standby_active_sec").toString() standbySleepSec = provider.getInt("chat.standby_sleep_sec").toString() standbyMinSleepSec = provider.getInt("chat.standby_min_sleep_sec").toString() standbyKeepaliveMs = provider.getInt("chat.standby_keepalive_ms").toString() val stored = provider.getString("debug.categories") ?: "" - catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap() + 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() try { servers = provider.loadServersFromConfig().await() + serversLoaded = true } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "load network settings failed: ${e.javaClass.simpleName}: ${e.message}") configFailure = true - } finally { serversLoaded = true } + } availableIfaces = allIface.drop(1) connectionMode = provider.getConnectionMode() autoSocketProtocols = provider.getAutoSocketProtocols() @@ -381,40 +392,46 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, servers = s val revision = ++serverSaveRevision val request = provider.saveServersToConfig(s) - scope.launch { + provider.changes.submit("manual sockets") { try { request.await() } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "save network settings failed: ${e.javaClass.simpleName}: ${e.message}") if (revision == serverSaveRevision) configFailure = true + throw e } } } val switchMode = { mode: String -> - connectionMode = mode; onDirty(true) - scope.launch(Dispatchers.IO) { + onDirty(true) + provider.changes.submit("connection mode") { try { provider.setConnectionMode(mode) + connectionMode = mode if (NativeLib.isReady()) check(NativeLib.restart(provider.getString("db.path") ?: "", provider.getInt("control.port"), provider.buildConfigText().await())) { "core restart rejected connection mode" } } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "connection mode change failed: ${e.javaClass.simpleName}: ${e.message}") withContext(Dispatchers.Main) { configFailure = true } + throw e } } } val setDebug = { level: String -> - NativeLib.setDebugLevel("", level) - scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level + provider.changes.submit("debug level") { + provider.setValue("debug.console_level", level) + NativeLib.setDebugLevel("", level); debugLevel = level + } } val setCatLevel = { cat: String, lvl: String -> val updated = catLevels.toMutableMap() if (lvl == "") updated.remove(cat) else updated[cat] = lvl - catLevels = updated val csv = updated.entries.joinToString(",") { "${it.key}=${it.value}" } - scope.launch { provider.setValue("debug.categories", csv) } - if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE") else NativeLib.setDebugLevel(cat, lvl) + provider.changes.submit("debug category") { + provider.setValue("debug.categories", csv); catLevels = updated + NativeLib.setDebugLevel(cat, lvl.ifEmpty { "NONE" }) + } } var ctrlExpanded by remember { mutableStateOf(false) } @@ -437,7 +454,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, /* Node */ item(key = "node-title") { Text(stringResource(R.string.node), style = MaterialTheme.typography.titleMedium) } item(key = "listen-port") { - OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, + OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; provider.changes.submit("listen port") { provider.setValue("node.listen_port", v) } }, label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) } @@ -454,13 +471,13 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, ctrlIfaces.forEach { (n, ip) -> DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true) - scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false + provider.changes.submit("control address") { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false }) } } } Spacer(Modifier.width(8.dp)) - OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } }, + OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; provider.changes.submit("control port") { provider.setValue("control.port", v) } }, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } @@ -505,7 +522,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, onClick = { if (autoSocketProtocols != protocols) { savingSocketProtocols = true - scope.launch { + provider.changes.submit("socket protocols") { try { withContext(Dispatchers.IO) { provider.setAutoSocketProtocols(protocols) @@ -519,6 +536,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "socket protocol change failed: ${e.message}") configFailure = true + throw e } finally { savingSocketProtocols = false } } } @@ -625,12 +643,14 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField(standbyActiveSec, { v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) { - scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } }, + provider.changes.submit("standby active") { provider.setValue("chat.standby_active_sec", v) + if (NativeLib.isReady()) NativeLib.setChatSetting("standby_active_sec", v) } } }, label = { Text(stringResource(R.string.standby_active_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) OutlinedTextField(standbySleepSec, { v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) { - scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } }, + provider.changes.submit("standby sleep") { provider.setValue("chat.standby_sleep_sec", v) + if (NativeLib.isReady()) NativeLib.setChatSetting("standby_sleep_sec", v) } } }, label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } @@ -638,14 +658,16 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, item(key = "standby-min-sleep") { OutlinedTextField(standbyMinSleepSec, { v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) { - scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } }, + provider.changes.submit("standby minimum sleep") { provider.setValue("chat.standby_min_sleep_sec", v) + if (NativeLib.isReady()) NativeLib.setChatSetting("standby_min_sleep_sec", v) } } }, label = { Text(stringResource(R.string.standby_min_sleep_seconds)) }, modifier = Modifier.heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } item(key = "standby-keepalive") { OutlinedTextField(standbyKeepaliveMs, { v -> standbyKeepaliveMs = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1000..600000) { - scope.launch { provider.setValue("chat.standby_keepalive_ms", v) }; NativeLib.setChatSetting("standby_keepalive_ms", v) } }, + provider.changes.submit("standby keepalive") { provider.setValue("chat.standby_keepalive_ms", v) + if (NativeLib.isReady()) NativeLib.setChatSetting("standby_keepalive_ms", v) } } }, label = { Text(stringResource(R.string.standby_keepalive_ms)) }, modifier = Modifier.heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } @@ -658,10 +680,11 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, Row(verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f)) Switch(checked = autoConnect, onCheckedChange = { - autoConnect = it val s = if (it) "1" else "0" - scope.launch { provider.setValue("chat.group_autoconnect", s) } - NativeLib.setChatSetting("group_autoconnect", s) + provider.changes.submit("group autoconnect") { + provider.setValue("chat.group_autoconnect", s); autoConnect = s == "1" + if (NativeLib.isReady()) NativeLib.setChatSetting("group_autoconnect", s) + } }) } } @@ -672,7 +695,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, item(key = "debug-title") { Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium) } item(key = "debug-level") { DebugLevelDropdown(label = stringResource(R.string.global_level), current = debugLevel.uppercase(), onSelect = setDebug) } item(key = "log-file") { - OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, + OutlinedTextField(logFile, { v -> logFile = v; provider.changes.submit("log file") { provider.setValue("debug.log_file", v) } }, label = { Text(stringResource(R.string.log_file)) }, singleLine = true) } item(key = "log-udp-enabled") { @@ -727,7 +750,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, Switch(checked = startOnBoot, enabled = !savingStartOnBoot, onCheckedChange = { enabled -> savingStartOnBoot = true startOnBootError = false - scope.launch { + provider.changes.submit("start on boot") { try { provider.setStartOnBoot(enabled) } catch (e: CancellationException) { @@ -735,6 +758,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, } catch (e: Exception) { LogManager.addLog("ERROR", "CONFIG", "Start on boot setting save failed: enabled=$enabled error=$e") startOnBootError = true + throw e } finally { savingStartOnBoot = false } @@ -751,13 +775,17 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) Button(onClick = { - runBlocking { provider.setServiceEnabled(true) } - ContextCompat.startForegroundService(ChatApplication.instance, - Intent(ChatApplication.instance, HeadlessService::class.java)) + provider.changes.submit("start core service") { + provider.setServiceEnabled(true) + checkNotNull(ContextCompat.startForegroundService(ChatApplication.instance, + Intent(ChatApplication.instance, HeadlessService::class.java))) { "Core service component unavailable" } + } }, enabled = !serviceEnabled) { Text(stringResource(R.string.service_start)) } Button(onClick = { - runBlocking { provider.setServiceEnabled(false) } - ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) + provider.changes.submit("stop core service") { + provider.setServiceEnabled(false) + ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) + } }, enabled = serviceEnabled, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) } } @@ -767,7 +795,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, item(key = "restart") { Button(onClick = { - scope.launch(Dispatchers.IO) { + provider.changes.submit("restart core") { try { if (NativeLib.isReady()) { val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") @@ -777,6 +805,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "restart failed: ${e.javaClass.simpleName}: ${e.message}") withContext(Dispatchers.Main) { configFailure = true } + throw e } } }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { @@ -786,9 +815,11 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, item(key = "exit-spacer") { Spacer(Modifier.height(8.dp)) } item(key = "exit") { Button(onClick = { - runBlocking { provider.setServiceEnabled(false) } - ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) - onExit() + provider.changes.submit("stop service and exit") { + provider.setServiceEnabled(false) + ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) + onExit() + } }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.stop_all_exit))