From b0753bc19d1f63312ecb5ee9e26b68680fccc9a6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 13 Aug 2026 21:26:50 +0300 Subject: [PATCH] =?UTF-8?q?android:=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0?= =?UTF-8?q?=20invite=20=D0=B2=20=D1=87=D0=B0=D1=82=D0=B5=20+=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=20=D1=82=D0=B0=D0=BF-=D0=B7=D0=BE=D0=BD?= =?UTF-8?q?=D1=8B=20#,=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=B0=20=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/utun/chat/MainActivity.kt | 142 ++++++++++-------- .../java/com/utun/chat/data/ChatRepository.kt | 14 +- .../utun/chat/ui/screens/ChannelListScreen.kt | 5 +- .../com/utun/chat/ui/screens/ChatScreen.kt | 3 + .../utun/chat/ui/screens/MemberListScreen.kt | 2 + .../jni_bridge/android_jni_bridge.c | 4 +- 6 files changed, 99 insertions(+), 71 deletions(-) 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 4bfbc72a..513de8e3 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 @@ -1,19 +1,27 @@ package com.utun.chat +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel +import com.utun.chat.data.Channel import com.utun.chat.data.InviteData import com.utun.chat.data.LogManager 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 kotlinx.coroutines.launch class MainActivity : ComponentActivity() { enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan } @@ -32,6 +40,23 @@ class MainActivity : ComponentActivity() { var showJoinDialog by remember { mutableStateOf(false) } var joinAutoLink by remember { mutableStateOf(null) } var inviteLinkText by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + val showInvite: (Channel) -> Unit = { ch -> + val chId = ch.id.toLongOrNull() + if (chId == null) { + LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}") + scope.launch { snackbarHostState.showSnackbar("Invalid channel id") } + } else { + val link = NativeLib.encodeInviteLink(chId) + if (link != null) inviteLinkText = link + else { + LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId") + scope.launch { snackbarHostState.showSnackbar("Could not generate invite link") } + } + } + } BackHandler(enabled = screen != Screen.ChannelList) { when (screen) { @@ -48,72 +73,67 @@ class MainActivity : ComponentActivity() { onDispose { NativeLib.subscribeNativeLogs(false) } } - when (screen) { - Screen.ChannelList -> { - val channels by vm.channels.collectAsState() - val stuck by vm.isStuck.collectAsState() - ChannelListScreen( - channels = channels, - connected = connected, - isStuck = stuck, - onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, - onCreateChannel = { vm.createChannel("general", "channel_1") }, - onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, - onInviteClick = { ch -> - val chId = ch.id.toLongOrNull() - if (chId == null) { - LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}") - } else { - val link = NativeLib.encodeInviteLink(chId) - if (link != null) inviteLinkText = link - else LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId") - } - }, - onSettings = { screen = Screen.Settings }, - onLogs = { screen = Screen.Logs }, - onRefresh = { vm.requestRefresh() } - ) - } - Screen.Chat -> { - val messages by vm.messages.collectAsState() - val channel = vm.currentChannel.collectAsState().value - if (channel != null) { - ChatScreen( - channel = channel, - messages = messages, + Box(modifier = Modifier.fillMaxSize()) { + when (screen) { + Screen.ChannelList -> { + val channels by vm.channels.collectAsState() + val stuck by vm.isStuck.collectAsState() + ChannelListScreen( + channels = channels, connected = connected, - viewModel = vm, - onSendMessage = { vm.sendMessage(it) }, - onMembersClick = { screen = Screen.MemberList }, - onBack = { screen = Screen.ChannelList } + isStuck = stuck, + onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, + onCreateChannel = { vm.createChannel("general", "channel_1") }, + onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, + onInviteClick = showInvite, + onSettings = { screen = Screen.Settings }, + onLogs = { screen = Screen.Logs }, + onRefresh = { vm.requestRefresh() } ) } - } - Screen.MemberList -> { - val members by vm.members.collectAsState() - val selectedMember by vm.selectedMember.collectAsState() - val channel = vm.currentChannel.collectAsState().value - if (channel != null) { - MemberListScreen( - channel = channel, - members = members, - selectedMember = selectedMember, - viewModel = vm, - onBack = { screen = Screen.Chat } + Screen.Chat -> { + val messages by vm.messages.collectAsState() + val channel = vm.currentChannel.collectAsState().value + if (channel != null) { + ChatScreen( + channel = channel, + messages = messages, + connected = connected, + viewModel = vm, + onSendMessage = { vm.sendMessage(it) }, + onMembersClick = { screen = Screen.MemberList }, + onInvite = { showInvite(channel) }, + onBack = { screen = Screen.ChannelList } + ) + } + } + Screen.MemberList -> { + val members by vm.members.collectAsState() + val selectedMember by vm.selectedMember.collectAsState() + val channel = vm.currentChannel.collectAsState().value + if (channel != null) { + MemberListScreen( + channel = channel, + members = members, + selectedMember = selectedMember, + viewModel = vm, + onBack = { screen = Screen.Chat } + ) + } + } + Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() }) + Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) + Screen.QrScan -> { + QrScanScreen( + onResult = { link -> + qrResult = link + screen = Screen.ChannelList + }, + onBack = { screen = Screen.ChannelList } ) } } - Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() }) - Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) - Screen.QrScan -> { - QrScanScreen( - onResult = { link -> - qrResult = link - screen = Screen.ChannelList - }, - onBack = { screen = Screen.ChannelList } - ) - } + SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter)) } /* Show join dialog after QR scan returns */ 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 efd1edc2..93e80ca1 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 @@ -87,7 +87,7 @@ data class MemberAddr(val family: Int = 0, val protocol: String = "", val addres data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAddr: String = "", val localPort: Int = 0, val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0, val linkState: Int = 0, val linkStatus: Int = 0, - val isServer: Boolean = false, val socketName: String = "") { + val isServer: Boolean = false, val socketName: String = "", val protocol: String = "") { val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" } } @@ -170,8 +170,8 @@ fun MemberWire.toChatMember(): ChatMember = ChatMember( fun cleanSocketName(raw: String): String = raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1") -/* ── MEMBER_DETAIL (event 22) link format: 43 bytes per link ── */ -private const val MEMBER_DETAIL_LINK_SIZE = 43 +/* ── MEMBER_DETAIL (event 22) link format: 63 bytes per link ── */ +private const val MEMBER_DETAIL_LINK_SIZE = 63 private fun addrFromBytes(family: Int, bytes: ByteArray): String { if (family == 4 && bytes.size >= 4) @@ -203,12 +203,15 @@ fun parseMemberDetail(data: ByteArray): MemberLinks? { val lp = lbuf.getShort().toInt() and 0xFFFF val rf = lbuf.get().toInt() and 0xFF; val ra = ByteArray(16); lbuf.get(ra) val rp = lbuf.getShort().toInt() and 0xFFFF + val snBytes = ByteArray(20); lbuf.get(snBytes) + val sn = String(snBytes).trimEnd('\u0000').trimEnd() val effState = if (isTcp != 0 && tcpReady != 0) 3 else state val effStatus = if (isTcp != 0 && tcpReady != 0) 1 else status links.add(LinkStatus( localFamily = lf, localAddr = addrFromBytes(lf, la), localPort = lp, remoteFamily = rf, remoteAddr = addrFromBytes(rf, ra), remotePort = rp, - linkState = effState, linkStatus = effStatus, isServer = isServer != 0 + linkState = effState, linkStatus = effStatus, isServer = isServer != 0, + socketName = sn, protocol = if (isTcp != 0) "TCP" else "UDP" )) offset += MEMBER_DETAIL_LINK_SIZE } @@ -421,7 +424,8 @@ class ChatRepository { linkState = a.optInt("st", 0), linkStatus = a.optInt("ls", 0), isServer = a.optInt("is", 0) != 0, - socketName = a.optString("sn", "") + socketName = a.optString("sn", ""), + protocol = a.optString("pt", "") )) } } 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 f67879da..3207076f 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 @@ -88,9 +88,8 @@ fun ChannelListScreen( Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) Row(Modifier.fillMaxWidth().padding(12.dp)) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { - Surface(shape = MaterialTheme.shapes.small, - modifier = Modifier.clickable { onInviteClick(ch) }) { - Text("#", fontSize = 22.sp, modifier = Modifier.padding(8.dp)) + IconButton(onClick = { onInviteClick(ch) }, modifier = Modifier.size(44.dp)) { + Text("#", fontSize = 22.sp) } if (badgeText.isNotEmpty()) { val badgeSize = 18.dp 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 8fd60dfb..72cf23b7 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.People import androidx.compose.material3.* import androidx.compose.runtime.* @@ -37,6 +38,7 @@ fun ChatScreen( viewModel: ChatViewModel, onSendMessage: (String) -> Unit, onMembersClick: () -> Unit, + onInvite: () -> Unit, onBack: () -> Unit ) { var text by remember { mutableStateOf("") } @@ -112,6 +114,7 @@ fun ChatScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, actions = { + IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") } IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } } ) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index f5027297..897e3902 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -305,6 +305,8 @@ private fun MemberDetailCard( Row(verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(8.dp).clip(CircleShape).background(dcol)) Spacer(Modifier.width(6.dp)) + Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = Color.Gray) + Spacer(Modifier.width(6.dp)) Text("${formatAddr(lk.localAddr)}:${lk.localPort}", modifier = Modifier.weight(1f), textAlign = TextAlign.Start, diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index d55e85c6..24e2ce3d 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1052,8 +1052,8 @@ char* utun_bridge_get_member_links_json(uint64_t node_id) { const char* sep = first ? "" : ","; first = 0; const char* sn = lk->conn ? lk->conn->name : ""; pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, - "%s{\"id\":%d,\"lf\":%d,\"la\":\"%s\",\"lp\":%d,\"rf\":%d,\"ra\":\"%s\",\"rp\":%d,\"st\":%d,\"ls\":%d,\"is\":%d,\"sn\":\"%s\"}", - sep, lk->local_link_id, lf, la, (int)lp, rf, ra, (int)rp, st, ls, lk->is_server, sn); + "%s{\"id\":%d,\"pt\":\"%s\",\"lf\":%d,\"la\":\"%s\",\"lp\":%d,\"rf\":%d,\"ra\":\"%s\",\"rp\":%d,\"st\":%d,\"ls\":%d,\"is\":%d,\"sn\":\"%s\"}", + sep, lk->local_link_id, lk->is_tcp ? "TCP" : "UDP", lf, la, (int)lp, rf, ra, (int)rp, st, ls, lk->is_server, sn); } if (pos < (int)sizeof(buf)) pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}");