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
This commit is contained in:
evgeny committed 2026-08-01 23:40:58 +03:00
1 parent 6719a1e23c
commit 50156e12af
19 files changed
+1022 -23

No files matched your search

@@ -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<String?>(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 -> {
@@ -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<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 {
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<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() {}
}
@@ -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
@@ -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(
@@ -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") }
}
)
}
@@ -45,6 +45,15 @@ class ChatViewModel : ViewModel() {
private val _generatingKeys = MutableStateFlow(false)
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)
val isStuck: StateFlow<Boolean> = _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
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -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 <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) {
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__ */
@@ -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