Apply developer settings only after accepted persistence completes

This commit is contained in:
evgeny committed 2026-10-29 13:35:19 +01:00
1 parent a9495de90f
commit e8aeaaf365
1 file changed
+71 -40
@@ -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))