diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index ca8c31e4..00ac653b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -9,6 +9,8 @@ import java.nio.ByteOrder const val EVT_NODEINFO_UPDATED = 16 const val EVT_LINK_STATUS_CHANGED = 17 const val EVT_LOCAL_SOCKETS = 19 +const val EVT_CONN_LIST = 20 +const val EVT_CONN_METRICS = 21 data class NodeStatus( val nodeId: Long = 0, @@ -19,7 +21,8 @@ data class NodeStatus( val bgpPresence: Boolean = false, val bgpUp: Boolean = false, val bestRtt: Int = 0, - val rttType: Int = 0 // 0=interface, 1=nat, 2=real + val bgpRtt: Int = 0, + val bgpNodes: Int = 0 ) { val online: Boolean get() = (directPresence && directUp) || (indirectPresence && indirectUp) val hasPresence: Boolean get() = directPresence || indirectPresence || bgpPresence @@ -27,13 +30,14 @@ data class NodeStatus( fun parseNodeStatus(data: ByteArray): NodeStatus? { if (data.size < 12) return null - val buf = ByteBuffer.wrap(data, 0, 12).order(ByteOrder.LITTLE_ENDIAN) + val buf = ByteBuffer.wrap(data, 0, data.size).order(ByteOrder.LITTLE_ENDIAN) val nodeId = buf.getLong() val presence = buf.get().toInt() and 0xFF val up = buf.get().toInt() and 0xFF val rttPacked = buf.getShort().toInt() and 0xFFFF - val bestRtt = rttPacked and 0x3FFF - val rttType = (rttPacked shr 14) and 3 + val bestRtt = if (rttPacked == 0xFFFF) -1 else rttPacked + val bgpRtt = if (data.size >= 16) buf.getShort().toInt() and 0xFFFF else 0 + val bgpNodes = if (data.size >= 16) buf.getShort().toInt() and 0xFFFF else 0 return NodeStatus( nodeId = nodeId, directPresence = (presence and 1) != 0, @@ -43,7 +47,8 @@ fun parseNodeStatus(data: ByteArray): NodeStatus? { bgpPresence = (presence and 4) != 0, bgpUp = (up and 4) != 0, bestRtt = bestRtt, - rttType = rttType + bgpRtt = bgpRtt, + bgpNodes = bgpNodes ) } @@ -57,25 +62,34 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L 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 rttType: Int = 0, val isSelf: Boolean = false, + val isModer: Boolean = false, val isStorage: Boolean = false, + val nodeType: Int = 0, + val rtt: Int = 0, val isSelf: Boolean = false, val directUp: Boolean = false, val indirectUp: Boolean = false, val directPresence: Boolean = false, val indirectPresence: Boolean = false, - val bgpPresence: Boolean = false) + val bgpPresence: Boolean = false, val bgpRtt: Int = 0, + val bgpNodes: Int = 0) { + val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0) +} data class MemberDetail(val nodeId: Long, val name: String = "", val online: Boolean = false, val lastSeen: Long = 0, val created: Long = 0, val addresses: List = emptyList(), - val bestRtt: Int = 0, val rttType: Int = 0, + val bestRtt: Int = 0, val directPresence: Boolean = false, val directUp: Boolean = false, val indirectPresence: Boolean = false, val indirectUp: Boolean = false, - val bgpPresence: Boolean = false) + val bgpPresence: Boolean = false, val bgpRtt: Int = 0, + val bgpNodes: Int = 0) 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 LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAddr: String = "", val localPort: Int = 0, + val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0, + val linkState: Int = 0, val linkStatus: Int = 0, + val isServer: Boolean = false, val socketName: String = "") { + val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" } +} data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false, val linksUp: Int = 0, val links: List = emptyList()) @@ -83,11 +97,128 @@ data class MemberLinks(val connected: Boolean = false, val initialized: Boolean data class LocalSocketState( val sockId: Int = 0, val ifName: String = "", val bindIp: String = "", val port: Int = 0, val protocol: String = "", val state: Int = 0, - val linksCount: Int = 0 + val linksUp: Int = 0, val linksDown: Int = 0 ) { val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" } } +data class ConnInfo( + val nodeId: Long, val name: String, val linksUp: Boolean = false, + val initialized: Boolean = false, val proxyClient: Boolean = false, + val proxyServer: Boolean = false, val linkCount: Int = 0, val rtt: Int = 0 +) { + val status: String get() = if (linksUp) "UP" else if (initialized) "init" else "DOWN" + val rttDisplay: String get() = if (rtt > 0) String.format("%.1f ms", rtt / 10f) else "-" + val proxyRole: String get() = when { proxyClient && proxyServer -> "CS"; proxyClient -> "C"; proxyServer -> "S"; else -> "" } +} + +/* Format: [count:2][entry:46B]* */ +fun parseConnList(data: ByteArray): List { + if (data.size < 2) return emptyList() + val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val count = buf.getShort().toInt() and 0xFFFF + val list = mutableListOf() + for (i in 0 until count) { + if (buf.position() + 46 > data.size) break + val nodeId = buf.getLong() + val flags = buf.get().toInt() and 0xFF + val linkCount = buf.get().toInt() and 0xFF + val rtt = buf.getShort().toInt() and 0xFFFF + buf.getShort() + val nameBytes = ByteArray(32); buf.get(nameBytes) + val name = String(nameBytes).trimEnd('\u0000').trimEnd() + if (nodeId == 0L) continue + list.add(ConnInfo( + nodeId = nodeId, name = name.ifEmpty { "%016X".format(nodeId) }, + linksUp = (flags and 1) != 0, initialized = (flags and 2) != 0, + proxyClient = (flags and 4) != 0, proxyServer = (flags and 8) != 0, + linkCount = linkCount, rtt = rtt + )) + } + return list +} + +/* ── Member wire format (79 bytes, from chat_member.h) ── */ +data class MemberWire( + val nodeId: Long, val online: Int, val isSelf: Boolean, + val flags: Int, val rtt: Int, val natType: Int, + val storage: Boolean, val name: String +) + +fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? { + if (offset + 79 > data.size) return null + val buf = ByteBuffer.wrap(data, offset, 79).order(ByteOrder.LITTLE_ENDIAN) + val nodeId = buf.getLong() + val online = buf.get().toInt() and 0xFF + val isSelf = buf.get().toInt() != 0 + val flags = buf.get().toInt() and 0xFF + val rtt = buf.getShort().toInt() and 0xFFFF + val natType = buf.get().toInt() and 0xFF + val storage = buf.get().toInt() != 0 + val nameBytes = ByteArray(64); buf.get(nameBytes) + val name = String(nameBytes).trimEnd('\u0000') + return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name) +} + +fun MemberWire.toChatMember(): ChatMember = ChatMember( + nodeId = nodeId, name = name, online = online != 0, + isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0, + isModer = (flags and 4) != 0, isStorage = storage, + rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf +) + +fun cleanSocketName(raw: String): String = + raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1") + +/* ── MEMBER_DETAIL (event 22) link format: 43 bytes per link ── */ +private const val MEMBER_DETAIL_LINK_SIZE = 43 + +private fun addrFromBytes(family: Int, bytes: ByteArray): String { + if (family == 4 && bytes.size >= 4) + return "${bytes[0].toInt() and 0xFF}.${bytes[1].toInt() and 0xFF}.${bytes[2].toInt() and 0xFF}.${bytes[3].toInt() and 0xFF}" + if (family == 6 && bytes.size >= 16) + return bytes.take(16).joinToString(":") { "%02x".format(it) } + return "?" +} + +fun parseMemberDetail(data: ByteArray): MemberLinks? { + if (data.size < 12) return null + val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + buf.getLong() + val flags = buf.get().toInt() and 0xFF + buf.getShort() + val linkCount = buf.get().toInt() and 0xFF + + val links = mutableListOf() + var offset = 12 + for (i in 0 until linkCount) { + if (offset + MEMBER_DETAIL_LINK_SIZE > data.size) break + val lbuf = ByteBuffer.wrap(data, offset, MEMBER_DETAIL_LINK_SIZE).order(ByteOrder.LITTLE_ENDIAN) + val isTcp = lbuf.get().toInt() and 0xFF + val state = lbuf.get().toInt() and 0xFF + val status = lbuf.get().toInt() and 0xFF + val tcpReady = lbuf.get().toInt() and 0xFF + val isServer = lbuf.get().toInt() and 0xFF + val lf = lbuf.get().toInt() and 0xFF; val la = ByteArray(16); lbuf.get(la) + val lp = lbuf.getShort().toInt() and 0xFFFF + val rf = lbuf.get().toInt() and 0xFF; val ra = ByteArray(16); lbuf.get(ra) + val rp = lbuf.getShort().toInt() and 0xFFFF + val effState = if (isTcp != 0 && tcpReady != 0) 3 else state + val effStatus = if (isTcp != 0 && tcpReady != 0) 1 else status + links.add(LinkStatus( + localFamily = lf, localAddr = addrFromBytes(lf, la), localPort = lp, + remoteFamily = rf, remoteAddr = addrFromBytes(rf, ra), remotePort = rp, + linkState = effState, linkStatus = effStatus, isServer = isServer != 0 + )) + offset += MEMBER_DETAIL_LINK_SIZE + } + + return MemberLinks( + connected = (flags and 1) != 0, initialized = (flags and 2) != 0, + linksUp = if ((flags and 4) != 0) 1 else 0, links = links + ) +} + class ChatRepository { private val myNodeId: Long = NativeLib.getMyNodeId() @@ -133,20 +264,18 @@ class ChatRepository { var displayText = text var fileSize = 0L - /* Parse download state from localAttrs */ var downloadState = "" val localAttrs = obj.optString("localAttrs", "") if (filePath.isNotEmpty()) downloadState = "downloaded" else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded" - /* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */ var waveform = emptyList() var voiceDurationMs = 0 if (contentType == "audio/opus" && text.startsWith("wf=")) { try { val wfEnd = text.indexOf(";dur=") if (wfEnd > 0) { - val wfRaw = text.substring(3, wfEnd) // 100 base64 chars + val wfRaw = text.substring(3, wfEnd) waveform = if (wfRaw.length >= 50) { wfRaw.map { c -> val idx = when (c) { @@ -158,7 +287,6 @@ class ChatRepository { (idx.toFloat() / 63f).coerceIn(0f, 1f) } } else { - /* old CSV format fallback */ val parts = wfRaw.split(",") if (parts.size >= 2) { parts.map { s -> @@ -174,7 +302,6 @@ class ChatRepository { } catch (_: Exception) {} } - /* Parse file attachment: "filename.ext|file_size|block_size|num_blocks|sigs" */ if (contentType == "application/octet-stream") { try { val pipeIdx = text.indexOf('|') @@ -226,6 +353,7 @@ class ChatRepository { online = obj.optInt("on", 0) != 0, isAdmin = obj.optInt("ad", 0) != 0, isSuper = obj.optInt("sp", 0) != 0, + isModer = obj.optInt("md", 0) != 0, isStorage = obj.optInt("st", 0) != 0, nodeType = obj.optInt("tp", 0), rtt = obj.optInt("rt", 0), @@ -270,8 +398,8 @@ class ChatRepository { 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 setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) = + NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage) fun getMemberLinks(nodeId: Long): MemberLinks { try { @@ -283,11 +411,17 @@ class ChatRepository { 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), + linkId = a.optInt("id", 0), + localFamily = a.optInt("lf", 0), + localAddr = a.optString("la", ""), + localPort = a.optInt("lp", 0), + remoteFamily = a.optInt("rf", 0), + remoteAddr = a.optString("ra", ""), + remotePort = a.optInt("rp", 0), linkState = a.optInt("st", 0), - linkStatus = a.optInt("ls", 0) + linkStatus = a.optInt("ls", 0), + isServer = a.optInt("is", 0) != 0, + socketName = a.optString("sn", "") )) } } @@ -307,16 +441,20 @@ class ChatRepository { val list = mutableListOf() for (i in 0 until arr.length()) { val obj = arr.getJSONObject(i) + val socketName = obj.optString("nm", "") + val cleanName = socketName.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), "") list.add(LocalSocketState( sockId = obj.optInt("si", 0), - ifName = obj.optString("nm", ""), + ifName = cleanName, bindIp = obj.optString("ad", ""), port = obj.optInt("po", 0), protocol = obj.optString("pt", ""), state = obj.optInt("st", 0), - linksCount = obj.optInt("lk", 0) + linksUp = obj.optInt("lu", 0), + linksDown = obj.optInt("ld", 0) )) } + list.sortWith(compareBy { it.ifName }.thenBy { it.bindIp }.thenBy { it.protocol }.thenBy { it.port }) return list } catch (e: Exception) { Log.w("utun-gui", "getLocalSockets error", e) } return emptyList() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index c5e074ea..e82449c3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -81,8 +81,8 @@ object NativeLib { 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 setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) { + nativeSetMemberFlags(channelId, nodeId, admin, supernode, moder, storage) } fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}" @@ -145,7 +145,7 @@ object NativeLib { 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 nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) private external fun nativeGetMemberLinks(nodeId: Long): String? /* ── Local sockets JNI ── */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index f7825b4a..20552033 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -21,6 +21,7 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.data.Channel @@ -29,6 +30,7 @@ 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.data.LocalSocketState import com.utun.chat.viewmodel.ChatViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -46,6 +48,7 @@ fun MemberListScreen( LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) } val memberLinks by viewModel.memberLinks.collectAsState() + val localSockets by viewModel.localSockets.collectAsState() LaunchedEffect(selectedMember?.nodeId) { val nodeId = selectedMember?.nodeId @@ -89,6 +92,7 @@ fun MemberListScreen( MemberDetailCard( detail = detail, memberLinks = memberLinks, + localSocketStates = localSockets, isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf }, onDismiss = { } ) @@ -100,10 +104,10 @@ fun MemberListScreen( MemberEditDialog( member = editMember!!, onDismiss = { showEditDialog = false }, - onSave = { nick, admin, supernode, storage -> + onSave = { nick, admin, supernode, moder, 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) + if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || moder != editMember!!.isModer || storage != editMember!!.isStorage) + viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, moder, storage) showEditDialog = false } ) @@ -188,20 +192,22 @@ private fun MemberListItem( } } +fun formatAddr(addr: String): String { + if (addr.isEmpty() || addr == "?") return addr + val trimmed = addr.trim('[', ']') + if (trimmed.all { it == '0' || it == ':' }) return "[::]" + if (trimmed == "::") return "[::]" + return trimmed +} + @Composable private fun MemberDetailCard( detail: MemberDetail, memberLinks: MemberLinks, + localSocketStates: List, isSelf: Boolean = false, 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() @@ -220,106 +226,139 @@ private fun MemberDetailCard( ) Spacer(Modifier.width(8.dp)) Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp) + if (isSelf) { + Spacer(Modifier.width(4.dp)) + Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50)) + } } 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 - ) - + val headerParts = mutableListOf("0x${detail.nodeId.toString(16).padStart(16, '0')}") 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" + sec < 60 -> "${sec}s" + sec < 3600 -> "${sec / 60}m" + sec < 86400 -> "${sec / 3600}h" + else -> "${sec / 86400}d" } - Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray) + headerParts.add("seen $ago") } - 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" + sec < 604800 -> "${sec / 86400}d" + else -> "${sec / 2592000}mo" } - Text("Created: $ago", fontSize = 12.sp, color = Color.Gray) + headerParts.add("created $ago") } + Text( + text = headerParts.joinToString(" · "), + fontFamily = FontFamily.Monospace, + fontSize = 11.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 -> - if (isSelf) { - 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 - ) - } else { - val dcol = linkDotColor(a) + if (isSelf) { + if (localSocketStates.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text("Listen sockets:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + localSocketStates.forEach { sk -> + val dot = if (sk.state == 2) Color(0xFF4CAF50) else Color(0xFF9E9E9E) Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(dcol) - ) + Box(Modifier.size(8.dp).clip(CircleShape).background(dot)) Spacer(Modifier.width(6.dp)) Text( - text = "${a.protocol} ${a.address}:${a.port}" + - (if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""), + text = "*[${sk.linksUp}/${sk.linksDown}] ${sk.protocol} ${sk.ifName} ${sk.bindIp}:${sk.port}", fontFamily = FontFamily.Monospace, - fontSize = 11.sp, + fontSize = 13.sp, color = Color.DarkGray ) } } } - } - val rttTypeName = when (detail.rttType) { 0 -> "interface" 1 -> "nat" 2 -> "real" else -> "?" } - Spacer(Modifier.height(6.dp)) - Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium) - if (isSelf) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50)) - Spacer(Modifier.width(4.dp)) - Text("self (local)", fontSize = 12.sp, color = Color(0xFF4CAF50)) + } else { + if (detail.addresses.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + detail.addresses.forEach { a -> + Text( + text = "${a.protocol} ${a.address}:${a.port}", + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = Color.DarkGray + ) + } } - } else if (detail.directPresence) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(8.dp).clip(CircleShape) - .background(if (detail.directUp) Color(0xFF4CAF50) else Color(0xFFE53935))) - Spacer(Modifier.width(6.dp)) - Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" + - (if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt}ms ($rttTypeName)" else ""), - fontSize = 12.sp, color = Color.DarkGray) + + if (memberLinks.links.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text("Connections:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + memberLinks.links.forEachIndexed { idx, lk -> + val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935) + val arrow = if (lk.isServer) "◀" else "▶" + val arrowColor = if (lk.isServer) Color(0xFFFF9800) else Color(0xFF2196F3) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(dcol)) + Spacer(Modifier.width(6.dp)) + Text("${formatAddr(lk.localAddr)}:${lk.localPort}", + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.DarkGray) + Spacer(Modifier.width(4.dp)) + Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp) + Spacer(Modifier.width(4.dp)) + Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}", + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.DarkGray) + } + val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName) + if (cleanSn.isNotEmpty()) { + Row { + Spacer(Modifier.width(14.dp)) + Text(cleanSn, fontSize = 12.sp, color = Color.Gray) + } + } + } + if (idx < memberLinks.links.lastIndex) Spacer(Modifier.height(4.dp)) + } } - } - if (detail.indirectPresence) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(8.dp).clip(CircleShape) - .background(if (detail.indirectUp) Color(0xFF4CAF50) else Color(0xFFE53935))) - Spacer(Modifier.width(6.dp)) - Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}", - fontSize = 12.sp, color = Color.DarkGray) + + Spacer(Modifier.height(6.dp)) + Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium) + if (detail.directPresence) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape) + .background(if (detail.directUp) Color(0xFF4CAF50) else Color(0xFFE53935))) + Spacer(Modifier.width(6.dp)) + Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" + + (if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""), + fontSize = 13.sp, color = Color.DarkGray) + } } - } - if (detail.bgpPresence) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF4CAF50))) - Spacer(Modifier.width(6.dp)) - Text("BGP: visible", fontSize = 12.sp, color = Color.DarkGray) + if (detail.indirectPresence) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape) + .background(if (detail.indirectUp) Color(0xFF4CAF50) else Color(0xFFE53935))) + Spacer(Modifier.width(6.dp)) + Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}", + fontSize = 13.sp, color = Color.DarkGray) + } + } + if (detail.bgpPresence) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF4CAF50))) + Spacer(Modifier.width(6.dp)) + Text("BGP: ${detail.bgpNodes} nodes" + + (if (detail.bestRtt > 0) " ${detail.bestRtt}ms" else ""), + fontSize = 13.sp, color = Color.DarkGray) + } + } + if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) { + Text("offline", fontSize = 13.sp, color = Color.Gray) } - } - if (!isSelf && !detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) { - Text("offline", fontSize = 12.sp, color = Color.Gray) } } } @@ -329,11 +368,12 @@ private fun MemberDetailCard( private fun MemberEditDialog( member: ChatMember, onDismiss: () -> Unit, - onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit + onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: 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 moder by remember { mutableStateOf(member.isModer) } var storage by remember { mutableStateOf(member.isStorage) } AlertDialog( @@ -358,6 +398,10 @@ private fun MemberEditDialog( Checkbox(checked = supernode, onCheckedChange = { supernode = it }) Text("Supernode", modifier = Modifier.clickable { supernode = !supernode }) } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = moder, onCheckedChange = { moder = it }) + Text("Moderator", modifier = Modifier.clickable { moder = !moder }) + } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = storage, onCheckedChange = { storage = it }) Text("Storage", modifier = Modifier.clickable { storage = !storage }) @@ -365,7 +409,7 @@ private fun MemberEditDialog( } }, confirmButton = { - TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") } + TextButton(onClick = { onSave(nick, admin, supernode, moder, storage) }) { Text("Save") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index a786b027..af92ba7b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -233,9 +233,10 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool Text(s.stateLabel, style = MaterialTheme.typography.labelSmall, color = stateColor) Spacer(Modifier.width(6.dp)) Text(s.protocol, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - if (s.linksCount > 0) { + if (s.linksUp + s.linksDown > 0) { Spacer(Modifier.width(4.dp)) - Text("${s.linksCount} links", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("*[${s.linksUp}/${s.linksDown}]", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 27601f44..7b679f7b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -205,15 +205,82 @@ class ChatViewModel : ViewModel() { _nodeStatuses.value = _nodeStatuses.value + (ns.nodeId to ns) val cur = _currentChannel.value if (cur != null) refreshMembers(cur.id) + val sel = _selectedMember.value + if (sel != null && sel.nodeId == ns.nodeId) + _selectedMember.value = sel.copy( + online = ns.online, bestRtt = ns.bestRtt, + directPresence = ns.directPresence, directUp = ns.directUp, + indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp, + bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, bgpNodes = ns.bgpNodes) } 17 -> { /* LINK_STATUS_CHANGED: [peer_id:8][link_id:1][state:1][status:1][links_up:1][initialized:1] */ if (data == null || data.size < 13) return val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) val nodeId = buf.getLong() + val linkId = buf.get().toInt() and 0xFF + val state = buf.get().toInt() and 0xFF + val status = buf.get().toInt() and 0xFF + val linksUp = buf.get().toInt() and 0xFF + val initialized = buf.get().toInt() and 0xFF + LogManager.addLog("DEBUG", "VM", "LINK_STATUS_CHANGED node=0x%x link=%d st=%d ls=%d up=%d init=%d" + .format(nodeId, linkId, state, status, linksUp, initialized.toInt())) + val curNs = _nodeStatuses.value[nodeId] ?: NodeStatus(nodeId = nodeId) + _nodeStatuses.value = _nodeStatuses.value + (nodeId to curNs.copy( + directUp = linksUp > 0, directPresence = initialized != 0)) val detail = _selectedMember.value - if (detail != null && detail.nodeId == nodeId) refreshMemberLinks(nodeId) + if (detail != null && detail.nodeId == nodeId) { + _selectedMember.value = detail.copy( + directUp = linksUp > 0, directPresence = initialized != 0, + online = initialized != 0 && linksUp > 0) + val curLinks = _memberLinks.value + val updated = curLinks.links.map { lk -> + if (lk.linkId == linkId) lk.copy(linkState = state, linkStatus = status) else lk + } + val found = curLinks.links.any { it.linkId == linkId } + _memberLinks.value = curLinks.copy(linksUp = linksUp, links = updated) + if (!found || status != 1) refreshMemberLinks(nodeId) + } + val cur = _currentChannel.value + if (cur != null) refreshMembers(cur.id) } 19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */ + 24 -> { /* MEMBER_UPDATED: [ch_id_len:1][ch_id:var][count:1][member:79B] */ + if (data == null || data.size < 3 + 79) return + val cl = data[0].toInt() and 0xFF + val w = parseMemberWire(data, 1 + cl + 1) ?: return + val m = w.toChatMember() + val cur = _currentChannel.value + if (cur != null && cur.id == String(data, 1, cl)) { + _members.value = _members.value.toMutableList().apply { + val idx = indexOfFirst { it.nodeId == w.nodeId } + if (idx >= 0) set(idx, m) else add(m) + } + } + val sel = _selectedMember.value + if (sel != null && sel.nodeId == w.nodeId) + _selectedMember.value = sel.copy(online = w.online != 0) + } + 25 -> { /* MEMBER_REMOVED: [ch_id_len:1][ch_id:var][node_id:8] */ + if (data == null || data.size < 10) return + val cl = data[0].toInt() and 0xFF + val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val nodeId = buf.getLong() + val cur = _currentChannel.value + if (cur != null && cur.id == String(data, 1, cl)) + _members.value = _members.value.filter { it.nodeId != nodeId } + if (_selectedMember.value?.nodeId == nodeId) + _selectedMember.value = null + } + 22 -> { /* MEMBER_DETAIL: бинарный снапшот links+sockets */ + if (data == null || data.size < 12) return + val ml = parseMemberDetail(data) ?: return + val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val nodeId = buf.getLong() + if (_selectedMember.value?.nodeId == nodeId) { + _memberLinks.value = ml + _selectedMember.value = _selectedMember.value?.copy(online = ml.connected && ml.linksUp > 0) + } + } } } @@ -262,10 +329,11 @@ class ChatViewModel : ViewModel() { _members.value = raw.map { m -> val ns = statuses[m.nodeId] if (ns != null) m.copy( - online = ns.online, rtt = ns.bestRtt, rttType = ns.rttType, + online = ns.online, rtt = ns.bestRtt, directUp = ns.directUp, indirectUp = ns.indirectUp, directPresence = ns.directPresence, indirectPresence = ns.indirectPresence, - bgpPresence = ns.bgpPresence + bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, + bgpNodes = ns.bgpNodes ) else m } } @@ -277,10 +345,11 @@ class ChatViewModel : ViewModel() { viewModelScope.launch { val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) } if (d != null && ns != null) _selectedMember.value = d.copy( - online = ns.online, bestRtt = ns.bestRtt, rttType = ns.rttType, + online = ns.online, bestRtt = ns.bestRtt, directPresence = ns.directPresence, directUp = ns.directUp, indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp, - bgpPresence = ns.bgpPresence + bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, + bgpNodes = ns.bgpNodes ) else _selectedMember.value = d } @@ -292,9 +361,9 @@ class ChatViewModel : ViewModel() { refreshMembers(channelId) } - fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) { val r = repo ?: return - r.setMemberFlags(channelId, nodeId, admin, supernode, storage) + r.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage) refreshMembers(channelId) }