From 299518e86639cb04bd60278ef9a6465bf95ad6e2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 10:37:30 +0100 Subject: [PATCH] Preserve confirmed settings and manual socket choices after failures --- .../java/com/utun/chat/data/ConfigProvider.kt | 65 ++++++++++--------- tools/chatgui-android/doc/ARCHITECTURE.md | 4 ++ 2 files changed, 39 insertions(+), 30 deletions(-) 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 84c7da9b..f3a31abb 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 @@ -152,15 +152,6 @@ class ConfigProvider(private val context: Context) { prefs[radioCompressorMaxGainDbKey] ?: 15, prefs[chatCompressorRiseRate] ?: 6) }.distinctUntilChanged() - private fun keysFilePath(): String { - val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath - return "$dir/keys.conf" - } - - fun deleteKeysFile() { - File(keysFilePath()).delete() - } - private fun readKeysFromFile(f: File): Pair? { if (!f.exists()) return null try { @@ -394,6 +385,10 @@ class ConfigProvider(private val context: Context) { } suspend fun setValue(key: String, value: String) { + require(value.none { it == '\r' || it == '\n' || it == '\u0000' }) { + LogManager.addLog("ERROR", "CFG", "Setting rejected: control characters key=$key") + "Configuration value must fit one line" + } context.dataStore.edit { prefs -> when (key) { "gui.utun_enabled" -> prefs[guiUtunEnabled] = if (value == "1" || value == "yes" || value == "true") 1 else 0 @@ -435,8 +430,13 @@ class ConfigProvider(private val context: Context) { "radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0 "radio.compressor_enabled" -> prefs[radioCompressorEnabledKey] = value.toIntOrNull() ?: 1 "radio.compressor_max_gain_db" -> prefs[radioCompressorMaxGainDbKey] = value.toIntOrNull() ?: 15 + else -> { + LogManager.addLog("ERROR", "CFG", "Unknown setting rejected key=$key") + throw IllegalArgumentException("Unknown configuration key: $key") + } } } + LogManager.addLog("INFO", "CFG", "Setting saved key=$key") } private fun radioAutoSettings(prefs: Preferences) = RadioAutoSettings( @@ -500,12 +500,8 @@ class ConfigProvider(private val context: Context) { LogManager.addLog("INFO", "AudioSettings", "Saved: $settings") } - suspend fun generateKeys() { - /* Keys are generated in C (instance_lite via ensure_keys). - * They are saved back via NativeLib.start() → nativeGetGeneratedKeys(). */ - } - private companion object { + private val ipv6FlagsWarning = java.util.concurrent.atomic.AtomicBoolean(false) /** Accept only global unicast (2000::/3) and unique local (fc00::/7) IPv6. */ fun isUsableV6(addr: Inet6Address): Boolean { if (addr.isLoopbackAddress || addr.isLinkLocalAddress || addr.isMulticastAddress) return false @@ -516,17 +512,16 @@ class ConfigProvider(private val context: Context) { } // 0x20 = IFA_F_TEMPORARY в /proc/net/if_inet6 - fun isTemporaryV6(ifName: String, ip: String): Boolean { - val hexAddr = ip.split("%")[0].replace(":", "").uppercase().padStart(32, '0') + fun isTemporaryV6(ifName: String, address: Inet6Address): Boolean { return try { File("/proc/net/if_inet6").useLines { lines -> - lines.any { line -> - val parts = line.trim().split("\\s+".toRegex(), limit = 6) - parts.size >= 6 && parts[0] == hexAddr && parts[5] == ifName && - parts[4].toIntOrNull(16)?.let { (it and 0x20) != 0 } == true - } + temporaryIpv6Address(ifName, address.address, lines) } - } catch (_: Exception) { false } + } catch (e: Exception) { + if (ipv6FlagsWarning.compareAndSet(false, true)) LogManager.addLog("WARN", "CFG", + "IPv6 temporary flags unavailable; keeping usable IPv6 addresses: ${e.javaClass.simpleName}: ${e.message}") + false + } } } @@ -553,7 +548,12 @@ class ConfigProvider(private val context: Context) { } suspend fun setConnectionMode(mode: String) { + require(mode == "auto" || mode == "manual") { + LogManager.addLog("ERROR", "CFG", "Invalid connection mode rejected: $mode") + "Unknown connection mode" + } context.dataStore.edit { prefs -> prefs[connectionModeKey] = mode } + LogManager.addLog("INFO", "CFG", "Connection mode saved: $mode") } fun getAutoSocketProtocols(): AutoSocketProtocols = runBlocking { @@ -664,6 +664,7 @@ class ConfigProvider(private val context: Context) { LogManager.addLog("INFO", "PTT", "Volume PTT enabled=$enabled") } catch (e: IOException) { LogManager.addLog("ERROR", "PTT", "Cannot save volume PTT enabled=$enabled: $e") + throw e } } @@ -683,9 +684,10 @@ class ConfigProvider(private val context: Context) { } suspend fun setRadioOverlay(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled } radioOverlayCached = enabled radioOverlayLoaded = true - context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled } + LogManager.addLog("INFO", "radio", "Overlay setting saved enabled=$enabled") } @Volatile private var radioMicSourceCached = 0 @@ -700,9 +702,10 @@ class ConfigProvider(private val context: Context) { } suspend fun setRadioMicSource(source: Int) { + context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source } radioMicSourceCached = source radioMicSourceLoaded = true - context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source } + LogManager.addLog("INFO", "radio", "Microphone source saved source=$source") } @Volatile private var radioCaptureStereoCached = 0 @@ -717,9 +720,10 @@ class ConfigProvider(private val context: Context) { } suspend fun setRadioCaptureStereo(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[radioCaptureStereoKey] = if (enabled) 1 else 0 } radioCaptureStereoCached = if (enabled) 1 else 0 radioCaptureStereoLoaded = true - context.dataStore.edit { prefs -> prefs[radioCaptureStereoKey] = if (enabled) 1 else 0 } + LogManager.addLog("INFO", "radio", "Stereo capture setting saved enabled=$enabled") } /* ── Server entries ── */ @@ -730,14 +734,14 @@ class ConfigProvider(private val context: Context) { val seen = mutableSetOf() try { val nis = NetworkInterface.getNetworkInterfaces() - while (nis.hasMoreElements()) { + while (nis != null && nis.hasMoreElements()) { val ni = nis.nextElement() if (!ni.isUp || ni.isLoopback) continue for (a in ni.interfaceAddresses) { val addr = a.address val ip = when { addr is Inet4Address -> addr.hostAddress - addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr.hostAddress ?: "") -> addr.hostAddress?.let { it.split("%")[0] } + addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr) -> addr.hostAddress?.substringBefore('%') else -> null } ?: continue val isV6 = ip.contains(":") @@ -748,6 +752,7 @@ class ConfigProvider(private val context: Context) { } } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "detectInterfaces failed: ${e.message}") + throw e // Частичный снимок не должен заменять работающую конфигурацию. } return servers } @@ -940,14 +945,14 @@ class ConfigProvider(private val context: Context) { val list = mutableListOf>() try { val nis = NetworkInterface.getNetworkInterfaces() - while (nis.hasMoreElements()) { + while (nis != null && nis.hasMoreElements()) { val ni = nis.nextElement() if (!ni.isUp || ni.isLoopback) continue for (a in ni.interfaceAddresses) { val addr = a.address val ip = when { addr is Inet4Address -> addr.hostAddress - addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr.hostAddress ?: "") -> addr.hostAddress?.let { it.split("%")[0] } + addr is Inet6Address && isUsableV6(addr) && !isTemporaryV6(ni.name, addr) -> addr.hostAddress?.substringBefore('%') else -> null } ?: continue list.add(ni.name to ip) @@ -971,7 +976,7 @@ class ConfigProvider(private val context: Context) { val stored = loadServers(cfgFile) val saved = stored.active + stored.inactive val mode = snapshot.preferences[connectionModeKey] ?: "auto" - val active = if (mode == "auto" || stored.active.isEmpty()) detectInterfaces(saved, protocols) else stored.active + val active = if (mode == "auto") detectInterfaces(saved, protocols) else stored.active val activeKeys = active.map { it.socketKey }.toSet() val inactive = saved.filter { it.socketKey !in activeKeys }.distinctBy { it.socketKey } LogManager.addLog("INFO", "CFG", "buildConfigText: mode=$mode protocols=${protocols.configValue} " + diff --git a/tools/chatgui-android/doc/ARCHITECTURE.md b/tools/chatgui-android/doc/ARCHITECTURE.md index 20473a8a..bb97393a 100644 --- a/tools/chatgui-android/doc/ARCHITECTURE.md +++ b/tools/chatgui-android/doc/ARCHITECTURE.md @@ -126,6 +126,10 @@ socket=info ``` Секции `[server:<имя>]` с addr/type/transport/netif строятся из интерфейсов устройства. +В manual пустой список остаётся пустым; автоматическое обнаружение не включает отключённые пользователем сокеты. +Ошибка перечисления интерфейсов отклоняет новый снимок до замены файла и сохраняет прежний runtime config. +Флаг временного IPv6 сопоставляется по 16 байтам адреса с /proc; недоступность /proc на Android +логируется один раз и оставляет пригодные IPv6-адреса. Кэши overlay/microphone/stereo меняются после DataStore commit. NetworkChangeMonitor передаёт новый INI в updateSockets: uasync согласует сокеты через `auto_socket_reload_config`, сохраняя ядро/чат. В auto_sockets=android общий `auto_socket_on_network_change()` не запускает scan: нужен именно reload из Kotlin.