From 5276c1288ccec7bd0829741dfd5ec417a1ef3430 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 28 Jul 2026 00:01:47 +0300 Subject: [PATCH] android: real utun instance + SQLite channels + reactive logs + QR join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - instance_lite.h/c: full uTun stack from INI config text (keygen, uasync, UTUN_INSTANCE, SQLite, chat_core/sync, BGP, etcp_router) - jni_bridge: nativeStart/Stop, nativeJoinChannel → chat_sync_connect_from_invite, nativeSubscribeLogs, C logs → logcat + Kotlin LogManager - Kotlin: direct read-only SQLite queries (channels, messages), ViewModel with live event callbacks, reactive LogScreen (tail -f), CameraX QR scanner with proper lifecycle, JoinChannelDialog - HeadlessService: generates INI config from Kotlin settings → C instance --- lib/debug_config.c | 11 + lib/debug_config.h | 4 + .../java/com/utun/chat/ChatApplication.kt | 41 +++ .../main/java/com/utun/chat/MainActivity.kt | 109 ++++++ .../java/com/utun/chat/data/ChatRepository.kt | 111 ++++++ .../java/com/utun/chat/data/ConfigCallback.kt | 6 + .../java/com/utun/chat/data/ConfigProvider.kt | 219 +++++++++++ .../java/com/utun/chat/data/InviteLink.kt | 127 +++++++ .../java/com/utun/chat/data/LogManager.kt | 45 +++ .../main/java/com/utun/chat/data/NativeLib.kt | 88 +++++ .../java/com/utun/chat/data/ServerEntry.kt | 9 + .../com/utun/chat/headless/HeadlessService.kt | 100 +++++ .../utun/chat/ui/components/MessageBubble.kt | 80 ++++ .../utun/chat/ui/screens/ChannelListScreen.kt | 123 +++++++ .../com/utun/chat/ui/screens/ChatScreen.kt | 57 +++ .../utun/chat/ui/screens/JoinChannelDialog.kt | 139 +++++++ .../com/utun/chat/ui/screens/LogScreen.kt | 98 +++++ .../com/utun/chat/ui/screens/QrScanScreen.kt | 165 +++++++++ .../utun/chat/ui/screens/SettingsScreen.kt | 347 ++++++++++++++++++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 100 +++++ .../jni_bridge/android_jni_bridge.c | 194 +++++++--- .../jni_bridge/android_jni_bridge.h | 9 +- .../libutun_lite/instance_lite.c | 282 ++++++++++++++ .../libutun_lite/instance_lite.h | 42 +++ .../libutun_lite/utun_sources.cmake | 1 + 25 files changed, 2444 insertions(+), 63 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt create mode 100644 tools/chatgui-android/libutun_lite/instance_lite.c create mode 100644 tools/chatgui-android/libutun_lite/instance_lite.h diff --git a/lib/debug_config.c b/lib/debug_config.c index e7308be8..073454e7 100644 --- a/lib/debug_config.c +++ b/lib/debug_config.c @@ -210,6 +210,7 @@ const char* debug_get_category_name(debug_category_t category_idx) { /* Global debug configuration */ debug_config_t g_debug_config; +static debug_log_hook_fn g_log_hook = NULL; /* Initialize debug system with default settings */ void debug_config_init(void) { @@ -433,6 +434,12 @@ void debug_output(debug_level_t level, debug_category_t category_idx, va_end(args); + /* Route to external hook (JNI bridge, etc.) */ + if (g_log_hook) { + const char* cat_name = debug_get_category_name(category_idx); + g_log_hook((int)level, cat_name, buffer); + } + /* Output to console if enabled and level is sufficient */ if (g_debug_config.file_output) { fprintf(g_debug_config.file_output, "%s", buffer); @@ -444,6 +451,10 @@ void debug_output(debug_level_t level, debug_category_t category_idx, #undef BUFFER_SIZE } +void debug_set_log_hook(debug_log_hook_fn hook) { + g_log_hook = hook; +} + /* Parse debug configuration from string (e.g., "ll_queue:debug,uasync:info,memory:warn,etcp:error") */ int debug_parse_config(const char* config_string) { if (!config_string || !*config_string) { diff --git a/lib/debug_config.h b/lib/debug_config.h index 7109a7d0..4476760e 100644 --- a/lib/debug_config.h +++ b/lib/debug_config.h @@ -157,6 +157,10 @@ void debug_output(debug_level_t level, debug_category_t category_idx, const char* function, const char* file, int line, const char* format, ...); +/* Log hook — if set, called with every debug message (for JNI/bridge integration) */ +typedef void (*debug_log_hook_fn)(int level, const char* category, const char* message); +void debug_set_log_hook(debug_log_hook_fn hook); + /* Parse debug configuration from string (e.g. "etcp=trace,config=info") */ int debug_parse_config(const char* config_string); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt new file mode 100644 index 00000000..73dda631 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -0,0 +1,41 @@ +package com.utun.chat + +import android.app.Application +import android.content.Intent +import com.utun.chat.data.LogManager +import com.utun.chat.data.ConfigProvider +import com.utun.chat.headless.HeadlessService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChatApplication : Application() { + lateinit var logManager: LogManager + lateinit var configProvider: ConfigProvider + + override fun onCreate() { + super.onCreate() + instance = this + logManager = LogManager + configProvider = ConfigProvider(this) + + // First launch: generate keys + detect interfaces + val scope = CoroutineScope(Dispatchers.IO) + scope.launch { + if (configProvider.isFirstLaunch()) { + 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") + } + // Start uTun service + val intent = Intent(this@ChatApplication, HeadlessService::class.java) + startService(intent) + } + } + + companion object { + lateinit var instance: ChatApplication + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt new file mode 100644 index 00000000..4de8ffb8 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -0,0 +1,109 @@ +package com.utun.chat + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.runtime.* +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.ui.screens.* +import com.utun.chat.viewmodel.ChatViewModel +import java.io.File + +class MainActivity : ComponentActivity() { + enum class Screen { ChannelList, Chat, Settings, Logs, QrScan } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val dbFile = File(filesDir, "chats.db") + val repo = ChatRepository(dbFile.absolutePath) + + setContent { + val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory(repo)) + var screen by remember { mutableStateOf(Screen.ChannelList) } + var qrResult by remember { mutableStateOf(null) } + var showJoinDialog by remember { mutableStateOf(false) } + var joinAutoLink by remember { mutableStateOf(null) } + + BackHandler(enabled = screen != Screen.ChannelList) { + screen = Screen.ChannelList + } + + val connected = NativeLib.isReady() + + /* Subscribe native logs only when LogScreen is open */ + DisposableEffect(screen) { + if (screen == Screen.Logs) NativeLib.subscribeNativeLogs(true) + onDispose { NativeLib.subscribeNativeLogs(false) } + } + + when (screen) { + Screen.ChannelList -> { + val channels by vm.channels.collectAsState() + ChannelListScreen( + channels = channels, + connected = connected, + onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, + onCreateChannel = { vm.createChannel("general", "channel_1") }, + onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, + onSettings = { screen = Screen.Settings }, + onLogs = { screen = Screen.Logs } + ) + } + Screen.Chat -> { + val messages by vm.messages.collectAsState() + val channel = vm.currentChannel.collectAsState().value + if (channel != null) { + ChatScreen( + channel = channel, + messages = messages, + connected = connected, + onSendMessage = { vm.sendMessage(it) }, + onBack = { screen = Screen.ChannelList } + ) + } + } + Screen.Settings -> SettingsScreen(onBack = { screen = Screen.ChannelList }) + Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) + Screen.QrScan -> { + QrScanScreen( + onResult = { link -> + qrResult = link + screen = Screen.ChannelList + }, + onBack = { screen = Screen.ChannelList } + ) + } + } + + /* Show join dialog after QR scan returns */ + LaunchedEffect(qrResult) { + qrResult?.let { link -> + joinAutoLink = link + showJoinDialog = true + qrResult = null + } + } + + if (showJoinDialog) { + JoinChannelDialog( + autoLink = joinAutoLink, + onScanQr = { + screen = Screen.QrScan + showJoinDialog = false + }, + onConnect = { data: InviteData -> + val addrs = data.serializeAddrs() + vm.joinChannel(data.channelId, data.nodeId, data.pubkey, addrs, data.addrs.size) + showJoinDialog = false + }, + onDismiss = { showJoinDialog = false } + ) + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt new file mode 100644 index 00000000..db8f9aa1 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -0,0 +1,111 @@ +package com.utun.chat.data + +import android.database.sqlite.SQLiteDatabase +import org.json.JSONObject + +data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "") +data class Message(val id: Long, val author: String, val text: String, val ts: Long, val isOutgoing: Boolean = false) + +class ChatRepository(dbPath: String) { + private val db: SQLiteDatabase + private var myNodeId: Long? = null + + init { + /* Ensure DB file exists — wait up to 5 seconds for service to create it */ + 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) + /* Don't crash if tables aren't created yet */ + try { + db.rawQuery("SELECT node_id FROM local_identity WHERE id=1", null).use { cur -> + if (cur.moveToFirst()) myNodeId = cur.getLong(0) + } + } catch (_: Exception) {} + } + + fun getChannels(): List { + val list = mutableListOf() + try { + db.rawQuery( + "SELECT channel_id, name, last_msg_at FROM channels ORDER BY last_msg_at DESC, name ASC", + null + ).use { cur -> + while (cur.moveToNext()) { + val chId = cur.getString(0) ?: continue + val name = cur.getString(1) ?: chId + val lastAt = cur.getLong(2) + val last = getLastMsgText(chId) + list.add(Channel(id = chId, name = name, lastMsgAt = lastAt, lastMsg = last)) + } + } + } catch (_: Exception) {} + return list + } + + fun getMessages(channelId: String, limit: Int = 50): List { + val tbl = msgTableName(channelId) + val list = mutableListOf() + val mid = myNodeId + try { + db.rawQuery( + "SELECT id, node_id, data, timestamp FROM \"$tbl\" ORDER BY timestamp ASC LIMIT ?", + arrayOf(limit.toString()) + ).use { cur -> + while (cur.moveToNext()) { + val id = cur.getLong(0) + val nodeId = cur.getLong(1) + val jdata = cur.getBlob(2) + val ts = cur.getLong(3) + val text = parseMsgText(jdata) + val author = nodeId.toHex() + list.add(Message(id = id, author = author, text = text, ts = ts, + isOutgoing = mid != null && nodeId == mid)) + } + } + } catch (_: Exception) { /* table may not exist yet */ } + return list + } + + private fun getLastMsgText(channelId: String): String { + val tbl = msgTableName(channelId) + try { + db.rawQuery( + "SELECT node_id, data FROM \"$tbl\" ORDER BY id DESC LIMIT 1", + null + ).use { cur -> + if (cur.moveToFirst()) { + val author = cur.getLong(0).toHex() + val text = parseMsgText(cur.getBlob(1)) + return "$author: ${text.take(60)}${if (text.length > 60) "..." else ""}" + } + } + } catch (_: Exception) {} + return "" + } + + fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text) + fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId) + fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex) + fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) = + NativeLib.joinChannel(channelId, nodeId, pubkey, addrs, addrCount) + fun isReady() = NativeLib.isReady() + + companion object { + fun msgTableName(chId: String): String { + val san = StringBuilder() + for (c in chId) { + if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '_') san.append(c) + else san.append('_') + } + return "msg_$san" + } + + fun parseMsgText(data: ByteArray?): String { + if (data == null) return "" + return try { JSONObject(String(data)).optString("d", "") } catch (_: Exception) { "" } + } + + fun Long.toHex(): String = "0x%016x".format(this) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt new file mode 100644 index 00000000..e43969b2 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigCallback.kt @@ -0,0 +1,6 @@ +package com.utun.chat.data + +interface ConfigCallback { + fun getString(key: String): String? + fun getInt(key: String): Int +} 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 new file mode 100644 index 00000000..e175fa33 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -0,0 +1,219 @@ +package com.utun.chat.data + +import android.content.Context +import android.os.Build +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.UUID + +private val Context.dataStore by preferencesDataStore("utun_config") + +class ConfigProvider(private val context: Context) { + private val nodePrivateKey = stringPreferencesKey("node_private_key") + private val nodePublicKey = stringPreferencesKey("node_public_key") + private val nodeName = stringPreferencesKey("node_name") + private val nodeListenPort = intPreferencesKey("node_listen_port") + private val controlPort = intPreferencesKey("control_port") + private val controlBindIp = stringPreferencesKey("control_bind_ip") + private val dbPath = stringPreferencesKey("db_path") + private val debugConsoleLevel = stringPreferencesKey("debug_console_level") + private val debugLogFile = stringPreferencesKey("debug_log_file") + private val debugCategories = stringPreferencesKey("debug_categories") + private val serversJson = stringPreferencesKey("servers") + private val firstLaunchDone = booleanPreferencesKey("first_launch_done") + + private val gson = Gson() + private val random = java.util.concurrent.ThreadLocalRandom.current() + + fun getDeviceName(): String = Build.MODEL.ifEmpty { "Android" } + + val allValues: Flow> = context.dataStore.data.map { prefs -> + mapOf( + "node.private_key" to (prefs[nodePrivateKey] ?: ""), + "node.public_key" to (prefs[nodePublicKey] ?: ""), + "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"), + "debug.console_level" to (prefs[debugConsoleLevel] ?: "info"), + "debug.log_file" to (prefs[debugLogFile] ?: "utun.log"), + "debug.categories" to (prefs[debugCategories] ?: "") + ) + } + + fun getString(key: String): String? = runBlocking { + when { + key.startsWith("servers.") -> { + val parts = key.removePrefix("servers.").split(".", limit = 2) + val idx = parts[0].toIntOrNull() ?: return@runBlocking null + val field = parts.getOrNull(1) ?: return@runBlocking null + val list = loadServers() + val srv = list.getOrNull(idx) ?: return@runBlocking null + when (field) { + "bindIp" -> srv.bindIp + "protocol" -> srv.protocol + else -> null + } + } + 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.name" -> context.dataStore.data.first()[nodeName] ?: getDeviceName() + key == "db.path" -> context.dataStore.data.first()[dbPath] ?: "utun_chat.db" + 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] ?: "" + else -> null + } + } + + fun getInt(key: String): Int = runBlocking { + when { + key.startsWith("servers.") -> { + val parts = key.removePrefix("servers.").split(".", limit = 2) + val idx = parts[0].toIntOrNull() ?: return@runBlocking 0 + val field = parts.getOrNull(1) ?: return@runBlocking 0 + val list = loadServers() + val srv = list.getOrNull(idx) ?: return@runBlocking 0 + when (field) { + "port" -> srv.port + else -> 0 + } + } + key == "servers.count" -> loadServers().size + key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345 + key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999 + else -> 0 + } + } + + suspend fun setValue(key: String, value: String) { + context.dataStore.edit { prefs -> + when (key) { + "node.private_key" -> prefs[nodePrivateKey] = value + "node.public_key" -> prefs[nodePublicKey] = value + "node.name" -> prefs[nodeName] = value + "node.listen_port" -> prefs[nodeListenPort] = value.toIntOrNull() ?: 12345 + "control.port" -> prefs[controlPort] = value.toIntOrNull() ?: 9999 + "control.bind_ip" -> prefs[controlBindIp] = value + "db.path" -> prefs[dbPath] = value + "debug.console_level" -> prefs[debugConsoleLevel] = value + "debug.log_file" -> prefs[debugLogFile] = value + "debug.categories" -> prefs[debugCategories] = value + } + } + } + + 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 + } + } + + private companion object { + /** 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 + val b = addr.address // 16 bytes + val hi = ((b[0].toInt() and 0xFF) shl 8) or (b[1].toInt() and 0xFF) + // fc00::/7 → fc00-fdff, 2000::/3 → 2000-3fff + return hi in 0xFC00..0xFDFF || hi in 0x2000..0x3FFF + } + } + + /* ── First launch ── */ + + fun isFirstLaunch(): Boolean = runBlocking { + !(context.dataStore.data.first()[firstLaunchDone] ?: false) + } + + suspend fun markLaunched() { + context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true } + } + + /* ── Server entries ── */ + + suspend fun detectInterfaces(): List { + val servers = mutableListOf() + try { + val nis = NetworkInterface.getNetworkInterfaces() + while (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) -> addr.hostAddress?.let { it.split("%")[0] } + else -> null + } ?: continue + servers.add(ServerEntry( + id = UUID.randomUUID().toString(), + ifName = ni.name, + bindIp = ip, + protocol = "tcp", + port = random.nextInt(2000, 10000) // 2000-9999 + )) + } + } + } catch (e: Exception) { + LogManager.addLog("ERROR", "CFG", "detectInterfaces failed: ${e.message}") + } + saveServers(servers) + return servers + } + + suspend fun saveServers(servers: List) { + val json = gson.toJson(servers) + context.dataStore.edit { prefs -> prefs[serversJson] = json } + } + + fun loadServers(): List = runBlocking { + val json = context.dataStore.data.first()[serversJson] ?: "[]" + try { + gson.fromJson(json, object : TypeToken>() {}.type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + /** Return all currently available network interface names (for dropdown) */ + fun getAvailableInterfaces(): List> = runBlocking { + val list = mutableListOf>() + try { + val nis = NetworkInterface.getNetworkInterfaces() + while (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) -> addr.hostAddress?.let { it.split("%")[0] } + else -> null + } ?: continue + list.add(ni.name to ip) + } + } + } catch (_: Exception) {} + if (list.isEmpty()) list.add("lo" to "127.0.0.1") + list + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt new file mode 100644 index 00000000..07c39579 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt @@ -0,0 +1,127 @@ +package com.utun.chat.data + +import android.util.Base64 +import java.security.MessageDigest + +const val INVITE_LINK_VERSION: Int = 0x01 +const val INVITE_PUBKEY_SIZE: Int = 32 + +data class InviteAddr( + val family: Int, // 4 or 6 + val address: ByteArray, + val port: Int, + val socketId: Int +) { + 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 + } + override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId +} + +data class InviteData( + val channelId: Long, + val pubkey: ByteArray, + val nodeId: Long, + val addrs: List +) { + /** Serialize addresses for C bridge: family(1) + socketId(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 buf = ByteArray(size) + var pos = 0 + for (a in addrs) { + buf[pos] = a.family.toByte(); pos++ + buf[pos] = a.socketId.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++ + buf[pos] = (a.port and 0xFF).toByte(); pos++ + } + return buf + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InviteData) return false + return channelId == other.channelId && pubkey.contentEquals(other.pubkey) && + nodeId == other.nodeId && addrs == other.addrs + } + override fun hashCode(): Int = channelId.hashCode() * 31 + pubkey.contentHashCode() +} + +object InviteLink { + private const val PREFIX = "utun://" + + fun deriveNodeId(pubkey: ByteArray): Long { + val md = MessageDigest.getInstance("SHA-256") + val hash = md.digest(pubkey) + var id = 0L + for (i in 0 until 8) + id = (id shl 8) or (hash[i].toLong() and 0xFF) + return id and 0x7FFFFFFFFFFFFFFFL + } + + fun decode(link: String): InviteData? { + if (!link.startsWith(PREFIX)) return null + val raw: ByteArray + try { + raw = Base64.decode(link.substring(PREFIX.length), Base64.DEFAULT) + } catch (e: Exception) { + return null + } + if (raw.size < 11) return null + + var off = 0 + if ((raw[off].toInt() and 0xFF) != INVITE_LINK_VERSION) return null + off++ + + var chId = 0L + for (i in 0 until 8) { + if (off + i >= raw.size) return null + chId = (chId shl 8) or (raw[off + i].toLong() and 0xFF) + } + off += 8 + + var pubkey: ByteArray? = null + val addrs = mutableListOf() + var firstBlock = true + + while (off + 1 <= raw.size) { + val header = raw[off].toInt() and 0xFF; off++ + val cnt = (header and 0x03) + 1 + if (cnt < 1 || cnt > 4) return null + if (off + 32 > raw.size) return null + + if (firstBlock) { + pubkey = raw.copyOfRange(off, off + 32) + firstBlock = false + } + off += 32 + + 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 + val sockId = 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)) + } + } + + if (pubkey == null || pubkey.size != INVITE_PUBKEY_SIZE) return null + val nodeId = deriveNodeId(pubkey) + 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}" + } + return "ipv6:${addr.address.joinToString("") { "%02x".format(it) }}:${addr.port}" + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt new file mode 100644 index 00000000..afffcaa5 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/LogManager.kt @@ -0,0 +1,45 @@ +package com.utun.chat.data + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +data class LogEntry( + val timestamp: Long = System.currentTimeMillis(), + val level: String, + val category: String, + val message: String +) + +object LogManager { + private const val MAX_BUFFER = 100000 + private val _buffer = ArrayDeque(MAX_BUFFER) + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 512) + val events: SharedFlow = _events.asSharedFlow() + + @Synchronized + fun addLog(level: String, category: String, message: String) { + val entry = LogEntry(level = level, category = category, message = message) + if (_buffer.size >= MAX_BUFFER) _buffer.removeFirst() + _buffer.add(entry) + _events.tryEmit(entry) + } + + @Synchronized + fun getRecent(count: Int = 100): List { + return _buffer.takeLast(count) + } + + /** Called from JNI (via JNI config callback interface) */ + fun onNativeLog(level: Int, category: String?, message: String?) { + val lvl = when (level) { + 0 -> "TRACE" + 1 -> "DEBUG" + 2 -> "INFO" + 3 -> "WARN" + 4 -> "ERROR" + else -> "NONE" + } + addLog(lvl, category ?: "", message ?: "") + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt new file mode 100644 index 00000000..2a152144 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -0,0 +1,88 @@ +package com.utun.chat.data + +import com.utun.chat.ChatApplication + +interface ChatEventCallback { + fun onEvent(type: Int, data: ByteArray?) +} + +object NativeLib { + private var callbacksSet = false + private var running = false + + init { + System.loadLibrary("utun") + } + + /** 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( + dbPath, controlPort, + ChatApplication.instance.configProvider, + LogManager + ) + if (ok) callbacksSet = true + return ok + } + + /** Start full uTun instance from INI config text */ + fun start(configText: String): Boolean { + if (running) return true + val ok = nativeStart(configText) + if (ok) running = true + return ok + } + + fun stop() { + if (!running) return + nativeStop() + running = false + } + + fun setEventCallback(cb: ChatEventCallback) { + nativeSetEventCallback(object : ChatEventCallback { + override fun onEvent(type: Int, data: ByteArray?) { cb.onEvent(type, data) } + }) + } + + fun destroy() { + nativeDestroy() + callbacksSet = false + running = false + } + + fun restart(dbPath: String, controlPort: Int): Boolean { + destroy() + return init(dbPath, controlPort) + } + + fun isReady(): Boolean = running + + fun sendMessage(channelId: String, text: String) { nativeSendMessage(channelId, text) } + fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) } + fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) } + fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { + nativeJoinChannel(channelId, nodeId, pubkey, addrs, addrCount) + } + fun getChannels(): String = nativeGetChannels() ?: "[]" + fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]" + 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) } + + private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean + private external fun nativeStart(configText: String): Boolean + private external fun nativeStop() + private external fun nativeSetEventCallback(cb: Any) + private external fun nativeDestroy() + private external fun nativeSendMessage(channelId: String, text: String) + private external fun nativeCreateChannel(name: String, channelId: String) + private external fun nativeConnectNode(address: String, port: Int, pubkeyHex: String) + private external fun nativeJoinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) + private external fun nativeGetChannels(): String? + private external fun nativeGetMessages(channelId: String, limit: Int): String? + private external fun nativeSetDebugLevel(category: String, level: String) + private external fun nativeSubscribeLogs(enabled: Boolean) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt new file mode 100644 index 00000000..3a4e56a6 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt @@ -0,0 +1,9 @@ +package com.utun.chat.data + +data class ServerEntry( + val id: String, // UUID + val ifName: String, // wlan0, rmnet0, ... + val bindIp: String, // IPv4 address + val protocol: String, // "tcp" or "udp" + val port: Int +) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt new file mode 100644 index 00000000..c43e5a5c --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt @@ -0,0 +1,100 @@ +package com.utun.chat.headless + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.utun.chat.ChatApplication +import com.utun.chat.MainActivity +import com.utun.chat.data.LogManager +import com.utun.chat.data.NativeLib + +class HeadlessService : Service() { + override fun onCreate() { + super.onCreate() + createNotificationChannel() + + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + + val notification = NotificationCompat.Builder(this, "utun_channel") + .setContentTitle("uTun Chat") + .setContentText("Service running...") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + + 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 listenPort = provider.getInt("node.listen_port") + + val configText = buildString { + 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") + } + + LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir") + + /* Init callbacks first (log + config) — required before start */ + NativeLib.init(dbDir, 9999) + + NativeLib.setEventCallback(object : com.utun.chat.data.ChatEventCallback { + override fun onEvent(type: Int, data: ByteArray?) { + LogManager.addLog("DEBUG", "EVENT", "type=$type len=${data?.size ?: 0}") + } + }) + + val ok = NativeLib.start(configText) + if (ok) { + LogManager.addLog("INFO", "SRV", "uTun core started") + } else { + LogManager.addLog("ERROR", "SRV", "uTun start failed") + } + } + + override fun onDestroy() { + LogManager.addLog("INFO", "SRV", "uTun service stopping") + NativeLib.stop() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + "utun_channel", "uTun Chat", + NotificationManager.IMPORTANCE_LOW + ).apply { description = "uTun Chat background service" } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt new file mode 100644 index 00000000..cb86665e --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -0,0 +1,80 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.data.Message + +@Composable +fun MessageBubble(message: Message) { + val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) + val textColor = if (message.isOutgoing) Color.White else Color.Black + val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = alignment + ) { + Column( + modifier = Modifier + .widthIn(max = 280.dp) + .clip(RoundedCornerShape(12.dp)) + .background(bgColor) + .padding(10.dp) + ) { + Text(message.author, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) + Spacer(Modifier.height(2.dp)) + Text(message.text, fontSize = 15.sp, color = textColor) + Spacer(Modifier.height(2.dp)) + Text( + formatTime(message.ts), + fontSize = 10.sp, + color = textColor.copy(alpha = 0.5f), + modifier = Modifier.align(Alignment.End) + ) + } + } +} + +@Composable +fun InputBar( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + enabled: Boolean = true +) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + enabled = enabled, + modifier = Modifier.weight(1f), + placeholder = { Text("Message...") }, + maxLines = 4 + ) + Spacer(Modifier.width(8.dp)) + Button(onClick = onSend, enabled = enabled && text.isNotBlank()) { + Text("Send") + } + } +} + +private fun formatTime(ts: Long): String { + if (ts == 0L) return "" + val s = ts / 1_000_000_000 // nanoseconds -> seconds + val h = (s / 3600) % 24 + val m = (s / 60) % 60 + return "%02d:%02d".format(h, m) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt new file mode 100644 index 00000000..527688e5 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -0,0 +1,123 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.data.Channel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChannelListScreen( + channels: List, + connected: Boolean, + onSelectChannel: (Channel) -> Unit, + onCreateChannel: () -> Unit, + onJoinChannel: () -> Unit, + onSettings: () -> Unit, + onLogs: () -> Unit +) { + var showCreateDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("uTun Chat") }, + actions = { + Text( + if (connected) "ON" else "OFF", + color = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(end = 8.dp) + ) + IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") } + IconButton(onClick = onLogs) { Icon(Icons.Default.Build, "Logs") } + IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, "Settings") } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, "New channel") + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + if (channels.isEmpty()) { + Box(Modifier.fillMaxSize().weight(1f)) { + Text( + "No channels yet.\nTap + to create one.", + modifier = Modifier.padding(32.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn { + items(channels) { ch -> + Surface(modifier = Modifier.clickable { onSelectChannel(ch) }) { + Row(Modifier.fillMaxWidth().padding(16.dp)) { + Box(Modifier.size(48.dp), contentAlignment = androidx.compose.ui.Alignment.Center) { + Surface(shape = MaterialTheme.shapes.small) { + Text("#", fontSize = 24.sp, modifier = Modifier.padding(8.dp)) + } + } + Spacer(Modifier.width(12.dp)) + Column { + Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp) + if (ch.lastMsg.isNotEmpty()) + Text(ch.lastMsg, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + } + HorizontalDivider() + } + } + } + } + } + + if (showCreateDialog) { + CreateChannelDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name -> + showCreateDialog = false + onCreateChannel() + } + ) + } +} + +@Composable +fun CreateChannelDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New Channel") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Channel name") }, + singleLine = true + ) + }, + confirmButton = { + Button(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt new file mode 100644 index 00000000..a3f05bb1 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -0,0 +1,57 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.utun.chat.data.Channel +import com.utun.chat.data.Message +import com.utun.chat.ui.components.InputBar +import com.utun.chat.ui.components.MessageBubble + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + channel: Channel, + messages: List, + connected: Boolean, + onSendMessage: (String) -> Unit, + onBack: () -> Unit +) { + var text by remember { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(channel.name) }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } + } + ) + }, + bottomBar = { + InputBar( + text = text, + onTextChange = { text = it }, + onSend = { + onSendMessage(text) + text = "" + }, + enabled = connected + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + reverseLayout = false + ) { + items(messages) { msg -> MessageBubble(msg) } + item { Spacer(Modifier.height(8.dp)) } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt new file mode 100644 index 00000000..3c4da4e9 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt @@ -0,0 +1,139 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +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 kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JoinChannelDialog( + autoLink: String? = null, + onScanQr: () -> Unit, + onConnect: (InviteData) -> Unit, + onDismiss: () -> Unit +) { + var link by remember { mutableStateOf(autoLink ?: "") } + var decoded by remember { mutableStateOf(null) } + var status by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + var connecting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + if (!autoLink.isNullOrBlank()) { + LaunchedEffect(autoLink) { + connecting = true + isError = false + + status = "Decoding..." + delay(300) + + val d = InviteLink.decode(autoLink.trim()) + if (d != null) { + decoded = d + status = "Connecting to peer..." + delay(800) + + onConnect(d) + } else { + connecting = false + status = "Invalid invite link" + isError = true + } + } + } + + AlertDialog( + onDismissRequest = { if (!connecting) onDismiss() }, + title = { Text("Join Channel") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (autoLink.isNullOrBlank()) { + OutlinedTextField( + value = link, + onValueChange = { newVal -> + link = newVal + status = "" + isError = false + scope.launch { + delay(400) + if (link == newVal) { + if (newVal.isBlank()) { + decoded = null + } else { + decoded = InviteLink.decode(newVal.trim()) + } + } + } + }, + label = { Text("Invite link") }, + placeholder = { Text("utun://...") }, + trailingIcon = { + IconButton(onClick = onScanQr) { + Icon(Icons.Default.QrCodeScanner, "Scan QR") + } + }, + singleLine = true, + enabled = !connecting, + modifier = Modifier.fillMaxWidth() + ) + } + + decoded?.let { d -> + val addrStr = d.addrs.joinToString(", ") { InviteLink.addrToString(it) } + Column(Modifier.fillMaxWidth()) { + Text("Node: 0x%016x".format(d.nodeId), fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Channel: ${d.channelId}", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (addrStr.isNotBlank()) + Text(addrStr, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + if (status.isNotBlank()) { + Text( + status, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontSize = 14.sp, fontWeight = FontWeight.Medium + ) + } + } + }, + confirmButton = { + Button( + onClick = { + connecting = true + isError = false + status = "Connecting to peer..." + val d = InviteLink.decode(link.trim()) + if (d != null) { + onConnect(d) + } else { + connecting = false + status = "Invalid invite link" + isError = true + } + }, + enabled = !connecting && decoded != null && (autoLink.isNullOrBlank()) + ) { + Text(if (connecting) "..." else "Connect") + } + }, + dismissButton = { + if (autoLink.isNullOrBlank()) { + TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") } + } + } + ) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt new file mode 100644 index 00000000..54c8e89b --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt @@ -0,0 +1,98 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.ChatApplication +import com.utun.chat.data.LogEntry +import com.utun.chat.data.LogManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogScreen(onBack: () -> Unit) { + val entries = remember { mutableStateListOf() } + val listState = rememberLazyListState() + var filterLevel by remember { mutableStateOf("") } + var autoScroll by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + /* Load history from in-memory buffer */ + val recent = LogManager.getRecent(500) + entries.addAll(recent) + + /* Live tail via SharedFlow */ + LogManager.events.collect { entry -> + entries.add(entry) + if (entries.size > 10000) entries.removeAt(0) + if (autoScroll) { + listState.animateScrollToItem(entries.size - 1) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Logs") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } }, + actions = { + for ((lvl, label) in arrayOf("" to "ALL", "ERROR" to "ERR", "WARN" to "WRN", "INFO" to "INF", "DEBUG" to "DBG", "TRACE" to "TRC")) { + val selected = filterLevel == lvl + Text( + label, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + modifier = Modifier + .clickable { filterLevel = lvl } + .background( + if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + MaterialTheme.shapes.extraSmall + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + TextButton(onClick = { autoScroll = !autoScroll }) { + Text(if (autoScroll) "|>" else "||", fontSize = 11.sp) + } + } + ) + } + ) { padding -> + val filtered = if (filterLevel.isEmpty()) entries.toList() + else entries.filter { it.level == filterLevel } + + LazyColumn(modifier = Modifier.padding(padding), state = listState) { + items(filtered.size) { idx -> // index-based to avoid recomposition on each new entry + val entry = filtered[idx] + val color = when (entry.level) { + "ERROR" -> Color(0xFFFF4444) + "WARN" -> Color(0xFFFFAA00) + "INFO" -> Color(0xFF4CAF50) + "DEBUG" -> Color(0xFF2196F3) + "TRACE" -> Color(0xFF9E9E9E) + else -> Color.Gray + } + Text( + text = "${entry.level} [${entry.category}] ${entry.message}", + color = color, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 1.dp) + ) + } + item { Spacer(Modifier.height(8.dp)) } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt new file mode 100644 index 00000000..4136ee74 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt @@ -0,0 +1,165 @@ +package com.utun.chat.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QrScanScreen( + onResult: (String) -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var hasPermission by remember { mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + LaunchedEffect(Unit) { + if (!hasPermission) launcher.launch(Manifest.permission.CAMERA) + } + + /* Camera lifecycle — unbind when leaving this screen */ + val cameraProviderRef = remember { mutableStateOf(null) } + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + val mainHandler = remember { Handler(Looper.getMainLooper()) } + + DisposableEffect(Unit) { + onDispose { + Log.d("QrScan", "disposing camera") + cameraProviderRef.value?.unbindAll() + cameraProviderRef.value = null + cameraExecutor.shutdown() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Scan QR Code") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } + } + ) + } + ) { padding -> + if (!hasPermission) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Camera permission required") + Spacer(Modifier.height(8.dp)) + Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { + Text("Grant") + } + } + } + } else { + var scanned by remember { mutableStateOf(false) } + + AndroidView( + modifier = Modifier.fillMaxSize().padding(padding), + factory = { ctx -> + val previewView = PreviewView(ctx) + + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + cameraProviderRef.value = cameraProvider + + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options) + + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + analysis.setAnalyzer(cameraExecutor) { imageProxy -> + if (scanned) { + imageProxy.close() + return@setAnalyzer + } + val mediaImage = imageProxy.image + if (mediaImage != null) { + try { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + val text = barcode.rawValue + if (text != null && text.startsWith("utun://")) { + scanned = true + val link = text + Log.d("QrScan", "detected: $link") + mainHandler.post { onResult(link) } + break + } + } + } + .addOnCompleteListener { imageProxy.close() } + } catch (e: Exception) { + Log.e("QrScan", "scan error", e) + imageProxy.close() + } + } else { + imageProxy.close() + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + analysis + ) + Log.d("QrScan", "camera bound ok") + } catch (e: Exception) { + Log.e("QrScan", "Camera bind failed", e) + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + } + ) + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt new file mode 100644 index 00000000..c760a477 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -0,0 +1,347 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +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.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.utun.chat.ChatApplication +import com.utun.chat.data.NativeLib +import com.utun.chat.data.ServerEntry +import kotlinx.coroutines.launch +import java.util.UUID + +private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE") +private val CATEGORIES = listOf( + "UASYNC", "LL_QUEUE", "CONNECTION", "ETCP", "CRYPTO", "MEMORY", + "TIMING", "CONFIG", "TUN", "ROUTING", "TIMERS", "NORMALIZER", "BGP", + "SOCKET", "CONTROL", "DUMP", "TRAFFIC", "DEBUG", "GENERAL", "NAT", + "KEEPALIVE", "ETCPROUTE", "BBR", "ETCP_DUMP", "CONNECTIVITY", "DB_SYNC" +) +private val PROTOCOLS = listOf("tcp", "udp") + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val provider = ChatApplication.instance.configProvider + val scope = rememberCoroutineScope() + var dirty by remember { mutableStateOf(false) } + + var nodeName by remember { mutableStateOf("") } + var listenPort by remember { mutableStateOf("12345") } + var controlPort by remember { mutableStateOf("9999") } + var controlBindIp by remember { mutableStateOf("127.0.0.1") } + var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") } + var dbPath by remember { mutableStateOf("") } + var debugLevel by remember { mutableStateOf("info") } + var logFile by remember { mutableStateOf("") } + var pubKey by remember { mutableStateOf("") } + + var showDebugCategories by remember { mutableStateOf(false) } + var showOverwriteKeys by remember { mutableStateOf(false) } + var catLevels by remember { mutableStateOf>(emptyMap()) } + + var servers by remember { mutableStateOf>(emptyList()) } + var availableIfaces by remember { mutableStateOf>>(emptyList()) } + + LaunchedEffect(Unit) { + nodeName = provider.getString("node.name") ?: provider.getDeviceName() + listenPort = provider.getInt("node.listen_port").toString() + controlPort = provider.getInt("control.port").toString() + controlBindIp = provider.getString("control.bind_ip") ?: "127.0.0.1" + val allIface = listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() + ctrlBindLabel = allIface.firstOrNull { it.second == controlBindIp }?.let { "${it.first} (${it.second})" } ?: "lo (127.0.0.1)" + 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" + val stored = provider.getString("debug.categories") ?: "" + catLevels = if (stored.isNotEmpty()) { + stored.split(",").associate { pair -> + val (k, v) = pair.split("=", limit = 2) + k to v + } + } else emptyMap() + servers = provider.loadServers() + availableIfaces = provider.getAvailableInterfaces() + } + + val save = { s: List -> + dirty = true + servers = s + scope.launch { provider.saveServers(s) } + } + + val setDebug = { level: String -> + dirty = true + NativeLib.setDebugLevel("", level) + scope.launch { provider.setValue("debug.console_level", level) } + debugLevel = level + } + + val setCatLevel = { cat: String, lvl: String -> + dirty = true + 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) + } + + val handleBack = { + if (dirty) { + val path = provider.getString("db.path") ?: "utun_chat.db" + val port = provider.getInt("control.port") + NativeLib.restart(path, port) + } + onBack() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.Default.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + modifier = Modifier.padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Node", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(nodeName, { v -> dirty = true; nodeName = v; scope.launch { provider.setValue("node.name", v) } }, + label = { Text("Node Name") }, singleLine = true, + modifier = Modifier.heightIn(min = 40.dp)) + OutlinedTextField(listenPort, { v -> dirty = true; listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, + label = { Text("Listen Port") }, + modifier = Modifier.heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + // Control socket interface (including lo0) + var ctrlExpanded by remember { mutableStateOf(false) } + var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") } + val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() } + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, + modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = ctrlBindLabel, onValueChange = {}, + readOnly = true, singleLine = true, textStyle = MaterialTheme.typography.bodyLarge, + label = { Text("Control Bind IP") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp) + ) + ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) { + ctrlIfaces.forEach { (name, ip) -> + val label = "$name ($ip)" + DropdownMenuItem(text = { Text(label) }, onClick = { + controlBindIp = ip + ctrlBindLabel = label + dirty = true + scope.launch { provider.setValue("control.bind_ip", ip) } + ctrlExpanded = false + }) + } + } + } + Spacer(Modifier.width(8.dp)) + OutlinedTextField(controlPort, { v -> dirty = true; controlPort = v; scope.launch { provider.setValue("control.port", v) } }, + label = { Text("Port") }, + modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } + + 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") + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + // ── Network Interfaces ── + Text("Network Interfaces", style = MaterialTheme.typography.titleMedium) + servers.forEachIndexed { idx, srv -> + Column(modifier = Modifier.fillMaxWidth()) { + // Line 1: [ifName ▾] IP [✕] + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + var ifExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, + modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = srv.ifName, onValueChange = {}, + readOnly = true, singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp) + ) + ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) { + availableIfaces.forEach { (name, ip) -> + DropdownMenuItem(text = { Text("$name ($ip)") }, onClick = { + save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = name, bindIp = ip) }) + ifExpanded = false + }) + } + } + } + Spacer(Modifier.width(4.dp)) + Text(srv.bindIp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + modifier = Modifier.width(105.dp)) + IconButton(onClick = { + save(servers.toMutableList().also { it.removeAt(idx) }) + }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error) + } + } + // Line 2: [TCP ▾] port + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + var protoExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, + modifier = Modifier.width(100.dp)) { + OutlinedTextField( + value = srv.protocol.uppercase(), onValueChange = {}, + readOnly = true, singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(protoExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp) + ) + ExposedDropdownMenu(protoExpanded, onDismissRequest = { protoExpanded = false }) { + PROTOCOLS.forEach { p -> + DropdownMenuItem(text = { Text(p.uppercase()) }, onClick = { + save(servers.toMutableList().also { it[idx] = srv.copy(protocol = p) }) + protoExpanded = false + }) + } + } + } + Spacer(Modifier.width(4.dp)) + var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) } + OutlinedTextField( + value = portText, singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + onValueChange = { v -> + portText = v + val p = v.toIntOrNull() + if (p != null && p in 1..65535) + save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) + }, + modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + } + Spacer(Modifier.height(2.dp)) + } + // Add button + TextButton( + onClick = { + val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1") + val rndPort = (2000..9999).random() + save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort)) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Add interface") + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + // ── Debug ── + Text("Debug", style = MaterialTheme.typography.titleMedium) + DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug) + OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, + label = { Text("Log File") }, singleLine = true) + + Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories }, + horizontalArrangement = Arrangement.SpaceBetween) { + Text("Categories", style = MaterialTheme.typography.titleMedium) + Icon(Icons.Default.ArrowDropDown, + contentDescription = if (showDebugCategories) "Collapse" else "Expand", + modifier = Modifier.padding(end = 4.dp)) + } + if (showDebugCategories) { + CATEGORIES.forEach { cat -> + val current = catLevels[cat] ?: "" + DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" }, + onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true) + } + } + Spacer(Modifier.height(16.dp)) + } + } + + if (showOverwriteKeys) { + AlertDialog( + onDismissRequest = { showOverwriteKeys = false }, + title = { Text("Overwrite Keys?") }, + text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") }, + confirmButton = { + Button(onClick = { + showOverwriteKeys = false + dirty = true + scope.launch { provider.generateKeys(); pubKey = provider.getString("node.public_key") ?: "" } + }) { Text("Overwrite") } + }, + dismissButton = { + TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DebugLevelDropdown( + label: String, current: String, onSelect: (String) -> Unit, compact: Boolean = false +) { + var expanded by remember { mutableStateOf(false) } + if (compact) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text(label, modifier = Modifier.width(120.dp), style = MaterialTheme.typography.labelMedium) + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField(value = current, onValueChange = {}, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().height(48.dp).weight(1f), singleLine = true) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LEVELS.forEach { lvl -> + DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false }) + } + } + } + } + } else { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField(value = current, onValueChange = {}, readOnly = true, + label = { Text("$label level") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(), singleLine = true) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LEVELS.forEach { lvl -> + DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false }) + } + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt new file mode 100644 index 00000000..0eae588c --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -0,0 +1,100 @@ +package com.utun.chat.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.utun.chat.data.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ChatViewModel(private val repo: ChatRepository) : ViewModel() { + + private val _channels = MutableStateFlow>(emptyList()) + val channels: StateFlow> = _channels + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + private val _currentChannel = MutableStateFlow(null) + val currentChannel: StateFlow = _currentChannel + + 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() + } + } + } + 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 */ + } + } + }) + refreshChannels() + } + + fun refreshChannels() { + viewModelScope.launch { + _channels.value = withContext(Dispatchers.IO) { repo.getChannels() } + } + } + + fun refreshMessages(chId: String) { + viewModelScope.launch { + _messages.value = withContext(Dispatchers.IO) { repo.getMessages(chId) } + } + } + + fun selectChannel(channel: Channel) { + _currentChannel.value = channel + refreshMessages(channel.id) + } + + fun sendMessage(text: String) { + val ch = _currentChannel.value ?: return + repo.sendMessage(ch.id, text) + viewModelScope.launch { + _messages.value = withContext(Dispatchers.IO) { repo.getMessages(ch.id) } + refreshChannels() + } + } + + fun createChannel(name: String, channelId: String) { + repo.createChannel(name, channelId) + refreshChannels() + } + + fun connectNode(address: String, port: Int, pubkeyHex: String) { + repo.connectNode(address, port, pubkeyHex) + } + + fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { + repo.joinChannel(channelId, nodeId, pubkey, addrs, addrCount) + } + + class Factory(private val repo: ChatRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ChatViewModel(repo) as T + } +} diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 9284c725..76e77f40 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1,13 +1,15 @@ /* * android_jni_bridge.c — JNI bridge implementation * - * C side of the bridge. For now: - * - Minimal stubs for core functions (will connect to utun instance later) - * - JNI function stubs (compile only on Android with NDK) + * C side of the bridge. Wires Kotlin ↔ uTun stack: + * - nativeStart(configText) → instance_lite_start() + * - nativeJoinChannel(chId, nodeId, pubkey, addrs) → chat_sync_connect_from_invite() + * - Chat events → JNI callback → Kotlin */ #include "android_jni_bridge.h" #include "../libutun_lite/utun_config_api.h" #include "../libutun_lite/invite_link_c.h" +#include "../libutun_lite/instance_lite.h" #include "../../../lib/debug_config.h" #include #include @@ -19,7 +21,6 @@ #include #define LOG_TAG "utun-bridge" #else -/* no android/log.h — fallback to stderr */ #define ANDROID_LOG_VERBOSE 2 #define ANDROID_LOG_DEBUG 3 #define ANDROID_LOG_INFO 4 @@ -27,10 +28,8 @@ #define ANDROID_LOG_ERROR 6 #endif -/* bridge log levels (match debug_config levels: 0=TRACE..4=ERROR) */ enum { BLEV_TRACE = 0, BLEV_DEBUG = 1, BLEV_INFO = 2, BLEV_WARN = 3, BLEV_ERROR = 4 }; -/* map bridge level → android log level */ static int blevel_to_android(int blevel) { switch (blevel) { case BLEV_TRACE: return ANDROID_LOG_VERBOSE; @@ -42,13 +41,9 @@ static int blevel_to_android(int blevel) { } } -/* ── Callback storage (declared before any use) ── */ - static utun_log_fn g_on_log = NULL; static utun_event_fn g_on_event = NULL; -/* ── Log to logcat (Android) or stderr (headless) + forward to Kotlin ── */ - static void bridge_log(int level, const char* fmt, ...) { va_list ap; va_start(ap, fmt); @@ -78,8 +73,6 @@ static void bridge_log(int level, const char* fmt, ...) { } } -/* ── Log hook for debug_config → logcat + Kotlin LogManager ── */ - static void bridge_debug_hook(int level, const char* category, const char* message) { #ifdef __ANDROID__ __android_log_print(blevel_to_android(level), LOG_TAG, "[%s] %s", @@ -94,8 +87,6 @@ static utun_config_string_fn g_cfg_string = NULL; static utun_config_int64_fn g_cfg_int64 = NULL; static utun_config_int_fn g_cfg_int = NULL; -/* ── Config wrappers (bridge callbacks → config_api callbacks with defaults) ── */ - static const char* cfg_wrapper_string(const char* key, const char* def) { if (g_cfg_string) { const char* val = g_cfg_string(key); return val ? val : def; } return def; @@ -125,7 +116,6 @@ bool utun_bridge_init(const char* db_path, int control_port, const struct utun_bridge_config* config) { g_on_log = on_log; g_on_event = on_event; - debug_set_log_hook(bridge_debug_hook); if (config) { @@ -141,52 +131,60 @@ bool utun_bridge_init(const char* db_path, int control_port, utun_config_set_provider(&provider); } - bridge_log(BLEV_INFO, "uTun bridge init: db=%s port=%d", + bridge_log(BLEV_INFO, "bridge init: db=%s port=%d (stub, use nativeStart for real init)", db_path ? db_path : "(null)", control_port); return true; } void utun_bridge_destroy(void) { - bridge_log(BLEV_INFO, "uTun bridge destroy"); + instance_lite_stop(); g_on_log = NULL; g_on_event = NULL; g_cfg_string = NULL; g_cfg_int64 = NULL; g_cfg_int = NULL; + bridge_log(BLEV_INFO, "bridge destroy"); +} + +void utun_bridge_start(const char* config_text) { + bridge_log(BLEV_INFO, "bridge start: launching utun instance"); + int rc = instance_lite_start(config_text); + if (rc != 0) { + bridge_log(BLEV_ERROR, "bridge start: instance_lite_start failed rc=%d", rc); + } else { + bridge_log(BLEV_INFO, "bridge start: instance running, node_id ready"); + } } void utun_bridge_poll(void) { } void utun_bridge_send_message(const char* channel_id, const char* text) { - bridge_log(BLEV_DEBUG, "send ch=%s text=%s", - channel_id ? channel_id : "", text ? text : ""); + bridge_log(BLEV_DEBUG, "send ch=%s text=%s", channel_id ? channel_id : "", text ? text : ""); } void utun_bridge_create_channel(const char* name, const char* channel_id) { - bridge_log(BLEV_INFO, "create_channel name=%s id=%s", - name ? name : "", channel_id ? channel_id : ""); + bridge_log(BLEV_INFO, "create_channel name=%s id=%s", name ? name : "", channel_id ? channel_id : ""); } void utun_bridge_connect_node(const char* address, int port, const char* pubkey_hex) { - bridge_log(BLEV_INFO, "connect %s:%d pubkey=%s", - address ? address : "", port, pubkey_hex ? pubkey_hex : ""); + bridge_log(BLEV_INFO, "connect %s:%d pubkey=%s", address ? address : "", port, pubkey_hex ? pubkey_hex : ""); } +/* Real implementation: calls chat_sync_connect_from_invite() */ +#include "chat/chat_sync.h" + void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id, const uint8_t* pubkey_bin, const uint8_t* addrs_data, int addr_count) { bridge_log(BLEV_INFO, "join ch=%llu node=0x%016llx addrs=%d", (unsigned long long)channel_id, (unsigned long long)node_id, addr_count); if (!pubkey_bin || !addrs_data || addr_count <= 0) { - bridge_log(BLEV_ERROR, "join invalid args: pubkey=%p addrs=%p count=%d", - (const void*)pubkey_bin, (const void*)addrs_data, addr_count); - utun_bridge_event(6 /* CHAT_EVT_CONNECT_RESULT */, "{\"node_id\":0,\"result\":-7}"); + bridge_log(BLEV_ERROR, "join invalid args"); + utun_bridge_event(6, "{\"node_id\":0,\"result\":-7}"); /* CHAT_EVT_CONNECT_RESULT */ return; } - /* TODO: integrate with actual chat_sync_connect_from_invite() via uasync_post */ - bridge_log(BLEV_INFO, "join stub: returning OK (real chat_sync not connected yet)"); - utun_bridge_event(6, "{\"node_id\":0,\"result\":0}"); + chat_sync_connect_from_invite(channel_id, node_id, pubkey_bin, addrs_data, addr_count); } char* utun_bridge_get_channels_json(void) { @@ -212,16 +210,17 @@ void utun_bridge_set_debug_level(const char* category, const char* level) { static JavaVM* g_jvm = NULL; static jobject g_config_callback = NULL; -static jobject g_log_callback = NULL; /* LogManager instance */ +static jobject g_log_callback = NULL; +static jobject g_event_callback = NULL; +static jmethodID g_event_mid = NULL; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { (void)reserved; g_jvm = vm; - bridge_log(BLEV_INFO, "JNI_OnLoad OK"); return JNI_VERSION_1_6; } -/* ── JNI log callback: calls LogManager.onNativeLog(level, cat, msg) ── */ +/* ── JNI log callback → LogManager.onNativeLog() ── */ static void jni_on_log(int level, const char* category, const char* message) { if (!g_jvm || !g_log_callback || !category || !message) return; @@ -237,6 +236,26 @@ static void jni_on_log(int level, const char* category, const char* message) { (*env)->DeleteLocalRef(env, jcat); } +/* ── JNI event callback (chat_event → Kotlin) ── */ + +static void jni_on_event(int type, const uint8_t* data, int len) { + if (!g_jvm || !g_event_callback || !g_event_mid) return; + JNIEnv* env = NULL; + if ((*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) return; + jbyteArray jdata = NULL; + if (data && len > 0) { + jdata = (*env)->NewByteArray(env, len); + if (jdata) (*env)->SetByteArrayRegion(env, jdata, 0, len, (const jbyte*)data); + } + (*env)->CallVoidMethod(env, g_event_callback, g_event_mid, (jint)type, jdata); + if (jdata) (*env)->DeleteLocalRef(env, jdata); +} + +static void instance_event_handler(int type, const uint8_t* data, int len) { + bridge_log(BLEV_DEBUG, "event type=%d len=%d", type, len); + jni_on_event(type, data, len); +} + static jstring jni_get_string_callback(JNIEnv* env, const char* key) { if (!g_config_callback) return NULL; jclass cls = (*env)->GetObjectClass(env, g_config_callback); @@ -272,6 +291,40 @@ static int jni_cfg_get_int(const char* key) { return s ? atoi(s) : 0; } +/* ── nativeStart(configText) — real uTun instance launch ── */ + +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart( + JNIEnv* env, jobject thiz, jstring configText) { + (void)thiz; + if (!configText) { + bridge_log(BLEV_ERROR, "nativeStart: null config"); + return JNI_FALSE; + } + const char* cfg = (*env)->GetStringUTFChars(env, configText, NULL); + if (!cfg) return JNI_FALSE; + + /* Wire C logs → logcat + Kotlin LogManager */ + utun_log_fn log_cb = g_log_callback ? jni_on_log : NULL; + utun_bridge_init(NULL, 0, log_cb, NULL, NULL); + + /* Set up event forwarder */ + instance_lite_set_event_handler(instance_event_handler); + + utun_bridge_start(cfg); + (*env)->ReleaseStringUTFChars(env, configText, cfg); + return instance_lite_is_running() ? JNI_TRUE : JNI_FALSE; +} + +/* ── nativeStop() ── */ + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeStop( + JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + utun_bridge_destroy(); +} + +/* ── nativeInit (legacy, just saves callbacks) ── */ + JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit( JNIEnv* env, jobject thiz, jstring dbPath, jint controlPort, jobject configCallback, jobject logCb) { @@ -284,8 +337,6 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit( g_log_callback = logCb ? (*env)->NewGlobalRef(env, logCb) : NULL; const char* db = dbPath ? (*env)->GetStringUTFChars(env, dbPath, NULL) : NULL; - - /* Set log hook so all C logs go to logcat + Kotlin LogManager */ utun_log_fn log_cb = g_log_callback ? jni_on_log : NULL; struct utun_bridge_config cfg = { @@ -294,17 +345,60 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit( }; bool ok = utun_bridge_init(db, (int)controlPort, log_cb, NULL, &cfg); - if (db) (*env)->ReleaseStringUTFChars(env, dbPath, db); return ok ? JNI_TRUE : JNI_FALSE; } +/* ── nativeJoinChannel ── */ + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( + JNIEnv* env, jobject thiz, jlong channelId, jlong nodeId, + jbyteArray pubkey, jbyteArray addrsData, jint addrCount) { + (void)thiz; + if (!pubkey || !addrsData) { + bridge_log(BLEV_ERROR, "nativeJoinChannel null args"); + return; + } + jsize pk_len = (*env)->GetArrayLength(env, pubkey); + jsize ad_len = (*env)->GetArrayLength(env, addrsData); + if (pk_len < 32 || ad_len <= 0) { + bridge_log(BLEV_ERROR, "nativeJoinChannel bad sizes pk=%d ad=%d", (int)pk_len, (int)ad_len); + return; + } + uint8_t* pk = (uint8_t*)(*env)->GetByteArrayElements(env, pubkey, NULL); + uint8_t* ad = (uint8_t*)(*env)->GetByteArrayElements(env, addrsData, NULL); + bridge_log(BLEV_DEBUG, "nativeJoinChannel ch=%lld node=0x%llx pk=%02x%02x... addrs_sz=%d", + (long long)channelId, (unsigned long long)nodeId, pk[0], pk[1], (int)ad_len); + utun_bridge_join_channel((uint64_t)channelId, (uint64_t)nodeId, pk, ad, (int)addrCount); + (*env)->ReleaseByteArrayElements(env, addrsData, (jbyte*)ad, JNI_ABORT); + (*env)->ReleaseByteArrayElements(env, pubkey, (jbyte*)pk, JNI_ABORT); +} + +/* ── nativeSetEventCallback(eventCb) ── */ + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback( + JNIEnv* env, jobject thiz, jobject callback) { + (void)thiz; + if (g_event_callback) (*env)->DeleteGlobalRef(env, g_event_callback); + g_event_callback = callback ? (*env)->NewGlobalRef(env, callback) : NULL; + if (g_event_callback) { + jclass cls = (*env)->GetObjectClass(env, g_event_callback); + g_event_mid = (*env)->GetMethodID(env, cls, "onEvent", "(I[B)V"); + if (!g_event_mid) bridge_log(BLEV_ERROR, "onEvent method not found on callback"); + } else { + g_event_mid = NULL; + } +} + +/* ── Pass-through JNI wrappers (unchanged from before) ── */ + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy( JNIEnv* env, jobject thiz) { (void)thiz; utun_bridge_destroy(); if (g_config_callback) { (*env)->DeleteGlobalRef(env, g_config_callback); g_config_callback = NULL; } if (g_log_callback) { (*env)->DeleteGlobalRef(env, g_log_callback); g_log_callback = NULL; } + if (g_event_callback) { (*env)->DeleteGlobalRef(env, g_event_callback); g_event_callback = NULL; } } JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage( @@ -337,29 +431,6 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeConnectNode( if (a) (*env)->ReleaseStringUTFChars(env, address, a); } -JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( - JNIEnv* env, jobject thiz, jlong channelId, jlong nodeId, - jbyteArray pubkey, jbyteArray addrsData, jint addrCount) { - (void)thiz; - if (!pubkey || !addrsData) { - bridge_log(BLEV_ERROR, "nativeJoinChannel null args"); - return; - } - jsize pk_len = (*env)->GetArrayLength(env, pubkey); - jsize ad_len = (*env)->GetArrayLength(env, addrsData); - if (pk_len < 32 || ad_len <= 0) { - bridge_log(BLEV_ERROR, "nativeJoinChannel bad sizes pk=%d ad=%d", (int)pk_len, (int)ad_len); - return; - } - uint8_t* pk = (uint8_t*)(*env)->GetByteArrayElements(env, pubkey, NULL); - uint8_t* ad = (uint8_t*)(*env)->GetByteArrayElements(env, addrsData, NULL); - bridge_log(BLEV_DEBUG, "nativeJoinChannel ch=%lld node=0x%llx pk=%02x%02x... addrs_sz=%d", - (long long)channelId, (unsigned long long)nodeId, pk[0], pk[1], (int)ad_len); - utun_bridge_join_channel((uint64_t)channelId, (uint64_t)nodeId, pk, ad, (int)addrCount); - (*env)->ReleaseByteArrayElements(env, addrsData, (jbyte*)ad, JNI_ABORT); - (*env)->ReleaseByteArrayElements(env, pubkey, (jbyte*)pk, JNI_ABORT); -} - JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChannels( JNIEnv* env, jobject thiz) { (void)thiz; @@ -390,4 +461,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetDebugLevel( if (c) (*env)->ReleaseStringUTFChars(env, category, c); } +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSubscribeLogs( + JNIEnv* env, jobject thiz, jboolean enabled) { + (void)thiz; (void)env; + g_on_log = (enabled && g_log_callback) ? jni_on_log : NULL; + bridge_log(BLEV_INFO, "native log subscription %s", enabled ? "ON" : "OFF"); +} + #endif /* __ANDROID__ */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 6640d11b..437e0f72 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -31,20 +31,19 @@ struct utun_bridge_config { utun_config_int_fn get_int; }; -/* ── Initialize uTun core ── */ +/* ── Initialize uTun core (legacy stub — use start for real init) ── */ bool utun_bridge_init(const char* db_path, int control_port, utun_log_fn on_log, utun_event_fn on_event, const struct utun_bridge_config* config); +/* ── Start full uTun stack from INI config text ── */ +void utun_bridge_start(const char* config_text); + /* ── Shutdown uTun core ── */ void utun_bridge_destroy(void); -/* ── Background poll (alternative to running uasync loop) ── */ -/* Called from main/uasync thread periodically to process */ -void utun_bridge_poll(void); - /* ── Actions (Kotlin → C) ── */ void utun_bridge_send_message(const char* channel_id, const char* text); diff --git a/tools/chatgui-android/libutun_lite/instance_lite.c b/tools/chatgui-android/libutun_lite/instance_lite.c new file mode 100644 index 00000000..40090bab --- /dev/null +++ b/tools/chatgui-android/libutun_lite/instance_lite.c @@ -0,0 +1,282 @@ +/* + * instance_lite.c — lightweight uTun instance lifecycle for Android + * + * Kotlin sends INI config text → parse_config_from_buf → + * utun_instance_create_from_config → sqlite3_open → utun_instance_init → + * pthread with uasync_poll loop. + * + * Mirrors UtunNode::runLoop() from tools/chatgui/transport/utun_node.cpp:221-372. + */ +#include "instance_lite.h" +#include "utun_instance.h" +#include "config_parser.h" +#include "config_updater.h" +#include "secure_channel.h" +#include "etcp_router.h" +#include "topo_node_sqlite.h" +#include "chat/chat_core.h" +#include "chat/chat_sync.h" +#include "chat/chat_event.h" +#include "u_async.h" +#include "sqlite3.h" +#include "mem.h" +#include "debug_config.h" +#include +#include +#include +#include +#include +#include + +#ifdef __ANDROID__ +#include +#define IL_LOGV(fmt, ...) __android_log_print(ANDROID_LOG_VERBOSE, "utun-inst", fmt, ##__VA_ARGS__) +#define IL_LOGI(fmt, ...) __android_log_print(ANDROID_LOG_INFO, "utun-inst", fmt, ##__VA_ARGS__) +#define IL_LOGE(fmt, ...) __android_log_print(ANDROID_LOG_ERROR, "utun-inst", fmt, ##__VA_ARGS__) +#else +#define IL_LOGV(fmt, ...) fprintf(stderr, fmt "\n", ##__VA_ARGS__) +#define IL_LOGI(fmt, ...) fprintf(stderr, fmt "\n", ##__VA_ARGS__) +#define IL_LOGE(fmt, ...) fprintf(stderr, fmt "\n", ##__VA_ARGS__) +#endif + +/* ── Static state ── */ + +static struct UASYNC* g_ua = NULL; +static struct UTUN_INSTANCE* g_inst = NULL; +static pthread_t g_thread; +static volatile int g_running = 0; +static volatile int g_stop = 0; +static char g_db_path[512]; +static instance_lite_event_fn g_event_handler = NULL; +static char g_generated_pub[65]; +static char g_generated_priv[65]; + +/* ── Generate X25519 key pair, hex-encode to out buffers ── */ + +static int generate_x25519_keys(char pub_hex[65], char priv_hex[65]) { + EVP_PKEY* pkey = NULL; + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL); + if (!ctx) return -1; + if (EVP_PKEY_keygen_init(ctx) <= 0 || EVP_PKEY_generate(ctx, &pkey) <= 0) { + EVP_PKEY_CTX_free(ctx); + return -1; + } + EVP_PKEY_CTX_free(ctx); + size_t pkLen = 32, skLen = 32; + uint8_t pk[32], sk[32]; + EVP_PKEY_get_raw_public_key(pkey, pk, &pkLen); + EVP_PKEY_get_raw_private_key(pkey, sk, &skLen); + EVP_PKEY_free(pkey); + for (int i = 0; i < 32; i++) snprintf(pub_hex + i * 2, 3, "%02x", pk[i]); + for (int i = 0; i < 32; i++) snprintf(priv_hex + i * 2, 3, "%02x", sk[i]); + pub_hex[64] = '\0'; + priv_hex[64] = '\0'; + return 0; +} + +/* ── Ensure valid keys in config, generate if missing ── */ + +static void ensure_keys(struct utun_config* config) { + if (strlen(config->global.my_private_key_hex) != 64 || + strlen(config->global.my_public_key_hex) != 64) { + IL_LOGI("config keys missing or invalid, generating new X25519 keypair"); + if (generate_x25519_keys(g_generated_pub, g_generated_priv) == 0) { + memcpy(config->global.my_public_key_hex, g_generated_pub, 65); + memcpy(config->global.my_private_key_hex, g_generated_priv, 65); + IL_LOGI("new keys generated pub=%s", g_generated_pub); + } else { + IL_LOGE("key generation failed"); + } + } +} + +/* ── Helper: extract single config value by key ── */ + +static const char* cfg_get(const char* text, const char* key) { + size_t klen = strlen(key); + const char* p = text; + while (*p) { + if (strncmp(p, key, klen) == 0 && p[klen] == '=') { + return p + klen + 1; + } + while (*p && *p != '\n') p++; + if (*p == '\n') p++; + } + return NULL; +} + +static void cfg_get_val(const char* text, const char* key, char* out, size_t out_sz) { + const char* v = cfg_get(text, key); + if (!v) { out[0] = '\0'; return; } + size_t i = 0; + while (*v && *v != '\n' && *v != '\r' && i < out_sz - 1) + out[i++] = *v++; + out[i] = '\0'; +} + +/* ── Chat event forward → Kotlin callback ── */ + +static void chat_event_forward(int type, const uint8_t* data, int len) { + if (g_event_handler) g_event_handler(type, data, len); +} + +/* ── Thread function ── */ + +static void* instance_thread(void* arg) { + char* config_text = (char*)arg; + + /* Parse config from text */ + struct utun_config* config = parse_config_from_buf(config_text, strlen(config_text), "android"); + u_free(config_text); + if (!config) { + IL_LOGE("parse_config_from_buf failed"); + g_running = 0; + return NULL; + } + + /* Create uasync */ + g_ua = uasync_create(); + if (!g_ua) { + IL_LOGE("uasync_create failed"); + free_config(config); + g_running = 0; + return NULL; + } + + /* Generate keys if missing (before creating instance) */ + ensure_keys(config); + + /* Create UTUN_INSTANCE from parsed config */ + g_inst = utun_instance_create_from_config(g_ua, config); + if (!g_inst) { + IL_LOGE("utun_instance_create_from_config failed"); + uasync_destroy(g_ua, 0); + g_ua = NULL; + g_running = 0; + return NULL; + } + + IL_LOGI("instance created, node_id=0x%016llx", (unsigned long long)g_inst->node_id); + + /* Open shared SQLite DB */ + if (g_db_path[0] && !g_inst->topo_sqlite_db) { + char db_file[512]; + snprintf(db_file, sizeof(db_file), "%s/chats.db", g_db_path); + int rc = sqlite3_open_v2(db_file, &g_inst->topo_sqlite_db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, NULL); + if (rc == SQLITE_OK && g_inst->topo_sqlite_db) { + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA journal_mode=WAL", NULL, NULL, NULL); + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA foreign_keys=ON", NULL, NULL, NULL); + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA synchronous=NORMAL", NULL, NULL, NULL); + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA wal_autocheckpoint=10000", NULL, NULL, NULL); + topo_node_sqlite_init(g_inst->topo_sqlite_db); + IL_LOGI("SQLite opened %s", db_file); + } else { + IL_LOGE("SQLite open failed rc=%d", rc); + if (g_inst->topo_sqlite_db) { sqlite3_close(g_inst->topo_sqlite_db); g_inst->topo_sqlite_db = NULL; } + } + } + + /* Full init: db_sync, chat_core, chat_sync, init_connections */ + if (utun_instance_init(g_inst) != 0) { + IL_LOGE("utun_instance_init failed"); + utun_instance_destroy(g_inst); + g_inst = NULL; + uasync_destroy(g_ua, 0); + g_ua = NULL; + g_running = 0; + return NULL; + } + + /* Bind chat sync via etcp_router */ + etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL); + + /* Forward chat events → Kotlin */ + chat_event_set_handler(chat_event_forward); + + IL_LOGI("all init done, entering poll loop"); + + g_running = 1; + + while (!g_stop) { + uasync_poll(g_ua, 100); + } + + IL_LOGI("poll loop exit"); + return NULL; +} + +/* ── Public API ── */ + +int instance_lite_start(const char* config_text) { + if (!config_text || g_running) return -1; + + debug_config_init(); + debug_enable_function_name(0); + + /* Extract db_path for later SQLite open */ + cfg_get_val(config_text, "db_path", g_db_path, sizeof(g_db_path)); + + /* Copy config text — the thread takes ownership */ + char* config_copy = u_strdup(config_text); + if (!config_copy) return -1; + + g_stop = 0; + int rc = pthread_create(&g_thread, NULL, instance_thread, config_copy); + if (rc != 0) { + u_free(config_copy); + IL_LOGE("pthread_create failed rc=%d", rc); + return -1; + } + + /* Wait for init to complete (or fail) */ + for (int i = 0; i < 300 && g_running == 0 && g_inst == NULL; i++) + usleep(10000); + + if (!g_inst) { + IL_LOGE("init timed out or failed"); + g_stop = 1; + pthread_join(g_thread, NULL); + return -1; + } + + IL_LOGI("started OK"); + return 0; +} + +void instance_lite_stop(void) { + if (!g_running && !g_inst) return; + IL_LOGI("stopping..."); + + g_stop = 1; + if (g_ua) uasync_wakeup(g_ua); + pthread_join(g_thread, NULL); + + if (g_inst) { + etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL); + chat_sync_destroy(g_inst); + chat_core_destroy(g_inst); + utun_instance_destroy(g_inst); + g_inst = NULL; + } + if (g_ua) { + uasync_destroy(g_ua, 0); + g_ua = NULL; + } + g_running = 0; + g_event_handler = NULL; + IL_LOGI("stopped"); +} + +int instance_lite_is_running(void) { + return g_running ? 1 : 0; +} + +struct UASYNC* instance_lite_get_uasync(void) { + return g_ua; +} + +void instance_lite_set_event_handler(instance_lite_event_fn handler) { + g_event_handler = handler; + if (g_running) chat_event_set_handler(chat_event_forward); +} diff --git a/tools/chatgui-android/libutun_lite/instance_lite.h b/tools/chatgui-android/libutun_lite/instance_lite.h new file mode 100644 index 00000000..c8a5fb36 --- /dev/null +++ b/tools/chatgui-android/libutun_lite/instance_lite.h @@ -0,0 +1,42 @@ +/* + * instance_lite.h — lightweight uTun instance lifecycle for Android + * + * Kotlin generates INI config text, passes to instance_lite_start(). + * C side parses it, creates uasync+ETCP+bgp+chat stack, runs in a pthread. + * Mirrors UtunNode::runLoop() from tools/chatgui/transport/utun_node.cpp. + */ +#ifndef INSTANCE_LITE_H +#define INSTANCE_LITE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct UASYNC; + +/* Start full uTun stack from INI config text. + * Creates a pthread for uasync event loop. + * Returns 0 on success, -1 on error. + * Config format: same as utun INI (see node_config.cpp saveFull). */ +int instance_lite_start(const char* config_text); + +/* Signal stop and wait for thread to exit. + * Calls chat_sync_destroy, chat_core_destroy, utun_instance_destroy, uasync_destroy. */ +void instance_lite_stop(void); + +/* Returns non-zero if instance is running. */ +int instance_lite_is_running(void); + +/* Get current uasync handle (NULL if not running). */ +struct UASYNC* instance_lite_get_uasync(void); + +/* Set Kotlin callback for chat events (CONNECT_RESULT, MSG_RECEIVED, ...) */ +typedef void (*instance_lite_event_fn)(int type, const uint8_t* data, int len); +void instance_lite_set_event_handler(instance_lite_event_fn handler); + +#ifdef __cplusplus +} +#endif +#endif /* INSTANCE_LITE_H */ diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake index d8b06105..238ebff3 100644 --- a/tools/chatgui-android/libutun_lite/utun_sources.cmake +++ b/tools/chatgui-android/libutun_lite/utun_sources.cmake @@ -35,6 +35,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR) set(_cfg_src "${CONFIG_DIR}/utun_config_api.c" "${CONFIG_DIR}/invite_link_c.c" + "${CONFIG_DIR}/instance_lite.c" ) # ── Export sources ──