Browse Source

android: member list screen + link status dots, ipv6 autoconnect fixes

Android chatgui:
- New MemberListScreen with member list, detail card, edit dialog
- Link status dots (green/red/grey) per address in detail card (1s poll)
- Self-node name coloured green
- ChatScreen: people icon in TopAppBar

IPv6 autoconnect fixes (3 bugs in conn_mgr/topo):
- topo_node_sqlite_node_load: load v6 addresses from DB
- topo_node_registry_store: merge v6_addrs into existing node
- cm_sock_v6_classify: wildcard [::] treated as CM_V6_DIR
- cm_add_v6_link: htons(port) was missing

Memory fixes:
- strdup→u_strdup in bridge (7 instances)
- u_realloc cap update before success check (3 instances)
- char sql[256]→[512] for truncated SELECT

Chat member hash now includes addresses for change detection

Linux chatgui:
- Link status dots in AccountList detail panel (1s poll)
- Self-node name in green in member list
topo_upd
evgeny 2 months ago
parent
commit
50156e12af
  1. 26
      src/chat/member_sync.c
  2. 7
      src/routing_layer/conn_mgr_core.c
  3. 2
      src/routing_layer/topo_node.c
  4. 43
      src/routing_layer/topo_node_sqlite.c
  5. 22
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  6. 105
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  7. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  8. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  9. 319
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  10. 52
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  11. 376
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  12. 10
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  13. 1
      tools/chatgui/src/accountdelegate.cpp
  14. 43
      tools/chatgui/src/accountlist.cpp
  15. 6
      tools/chatgui/src/accountlist.h
  16. 3
      tools/chatgui/src/mainwindow.cpp
  17. 2
      tools/chatgui/src/memberlistmodel.cpp
  18. 5
      tools/chatgui/src/memberlistmodel.h
  19. 2
      tools/chatgui/transport/utun_node.h

26
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 char* atags = (const char*)sqlite3_column_text(stmt, 7);
const uint8_t* atsig = (const uint8_t*)sqlite3_column_blob(stmt, 8); const uint8_t* atsig = (const uint8_t*)sqlite3_column_blob(stmt, 8);
if (!x25 || !ed) continue; 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]; 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); EVP_DigestUpdate(sha_ctx, mh, MT_HASH_SIZE);
count++; count++;
} }

7
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) { uint8_t cm_sock_v6_classify(const struct ETCP_SOCKET* s) {
if (s->local_addr.ss_family != AF_INET6) return CM_V6_OTH; 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) { 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) { 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; 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; } 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)); struct sockaddr_storage sa; memcpy(&sa, &sin6, sizeof(sin6));
etcp_link_new(conn, s, &sa, 0); etcp_link_new(conn, s, &sa, 0);

2
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); struct TOPO_NODE* existing = topo_node_registry_find(groups, ni->node_id);
if (existing) { if (existing) {
topo_node_ref(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); topo_node_free_raw(groups, ni);
return existing; return existing;
} }

43
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 */ /* delete stale addresses for socket_ids no longer present */
{ {
char del_sql[256]; char del_sql[512];
if (sock_count > 0) { if (sock_count > 0) {
int pos = snprintf(del_sql, sizeof(del_sql), 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++) { for (uint8_t i = 0; i < sock_count; i++) {
if (i > 0) pos += snprintf(del_sql + pos, sizeof(del_sql) - (size_t)pos, ","); 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, "%d", (int)sock_ids[i]);
} }
pos += snprintf(del_sql + pos, sizeof(del_sql) - (size_t)pos, ") AND socket_id != 0"); pos += snprintf(del_sql + pos, sizeof(del_sql) - (size_t)pos, ") AND socket_id != 0");
} else { } 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; sqlite3_stmt* del_stmt = NULL;
if (sqlite3_prepare_v2(db, del_sql, -1, &del_stmt, NULL) == SQLITE_OK) { 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); 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)); struct TOPO_NODE* ni = u_calloc(1, sizeof(struct TOPO_NODE));
if (!ni) { u_free(name); return NULL; } 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); memcpy(ni->public_key, pubkey, 32); memcpy(ni->ed25519_public_key, ed_pubkey, 32);
ni->node_name = name; ni->node_name = name;
ni->v4_addrs = v4_head; 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", 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)); (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; return ni;
} }

22
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 import android.content.Intent
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -32,7 +32,10 @@ class MainActivity : ComponentActivity() {
var joinAutoLink by remember { mutableStateOf<String?>(null) } var joinAutoLink by remember { mutableStateOf<String?>(null) }
BackHandler(enabled = screen != Screen.ChannelList) { BackHandler(enabled = screen != Screen.ChannelList) {
screen = Screen.ChannelList when (screen) {
Screen.MemberList -> screen = Screen.Chat
else -> screen = Screen.ChannelList
}
} }
val connected = NativeLib.isReady() val connected = NativeLib.isReady()
@ -69,10 +72,25 @@ class MainActivity : ComponentActivity() {
connected = connected, connected = connected,
viewModel = vm, viewModel = vm,
onSendMessage = { vm.sendMessage(it) }, onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onBack = { screen = Screen.ChannelList } 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.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.QrScan -> { Screen.QrScan -> {

105
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 voiceDurationMs: Int = 0, val fileSize: Long = 0,
val downloadState: String = "" /* "", "downloading", "downloaded", "error" */) 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<MemberAddr> = 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<LinkStatus> = emptyList())
class ChatRepository { class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId() private val myNodeId: Long = NativeLib.getMyNodeId()
@ -138,5 +156,92 @@ class ChatRepository {
fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId) fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId)
fun isReady() = NativeLib.isReady() fun isReady() = NativeLib.isReady()
fun getMembers(channelId: String): List<ChatMember> {
val list = mutableListOf<ChatMember>()
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<MemberAddr>()
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<LinkStatus>()
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() {} fun close() {}
} }

16
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 setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
fun regenerateKeys() { nativeRegenerateKeys() } 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 ── */ /* ── Voice recording ── */
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId) fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
@ -130,6 +139,13 @@ object NativeLib {
private external fun nativePing() private external fun nativePing()
private external fun nativeIsResponsive(): Boolean 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 ── */ /* ── Voice recording JNI ── */
private external fun nativeVoiceStart(channelId: String): Boolean private external fun nativeVoiceStart(channelId: String): Boolean
private external fun nativeVoiceFeed(samples: ShortArray): Boolean private external fun nativeVoiceFeed(samples: ShortArray): Boolean

5
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.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -26,6 +27,7 @@ fun ChatScreen(
connected: Boolean, connected: Boolean,
viewModel: ChatViewModel, viewModel: ChatViewModel,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onMembersClick: () -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
@ -58,6 +60,9 @@ fun ChatScreen(
title = { Text(channel.name) }, title = { Text(channel.name) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = {
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
} }
) )
LazyColumn( LazyColumn(

319
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<ChatMember>,
selectedMember: MemberDetail?,
viewModel: ChatViewModel,
onBack: () -> Unit
) {
var showEditDialog by remember { mutableStateOf(false) }
var editMember by remember { mutableStateOf<ChatMember?>(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") }
}
)
}

52
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) private val _generatingKeys = MutableStateFlow(false)
val generatingKeys: StateFlow<Boolean> = _generatingKeys val generatingKeys: StateFlow<Boolean> = _generatingKeys
private val _members = MutableStateFlow<List<ChatMember>>(emptyList())
val members: StateFlow<List<ChatMember>> = _members
private val _selectedMember = MutableStateFlow<MemberDetail?>(null)
val selectedMember: StateFlow<MemberDetail?> = _selectedMember
private val _memberLinks = MutableStateFlow<MemberLinks>(MemberLinks())
val memberLinks: StateFlow<MemberLinks> = _memberLinks
private val _isStuck = MutableStateFlow(false) private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow<Boolean> = _isStuck val isStuck: StateFlow<Boolean> = _isStuck
@ -143,6 +152,15 @@ class ChatViewModel : ViewModel() {
refreshChannels() 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 */ 8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */
14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */ 14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */
if (repo == null || data == null || data.size < 2) return if (repo == null || data == null || data.size < 2) return
@ -208,6 +226,40 @@ class ChatViewModel : ViewModel() {
refreshMessages(channel.id) 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) { fun sendMessage(text: String) {
val r = repo ?: return val r = repo ?: return
val ch = _currentChannel.value ?: return val ch = _currentChannel.value ?: return

376
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -21,6 +21,7 @@
#include "../../../src/chat/chat_setting.h" #include "../../../src/chat/chat_setting.h"
#include "../../../src/utun_instance.h" #include "../../../src/utun_instance.h"
#include "../../../src/transport_layer/etcp.h" #include "../../../src/transport_layer/etcp.h"
#include "../../../src/transport_layer/etcp_connections.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@ -278,16 +279,16 @@ char* utun_bridge_get_channels_json(void) {
if (!chat_core_is_initialized() || !db) { if (!chat_core_is_initialized() || !db) {
bridge_log(BLEV_WARN, "get_channels: not initialized db=%p initialized=%d", bridge_log(BLEV_WARN, "get_channels: not initialized db=%p initialized=%d",
(void*)db, chat_core_is_initialized()); (void*)db, chat_core_is_initialized());
return strdup("[]"); return u_strdup("[]");
} }
size_t cap = 4096, pos = 1; size_t cap = 4096, pos = 1;
char* json = u_malloc(cap); char* json = u_malloc(cap);
if (!json) return strdup("[]"); if (!json) return u_strdup("[]");
json[0] = '['; json[0] = '[';
sqlite3_stmt* st = NULL; 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); 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; int first = 1;
while (sqlite3_step(st) == SQLITE_ROW) { 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}", 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); 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) if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", 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); 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) { char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
sqlite3* db = chat_core_get_db(); 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; uint64_t my_id = g_cc.my_node_id;
char tbl_msg[80]; 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); 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; 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; size_t cap = 4096, pos = 1;
char* json = u_malloc(cap); char* json = u_malloc(cap);
if (!json) { sqlite3_finalize(st); return strdup("[]"); } if (!json) { sqlite3_finalize(st); return u_strdup("[]"); }
json[0] = '['; json[0] = '[';
int first = 1; 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, 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_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "",
esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : ""); 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) 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}", 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, 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); return chat_setting_get_int(name, def);
} }
/* ──────────────────────────────────────────────────────────────────
* Channel members (queried from shared DB)
* ────────────────────────────────────────────────────────────────── */
#include "../../../src/routing_layer/topo_node_sqlite.h"
#include <openssl/evp.h>
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) { void utun_bridge_restart(const char* config_text) {
bridge_log(BLEV_INFO, "bridge restart: posting to instance"); bridge_log(BLEV_INFO, "bridge restart: posting to instance");
instance_lite_restart(config_text); 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); 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__ */ #endif /* __ANDROID__ */

10
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); void utun_bridge_chat_setting_set(const char* name, const char* value);
int utun_bridge_chat_setting_get_int(const char* name, int def); 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 #ifdef __cplusplus
} }
#endif #endif

1
tools/chatgui/src/accountdelegate.cpp

@ -35,6 +35,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QColor textColor = option.palette.color(QPalette::Text); QColor textColor = option.palette.color(QPalette::Text);
if (option.state & QStyle::State_Selected) textColor = option.palette.color(QPalette::HighlightedText); 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); painter->setPen(textColor);
QString name = index.data(Qt::DisplayRole).toString(); QString name = index.data(Qt::DisplayRole).toString();

43
tools/chatgui/src/accountlist.cpp

@ -10,6 +10,13 @@
#include <QFont> #include <QFont>
#include "../../../lib/platform_compat.h" #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) { QString AccountList::addrStr(const void* addr, int family) {
if (family == 4) { if (family == 4) {
const uint8_t* a = (const uint8_t*)addr; 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 = new QTimer(this);
m_detailTimer->setInterval(5000); m_detailTimer->setInterval(5000);
connect(m_detailTimer, &QTimer::timeout, this, &AccountList::onDetailTimerTick); 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) { void AccountList::setChannel(const QString& channelId) {
m_channelId = channelId; m_channelId = channelId;
m_model->setChannel(channelId); m_model->setChannel(channelId);
m_detailTimer->stop(); m_detailTimer->stop();
m_linkTimer->stop();
m_detailNodeId = 0; m_detailNodeId = 0;
m_detailText->clear(); m_detailText->clear();
} }
@ -106,6 +120,7 @@ void AccountList::showMemberDetail(quint64 nodeId) {
if (!m_db || !m_db->m_db) return; if (!m_db || !m_db->m_db) return;
m_detailTimer->stop(); m_detailTimer->stop();
m_linkTimer->stop();
m_detailNodeId = nodeId; m_detailNodeId = nodeId;
m_detailName.clear(); m_detailName.clear();
m_detailOnline = false; m_detailOnline = false;
@ -166,6 +181,7 @@ void AccountList::showMemberDetail(quint64 nodeId) {
} }
m_detailTimer->start(); m_detailTimer->start();
m_linkTimer->start();
updateDetailDisplay(); updateDetailDisplay();
} }
@ -184,10 +200,35 @@ void AccountList::updateDetailDisplay() {
if (m_detailCreated > 0) if (m_detailCreated > 0)
lines.append(QString("📅 %1").arg(relativeTimeStr(m_detailCreated))); 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) { for (const auto& a : m_detailAddrs) {
QString proto; QString proto;
switch (a.protocol) { case 1: proto = "UDP"; break; case 2: proto = "TCP"; break; default: proto = "UDP/TCP"; break; } 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("<span style='color:%1;'>●</span> %2:%3:%4")
.arg(linkColor, proto, addrStr(a.addr.constData(), a.family)).arg(a.port));
} }
m_detailText->setHtml(lines.join("<br>")); m_detailText->setHtml(lines.join("<br>"));

6
tools/chatgui/src/accountlist.h

@ -6,9 +6,11 @@
#include <QTimer> #include <QTimer>
#include <QMenu> #include <QMenu>
#include <QVector> #include <QVector>
#include <QElapsedTimer>
class MemberListModel; class MemberListModel;
class DbManager; class DbManager;
struct UTUN_INSTANCE;
struct DetailAddress { struct DetailAddress {
int family; int family;
@ -24,6 +26,7 @@ public:
void setChannel(const QString& channelId); void setChannel(const QString& channelId);
void refresh(); void refresh();
void setUtunInstance(struct UTUN_INSTANCE* inst) { m_inst = inst; }
signals: signals:
void memberRenamed(); void memberRenamed();
@ -46,8 +49,11 @@ private:
QTextEdit *m_detailText; QTextEdit *m_detailText;
QTimer *m_refreshTimer; QTimer *m_refreshTimer;
QTimer *m_detailTimer; QTimer *m_detailTimer;
QTimer *m_linkTimer;
QString m_channelId; QString m_channelId;
struct UTUN_INSTANCE* m_inst = nullptr;
quint64 m_detailNodeId = 0; quint64 m_detailNodeId = 0;
QString m_detailName; QString m_detailName;
bool m_detailOnline = false; bool m_detailOnline = false;

3
tools/chatgui/src/mainwindow.cpp

@ -133,8 +133,9 @@ void MainWindow::setupNode() {
connect(m_node, &UtunNode::error, this, [](const QString& msg) { connect(m_node, &UtunNode::error, this, [](const QString& msg) {
qWarning("UtunNode error: %s", qPrintable(msg)); qWarning("UtunNode error: %s", qPrintable(msg));
}); });
connect(m_node, &UtunNode::started, this, []() { connect(m_node, &UtunNode::started, this, [this]() {
qDebug("UtunNode started"); qDebug("UtunNode started");
if (m_accountList && m_node) m_accountList->setUtunInstance(m_node->instance());
}); });
connect(m_node, &UtunNode::stopped, this, [this]() { connect(m_node, &UtunNode::stopped, this, [this]() {
qDebug("UtunNode stopped"); qDebug("UtunNode stopped");

2
tools/chatgui/src/memberlistmodel.cpp

@ -34,6 +34,7 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const {
if (role == Qt::ToolTipRole) if (role == Qt::ToolTipRole)
return QString(it->online ? "online" : "offline") + " | 0x" + return QString(it->online ? "online" : "offline") + " | 0x" +
QString("%1").arg(it->nodeId, 16, 16, QChar('0')); QString("%1").arg(it->nodeId, 16, 16, QChar('0'));
if (role == Qt::UserRole + 2) return it->isSelf;
return {}; return {};
} }
@ -90,6 +91,7 @@ void MemberListModel::loadBatch(int from, int count) {
int row = from + i; int row = from + i;
MemberListItem& item = m_cache[row]; MemberListItem& item = m_cache[row];
item.nodeId = members[i].nodeId; item.nodeId = members[i].nodeId;
item.isSelf = (item.nodeId == m_db->myNodeId());
item.online = members[i].online; item.online = members[i].online;
QString shortId = QString("%1").arg(members[i].nodeId, 8, 16, QChar('0')).right(4).toUpper(); 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; item.name = members[i].name.isEmpty() ? shortId : members[i].name;

5
tools/chatgui/src/memberlistmodel.h

@ -13,11 +13,12 @@ class DbManager;
struct MemberListItem { struct MemberListItem {
quint64 nodeId = 0; quint64 nodeId = 0;
bool online = false; bool online = false;
bool isSelf = false;
QPixmap avatar; QPixmap avatar;
QString name; QString name;
QString localNick; QString localNick;
int rtt = -1; // ms (min из node_addresses, -1 если нет) int rtt = -1;
int natType = -1; // addr_type: 0=NET, 1=DIR, 2=EIM, 3=STR int natType = -1;
}; };
class MemberListModel : public QAbstractListModel { class MemberListModel : public QAbstractListModel {

2
tools/chatgui/transport/utun_node.h

@ -31,6 +31,8 @@ public:
void setDebugLevel(const QString& level); void setDebugLevel(const QString& level);
void setDebugCategories(const QString& categories); void setDebugCategories(const QString& categories);
struct UTUN_INSTANCE* instance() const { return m_instance; }
void send(uint64_t dstNodeId, const QByteArray& data); void send(uint64_t dstNodeId, const QByteArray& data);
QString nodeIdHex() const; QString nodeIdHex() const;

Loading…
Cancel
Save