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:
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
|
||||
|
||||
+93
-13
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user