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