From d0cca27eddeda6b38e4b0459978ea8f8f267e363 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 28 Jul 2026 00:34:07 +0300 Subject: [PATCH] invite: add proto field (UDP/TCP/BOTH) to invite link binary format Format per v4 addr: family(1) + socketId(1) + proto(1) + addr(4) + port(2) = 9 bytes (was 8) Proto values: 0x01=UDP, 0x02=TCP, 0x03=BOTH Affects: invite_link_c.h/c (Android), invite_link.h/cpp (desktop), chat_sync.c, conn_mgr.c, joindialog.cpp, utun_node.cpp --- BUILD_OPENWRT.md | 54 ++++++++ build_openwrt.sh | 124 ++++++++++++++++++ opencode.json | 11 ++ src/chat/chat_sync.c | 7 +- src/routing_layer/conn_mgr.c | 2 +- .../java/com/utun/chat/ChatApplication.kt | 15 +-- .../main/java/com/utun/chat/MainActivity.kt | 6 + .../java/com/utun/chat/data/ChatRepository.kt | 51 ++++--- .../main/java/com/utun/chat/data/NativeLib.kt | 2 + .../com/utun/chat/headless/HeadlessService.kt | 7 +- .../utun/chat/ui/components/MessageBubble.kt | 15 ++- .../utun/chat/ui/screens/ChannelListScreen.kt | 14 +- .../com/utun/chat/ui/screens/ChatScreen.kt | 17 ++- .../com/utun/chat/viewmodel/ChatViewModel.kt | 2 + .../jni_bridge/android_jni_bridge.c | 27 ++++ .../jni_bridge/android_jni_bridge.h | 3 + .../libutun_lite/invite_link_c.c | 7 +- .../libutun_lite/invite_link_c.h | 7 +- tools/chatgui/src/invite_link.cpp | 6 +- tools/chatgui/src/invite_link.h | 5 + tools/chatgui/src/joindialog.cpp | 1 + tools/chatgui/transport/utun_node.cpp | 3 + utun.sh | 4 + 23 files changed, 329 insertions(+), 61 deletions(-) create mode 100644 BUILD_OPENWRT.md create mode 100755 build_openwrt.sh create mode 100644 opencode.json create mode 100755 utun.sh diff --git a/BUILD_OPENWRT.md b/BUILD_OPENWRT.md new file mode 100644 index 00000000..916f72ee --- /dev/null +++ b/BUILD_OPENWRT.md @@ -0,0 +1,54 @@ +# Сборка uTun под OpenWRT + +## Быстрый старт + +```bash +cd ~/utun2 +./build_openwrt.sh # ASUS RT-AX53U (по умолчанию) +``` + +Первый запуск соберёт toolchain (30-120 минут), последующие — только пакет. + +## Другие платформы + +```bash +./build_openwrt.sh ramips/mt7621 asus_rt-ax54 # ASUS RT-AX54 +./build_openwrt.sh ramips/mt7621 xiaomi_mi-router-4a-gigabit +./build_openwrt.sh ath79/generic tplink_archer-c7-v2 +./build_openwrt.sh x86/64 # x86_64 PC +./build_openwrt.sh --list # список целей +``` + +## Ручная сборка (menuconfig) + +```bash +cd /home/user/openwrt +make menuconfig # Target System → MediaTek Ralink MIPS + # Subtarget → MT7621 based boards + # Target Profile → ASUS RT-AX53U + # Network → utun (M) +make -j$(nproc) # полная сборка прошивки +# или только пакет: +make package/utun2/compile -j$(nproc) V=s +``` + +Пакет: `bin/packages/*/base/utun_*.ipk` + +## Структура + +- `~/openwrt/package/utun2/Makefile` — OpenWRT-пакет +- `~/openwrt/` — клон buildroot (main, https://github.com/openwrt/openwrt) +- `~/utun2/build_openwrt.sh` — скрипт сборки + +## Зависимости (host) + +```bash +apt-get install -y build-essential git subversion libncurses-dev \ + libssl-dev ccache xsltproc gawk gettext unzip python3 libtool +``` + +## Примечания + +- `configure` генерируется через `autoreconf -fi` (уже сделан) +- Пакет использует `USE_SOURCE_DIR` — билд из /home/user/utun2 напрямую +- Для чистой пересборки: `make package/utun2/clean && make package/utun2/compile -j$(nproc)` diff --git a/build_openwrt.sh b/build_openwrt.sh new file mode 100755 index 00000000..3f34352a --- /dev/null +++ b/build_openwrt.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# OpenWRT build script for uTun +# Usage: ./build_openwrt.sh [TARGET] [PROFILE] +# +# Examples: +# ./build_openwrt.sh # ASUS RT-AX53U (default) +# ./build_openwrt.sh ramips/mt7621 asus_rt-ax53u +# ./build_openwrt.sh ath79/generic tplink_archer-c7-v2 +# ./build_openwrt.sh x86/64 +# ./build_openwrt.sh --list # list available targets +# +# First run builds the full toolchain (30-120 min). +# Subsequent runs only rebuild the utun package. + +set -e + +OPENWRT_ROOT=/home/user/openwrt + +# --- Default target: ASUS RT-AX53U --- +TARGET="${1:-ramips/mt7621}" +PROFILE="${2:-asus_rt-ax53u}" +J="${J:-$(nproc)}" + +# --- Help / list --- +if [ "$1" = "--list" ] || [ "$1" = "-l" ]; then + echo "=== Predefined targets ===" + echo " ramips/mt7621 asus_rt-ax53u ASUS RT-AX53U (default)" + echo " ramips/mt7621 asus_rt-ax54 ASUS RT-AX54" + echo " ramips/mt7621 xiaomi_mi-router-4a-gigabit Xiaomi Mi Router 4A" + echo " ath79/generic tplink_archer-c7-v2 TP-Link Archer C7 v2" + echo " x86/64 Generic x86_64 PC" + echo " x86/generic Generic x86 PC" + echo "" + echo "Full target list: grep 'TARGET_DEVICES' $OPENWRT_ROOT/target/linux/*/image/*.mk" + exit 0 +fi + +# --- Check OpenWRT exists --- +if [ ! -d "$OPENWRT_ROOT" ]; then + echo "ERROR: OpenWRT not found at $OPENWRT_ROOT" + echo "Clone: git clone --depth 1 https://github.com/openwrt/openwrt.git $OPENWRT_ROOT" + exit 1 +fi + +# --- Check utun package exists --- +if [ ! -f "$OPENWRT_ROOT/package/utun2/Makefile" ]; then + echo "ERROR: utun package not found in OpenWRT" + echo "Expected: $OPENWRT_ROOT/package/utun2/Makefile" + exit 1 +fi + +echo "============================================" +echo " uTun OpenWRT Build" +echo " Target: $TARGET" +echo " Profile: $PROFILE" +echo " Jobs: $J" +echo "============================================" + +cd "$OPENWRT_ROOT" + +# --- Select target --- +TARGET_NAME=$(echo "$TARGET" | cut -d/ -f1) +SUBTARGET_NAME=$(echo "$TARGET" | cut -d/ -f2) + +echo "" +echo "[1/3] Configuring target..." + +# Write .config +cat > .config << EOF +CONFIG_TARGET_${TARGET_NAME}=y +CONFIG_TARGET_${TARGET_NAME}_${SUBTARGET_NAME}=y +CONFIG_TARGET_${TARGET_NAME}_${SUBTARGET_NAME}_DEVICE_${PROFILE}=y +CONFIG_PACKAGE_utun=m +# Basic system +CONFIG_PACKAGE_libc=y +CONFIG_PACKAGE_libopenssl=y +CONFIG_PACKAGE_libpthread=y +CONFIG_ALL_NONSHARED=n +CONFIG_ALL_KMODS=n +CONFIG_ALL=n +CONFIG_DEVEL=y +CONFIG_CCACHE=y +# Build options +CONFIG_BUILD_LOG=y +EOF + +echo " Target: ${TARGET_NAME}/${SUBTARGET_NAME} Device: ${PROFILE}" + +# Expand config +make defconfig V=s 2>/dev/null || true + +echo "" +echo "[2/3] Building toolchain (if needed)..." +if [ ! -f "staging_dir/toolchain-*/bin/mips-openwrt-linux-gcc" ] && \ + [ ! -f "staging_dir/toolchain-*/bin/mipsel-openwrt-linux-gcc" ] && \ + [ ! -f "staging_dir/toolchain-*/bin/x86_64-openwrt-linux-gcc" ]; then + echo " Toolchain not found. Building... (this may take 30-120 min)" + make tools/install -j$J V=s 2>&1 | tail -5 + make toolchain/install -j$J V=s 2>&1 | tail -5 + echo " Toolchain built." +else + echo " Toolchain already built. Skipping." +fi + +echo "" +echo "[3/3] Building utun package..." +make package/utun2/compile -j$J V=s 2>&1 | tail -10 + +# --- Show result --- +IPK=$(find bin/packages -name "utun_*.ipk" 2>/dev/null | head -1) +if [ -n "$IPK" ]; then + echo "" + echo "============================================" + echo " BUILD SUCCESS!" + echo " Package: $IPK" + echo " Size: $(du -h "$IPK" | cut -f1)" + echo "============================================" +else + echo "" + echo "ERROR: Package not found. Check build log above." + # Try to find any built binary + find bin -name "utun*" 2>/dev/null + exit 1 +fi diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..76001349 --- /dev/null +++ b/opencode.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@isac322/kwin-mcp-opencode"], + "mcp": { + "kwin-mcp": { + "type": "local", + "command": ["/home/user/.local/bin/uvx", "kwin-mcp"], + "enabled": true + } + } +} diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index c578ea42..d0405c01 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -606,6 +606,7 @@ static void cm_invite_trampoline(void* arg) { const uint8_t* ap = inv->addrs_data; for (int i = 0; i < inv->addr_count; i++) { uint8_t family = *ap++; ap++; /* skip sock_id */ + uint8_t proto = *ap++; /* read proto */ if (family != 4) { ap += 18; continue; } sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); sqlite3_bind_int(st, 2, 4); sqlite3_bind_blob(st, 3, ap, 4, SQLITE_STATIC); ap += 4; @@ -632,13 +633,13 @@ static void cm_invite_trampoline(void* arg) { ni->node_id = node_id; memcpy(ni->public_key, inv->pubkey, 32); const uint8_t* ap = inv->addrs_data; for (int i = 0; i < inv->addr_count; i++) { - uint8_t family = *ap++; ap++; /* skip sock_id */ + uint8_t family = *ap++; uint8_t sock_id = *ap++; uint8_t proto = *ap++; if (family != 4) { ap += 18; continue; } struct TOPO_ADDR4* a4 = u_calloc(1, sizeof(*a4)); if (!a4) break; memcpy(a4->addr, ap, 4); ap += 4; a4->port = ((uint16_t)ap[0] << 8) | ap[1]; ap += 2; - a4->type = TOPO_ADDR_NAT; a4->protocol = TOPO_PROTO_UDP; + a4->type = TOPO_ADDR_NAT; a4->protocol = proto; a4->next = ni->v4_addrs; ni->v4_addrs = a4; } @@ -683,7 +684,7 @@ void chat_sync_connect_from_invite(uint64_t channel_id, uint64_t node_id, inv->node_id = node_id; memcpy(inv->pubkey, pubkey_bin, 32); - size_t addrs_sz = (size_t)addr_count * 8; + size_t addrs_sz = (size_t)addr_count * 9; inv->addrs_data = u_malloc(addrs_sz); if (!inv->addrs_data) { u_free(inv); return; } memcpy(inv->addrs_data, addrs_data, addrs_sz); diff --git a/src/routing_layer/conn_mgr.c b/src/routing_layer/conn_mgr.c index 0dfe4763..6f51b5b6 100644 --- a/src/routing_layer/conn_mgr.c +++ b/src/routing_layer/conn_mgr.c @@ -1355,7 +1355,7 @@ int conn_mgr_connect_from_invite(struct CONN_MGR* mgr, struct TOPO_NODE* ni, struct TOPO_ADDR4* a4 = memory_pool_alloc(groups->v4_addr_pool); if (!a4) continue; memset(a4, 0, sizeof(*a4)); memcpy(a4->addr, src->addr, 4); a4->port = src->port; - a4->type = TOPO_ADDR_NAT; a4->protocol = TOPO_PROTO_UDP; a4->socket_id = 0; + a4->type = TOPO_ADDR_NAT; a4->protocol = src->protocol; a4->socket_id = 0; a4->next = group_ni->v4_addrs; group_ni->v4_addrs = a4; } if (!group_ni->v4_addrs) { u_free(group_ni); if (cb) cb(CONN_MGR_ERR_NO_ADDRESSES, node_id, cb_arg); return CONN_MGR_ERR_NO_ADDRESSES; } 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 index 73dda631..9ba1b7b3 100644 --- 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 @@ -1,13 +1,8 @@ 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 @@ -19,19 +14,15 @@ class ChatApplication : Application() { logManager = LogManager configProvider = ConfigProvider(this) - // First launch: generate keys + detect interfaces - val scope = CoroutineScope(Dispatchers.IO) - scope.launch { - if (configProvider.isFirstLaunch()) { + /* First launch config */ + if (configProvider.isFirstLaunch()) { + kotlinx.coroutines.runBlocking { LogManager.addLog("INFO", "APP", "First launch — configuring") configProvider.generateKeys() configProvider.detectInterfaces() configProvider.markLaunched() LogManager.addLog("INFO", "APP", "First launch done, ${configProvider.loadServers().size} interfaces detected") } - // Start uTun service - val intent = Intent(this@ChatApplication, HeadlessService::class.java) - startService(intent) } } 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 index 4de8ffb8..47ec6dec 100644 --- 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 @@ -5,12 +5,15 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.compose.runtime.* +import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.utun.chat.data.ChatRepository import com.utun.chat.data.InviteData import com.utun.chat.data.NativeLib +import com.utun.chat.headless.HeadlessService import com.utun.chat.ui.screens.* import com.utun.chat.viewmodel.ChatViewModel +import android.content.Intent import java.io.File class MainActivity : ComponentActivity() { @@ -19,6 +22,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + /* Start uTun headless service (must be from foreground Activity on API 34+) */ + ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java)) + val dbFile = File(filesDir, "chats.db") val repo = ChatRepository(dbFile.absolutePath) 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 index db8f9aa1..399fdcf3 100644 --- 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 @@ -3,7 +3,8 @@ 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 Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "", + val peersOnline: Int = 0) data class Message(val id: Long, val author: String, val text: String, val ts: Long, val isOutgoing: Boolean = false) class ChatRepository(dbPath: String) { @@ -11,12 +12,10 @@ class ChatRepository(dbPath: String) { 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) @@ -31,18 +30,29 @@ class ChatRepository(dbPath: String) { "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)) + while (cur.moveToNext()) { + val chId = cur.getString(0) ?: continue + val name = cur.getString(1) ?: chId + val lastAt = cur.getLong(2) + val last = getLastMsgText(chId) + val online = getPeersOnline(chId) + list.add(Channel(id = chId, name = name, lastMsgAt = lastAt, lastMsg = last, peersOnline = online)) + } } - } } catch (_: Exception) {} return list } + fun getPeersOnline(channelId: String): Int { + val tbl = peersTableName(channelId) + try { + db.rawQuery("SELECT COUNT(*) FROM \"$tbl\"", null).use { cur -> + if (cur.moveToFirst()) return cur.getInt(0) + } + } catch (_: Exception) {} + return 0 + } + fun getMessages(channelId: String, limit: Int = 50): List { val tbl = msgTableName(channelId) val list = mutableListOf() @@ -63,17 +73,14 @@ class ChatRepository(dbPath: String) { isOutgoing = mid != null && nodeId == mid)) } } - } catch (_: Exception) { /* table may not exist yet */ } + } catch (_: Exception) {} 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 -> + 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)) @@ -89,16 +96,20 @@ class ChatRepository(dbPath: String) { 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 connectChannel(channelId: String) = NativeLib.connectChannel(channelId) fun isReady() = NativeLib.isReady() companion object { - fun msgTableName(chId: String): String { - val san = StringBuilder() + fun msgTableName(chId: String): String = "msg_" + sanitize(chId) + fun peersTableName(chId: String): String = "peers_" + sanitize(chId) + + private fun sanitize(chId: String): String { + val sb = 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('_') + if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '_') sb.append(c) + else sb.append('_') } - return "msg_$san" + return sb.toString() } fun parseMsgText(data: ByteArray?): String { 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 index 2a152144..2dee89c6 100644 --- 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 @@ -65,6 +65,7 @@ object NativeLib { fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { nativeJoinChannel(channelId, nodeId, pubkey, addrs, addrCount) } + fun connectChannel(channelId: String) { nativeConnectChannel(channelId) } fun getChannels(): String = nativeGetChannels() ?: "[]" fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]" fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) } @@ -81,6 +82,7 @@ object NativeLib { 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 nativeConnectChannel(channelId: String) private external fun nativeGetChannels(): String? private external fun nativeGetMessages(channelId: String, limit: Int): String? private external fun nativeSetDebugLevel(category: String, level: String) 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 index c43e5a5c..d050cfcd 100644 --- 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 @@ -40,6 +40,7 @@ class HeadlessService : Service() { val listenPort = provider.getInt("node.listen_port") val configText = buildString { + appendLine("[global]") if (nodeName.isNotBlank()) appendLine("my_node_name=$nodeName") if (pubKey.length == 64) appendLine("my_public_key=$pubKey") if (privKey.length == 64) appendLine("my_private_key=$privKey") @@ -64,12 +65,6 @@ class HeadlessService : Service() { /* 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") 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 index cb86665e..c61f0baf 100644 --- 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 @@ -26,18 +26,18 @@ fun MessageBubble(message: Message) { ) { Column( modifier = Modifier - .widthIn(max = 280.dp) + .widthIn(max = 300.dp) .clip(RoundedCornerShape(12.dp)) .background(bgColor) - .padding(10.dp) + .padding(12.dp) ) { - Text(message.author, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) + Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) Spacer(Modifier.height(2.dp)) - Text(message.text, fontSize = 15.sp, color = textColor) + Text(message.text, fontSize = 17.sp, color = textColor) Spacer(Modifier.height(2.dp)) Text( formatTime(message.ts), - fontSize = 10.sp, + fontSize = 11.sp, color = textColor.copy(alpha = 0.5f), modifier = Modifier.align(Alignment.End) ) @@ -50,10 +50,11 @@ fun InputBar( text: String, onTextChange: (String) -> Unit, onSend: () -> Unit, - enabled: Boolean = true + enabled: Boolean = true, + modifier: Modifier = Modifier ) { Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( 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 index 527688e5..8c2b9bfc 100644 --- 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 @@ -36,8 +36,8 @@ fun ChannelListScreen( title = { Text("uTun Chat") }, actions = { Text( - if (connected) "ON" else "OFF", - color = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + "ON", + color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(end = 8.dp) ) IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") } @@ -73,7 +73,15 @@ fun ChannelListScreen( } Spacer(Modifier.width(12.dp)) Column { - Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Row { + Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp) + if (ch.peersOnline > 0) { + Text(" (${ch.peersOnline})", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp)) + } + } if (ch.lastMsg.isNotEmpty()) Text(ch.lastMsg, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } 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 index a3f05bb1..77ba2bb6 100644 --- 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 @@ -3,6 +3,7 @@ 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.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* @@ -24,6 +25,11 @@ fun ChatScreen( onBack: () -> Unit ) { var text by remember { mutableStateOf("") } + val listState = rememberLazyListState() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) + } Scaffold( topBar = { @@ -42,15 +48,18 @@ fun ChatScreen( onSendMessage(text) text = "" }, - enabled = connected + enabled = true, + modifier = Modifier.navigationBarsPadding() ) } ) { padding -> LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - reverseLayout = false + modifier = Modifier.fillMaxSize().padding(padding).imePadding(), + state = listState ) { - items(messages) { msg -> MessageBubble(msg) } + items(messages.size) { idx -> + MessageBubble(messages[idx]) + } item { Spacer(Modifier.height(8.dp)) } } } 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 index 0eae588c..ba948235 100644 --- 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 @@ -35,6 +35,7 @@ class ChatViewModel(private val repo: ChatRepository) : ViewModel() { } } } + 8 -> refreshChannels() /* CHANNEL_PEERS_ONLINE */ 2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */ if (data != null && data.size >= 20) { val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) @@ -68,6 +69,7 @@ class ChatViewModel(private val repo: ChatRepository) : ViewModel() { fun selectChannel(channel: Channel) { _currentChannel.value = channel + repo.connectChannel(channel.id) refreshMessages(channel.id) } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 76e77f40..81a4230f 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -11,6 +11,8 @@ #include "../libutun_lite/invite_link_c.h" #include "../libutun_lite/instance_lite.h" #include "../../../lib/debug_config.h" +#include "../../../lib/u_async.h" +#include "../../../lib/mem.h" #include #include #include @@ -173,6 +175,7 @@ void utun_bridge_connect_node(const char* address, int port, const char* pubkey_ /* Real implementation: calls chat_sync_connect_from_invite() */ #include "chat/chat_sync.h" +#include "chat/chat_core.h" void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id, const uint8_t* pubkey_bin, @@ -187,6 +190,22 @@ void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id, chat_sync_connect_from_invite(channel_id, node_id, pubkey_bin, addrs_data, addr_count); } +void utun_bridge_connect_channel(const char* channel_id) { + if (!channel_id || !channel_id[0]) { + bridge_log(BLEV_ERROR, "connect_channel: null ch_id"); + return; + } + struct UASYNC* ua = instance_lite_get_uasync(); + if (!ua) { + bridge_log(BLEV_ERROR, "connect_channel: ua not ready, ch=%s", channel_id); + return; + } + char* cid = u_strdup(channel_id); + if (!cid) return; + bridge_log(BLEV_INFO, "connect_channel ch=%s", channel_id); + uasync_post(ua, chat_core_connect_channel_trampoline, cid); +} + char* utun_bridge_get_channels_json(void) { return strdup("[]"); } @@ -451,6 +470,14 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages( return result; } +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeConnectChannel( + JNIEnv* env, jobject thiz, jstring channelId) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + utun_bridge_connect_channel(ch); + if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetDebugLevel( JNIEnv* env, jobject thiz, jstring category, jstring level) { (void)thiz; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 437e0f72..fa54b51e 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -56,6 +56,9 @@ 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); +/* Connect to all peers of a channel (auto-connect on select) */ +void utun_bridge_connect_channel(const char* channel_id); + /* ── Queries ── */ char* utun_bridge_get_channels_json(void); diff --git a/tools/chatgui-android/libutun_lite/invite_link_c.c b/tools/chatgui-android/libutun_lite/invite_link_c.c index 9b12d2cc..39f776fe 100644 --- a/tools/chatgui-android/libutun_lite/invite_link_c.c +++ b/tools/chatgui-android/libutun_lite/invite_link_c.c @@ -105,13 +105,14 @@ int invite_link_decode(const char* link, size_t link_len, struct InviteDataC* ou for (int j = 0; j < cnt; j++) { int is_v6 = (header >> (2 + j)) & 1; int ip_len = is_v6 ? 16 : 4; - if (off + 1 + ip_len + 2 > raw_len) { + if (off + 2 + ip_len + 2 > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at addr %d", j); return -1; } if (out->addrCount >= INVITE_ADDR_MAX) break; struct InviteAddrC* a = &out->addrs[out->addrCount++]; a->socketId = raw[off++]; + a->proto = raw[off++]; a->family = is_v6 ? 6 : 4; memcpy(a->address, raw + off, (size_t)ip_len); off += ip_len; @@ -126,7 +127,7 @@ int invite_serialize_addrs(const struct InviteDataC* data, uint8_t* buf, size_t if (!data || !buf) return -1; size_t needed = 0; for (int i = 0; i < (int)data->addrCount; i++) - needed += 8U; /* family(1) + sockId(1) + ip4(4) + port(2) = 8; IPv6 skipped */ + needed += 9U; /* family(1) + sockId(1) + proto(1) + ip4(4) + port(2) = 9; IPv6 skipped */ if (needed > buf_size) return -1; size_t pos = 0; for (int i = 0; i < (int)data->addrCount; i++) { @@ -134,6 +135,7 @@ int invite_serialize_addrs(const struct InviteDataC* data, uint8_t* buf, size_t if (a->family != 4) continue; buf[pos++] = (uint8_t)a->family; buf[pos++] = a->socketId; + buf[pos++] = a->proto; memcpy(buf + pos, a->address, 4); pos += 4; buf[pos++] = (uint8_t)(a->port >> 8); buf[pos++] = (uint8_t)(a->port & 0xFF); @@ -165,6 +167,7 @@ int invite_link_encode(const struct InviteDataC* data, char* out, size_t out_siz const struct InviteAddrC* a = &data->addrs[ia + j]; int ip_len = (a->family == 6) ? 16 : 4; raw[pos++] = a->socketId; + raw[pos++] = a->proto; memcpy(raw + pos, a->address, (size_t)ip_len); pos += (size_t)ip_len; raw[pos++] = (uint8_t)(a->port >> 8); raw[pos++] = (uint8_t)(a->port & 0xFF); diff --git a/tools/chatgui-android/libutun_lite/invite_link_c.h b/tools/chatgui-android/libutun_lite/invite_link_c.h index 6975e324..9a7dd6d7 100644 --- a/tools/chatgui-android/libutun_lite/invite_link_c.h +++ b/tools/chatgui-android/libutun_lite/invite_link_c.h @@ -8,6 +8,10 @@ #define INVITE_PUBKEY_SIZE 32 #define INVITE_ADDR_MAX 32 +#define INVITE_PROTO_UDP 0x01 +#define INVITE_PROTO_TCP 0x02 +#define INVITE_PROTO_BOTH 0x03 + #ifdef __cplusplus extern "C" { #endif @@ -16,6 +20,7 @@ struct InviteAddrC { int family; uint8_t address[16]; uint8_t socketId; + uint8_t proto; uint16_t port; }; @@ -38,7 +43,7 @@ int invite_link_decode(const char* link, size_t link_len, struct InviteDataC* ou int invite_link_encode(const struct InviteDataC* data, char* out, size_t out_size); /* serialize per-addr format for chat_sync_connect_from_invite(): - family(1) + socketId(1) + address(4|16) + port(2 BE) + family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE) returns total bytes written, or -1 if buf too small */ int invite_serialize_addrs(const struct InviteDataC* data, uint8_t* buf, size_t buf_size); diff --git a/tools/chatgui/src/invite_link.cpp b/tools/chatgui/src/invite_link.cpp index 7b7e38d7..2f8beb22 100644 --- a/tools/chatgui/src/invite_link.cpp +++ b/tools/chatgui/src/invite_link.cpp @@ -14,7 +14,7 @@ QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, } QByteArray blob; - blob.reserve(1 + 8 + addrs.size() * (1 + 32 + 19)); + blob.reserve(1 + 8 + addrs.size() * (1 + 32 + 20)); blob.append(static_cast(INVITE_LINK_VERSION)); @@ -38,6 +38,7 @@ QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, for (int j = 0; j < cnt; j++) { blob.append(static_cast(addrs[i + j].socketId)); + blob.append(static_cast(addrs[i + j].protocol)); blob.append(addrs[i + j].address); quint16 p = addrs[i + j].port; blob.append(static_cast((p >> 8) & 0xFF)); @@ -112,13 +113,14 @@ InviteData decodeInviteLink(const QString& link) { for (int j = 0; j < cnt; j++) { bool isV6 = header & (1 << (2 + j)); int ipLen = isV6 ? 16 : 4; - if (off + 1 + ipLen + 2 > len) { + if (off + 2 + ipLen + 2 > len) { d.error = QStringLiteral("truncated at addr %1 of block").arg(j); GUI_ERROR("decodeInviteLink: truncated at addr %d of block", j); return d; } InviteAddr a; a.socketId = data[off++]; + a.proto = data[off++]; a.family = isV6 ? 6 : 4; a.address = QByteArray(reinterpret_cast(data + off), ipLen); off += ipLen; diff --git a/tools/chatgui/src/invite_link.h b/tools/chatgui/src/invite_link.h index 31b67bb7..335edced 100644 --- a/tools/chatgui/src/invite_link.h +++ b/tools/chatgui/src/invite_link.h @@ -8,11 +8,16 @@ #define INVITE_LINK_VERSION 0x01 +#define INVITE_PROTO_UDP 0x01 +#define INVITE_PROTO_TCP 0x02 +#define INVITE_PROTO_BOTH 0x03 + struct InviteAddr { int family; // 4=IPv4, 6=IPv6 QByteArray address; quint16 port; uint8_t socketId; // socket_id из ETCP + uint8_t proto; // INVITE_PROTO_UDP/TCP/BOTH }; struct InviteData { diff --git a/tools/chatgui/src/joindialog.cpp b/tools/chatgui/src/joindialog.cpp index 7750c0ba..31fdbf62 100644 --- a/tools/chatgui/src/joindialog.cpp +++ b/tools/chatgui/src/joindialog.cpp @@ -179,6 +179,7 @@ void JoinDialog::onConnectClicked() { for (const auto& a : d.addrs) { addrsBuf.append(static_cast(a.family)); addrsBuf.append(static_cast(a.socketId)); + addrsBuf.append(static_cast(a.proto)); addrsBuf.append(a.address); addrsBuf.append(static_cast((a.port >> 8) & 0xFF)); addrsBuf.append(static_cast(a.port & 0xFF)); diff --git a/tools/chatgui/transport/utun_node.cpp b/tools/chatgui/transport/utun_node.cpp index 839463af..17b13101 100644 --- a/tools/chatgui/transport/utun_node.cpp +++ b/tools/chatgui/transport/utun_node.cpp @@ -6,6 +6,7 @@ #include "../../lib/socket_compat.h" #include "../db/db_manager.h" +#include "../src/invite_link.h" // === C headers === extern "C" { @@ -157,6 +158,7 @@ QList UtunNode::getInviteAddresses(DbManager* db) { if (link->link_state == 3 && link->nat_type == NAT_TYPE_DIRECT) { NodeAddr a; a.socketId = link->conn ? (int)link->conn->sock_id : 0; + a.protocol = INVITE_PROTO_UDP; struct sockaddr_storage* sa = &link->remote_addr; if (sa->ss_family == AF_INET) { struct sockaddr_in* sin = (struct sockaddr_in*)sa; @@ -190,6 +192,7 @@ QList UtunNode::getInviteAddresses(DbManager* db) { NodeAddr a; a.socketId = (int)sock->sock_id; + a.protocol = INVITE_PROTO_UDP; if (sa->ss_family == AF_INET) { struct sockaddr_in* sin = (struct sockaddr_in*)sa; a.family = 4; diff --git a/utun.sh b/utun.sh new file mode 100755 index 00000000..68f937f1 --- /dev/null +++ b/utun.sh @@ -0,0 +1,4 @@ +#!/bin/sh +ip route add 85.192.42.96/32 via 192.168.40.1 dev wlo1 +ip route delete default +ip route add default via 10.99.0.1 dev tun_tcp