android: fix key regeneration, Clear DB, uasync eventfd, udp log from config

- jni_on_event: fix JNI_EDETACHED — AttachCurrentThread/DetachCurrentThread
- u_async.c: pipe→eventfd for reliable cross-thread wakeup
  add process_posted_tasks in poll() path, fix EINTR (saved_errno)
- bridge_debug_hook: keep udp_log_write, remove mutex from udp_log
- memory_pool.c: alloc/free logs INFO→DEBUG (reduce spam)
- instance_lite.c: chat_event_set_handler before ensure_keys,
  KEYS_GENERATED events, fchmod 0600 on keys.conf,
  UDP log from config [log_udp] section, regenerate_trampoline
- config_parser: parse [log_udp] ip/port
- NativeLib.kt: remove callbacksSet, simplify init/stop/destroy
- ChatViewModel.kt: unified event callback in init, pubKey StateFlow,
  requestRegenerateKeys, clearState fix
- SettingsScreen.kt: pubKey.collectAsState(), Clear DB via restart()
- HeadlessService.kt: remove setUdpLogTarget (now from config)
- android_udp_log.c: simplified — no mutex, buffer+send+timer in one thread
- utun_instance.c: NULL-check tcp_connections before destroy
- chat_event.h: +CHAT_EVT_KEYS_GENERATED 11, -NODE_CHANGED
- logreceiver: UDP log receiver tool
This commit is contained in:
evgeny committed 2026-07-28 16:29:33 +03:00
1 parent d0cca27edd
commit 064bed06f2
68 files changed
+5090 -291

No files matched your search

@@ -18,7 +18,6 @@ class ChatApplication : Application() {
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
LogManager.addLog("INFO", "APP", "First launch — configuring")
configProvider.generateKeys()
configProvider.detectInterfaces()
configProvider.markLaunched()
LogManager.addLog("INFO", "APP", "First launch done, ${configProvider.loadServers().size} interfaces detected")
@@ -7,14 +7,12 @@ import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.utun.chat.data.ChatRepository
import com.utun.chat.data.InviteData
import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import java.io.File
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, Settings, Logs, QrScan }
@@ -25,11 +23,9 @@ class MainActivity : ComponentActivity() {
/* Start uTun headless service (must be from foreground Activity on API 34+) */
ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java))
val dbFile = File(filesDir, "chats.db")
val repo = ChatRepository(dbFile.absolutePath)
setContent {
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory(repo))
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory())
var screen by remember { mutableStateOf(Screen.ChannelList) }
var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) }
@@ -73,7 +69,7 @@ class MainActivity : ComponentActivity() {
)
}
}
Screen.Settings -> SettingsScreen(onBack = { screen = Screen.ChannelList })
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.QrScan -> {
QrScanScreen(
@@ -12,9 +12,6 @@ class ChatRepository(dbPath: String) {
private var myNodeId: Long? = null
init {
val f = java.io.File(dbPath)
var tries = 0
while (tries < 50 && !f.exists()) { Thread.sleep(100); tries++ }
db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY)
try {
db.rawQuery("SELECT node_id FROM local_identity WHERE id=1", null).use { cur ->
@@ -99,6 +96,8 @@ class ChatRepository(dbPath: String) {
fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId)
fun isReady() = NativeLib.isReady()
fun close() { db.close() }
companion object {
fun msgTableName(chId: String): String = "msg_" + sanitize(chId)
fun peersTableName(chId: String): String = "peers_" + sanitize(chId)
@@ -17,6 +17,7 @@ import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.io.File
import java.util.UUID
private val Context.dataStore by preferencesDataStore("utun_config")
@@ -32,12 +33,40 @@ class ConfigProvider(private val context: Context) {
private val debugConsoleLevel = stringPreferencesKey("debug_console_level")
private val debugLogFile = stringPreferencesKey("debug_log_file")
private val debugCategories = stringPreferencesKey("debug_categories")
private val logUdpIp = stringPreferencesKey("log_udp_ip")
private val logUdpPort = intPreferencesKey("log_udp_port")
private val serversJson = stringPreferencesKey("servers")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val gson = Gson()
private val random = java.util.concurrent.ThreadLocalRandom.current()
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(): Pair<String, String>? {
val f = File(keysFilePath())
if (!f.exists()) return null
try {
val lines = f.readLines()
var pub = ""; var priv = ""
for (l in lines) {
when {
l.startsWith("public_key=") -> pub = l.removePrefix("public_key=").trim()
l.startsWith("private_key=") -> priv = l.removePrefix("private_key=").trim()
}
}
if (pub.length == 64 && priv.length == 64) return Pair(pub, priv)
} catch (_: Exception) {}
return null
}
fun getDeviceName(): String = Build.MODEL.ifEmpty { "Android" }
val allValues: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
@@ -47,10 +76,12 @@ class ConfigProvider(private val context: Context) {
"node.name" to (prefs[nodeName] ?: getDeviceName()),
"node.listen_port" to (prefs[nodeListenPort]?.toString() ?: "12345"),
"control.port" to (prefs[controlPort]?.toString() ?: "9999"),
"db.path" to (prefs[dbPath] ?: "utun_chat.db"),
"db.path" to (prefs[dbPath] ?: context.filesDir.absolutePath),
"debug.console_level" to (prefs[debugConsoleLevel] ?: "info"),
"debug.log_file" to (prefs[debugLogFile] ?: "utun.log"),
"debug.categories" to (prefs[debugCategories] ?: "")
"debug.categories" to (prefs[debugCategories] ?: ""),
"log_udp.ip" to (prefs[logUdpIp] ?: ""),
"log_udp.port" to (prefs[logUdpPort]?.toString() ?: "0")
)
}
@@ -69,14 +100,15 @@ class ConfigProvider(private val context: Context) {
}
}
key == "servers.count" -> loadServers().size.toString()
key == "node.private_key" -> context.dataStore.data.first()[nodePrivateKey]
key == "node.public_key" -> context.dataStore.data.first()[nodePublicKey]
key == "node.private_key" -> readKeysFromFile()?.second ?: context.dataStore.data.first()[nodePrivateKey]
key == "node.public_key" -> readKeysFromFile()?.first ?: context.dataStore.data.first()[nodePublicKey]
key == "node.name" -> context.dataStore.data.first()[nodeName] ?: getDeviceName()
key == "db.path" -> context.dataStore.data.first()[dbPath] ?: "utun_chat.db"
key == "db.path" -> context.dataStore.data.first()[dbPath] ?: context.filesDir.absolutePath
key == "control.bind_ip" -> context.dataStore.data.first()[controlBindIp] ?: "127.0.0.1"
key == "debug.console_level" -> context.dataStore.data.first()[debugConsoleLevel] ?: "info"
key == "debug.log_file" -> context.dataStore.data.first()[debugLogFile] ?: "utun.log"
key == "debug.categories" -> context.dataStore.data.first()[debugCategories] ?: ""
key == "log_udp.ip" -> context.dataStore.data.first()[logUdpIp] ?: "192.168.40.250"
else -> null
}
}
@@ -97,6 +129,7 @@ class ConfigProvider(private val context: Context) {
key == "servers.count" -> loadServers().size
key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345
key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999
key == "log_udp.port" -> context.dataStore.data.first()[logUdpPort] ?: 9999
else -> 0
}
}
@@ -114,17 +147,15 @@ class ConfigProvider(private val context: Context) {
"debug.console_level" -> prefs[debugConsoleLevel] = value
"debug.log_file" -> prefs[debugLogFile] = value
"debug.categories" -> prefs[debugCategories] = value
"log_udp.ip" -> prefs[logUdpIp] = value
"log_udp.port" -> prefs[logUdpPort] = value.toIntOrNull() ?: 0
}
}
}
suspend fun generateKeys() {
val priv = "priv_" + (1..64).map { "0123456789abcdef".random() }.joinToString("")
val pub = "pub_" + (1..64).map { "0123456789abcdef".random() }.joinToString("")
context.dataStore.edit { prefs ->
prefs[nodePrivateKey] = priv
prefs[nodePublicKey] = pub
}
/* Keys are generated in C (instance_lite via ensure_keys).
* They are saved back via NativeLib.start() → nativeGetGeneratedKeys(). */
}
private companion object {
@@ -216,4 +247,41 @@ class ConfigProvider(private val context: Context) {
if (list.isEmpty()) list.add("lo" to "127.0.0.1")
list
}
/** Build full INI config text for NativeLib.start() */
fun buildConfigText(): String {
val pubKey = getString("node.public_key") ?: ""
val privKey = getString("node.private_key") ?: ""
val nodeName = getString("node.name") ?: getDeviceName()
val listenPort = getInt("node.listen_port")
val dbDir = getString("db.path") ?: context.filesDir.absolutePath
return buildString {
appendLine("[global]")
if (nodeName.isNotBlank()) appendLine("my_node_name=$nodeName")
if (pubKey.length == 64) appendLine("my_public_key=$pubKey")
if (privKey.length == 64) appendLine("my_private_key=$privKey")
appendLine("db_path=$dbDir")
appendLine("db_sync_enabled=1")
appendLine()
appendLine("[server:main]")
appendLine("addr=0.0.0.0:$listenPort")
appendLine()
appendLine("[allowed_keys]")
appendLine("allow_all=yes")
appendLine()
appendLine("[ntp]")
appendLine("enabled=no")
appendLine()
appendLine("[debug]")
appendLine("console_level=info")
val udpIp = getString("log_udp.ip") ?: ""
val udpPort = getInt("log_udp.port")
if (udpIp.isNotEmpty() && udpPort > 0) {
appendLine()
appendLine("[log_udp]")
appendLine("ip=$udpIp")
appendLine("port=$udpPort")
}
}
}
}
@@ -1,24 +1,30 @@
package com.utun.chat.data
import android.util.Base64
import com.utun.chat.data.LogManager
import java.security.MessageDigest
const val INVITE_LINK_VERSION: Int = 0x01
const val INVITE_PUBKEY_SIZE: Int = 32
const val INVITE_PROTO_UDP: Int = 0x01
const val INVITE_PROTO_TCP: Int = 0x02
const val INVITE_PROTO_BOTH: Int = 0x03
data class InviteAddr(
val family: Int, // 4 or 6
val address: ByteArray,
val port: Int,
val socketId: Int
val socketId: Int,
val proto: Int // INVITE_PROTO_UDP/TCP/BOTH
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is InviteAddr) return false
return family == other.family && address.contentEquals(other.address) &&
port == other.port && socketId == other.socketId
port == other.port && socketId == other.socketId && proto == other.proto
}
override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId
override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId * 31 + proto
}
data class InviteData(
@@ -27,14 +33,15 @@ data class InviteData(
val nodeId: Long,
val addrs: List<InviteAddr>
) {
/** Serialize addresses for C bridge: family(1) + socketId(1) + address(4|16) + port(2 BE) */
/** Serialize addresses for C bridge: family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE) */
fun serializeAddrs(): ByteArray {
val size = addrs.fold(0) { acc, a -> acc + if (a.family == 4) 8 else 20 }
val size = addrs.fold(0) { acc, a -> acc + if (a.family == 4) 9 else 21 }
val buf = ByteArray(size)
var pos = 0
for (a in addrs) {
buf[pos] = a.family.toByte(); pos++
buf[pos] = a.socketId.toByte(); pos++
buf[pos] = a.proto.toByte(); pos++
val ipLen = if (a.family == 4) 4 else 16
System.arraycopy(a.address, 0, buf, pos, ipLen); pos += ipLen
buf[pos] = ((a.port shr 8) and 0xFF).toByte(); pos++
@@ -65,17 +72,32 @@ object InviteLink {
}
fun decode(link: String): InviteData? {
if (!link.startsWith(PREFIX)) return null
LogManager.addLog("DEBUG", "INVITE", "decode: ${link.take(80)}")
if (!link.startsWith(PREFIX)) {
LogManager.addLog("WARN", "INVITE", "decode: missing utun:// prefix, len=${link.length}")
return null
}
val b64 = link.substring(PREFIX.length)
LogManager.addLog("DEBUG", "INVITE", "decode: base64 len=${b64.length}")
val raw: ByteArray
try {
raw = Base64.decode(link.substring(PREFIX.length), Base64.DEFAULT)
raw = Base64.decode(b64, Base64.DEFAULT)
} catch (e: Exception) {
LogManager.addLog("ERROR", "INVITE", "decode: base64 failed: ${e.message}")
return null
}
LogManager.addLog("DEBUG", "INVITE", "decode: raw=${raw.size}B ver=${raw[0].toInt() and 0xFF}")
if (raw.size < 11) {
LogManager.addLog("ERROR", "INVITE", "decode: raw too short (${raw.size} < 11)")
return null
}
if (raw.size < 11) return null
var off = 0
if ((raw[off].toInt() and 0xFF) != INVITE_LINK_VERSION) return null
if ((raw[off].toInt() and 0xFF) != INVITE_LINK_VERSION) {
LogManager.addLog("ERROR", "INVITE", "decode: wrong version 0x%02x expected 0x%02x".format(
raw[off].toInt() and 0xFF, INVITE_LINK_VERSION))
return null
}
off++
var chId = 0L
@@ -85,6 +107,8 @@ object InviteLink {
}
off += 8
LogManager.addLog("DEBUG", "INVITE", "decode: chId=0x%016x".format(chId))
var pubkey: ByteArray? = null
val addrs = mutableListOf<InviteAddr>()
var firstBlock = true
@@ -104,24 +128,38 @@ object InviteLink {
for (j in 0 until cnt) {
val isV6 = ((header shr (2 + j)) and 1) != 0
val ipLen = if (isV6) 16 else 4
if (off + 1 + ipLen + 2 > raw.size) return null
if (off + 2 + ipLen + 2 > raw.size) return null
val sockId = raw[off].toInt() and 0xFF; off++
val proto = raw[off].toInt() and 0xFF; off++
val ip = raw.copyOfRange(off, off + ipLen); off += ipLen
val port = ((raw[off].toInt() and 0xFF) shl 8) or (raw[off + 1].toInt() and 0xFF); off += 2
addrs.add(InviteAddr(family = if (isV6) 6 else 4, address = ip, port = port, socketId = sockId))
val addr = InviteAddr(family = if (isV6) 6 else 4, address = ip, port = port, socketId = sockId, proto = proto)
addrs.add(addr)
LogManager.addLog("DEBUG", "INVITE", "decode: ${addrToString(addr)}")
}
}
if (pubkey == null || pubkey.size != INVITE_PUBKEY_SIZE) return null
val nodeId = deriveNodeId(pubkey)
val pkHex = pubkey.take(8).joinToString("") { "%02x".format(it) }
LogManager.addLog("INFO", "INVITE", "decode: ch=0x%016x node=0x%016x pk=%s... addrs=%d".format(
chId, nodeId, pkHex, addrs.size))
return InviteData(channelId = chId, pubkey = pubkey, nodeId = nodeId, addrs = addrs)
}
fun addrToString(addr: InviteAddr): String {
if (addr.family == 4 && addr.address.size == 4) {
val b = addr.address
return "${b[0].toInt() and 0xFF}.${b[1].toInt() and 0xFF}.${b[2].toInt() and 0xFF}.${b[3].toInt() and 0xFF}:${addr.port}"
val protoStr = when (addr.proto) {
INVITE_PROTO_UDP -> "UDP"
INVITE_PROTO_TCP -> "TCP"
INVITE_PROTO_BOTH -> "UDP+TCP"
else -> "?${addr.proto}"
}
return "ipv6:${addr.address.joinToString("") { "%02x".format(it) }}:${addr.port}"
val ip = if (addr.family == 4 && addr.address.size == 4) {
val b = addr.address
"${b[0].toInt() and 0xFF}.${b[1].toInt() and 0xFF}.${b[2].toInt() and 0xFF}.${b[3].toInt() and 0xFF}:${addr.port}"
} else {
"ipv6:${addr.address.joinToString("") { "%02x".format(it) }}:${addr.port}"
}
return "$protoStr $ip sock=${addr.socketId}"
}
}
@@ -7,7 +7,6 @@ interface ChatEventCallback {
}
object NativeLib {
private var callbacksSet = false
private var running = false
init {
@@ -16,14 +15,11 @@ object NativeLib {
/** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */
fun init(dbPath: String, controlPort: Int): Boolean {
if (callbacksSet) return true
val ok = nativeInit(
return nativeInit(
dbPath, controlPort,
ChatApplication.instance.configProvider,
LogManager
)
if (ok) callbacksSet = true
return ok
}
/** Start full uTun instance from INI config text */
@@ -48,13 +44,13 @@ object NativeLib {
fun destroy() {
nativeDestroy()
callbacksSet = false
running = false
}
fun restart(dbPath: String, controlPort: Int): Boolean {
destroy()
return init(dbPath, controlPort)
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
stop()
init(dbPath, controlPort)
return start(configText)
}
fun isReady(): Boolean = running
@@ -68,10 +64,13 @@ object NativeLib {
fun connectChannel(channelId: String) { nativeConnectChannel(channelId) }
fun getChannels(): String = nativeGetChannels() ?: "[]"
fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]"
fun clearDatabase(): Boolean = nativeClearDatabase()
fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) }
/** Enable/disable forwarding native C logs to LogManager (expensive, off by default) */
fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) }
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
fun regenerateKeys() { nativeRegenerateKeys() }
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
@@ -87,4 +86,7 @@ object NativeLib {
private external fun nativeGetMessages(channelId: String, limit: Int): String?
private external fun nativeSetDebugLevel(category: String, level: String)
private external fun nativeSubscribeLogs(enabled: Boolean)
private external fun nativeSetUdpLogTarget(ip: String, port: Int)
private external fun nativeRegenerateKeys()
private external fun nativeClearDatabase(): Boolean
}
@@ -33,36 +33,13 @@ class HeadlessService : Service() {
startForeground(1, notification)
val provider = ChatApplication.instance.configProvider
val dbDir = filesDir.absolutePath
val pubKey = provider.getString("node.public_key") ?: ""
val privKey = provider.getString("node.private_key") ?: ""
val nodeName = provider.getString("node.name") ?: provider.getDeviceName()
val dbDir = provider.getString("db.path") ?: filesDir.absolutePath
val listenPort = provider.getInt("node.listen_port")
val configText = buildString {
appendLine("[global]")
if (nodeName.isNotBlank()) appendLine("my_node_name=$nodeName")
if (pubKey.length == 64) appendLine("my_public_key=$pubKey")
if (privKey.length == 64) appendLine("my_private_key=$privKey")
appendLine("db_path=$dbDir")
appendLine("db_sync_enabled=1")
appendLine()
appendLine("[server:main]")
appendLine("addr=0.0.0.0:$listenPort")
appendLine()
appendLine("[allowed_keys]")
appendLine("allow_all=yes")
appendLine()
appendLine("[ntp]")
appendLine("enabled=no")
appendLine()
appendLine("[debug]")
appendLine("console_level=info")
}
val configText = provider.buildConfigText()
LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir")
/* Init callbacks first (log + config) — required before start */
NativeLib.init(dbDir, 9999)
val ok = NativeLib.start(configText)
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.InviteData
import com.utun.chat.data.InviteLink
import com.utun.chat.data.LogManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -46,6 +47,8 @@ fun JoinChannelDialog(
status = "Connecting to peer..."
delay(800)
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d"
.format(d.channelId, d.nodeId, d.addrs.size))
onConnect(d)
} else {
connecting = false
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -18,7 +19,10 @@ import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.ServerEntry
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE")
@@ -32,7 +36,7 @@ private val PROTOCOLS = listOf("tcp", "udp")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit) {
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
var dirty by remember { mutableStateOf(false) }
@@ -45,10 +49,15 @@ fun SettingsScreen(onBack: () -> Unit) {
var dbPath by remember { mutableStateOf("") }
var debugLevel by remember { mutableStateOf("info") }
var logFile by remember { mutableStateOf("") }
var pubKey by remember { mutableStateOf("") }
var logUdpIp by remember { mutableStateOf("") }
var logUdpPort by remember { mutableStateOf("") }
val pubKey by vm.pubKey.collectAsState()
val generatingKeys by vm.generatingKeys.collectAsState()
var showDebugCategories by remember { mutableStateOf(false) }
var showOverwriteKeys by remember { mutableStateOf(false) }
var showClearDb by remember { mutableStateOf(false) }
var clearingDb by remember { mutableStateOf(false) }
var catLevels by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var servers by remember { mutableStateOf<List<ServerEntry>>(emptyList()) }
@@ -64,7 +73,12 @@ fun SettingsScreen(onBack: () -> Unit) {
dbPath = provider.getString("db.path") ?: "utun_chat.db"
debugLevel = provider.getString("debug.console_level") ?: "info"
logFile = provider.getString("debug.log_file") ?: "utun.log"
pubKey = provider.getString("node.public_key") ?: "Not generated"
logUdpIp = provider.getString("log_udp.ip") ?: "192.168.40.250"
logUdpPort = provider.getInt("log_udp.port").toString().let { if (it == "0") "9999" else it }
val port = logUdpPort.toIntOrNull() ?: 0
if (logUdpIp.isNotEmpty() && port > 0) {
NativeLib.setUdpLogTarget(logUdpIp, port)
}
val stored = provider.getString("debug.categories") ?: ""
catLevels = if (stored.isNotEmpty()) {
stored.split(",").associate { pair ->
@@ -102,9 +116,12 @@ fun SettingsScreen(onBack: () -> Unit) {
val handleBack = {
if (dirty) {
val path = provider.getString("db.path") ?: "utun_chat.db"
val port = provider.getInt("control.port")
NativeLib.restart(path, port)
scope.launch(Dispatchers.IO) {
val path = provider.getString("db.path") ?: "utun_chat.db"
val port = provider.getInt("control.port")
val configText = provider.buildConfigText()
NativeLib.restart(path, port, configText)
}
}
onBack()
}
@@ -165,12 +182,19 @@ fun SettingsScreen(onBack: () -> Unit) {
Text("Keys", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true)
Button(onClick = {
val hasKey = pubKey.isNotEmpty() && pubKey != "Not generated" && pubKey != ""
if (hasKey) showOverwriteKeys = true
else { dirty = true; scope.launch { provider.generateKeys(); pubKey = provider.getString("node.public_key") ?: "" } }
}) {
Text("Generate Keys")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
if (pubKey.length == 64) showOverwriteKeys = true
else { dirty = true; vm.requestRegenerateKeys() }
}, enabled = !generatingKeys) {
Text(if (generatingKeys) "Generating..." else "Generate Keys")
}
Button(onClick = { showClearDb = true },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Clear DB")
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -274,6 +298,32 @@ fun SettingsScreen(onBack: () -> Unit) {
OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } },
label = { Text("Log File") }, singleLine = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(logUdpIp, { v ->
logUdpIp = v
scope.launch { provider.setValue("log_udp.ip", v) }
val port = logUdpPort.toIntOrNull() ?: 0
if (v.isNotEmpty() && port > 0) NativeLib.setUdpLogTarget(v, port)
else NativeLib.setUdpLogTarget("", 0)
},
label = { Text("UDP Log IP") },
modifier = Modifier.weight(1f).heightIn(min = 40.dp),
singleLine = true)
OutlinedTextField(logUdpPort, { v ->
logUdpPort = v
val port = v.toIntOrNull() ?: 0
scope.launch { provider.setValue("log_udp.port", v) }
if (logUdpIp.isNotEmpty() && port > 0 && port <= 65535)
NativeLib.setUdpLogTarget(logUdpIp, port)
else
NativeLib.setUdpLogTarget("", 0)
},
label = { Text("Port") },
modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true)
}
Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories },
horizontalArrangement = Arrangement.SpaceBetween) {
Text("Categories", style = MaterialTheme.typography.titleMedium)
@@ -301,7 +351,7 @@ fun SettingsScreen(onBack: () -> Unit) {
Button(onClick = {
showOverwriteKeys = false
dirty = true
scope.launch { provider.generateKeys(); pubKey = provider.getString("node.public_key") ?: "" }
vm.requestRegenerateKeys()
}) { Text("Overwrite") }
},
dismissButton = {
@@ -309,6 +359,36 @@ fun SettingsScreen(onBack: () -> Unit) {
}
)
}
if (showClearDb) {
AlertDialog(
onDismissRequest = { showClearDb = false },
title = { Text("Clear Database?") },
text = { Text("This will delete all channels, messages, and peers.\nThis cannot be undone.") },
confirmButton = {
Button(onClick = {
showClearDb = false
clearingDb = true
scope.launch(Dispatchers.IO) {
vm.clearState()
NativeLib.stop()
NativeLib.clearDatabase()
val path = provider.getString("db.path") ?: ""
val port = provider.getInt("control.port")
val configText = provider.buildConfigText()
NativeLib.restart(path, port, configText)
withContext(Dispatchers.Main) { clearingDb = false }
}
}, enabled = !clearingDb,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Text("Delete All")
}
},
dismissButton = {
TextButton(onClick = { showClearDb = false }) { Text("Cancel") }
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -3,13 +3,17 @@ package com.utun.chat.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.utun.chat.ChatApplication
import com.utun.chat.data.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ChatViewModel(private val repo: ChatRepository) : ViewModel() {
class ChatViewModel : ViewModel() {
private var repo: ChatRepository? = null
private val _channels = MutableStateFlow<List<Channel>>(emptyList())
val channels: StateFlow<List<Channel>> = _channels
@@ -20,83 +24,133 @@ class ChatViewModel(private val repo: ChatRepository) : ViewModel() {
private val _currentChannel = MutableStateFlow<Channel?>(null)
val currentChannel: StateFlow<Channel?> = _currentChannel
private val _pubKey = MutableStateFlow("")
val pubKey: StateFlow<String> = _pubKey
private val _generatingKeys = MutableStateFlow(false)
val generatingKeys: StateFlow<Boolean> = _generatingKeys
init {
NativeLib.setEventCallback(object : ChatEventCallback {
override fun onEvent(type: Int, data: ByteArray?) {
when (type) {
1 -> { /* MSG_RECEIVED: [ch_id_len:1][ch_id:var][author:8] */
if (data != null && data.size >= 2) {
val chLen = data[0].toInt() and 0xFF
if (data.size >= 1 + chLen) {
val chId = String(data, 1, chLen)
val cur = _currentChannel.value
if (cur != null && cur.id == chId) refreshMessages(chId)
refreshChannels()
}
}
}
8 -> refreshChannels() /* CHANNEL_PEERS_ONLINE */
2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */
if (data != null && data.size >= 20) {
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong()
val result = buf.getInt()
val chId = buf.getLong()
LogManager.addLog(
if (result == 0) "INFO" else "ERROR",
"JOIN", "connect to 0x%016x ch=$chId result=$result".format(nodeId))
if (result == 0) refreshChannels()
}
}
4 -> refreshChannels() /* CHANNEL_UPDATED */
override fun onEvent(type: Int, data: ByteArray?) = handleEvent(type, data)
})
viewModelScope.launch(Dispatchers.IO) {
val key = ChatApplication.instance.configProvider.getString("node.public_key")
if (key != null && key.length == 64) _pubKey.value = key
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
when (type) {
9 -> { /* DB_READY */
if (repo != null) return
val dbDir = ChatApplication.instance.configProvider.getString("db.path") ?: ""
if (dbDir.isEmpty()) return
repo = ChatRepository("$dbDir/chats.db")
refreshChannels()
}
11 -> { /* KEYS_GENERATED: [pub_hex:64] */
if (data != null && data.size >= 64) {
_pubKey.value = String(data, 0, 64)
_generatingKeys.value = false
}
}
})
refreshChannels()
1 -> { /* MSG_RECEIVED: [ch_id_len:1][ch_id:var][author:8] */
if (repo == null || data == null || data.size < 2) return
val chLen = data[0].toInt() and 0xFF
if (data.size >= 1 + chLen) {
val chId = String(data, 1, chLen)
val cur = _currentChannel.value
if (cur != null && cur.id == chId) refreshMessages(chId)
refreshChannels()
}
}
8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */
2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */
if (repo == null || data == null || data.size < 20) return
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong()
val result = buf.getInt()
val chId = buf.getLong()
LogManager.addLog(
if (result == 0) "INFO" else "ERROR",
"JOIN", "connect to 0x%016x ch=$chId result=$result".format(nodeId))
if (result == 0) refreshChannels()
}
4 -> if (repo != null) refreshChannels() /* CHANNEL_UPDATED */
}
}
fun requestRegenerateKeys() {
_generatingKeys.value = true
NativeLib.regenerateKeys()
viewModelScope.launch {
delay(5000)
if (_generatingKeys.value) {
_generatingKeys.value = false
LogManager.addLog("ERROR", "KEYS", "key regeneration timeout")
}
}
}
fun refreshChannels() {
val r = repo ?: return
viewModelScope.launch {
_channels.value = withContext(Dispatchers.IO) { repo.getChannels() }
_channels.value = withContext(Dispatchers.IO) { r.getChannels() }
}
}
fun refreshMessages(chId: String) {
val r = repo ?: return
viewModelScope.launch {
_messages.value = withContext(Dispatchers.IO) { repo.getMessages(chId) }
_messages.value = withContext(Dispatchers.IO) { r.getMessages(chId) }
}
}
fun selectChannel(channel: Channel) {
val r = repo ?: return
_currentChannel.value = channel
repo.connectChannel(channel.id)
r.connectChannel(channel.id)
refreshMessages(channel.id)
}
fun sendMessage(text: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return
repo.sendMessage(ch.id, text)
r.sendMessage(ch.id, text)
viewModelScope.launch {
_messages.value = withContext(Dispatchers.IO) { repo.getMessages(ch.id) }
_messages.value = withContext(Dispatchers.IO) { r.getMessages(ch.id) }
refreshChannels()
}
}
fun createChannel(name: String, channelId: String) {
repo.createChannel(name, channelId)
val r = repo ?: return
r.createChannel(name, channelId)
refreshChannels()
}
fun connectNode(address: String, port: Int, pubkeyHex: String) {
repo.connectNode(address, port, pubkeyHex)
val r = repo ?: return
r.connectNode(address, port, pubkeyHex)
}
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) {
repo.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
val r = repo ?: return
r.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
}
class Factory(private val repo: ChatRepository) : ViewModelProvider.Factory {
fun clearState() {
repo?.close()
repo = null
_channels.value = emptyList()
_messages.value = emptyList()
_currentChannel.value = null
_generatingKeys.value = false
}
class Factory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel(repo) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel() as T
}
}