diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index be5efbb2..b3722e02 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -126,8 +126,32 @@ static int _member_update_bucket_hash(void* ctx, const char* ns, uint8_t level, const char* atags = (const char*)sqlite3_column_text(stmt, 7); const uint8_t* atsig = (const uint8_t*)sqlite3_column_blob(stmt, 8); if (!x25 || !ed) continue; + + uint8_t addrs[2048]; int addr_off = 0; int addr_count = 0; + sqlite3_stmt* as = NULL; + sqlite3_prepare_v2(db, + "SELECT family, socket_id, address, port FROM node_addresses WHERE node_id=? AND addr_type=0" + " ORDER BY family, address, port", -1, &as, NULL); + if (as) { + sqlite3_bind_int64(as, 1, (sqlite3_int64)nid); + while (sqlite3_step(as) == SQLITE_ROW && addr_off < (int)sizeof(addrs) - 8) { + int fam = sqlite3_column_int(as, 0); + int sid = sqlite3_column_int(as, 1); + addrs[addr_off++] = (uint8_t)fam; + addrs[addr_off++] = (uint8_t)sid; + int ip_sz = fam == 4 ? 4 : 16; + memcpy(addrs + addr_off, sqlite3_column_blob(as, 2), (size_t)ip_sz); + addr_off += ip_sz; + uint16_t p = (uint16_t)sqlite3_column_int(as, 3); + addrs[addr_off++] = (uint8_t)(p >> 8); + addrs[addr_off++] = (uint8_t)(p & 0xFF); + addr_count++; + } + sqlite3_finalize(as); + } + uint8_t mh[MT_HASH_SIZE]; - _compute_member_hash(nid, x25, ed, sig, jts, usig, uts, NULL, 0, atags, atsig, mh); + _compute_member_hash(nid, x25, ed, sig, jts, usig, uts, addrs, addr_count, atags, atsig, mh); EVP_DigestUpdate(sha_ctx, mh, MT_HASH_SIZE); count++; } diff --git a/src/routing_layer/conn_mgr_core.c b/src/routing_layer/conn_mgr_core.c index 26977aba..864477a1 100644 --- a/src/routing_layer/conn_mgr_core.c +++ b/src/routing_layer/conn_mgr_core.c @@ -94,7 +94,10 @@ uint8_t cm_classify_v6_addr(const uint8_t addr[16]) { uint8_t cm_sock_v6_classify(const struct ETCP_SOCKET* s) { if (s->local_addr.ss_family != AF_INET6) return CM_V6_OTH; - return cm_classify_v6_addr(((const struct sockaddr_in6*)&s->local_addr)->sin6_addr.s6_addr); + const uint8_t* a6 = ((const struct sockaddr_in6*)&s->local_addr)->sin6_addr.s6_addr; + static const uint8_t z[16]; + if (!memcmp(a6, z, 16)) return CM_V6_DIR; + return cm_classify_v6_addr(a6); } void cm_add_v4_link(struct ETCP_CONN* conn, const uint8_t* addr, uint16_t port, struct ETCP_SOCKET* s) { @@ -106,7 +109,7 @@ void cm_add_v4_link(struct ETCP_CONN* conn, const uint8_t* addr, uint16_t port, void cm_add_v6_link(struct ETCP_CONN* conn, const uint8_t addr[16], uint16_t port, struct ETCP_SOCKET* s) { struct sockaddr_in6 sin6; memset(&sin6, 0, sizeof(sin6)); sin6.sin6_family = AF_INET6; - memcpy(&sin6.sin6_addr, addr, 16); sin6.sin6_port = port; + memcpy(&sin6.sin6_addr, addr, 16); sin6.sin6_port = htons(port); if (cm_classify_v6_addr(addr) == CM_V6_LL) { struct sockaddr_in6* our = (struct sockaddr_in6*)&s->local_addr; sin6.sin6_scope_id = our->sin6_scope_id; } struct sockaddr_storage sa; memcpy(&sa, &sin6, sizeof(sin6)); etcp_link_new(conn, s, &sa, 0); diff --git a/src/routing_layer/topo_node.c b/src/routing_layer/topo_node.c index a74c0016..313aa7a1 100644 --- a/src/routing_layer/topo_node.c +++ b/src/routing_layer/topo_node.c @@ -107,6 +107,8 @@ struct TOPO_NODE* topo_node_registry_store(struct TOPO_GROUPS* groups, struct TO struct TOPO_NODE* existing = topo_node_registry_find(groups, ni->node_id); if (existing) { topo_node_ref(existing); + if (!existing->v4_addrs && ni->v4_addrs) { existing->v4_addrs = ni->v4_addrs; ni->v4_addrs = NULL; } + if (!existing->v6_addrs && ni->v6_addrs) { existing->v6_addrs = ni->v6_addrs; ni->v6_addrs = NULL; } topo_node_free_raw(groups, ni); return existing; } diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index 55ba3b4d..264ee97c 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -154,17 +154,17 @@ int topo_node_sqlite_node_put(sqlite3* db, struct TOPO_GROUPS* groups, struct TO /* delete stale addresses for socket_ids no longer present */ { - char del_sql[256]; + char del_sql[512]; if (sock_count > 0) { int pos = snprintf(del_sql, sizeof(del_sql), - "DELETE FROM node_addresses WHERE node_id=? AND socket_id NOT IN ("); + "DELETE FROM node_addresses WHERE node_id=? AND addr_type!=0 AND socket_id NOT IN ("); for (uint8_t i = 0; i < sock_count; i++) { if (i > 0) pos += snprintf(del_sql + pos, sizeof(del_sql) - (size_t)pos, ","); pos += snprintf(del_sql + pos, sizeof(del_sql) - (size_t)pos, "%d", (int)sock_ids[i]); } pos += snprintf(del_sql + pos, sizeof(del_sql) - (size_t)pos, ") AND socket_id != 0"); } else { - snprintf(del_sql, sizeof(del_sql), "DELETE FROM node_addresses WHERE node_id=? AND socket_id != 0"); + snprintf(del_sql, sizeof(del_sql), "DELETE FROM node_addresses WHERE node_id=? AND addr_type!=0 AND socket_id != 0"); } sqlite3_stmt* del_stmt = NULL; if (sqlite3_prepare_v2(db, del_sql, -1, &del_stmt, NULL) == SQLITE_OK) { @@ -757,7 +757,35 @@ struct TOPO_NODE* topo_node_sqlite_node_load(sqlite3* db, struct TOPO_GROUPS* gr sqlite3_finalize(s); } } - if (!v4_head) { u_free(name); DEBUG_ERROR(DEBUG_CATEGORY_BGP, "node_load: node 0x%016llx has no IPv4/UDP addresses", (unsigned long long)node_id); return NULL; } + /* IPv6 addresses */ + struct TOPO_ADDR6* v6_head = NULL; struct TOPO_ADDR6* v6_tail = NULL; + { + sqlite3_stmt* s = NULL; + if (sqlite3_prepare_v2(db, + "SELECT address,port,addr_type,socket_id FROM node_addresses" + " WHERE node_id=? AND family=6 AND protocol=1 ORDER BY addr_type", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_int64(s, 1, (sqlite3_int64)node_id); + while (sqlite3_step(s) == SQLITE_ROW) { + const void* addr = sqlite3_column_blob(s, 0); + if (!addr || sqlite3_column_bytes(s, 0) < 16) continue; + int port = sqlite3_column_int(s, 1); + int at = sqlite3_column_int(s, 2); + int sock_id_raw = sqlite3_column_int(s, 3); + uint8_t sock_id = (uint8_t)(sock_id_raw ? sock_id_raw : ++sock_id_counter); + uint8_t type = (at == ADDR_TYPE_NETIF) ? TOPO_ADDR_INTERFACE : TOPO_ADDR_NAT; + struct TOPO_ADDR6* a6 = memory_pool_alloc(groups->v6_addr_pool); + if (!a6) continue; + memset(a6, 0, sizeof(*a6)); + memcpy(a6->addr, addr, 16); a6->port = (uint16_t)port; + a6->type = type; a6->socket_id = sock_id; a6->protocol = TOPO_PROTO_UDP; + if (!v6_head) v6_head = v6_tail = a6; + else { v6_tail->next = a6; v6_tail = a6; } + } + sqlite3_finalize(s); + } + } + if (!v4_head && !v6_head) { u_free(name); DEBUG_ERROR(DEBUG_CATEGORY_BGP, "node_load: node 0x%016llx has no addresses", (unsigned long long)node_id); return NULL; } struct TOPO_NODE* ni = u_calloc(1, sizeof(struct TOPO_NODE)); if (!ni) { u_free(name); return NULL; } @@ -765,9 +793,12 @@ struct TOPO_NODE* topo_node_sqlite_node_load(sqlite3* db, struct TOPO_GROUPS* gr memcpy(ni->public_key, pubkey, 32); memcpy(ni->ed25519_public_key, ed_pubkey, 32); ni->node_name = name; ni->v4_addrs = v4_head; + ni->v6_addrs = v6_head; - DEBUG_INFO(DEBUG_CATEGORY_BGP, "node_load: node 0x%016llx loaded from DB name=%s v4_addrs=%d", - (unsigned long long)node_id, name ? name : "", topo_list_count((struct _topo_head*)v4_head)); + DEBUG_INFO(DEBUG_CATEGORY_BGP, "node_load: node 0x%016llx loaded from DB name=%s v4=%d v6=%d", + (unsigned long long)node_id, name ? name : "", + topo_list_count((struct _topo_head*)v4_head), + topo_list_count((struct _topo_head*)v6_head)); return ni; } 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 2a2a502f..130382f9 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 @@ -15,7 +15,7 @@ import com.utun.chat.viewmodel.ChatViewModel import android.content.Intent class MainActivity : ComponentActivity() { - enum class Screen { ChannelList, Chat, Settings, Logs, QrScan } + enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,7 +32,10 @@ class MainActivity : ComponentActivity() { var joinAutoLink by remember { mutableStateOf(null) } BackHandler(enabled = screen != Screen.ChannelList) { - screen = Screen.ChannelList + when (screen) { + Screen.MemberList -> screen = Screen.Chat + else -> screen = Screen.ChannelList + } } val connected = NativeLib.isReady() @@ -69,10 +72,25 @@ class MainActivity : ComponentActivity() { connected = connected, viewModel = vm, onSendMessage = { vm.sendMessage(it) }, + onMembersClick = { screen = Screen.MemberList }, 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 -> { 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 3bf70d19..2d19d465 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 @@ -12,6 +12,24 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val voiceDurationMs: Int = 0, val fileSize: Long = 0, val downloadState: String = "" /* "", "downloading", "downloaded", "error" */) +data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, + val isAdmin: Boolean = false, val isSuper: Boolean = false, + val isStorage: Boolean = false, val nodeType: Int = 0, + val rtt: Int = 0, val isSelf: Boolean = false) + +data class MemberDetail(val nodeId: Long, val name: String = "", + val online: Boolean = false, val lastSeen: Long = 0, + val created: Long = 0, val addresses: List = emptyList()) + +data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "", + val port: Int = 0, val socketId: Int = 0, val rtt: Int = 0) + +data class LinkStatus(val family: Int = 0, val address: String = "", val port: Int = 0, + val linkState: Int = 0, val linkStatus: Int = 0) + +data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false, + val linksUp: Int = 0, val links: List = emptyList()) + class ChatRepository { private val myNodeId: Long = NativeLib.getMyNodeId() @@ -138,5 +156,92 @@ class ChatRepository { fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId) fun isReady() = NativeLib.isReady() + fun getMembers(channelId: String): List { + val list = mutableListOf() + try { + val arr = JSONArray(NativeLib.getChannelMembers(channelId)) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + list.add(ChatMember( + nodeId = obj.getLong("ni"), + name = obj.optString("n", ""), + online = obj.optInt("on", 0) != 0, + isAdmin = obj.optInt("ad", 0) != 0, + isSuper = obj.optInt("sp", 0) != 0, + isStorage = obj.optInt("st", 0) != 0, + nodeType = obj.optInt("tp", 0), + rtt = obj.optInt("rt", 0), + isSelf = obj.optInt("is", 0) != 0 + )) + } + } catch (e: Exception) { Log.w("utun-gui", "getMembers error", e) } + return list + } + + fun getMemberDetail(channelId: String, nodeId: Long): MemberDetail? { + try { + val obj = JSONObject(NativeLib.getMemberDetail(channelId, nodeId)) + if (obj.length() == 0) return null + val addrs = mutableListOf() + val arr = obj.optJSONArray("adr") + if (arr != null) { + for (i in 0 until arr.length()) { + val a = arr.getJSONObject(i) + addrs.add(MemberAddr( + family = a.optInt("fm", 0), + protocol = a.optString("pt", ""), + address = a.optString("ad", ""), + port = a.optInt("po", 0), + socketId = a.optInt("si", 0), + rtt = a.optInt("rt", 0) + )) + } + } + return MemberDetail( + nodeId = obj.getLong("ni"), + name = obj.optString("n", ""), + online = obj.optInt("on", 0) != 0, + lastSeen = obj.optLong("ls", 0), + created = obj.optLong("cr", 0), + addresses = addrs + ) + } catch (e: Exception) { Log.w("utun-gui", "getMemberDetail error", e) } + return null + } + + fun setMemberNick(channelId: String, nodeId: Long, nick: String) = + NativeLib.setMemberNick(channelId, nodeId, nick) + + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) = + NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, storage) + + fun getMemberLinks(nodeId: Long): MemberLinks { + try { + val obj = JSONObject(NativeLib.getMemberLinks(nodeId)) + if (!obj.optBoolean("cn", false)) return MemberLinks() + val links = mutableListOf() + val arr = obj.optJSONArray("lk") + if (arr != null) { + for (i in 0 until arr.length()) { + val a = arr.getJSONObject(i) + links.add(LinkStatus( + family = a.optInt("fm", 0), + address = a.optString("ad", ""), + port = a.optInt("po", 0), + linkState = a.optInt("st", 0), + linkStatus = a.optInt("ls", 0) + )) + } + } + return MemberLinks( + connected = obj.optBoolean("cn", false), + initialized = obj.optBoolean("in", false), + linksUp = obj.optInt("lu", 0), + links = links + ) + } catch (e: Exception) { Log.w("utun-gui", "getMemberLinks error", e) } + return MemberLinks() + } + fun close() {} } 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 94de7c30..b580fa1d 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 @@ -81,6 +81,15 @@ object NativeLib { fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) } fun regenerateKeys() { nativeRegenerateKeys() } + /* ── Channel members ── */ + fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]" + fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" + fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { + nativeSetMemberFlags(channelId, nodeId, admin, supernode, storage) + } + fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}" + /* ── Voice recording ── */ fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId) fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) @@ -130,6 +139,13 @@ object NativeLib { private external fun nativePing() private external fun nativeIsResponsive(): Boolean + /* ── Channel members JNI ── */ + private external fun nativeGetChannelMembers(channelId: String): String? + private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? + private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String) + private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) + private external fun nativeGetMemberLinks(nodeId: Long): String? + /* ── Voice recording JNI ── */ private external fun nativeVoiceStart(channelId: String): Boolean private external fun nativeVoiceFeed(samples: ShortArray): Boolean 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 df86dc92..38780bd3 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 @@ -6,6 +6,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.People import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -26,6 +27,7 @@ fun ChatScreen( connected: Boolean, viewModel: ChatViewModel, onSendMessage: (String) -> Unit, + onMembersClick: () -> Unit, onBack: () -> Unit ) { var text by remember { mutableStateOf("") } @@ -58,6 +60,9 @@ fun ChatScreen( title = { Text(channel.name) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + }, + actions = { + IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } } ) LazyColumn( 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 new file mode 100644 index 00000000..23aba9cc --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -0,0 +1,319 @@ +package com.utun.chat.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.data.Channel +import com.utun.chat.data.ChatMember +import com.utun.chat.data.MemberDetail +import com.utun.chat.data.MemberAddr +import com.utun.chat.data.MemberLinks +import com.utun.chat.data.LinkStatus +import com.utun.chat.viewmodel.ChatViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemberListScreen( + channel: Channel, + members: List, + selectedMember: MemberDetail?, + viewModel: ChatViewModel, + onBack: () -> Unit +) { + var showEditDialog by remember { mutableStateOf(false) } + var editMember by remember { mutableStateOf(null) } + + LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) } + + val memberLinks by viewModel.memberLinks.collectAsState() + + LaunchedEffect(selectedMember?.nodeId) { + val nodeId = selectedMember?.nodeId + if (nodeId == null) { viewModel.refreshMemberLinks(0); return@LaunchedEffect } + while (isActive) { + viewModel.refreshMemberLinks(nodeId) + delay(1000) + } + } + + Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + TopAppBar( + title = { Text("Members (${members.size})") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + } + ) + + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + items(members.size) { idx -> + val m = members[idx] + MemberListItem( + member = m, + isSelected = selectedMember?.nodeId == m.nodeId, + onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, + onEdit = { + editMember = m + showEditDialog = true + } + ) + } + } + + AnimatedVisibility( + visible = selectedMember != null, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + selectedMember?.let { detail -> + MemberDetailCard( + detail = detail, + memberLinks = memberLinks, + onDismiss = { } + ) + } + } + } + + if (showEditDialog && editMember != null) { + MemberEditDialog( + member = editMember!!, + onDismiss = { showEditDialog = false }, + onSave = { nick, admin, supernode, storage -> + if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick) + if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || storage != editMember!!.isStorage) + viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, storage) + showEditDialog = false + } + ) + } +} + +@Composable +private fun MemberListItem( + member: ChatMember, + isSelected: Boolean = false, + onClick: () -> Unit, + onEdit: () -> Unit +) { + val onlineColor = if (member.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E) + val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = bgColor, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(onlineColor) + ) + Spacer(Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = member.name, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified + ) + val roles = buildList { + if (member.isAdmin) add("Admin") + if (member.isSuper) add("Super") + if (member.isStorage) add("Storage") + } + if (roles.isNotEmpty() || member.rtt > 0) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (roles.isNotEmpty()) + Text( + text = roles.joinToString(", "), + fontSize = 11.sp, + color = MaterialTheme.colorScheme.primary + ) + if (member.rtt > 0) { + if (roles.isNotEmpty()) Text(" ", fontSize = 11.sp) + Text( + text = "${member.rtt / 10}ms", + fontSize = 11.sp, + color = Color.Gray + ) + } + } + } + } + + TextButton(onClick = onEdit) { Text("Edit") } + } + } +} + +@Composable +private fun MemberDetailCard( + detail: MemberDetail, + memberLinks: MemberLinks, + onDismiss: () -> Unit +) { + fun linkDotColor(addr: MemberAddr): Color { + val lk = memberLinks.links.find { + it.family == addr.family && it.address == addr.address && it.port == addr.port + } ?: return Color(0xFF9E9E9E) + return if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val dotColor = if (detail.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(dotColor) + ) + Spacer(Modifier.width(8.dp)) + Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + + Spacer(Modifier.height(4.dp)) + Text( + text = "ID: 0x${detail.nodeId.toString(16).padStart(16, '0')}", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = Color.Gray + ) + + if (detail.lastSeen > 0) { + val sec = System.currentTimeMillis() / 1000 - detail.lastSeen + val ago = when { + sec < 60 -> "${sec}s ago" + sec < 3600 -> "${sec / 60}m ago" + sec < 86400 -> "${sec / 3600}h ago" + else -> "${sec / 86400}d ago" + } + Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray) + } + + if (detail.created > 0) { + val sec = System.currentTimeMillis() / 1000 - detail.created + val ago = when { + sec < 604800 -> "${sec / 86400}d ago" + else -> "${sec / 2592000}mo ago" + } + Text("Created: $ago", fontSize = 12.sp, color = Color.Gray) + } + + if (detail.addresses.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + detail.addresses.forEach { a -> + val dcol = linkDotColor(a) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(dcol) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "${a.protocol} ${a.address}:${a.port}" + + (if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = Color.DarkGray + ) + } + } + } + } + } +} + +@Composable +private fun MemberEditDialog( + member: ChatMember, + onDismiss: () -> Unit, + onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit +) { + var nick by remember { mutableStateOf(member.name) } + var admin by remember { mutableStateOf(member.isAdmin) } + var supernode by remember { mutableStateOf(member.isSuper) } + var storage by remember { mutableStateOf(member.isStorage) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit ${member.name}") }, + text = { + Column { + OutlinedTextField( + value = nick, + onValueChange = { nick = it }, + label = { Text("Nickname") }, + enabled = !member.isSelf, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = admin, onCheckedChange = { admin = it }) + Text("Admin", modifier = Modifier.clickable { admin = !admin }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = supernode, onCheckedChange = { supernode = it }) + Text("Supernode", modifier = Modifier.clickable { supernode = !supernode }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = storage, onCheckedChange = { storage = it }) + Text("Storage", modifier = Modifier.clickable { storage = !storage }) + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} 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 29219a8f..79020071 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 @@ -45,6 +45,15 @@ class ChatViewModel : ViewModel() { private val _generatingKeys = MutableStateFlow(false) val generatingKeys: StateFlow = _generatingKeys + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members + + private val _selectedMember = MutableStateFlow(null) + val selectedMember: StateFlow = _selectedMember + + private val _memberLinks = MutableStateFlow(MemberLinks()) + val memberLinks: StateFlow = _memberLinks + private val _isStuck = MutableStateFlow(false) val isStuck: StateFlow = _isStuck @@ -143,6 +152,15 @@ class ChatViewModel : ViewModel() { refreshChannels() } } + 5 -> { /* MEMBERS_CHANGED: [ch_id_len:1][ch_id:var] */ + if (repo == null || data == null || data.size < 2) return + val chLen = data[0].toInt() and 0xFF + if (data.size >= 1 + chLen) { + val chId = String(data, 1, chLen) + val cur = _currentChannel.value + if (cur != null && cur.id == chId) refreshMembers(chId) + } + } 8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */ 14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */ if (repo == null || data == null || data.size < 2) return @@ -208,6 +226,40 @@ class ChatViewModel : ViewModel() { refreshMessages(channel.id) } + fun refreshMembers(chId: String) { + val r = repo ?: return + viewModelScope.launch { + _members.value = withContext(Dispatchers.IO) { r.getMembers(chId) } + } + } + + fun showMemberDetail(channelId: String, nodeId: Long) { + val r = repo ?: return + viewModelScope.launch { + _selectedMember.value = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) } + } + } + + fun setMemberNick(channelId: String, nodeId: Long, nick: String) { + val r = repo ?: return + r.setMemberNick(channelId, nodeId, nick) + refreshMembers(channelId) + } + + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { + val r = repo ?: return + r.setMemberFlags(channelId, nodeId, admin, supernode, storage) + refreshMembers(channelId) + } + + fun refreshMemberLinks(nodeId: Long) { + if (nodeId == 0L) { _memberLinks.value = MemberLinks(); return } + val r = repo ?: return + viewModelScope.launch { + _memberLinks.value = withContext(Dispatchers.IO) { r.getMemberLinks(nodeId) } + } + } + fun sendMessage(text: String) { val r = repo ?: return val ch = _currentChannel.value ?: return diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index eb2162d2..b87a43d6 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -21,6 +21,7 @@ #include "../../../src/chat/chat_setting.h" #include "../../../src/utun_instance.h" #include "../../../src/transport_layer/etcp.h" +#include "../../../src/transport_layer/etcp_connections.h" #include #include #include @@ -278,16 +279,16 @@ char* utun_bridge_get_channels_json(void) { if (!chat_core_is_initialized() || !db) { bridge_log(BLEV_WARN, "get_channels: not initialized db=%p initialized=%d", (void*)db, chat_core_is_initialized()); - return strdup("[]"); + return u_strdup("[]"); } size_t cap = 4096, pos = 1; char* json = u_malloc(cap); - if (!json) return strdup("[]"); + if (!json) return u_strdup("[]"); json[0] = '['; sqlite3_stmt* st = NULL; int rc = sqlite3_prepare_v2(db, "SELECT channel_id, name, last_msg_at FROM channels ORDER BY last_msg_at DESC, name ASC", -1, &st, NULL); - if (rc != SQLITE_OK) { u_free(json); return strdup("[]"); } + if (rc != SQLITE_OK) { u_free(json); return u_strdup("[]"); } int first = 1; while (sqlite3_step(st) == SQLITE_ROW) { @@ -346,7 +347,7 @@ char* utun_bridge_get_channels_json(void) { size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online); - while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; } + while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online); @@ -361,7 +362,7 @@ char* utun_bridge_get_channels_json(void) { char* utun_bridge_get_messages_json(const char* channel_id, int limit) { sqlite3* db = chat_core_get_db(); - if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return strdup("[]"); + if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return u_strdup("[]"); uint64_t my_id = g_cc.my_node_id; char tbl_msg[80]; @@ -371,11 +372,11 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) { snprintf(sql, sizeof(sql), "SELECT id, node_id, data, timestamp, local_attrs FROM \"%s\" ORDER BY timestamp ASC LIMIT %d", tbl_msg, limit); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return strdup("[]"); + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return u_strdup("[]"); size_t cap = 4096, pos = 1; char* json = u_malloc(cap); - if (!json) { sqlite3_finalize(st); return strdup("[]"); } + if (!json) { sqlite3_finalize(st); return u_strdup("[]"); } json[0] = '['; int first = 1; @@ -424,7 +425,7 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) { sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, esc_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "", esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : ""); - while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; } + while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, @@ -457,6 +458,313 @@ int utun_bridge_chat_setting_get_int(const char* name, int def) { return chat_setting_get_int(name, def); } +/* ────────────────────────────────────────────────────────────────── + * Channel members (queried from shared DB) + * ────────────────────────────────────────────────────────────────── */ + +#include "../../../src/routing_layer/topo_node_sqlite.h" +#include + +char* utun_bridge_get_channel_members_json(const char* channel_id) { + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return u_strdup("[]"); + + char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); + + sqlite3_stmt* cnt_st = NULL; + char sql[512]; + snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM \"%s\"", peers_tbl); + /* if table doesn't exist, return empty */ + if (sqlite3_prepare_v2(db, sql, -1, &cnt_st, NULL) != SQLITE_OK) return u_strdup("[]"); + int member_count = 0; + if (sqlite3_step(cnt_st) == SQLITE_ROW) member_count = sqlite3_column_int(cnt_st, 0); + sqlite3_finalize(cnt_st); + if (member_count == 0) return u_strdup("[]"); + + snprintf(sql, sizeof(sql), + "SELECT p.node_id, COALESCE(n.online,0), COALESCE(n.name,'')," + " COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," + " COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)" + " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id" + " ORDER BY n.online DESC, p.node_id ASC", peers_tbl); + + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + bridge_log(BLEV_ERROR, "get_channel_members: main prepare failed ch=%s err=%s", + channel_id, sqlite3_errmsg(db)); + return u_strdup("[]"); + } + + size_t cap = 4096, pos = 1; + char* json = u_malloc(cap); + if (!json) { sqlite3_finalize(st); return u_strdup("[]"); } + json[0] = '['; + + uint64_t my_id = g_cc.my_node_id; + int first = 1; + + while (sqlite3_step(st) == SQLITE_ROW) { + uint64_t node_id = (uint64_t)sqlite3_column_int64(st, 0); + int online = sqlite3_column_int(st, 1); + const char* name = (const char*)sqlite3_column_text(st, 2); + const char* local_nick = (const char*)sqlite3_column_text(st, 3); + const char* adm_tags = (const char*)sqlite3_column_text(st, 4); + int node_type = sqlite3_column_int(st, 5); + int node_rtt = sqlite3_column_int(st, 6); + int storage_flag = sqlite3_column_int(st, 7); + + int is_admin = 0, is_super = 0; + if (adm_tags) { + if (strstr(adm_tags, "admin=yes")) is_admin = 1; + if (strstr(adm_tags, "supernode=yes")) is_super = 1; + } + + char* esc_name = json_escape_alloc(local_nick && local_nick[0] ? local_nick : + (name && name[0] ? name : "")); + const char* sep = first ? "" : ","; + first = 0; + + size_t needed = snprintf(NULL, 0, + "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"st\":%d," + "\"tp\":%d,\"rt\":%d,\"is\":%d}", + sep, esc_name, (unsigned long long)node_id, online, + is_admin, is_super, storage_flag, + node_type, node_rtt, (node_id == my_id) ? 1 : 0); + + while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } + if (pos + needed + 2 <= cap) + pos += (size_t)snprintf(json + pos, cap - pos, + "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"st\":%d," + "\"tp\":%d,\"rt\":%d,\"is\":%d}", + sep, esc_name, (unsigned long long)node_id, online, + is_admin, is_super, storage_flag, + node_type, node_rtt, (node_id == my_id) ? 1 : 0); + u_free(esc_name); + } + sqlite3_finalize(st); + + json[pos++] = ']'; json[pos] = '\0'; + return json; +} + +char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id) { + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return u_strdup("{}"); + + char buf[8192]; + size_t pos = 0; + pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, "{\"ni\":%llu", (unsigned long long)node_id); + + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT COALESCE(name,''), COALESCE(online,0)," + " COALESCE(last_seen_at,0), COALESCE(created_at,0)" + " FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); + if (sqlite3_step(st) == SQLITE_ROW) { + const char* name = (const char*)sqlite3_column_text(st, 0); + int online = sqlite3_column_int(st, 1); + int64_t last_seen = sqlite3_column_int64(st, 2); + int64_t created = sqlite3_column_int64(st, 3); + char* esc_name = json_escape_alloc(name && name[0] ? name : ""); + pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, + ",\"n\":\"%s\",\"on\":%d,\"ls\":%lld,\"cr\":%lld", + esc_name, online, (long long)last_seen, (long long)created); + u_free(esc_name); + } + sqlite3_finalize(st); + } + + char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); + snprintf(buf + pos, sizeof(buf) - pos, ",\"adr\":["); pos += strlen(buf + pos); + + if (sqlite3_prepare_v2(db, + "SELECT family, protocol, address, port, socket_id, COALESCE(rtt,0)" + " FROM node_addresses WHERE node_id=? ORDER BY family, port", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); + int af = 1; + while (sqlite3_step(st) == SQLITE_ROW) { + int family = sqlite3_column_int(st, 0); + int proto = sqlite3_column_int(st, 1); + const uint8_t* addr = (const uint8_t*)sqlite3_column_blob(st, 2); + int addr_len = sqlite3_column_bytes(st, 2); + int port = sqlite3_column_int(st, 3); + int sid = sqlite3_column_int(st, 4); + int rtt = sqlite3_column_int(st, 5); + + char addr_str[64] = ""; + if (addr && addr_len >= (family == 4 ? 4 : 16)) { + if (family == 4) + snprintf(addr_str, sizeof(addr_str), "%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]); + else if (family == 6) { + const uint16_t* a = (const uint16_t*)addr; + snprintf(addr_str, sizeof(addr_str), "%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x", + ntohs(a[0]), ntohs(a[1]), ntohs(a[2]), ntohs(a[3]), + ntohs(a[4]), ntohs(a[5]), ntohs(a[6]), ntohs(a[7])); + } + } + const char* pn; + switch (proto) { case 1: pn = "UDP"; break; case 2: pn = "TCP"; break; default: pn = "UDP/TCP"; break; } + pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, + "%s{\"fm\":%d,\"pt\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"si\":%d,\"rt\":%d}", + af ? "" : ",", family, pn, addr_str[0] ? addr_str : "?", port, sid, rtt); + af = 0; + } + sqlite3_finalize(st); + } + + pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, "]}"); + return u_strdup(buf); +} + +void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick) { + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return; + + char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); + char sql[256]; + snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_nick=? WHERE node_id=?", peers_tbl); + + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) { + if (nick && nick[0]) + sqlite3_bind_text(st, 1, nick, -1, SQLITE_STATIC); + else + sqlite3_bind_null(st, 1); + sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id); + sqlite3_step(st); + sqlite3_finalize(st); + } + bridge_log(BLEV_DEBUG, "set_member_nick ch=%s nid=%llu nick=%s", + channel_id, (unsigned long long)node_id, nick ? nick : "(null)"); +} + +void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, + int admin, int supernode, int storage_flag) { + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return; + + char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); + + /* current version */ + int cur_ver = 0; + sqlite3_stmt* vst = NULL; + char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags FROM \"%s\" WHERE node_id=?", peers_tbl); + if (sqlite3_prepare_v2(db, sql, -1, &vst, NULL) == SQLITE_OK) { + sqlite3_bind_int64(vst, 1, (sqlite3_int64)node_id); + if (sqlite3_step(vst) == SQLITE_ROW) { + const char* tags = (const char*)sqlite3_column_text(vst, 0); + if (tags) { const char* vp = strstr(tags, "ver="); if (vp) cur_ver = atoi(vp + 4); } + } + sqlite3_finalize(vst); + } + + int new_ver = cur_ver + 1; + char adm_tags[128] = ""; + int off = 0; + off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, "ver=%d", new_ver); + if (admin) off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, ",admin=yes"); + if (supernode) off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, ",supernode=yes"); + if (storage_flag) off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, ",storage=yes"); + + /* get channel ed25519 private key for signing */ + uint8_t ch_ed_priv[32] = {0}; + int have_key = 0; + sqlite3_stmt* ks = NULL; + if (sqlite3_prepare_v2(db, + "SELECT ed25519_privkey FROM channels WHERE channel_id=?", + -1, &ks, NULL) == SQLITE_OK) { + sqlite3_bind_text(ks, 1, channel_id, -1, SQLITE_STATIC); + if (sqlite3_step(ks) == SQLITE_ROW) { + const uint8_t* kp = (const uint8_t*)sqlite3_column_blob(ks, 0); + if (kp && sqlite3_column_bytes(ks, 0) >= 32) { + memcpy(ch_ed_priv, kp, 32); have_key = 1; + } + } + sqlite3_finalize(ks); + } + + /* sign with Ed25519 */ + uint8_t sig[64] = {0}; + if (have_key) { + uint8_t msg[384]; size_t mlen = 0; + size_t tl = strlen(adm_tags); + if (tl > 191) tl = 191; + memcpy(msg + mlen, adm_tags, tl); mlen += tl; + memcpy(msg + mlen, &node_id, 8); mlen += 8; + EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, ch_ed_priv, 32); + if (pkey) { + EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); + if (mdctx) { + size_t siglen = 64; + if (EVP_DigestSignInit(mdctx, NULL, NULL, NULL, pkey) == 1) + EVP_DigestSign(mdctx, sig, &siglen, msg, mlen); + EVP_MD_CTX_free(mdctx); + } + EVP_PKEY_free(pkey); + } + } + + int nt = supernode ? 4 : 1; + snprintf(sql, sizeof(sql), + "UPDATE \"%s\" SET adm_tags=?, adm_tags_sig=?, node_type=?, storage=? WHERE node_id=?", + peers_tbl); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, adm_tags, -1, SQLITE_STATIC); + if (have_key) sqlite3_bind_blob(st, 2, sig, 64, SQLITE_STATIC); + else sqlite3_bind_null(st, 2); + sqlite3_bind_int(st, 3, nt); + sqlite3_bind_int(st, 4, storage_flag ? 1 : 0); + sqlite3_bind_int64(st, 5, (sqlite3_int64)node_id); + sqlite3_step(st); + sqlite3_finalize(st); + } + bridge_log(BLEV_INFO, "set_member_flags ch=%s nid=%llu admin=%d super=%d storage=%d tags=%s have_key=%d", + channel_id, (unsigned long long)node_id, admin, supernode, storage_flag, adm_tags, have_key); +} + +char* utun_bridge_get_member_links_json(uint64_t node_id) { + struct UTUN_INSTANCE* inst = chat_core_get_inst(); + if (!inst || node_id == 0) return u_strdup("{}"); + + struct ETCP_CONN* conn = instance_find_conn(inst, node_id); + if (!conn) return u_strdup("{}"); + + char buf[4096]; + int pos = snprintf(buf, sizeof(buf), + "{\"cn\":true,\"in\":%s,\"lu\":%d,\"lk\":[", + conn->initialized ? "true" : "false", conn->links_up); + + int first = 1; + for (struct ETCP_LINK* lk = conn->links; lk; lk = lk->next) { + if (pos >= (int)sizeof(buf) - 100) break; + const struct sockaddr_storage* sa = &lk->remote_addr; + char ip[INET6_ADDRSTRLEN] = ""; + int fm = 0; + uint16_t po = 0; + + if (sa->ss_family == AF_INET) { + struct sockaddr_in* sin = (struct sockaddr_in*)sa; + inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip)); + fm = 4; po = ntohs(sin->sin_port); + } else if (sa->ss_family == AF_INET6) { + struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa; + inet_ntop(AF_INET6, &sin6->sin6_addr, ip, sizeof(ip)); + fm = 6; po = ntohs(sin6->sin6_port); + } else continue; + + if (!ip[0]) continue; + const char* sep = first ? "" : ","; first = 0; + pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, + "%s{\"fm\":%d,\"ad\":\"%s\",\"po\":%d,\"st\":%d,\"ls\":%d}", + sep, fm, ip, (int)po, lk->link_state, lk->link_status); + } + pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}"); + return u_strdup(buf); +} + void utun_bridge_restart(const char* config_text) { bridge_log(BLEV_INFO, "bridge restart: posting to instance"); instance_lite_restart(config_text); @@ -1139,4 +1447,56 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeClose( utun_bridge_voice_decode_close((void*)(intptr_t)handle); } +/* ── Channel members JNI ── */ + +JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChannelMembers( + JNIEnv* env, jobject thiz, jstring channelId) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + char* json = utun_bridge_get_channel_members_json(ch); + jstring result = (*env)->NewStringUTF(env, json); + u_free(json); + (*env)->ReleaseStringUTFChars(env, channelId, ch); + return result; +} + +JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberDetail( + JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + char* json = utun_bridge_get_member_detail_json(ch, (uint64_t)nodeId); + jstring result = (*env)->NewStringUTF(env, json); + u_free(json); + (*env)->ReleaseStringUTFChars(env, channelId, ch); + return result; +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberNick( + JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, jstring nick) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + const char* nk = nick ? (*env)->GetStringUTFChars(env, nick, NULL) : NULL; + utun_bridge_set_member_nick(ch, (uint64_t)nodeId, nk); + if (nk) (*env)->ReleaseStringUTFChars(env, nick, nk); + (*env)->ReleaseStringUTFChars(env, channelId, ch); +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberFlags( + JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, + jboolean admin, jboolean supernode, jboolean storage) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + utun_bridge_set_member_flags(ch, (uint64_t)nodeId, admin, supernode, storage); + (*env)->ReleaseStringUTFChars(env, channelId, ch); +} + +JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberLinks( + JNIEnv* env, jobject thiz, jlong nodeId) { + (void)thiz; + char* json = utun_bridge_get_member_links_json((uint64_t)nodeId); + jstring result = (*env)->NewStringUTF(env, json); + u_free(json); + return result; +} + #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 2bb296a1..6fc7a067 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -111,6 +111,16 @@ void utun_bridge_set_debug_level(const char* category, const char* level); void utun_bridge_chat_setting_set(const char* name, const char* value); int utun_bridge_chat_setting_get_int(const char* name, int def); +/* ── Channel members ── */ + +char* utun_bridge_get_channel_members_json(const char* channel_id); +char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id); +void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick); +void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, + int admin, int supernode, int storage); + +char* utun_bridge_get_member_links_json(uint64_t node_id); + #ifdef __cplusplus } #endif diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp index fb8750bd..77aecb47 100644 --- a/tools/chatgui/src/accountdelegate.cpp +++ b/tools/chatgui/src/accountdelegate.cpp @@ -35,6 +35,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio QColor textColor = option.palette.color(QPalette::Text); if (option.state & QStyle::State_Selected) textColor = option.palette.color(QPalette::HighlightedText); + if (index.data(Qt::UserRole + 2).toBool()) textColor = QColor("#4CAF50"); painter->setPen(textColor); QString name = index.data(Qt::DisplayRole).toString(); diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 48158239..40f77cb7 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -10,6 +10,13 @@ #include #include "../../../lib/platform_compat.h" +extern "C" { + #include "utun_instance.h" + #include "etcp.h" + #include "etcp_connections.h" + #include "../lib/socket_compat.h" +} + QString AccountList::addrStr(const void* addr, int family) { if (family == 4) { const uint8_t* a = (const uint8_t*)addr; @@ -84,12 +91,19 @@ AccountList::AccountList(DbManager* db, QWidget *parent) m_detailTimer = new QTimer(this); m_detailTimer->setInterval(5000); connect(m_detailTimer, &QTimer::timeout, this, &AccountList::onDetailTimerTick); + + m_linkTimer = new QTimer(this); + m_linkTimer->setInterval(1000); + connect(m_linkTimer, &QTimer::timeout, this, [this]{ + if (m_detailNodeId != 0) updateDetailDisplay(); + }); } void AccountList::setChannel(const QString& channelId) { m_channelId = channelId; m_model->setChannel(channelId); m_detailTimer->stop(); + m_linkTimer->stop(); m_detailNodeId = 0; m_detailText->clear(); } @@ -106,6 +120,7 @@ void AccountList::showMemberDetail(quint64 nodeId) { if (!m_db || !m_db->m_db) return; m_detailTimer->stop(); + m_linkTimer->stop(); m_detailNodeId = nodeId; m_detailName.clear(); m_detailOnline = false; @@ -166,6 +181,7 @@ void AccountList::showMemberDetail(quint64 nodeId) { } m_detailTimer->start(); + m_linkTimer->start(); updateDetailDisplay(); } @@ -184,10 +200,35 @@ void AccountList::updateDetailDisplay() { if (m_detailCreated > 0) lines.append(QString("📅 %1").arg(relativeTimeStr(m_detailCreated))); + struct ETCP_CONN* conn = nullptr; + if (m_inst) conn = instance_find_conn(m_inst, m_detailNodeId); + for (const auto& a : m_detailAddrs) { QString proto; switch (a.protocol) { case 1: proto = "UDP"; break; case 2: proto = "TCP"; break; default: proto = "UDP/TCP"; break; } - lines.append(QString("%1:%2:%3").arg(proto, addrStr(a.addr.constData(), a.family)).arg(a.port)); + QString linkColor = "#9E9E9E"; + if (conn) { + for (struct ETCP_LINK* lk = conn->links; lk; lk = lk->next) { + const struct sockaddr_storage* sa = &lk->remote_addr; + int lfm = 0; QString lip; uint16_t lpo = 0; + char ipbuf[INET6_ADDRSTRLEN]; + if (sa->ss_family == AF_INET) { + struct sockaddr_in* sin = (struct sockaddr_in*)sa; + inet_ntop(AF_INET, &sin->sin_addr, ipbuf, sizeof(ipbuf)); + lfm = 4; lpo = ntohs(sin->sin_port); lip = QString::fromLocal8Bit(ipbuf); + } else if (sa->ss_family == AF_INET6) { + struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa; + inet_ntop(AF_INET6, &sin6->sin6_addr, ipbuf, sizeof(ipbuf)); + lfm = 6; lpo = ntohs(sin6->sin6_port); lip = QString::fromLocal8Bit(ipbuf); + } + if (lfm == a.family && lpo == (uint16_t)a.port && lip == addrStr(a.addr.constData(), a.family)) { + linkColor = (lk->link_state == 3 && lk->link_status == 1) ? "#4CAF50" : "#E53935"; + break; + } + } + } + lines.append(QString("● %2:%3:%4") + .arg(linkColor, proto, addrStr(a.addr.constData(), a.family)).arg(a.port)); } m_detailText->setHtml(lines.join("
")); diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index 422ba9c0..28de93ee 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -6,9 +6,11 @@ #include #include #include +#include class MemberListModel; class DbManager; +struct UTUN_INSTANCE; struct DetailAddress { int family; @@ -24,6 +26,7 @@ public: void setChannel(const QString& channelId); void refresh(); + void setUtunInstance(struct UTUN_INSTANCE* inst) { m_inst = inst; } signals: void memberRenamed(); @@ -46,8 +49,11 @@ private: QTextEdit *m_detailText; QTimer *m_refreshTimer; QTimer *m_detailTimer; + QTimer *m_linkTimer; QString m_channelId; + struct UTUN_INSTANCE* m_inst = nullptr; + quint64 m_detailNodeId = 0; QString m_detailName; bool m_detailOnline = false; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index c12d8d82..546a6181 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -133,8 +133,9 @@ void MainWindow::setupNode() { connect(m_node, &UtunNode::error, this, [](const QString& msg) { qWarning("UtunNode error: %s", qPrintable(msg)); }); - connect(m_node, &UtunNode::started, this, []() { + connect(m_node, &UtunNode::started, this, [this]() { qDebug("UtunNode started"); + if (m_accountList && m_node) m_accountList->setUtunInstance(m_node->instance()); }); connect(m_node, &UtunNode::stopped, this, [this]() { qDebug("UtunNode stopped"); diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index ee5891a3..0543bc2f 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -34,6 +34,7 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const { if (role == Qt::ToolTipRole) return QString(it->online ? "online" : "offline") + " | 0x" + QString("%1").arg(it->nodeId, 16, 16, QChar('0')); + if (role == Qt::UserRole + 2) return it->isSelf; return {}; } @@ -90,6 +91,7 @@ void MemberListModel::loadBatch(int from, int count) { int row = from + i; MemberListItem& item = m_cache[row]; item.nodeId = members[i].nodeId; + item.isSelf = (item.nodeId == m_db->myNodeId()); item.online = members[i].online; QString shortId = QString("%1").arg(members[i].nodeId, 8, 16, QChar('0')).right(4).toUpper(); item.name = members[i].name.isEmpty() ? shortId : members[i].name; diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index c247b167..fc6b635c 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -13,11 +13,12 @@ class DbManager; struct MemberListItem { quint64 nodeId = 0; bool online = false; + bool isSelf = false; QPixmap avatar; QString name; QString localNick; - int rtt = -1; // ms (min из node_addresses, -1 если нет) - int natType = -1; // addr_type: 0=NET, 1=DIR, 2=EIM, 3=STR + int rtt = -1; + int natType = -1; }; class MemberListModel : public QAbstractListModel { diff --git a/tools/chatgui/transport/utun_node.h b/tools/chatgui/transport/utun_node.h index 1e44b447..8da6ef70 100644 --- a/tools/chatgui/transport/utun_node.h +++ b/tools/chatgui/transport/utun_node.h @@ -31,6 +31,8 @@ public: void setDebugLevel(const QString& level); void setDebugCategories(const QString& categories); + struct UTUN_INSTANCE* instance() const { return m_instance; } + void send(uint64_t dstNodeId, const QByteArray& data); QString nodeIdHex() const;