Browse Source

android: restore live member/connection updates lost in Kotlin source restore

- ChatRepository: MemberWire, parseMemberWire, cleanSocketName, parseMemberDetail, LinkStatus.linkId, LocalSocketState.linksUp/linksDown
- ChatViewModel: handlers 24/25/22, fix 16 (selectedMember update), 17 (in-place sync + status!=1 refresh), moder param
- MemberListScreen: compact header, no-dot addresses, TextAlign.Start connections, cleanSocketName, fonts 13sp, spacing 4dp, self *[up/down]
- SettingsScreen: linksCount -> *[linksUp/linksDown]
- NativeLib: moder in setMemberFlags JNI
topo_upd
evgeny 2 months ago
parent
commit
5a42741d06
  1. 190
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  2. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  3. 164
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  4. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  5. 83
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

190
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<MemberAddr> = 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<LinkStatus> = 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<ConnInfo> {
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<ConnInfo>()
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<LinkStatus>()
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<Float>()
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<LocalSocketState>()
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<LocalSocketState> { 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()

6
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 ── */

164
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<LocalSocketState>,
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,86 +226,116 @@ 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")
}
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 ""),
text = headerParts.joinToString(" · "),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color.DarkGray
color = Color.Gray
)
} 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
)
}
}
}
} 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
)
}
val rttTypeName = when (detail.rttType) { 0 -> "interface" 1 -> "nat" 2 -> "real" else -> "?" }
}
if (memberLinks.links.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
if (isSelf) {
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) {
Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50))
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("self (local)", fontSize = 12.sp, color = Color(0xFF4CAF50))
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))
}
} else if (detail.directPresence) {
}
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}ms ($rttTypeName)" else ""),
fontSize = 12.sp, color = Color.DarkGray)
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, color = Color.DarkGray)
}
}
if (detail.indirectPresence) {
@ -308,18 +344,21 @@ private fun MemberDetailCard(
.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)
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)
Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt}ms" else ""),
fontSize = 13.sp, color = Color.DarkGray)
}
}
if (!isSelf && !detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 12.sp, color = Color.Gray)
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.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") }

5
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)
}
}
}

83
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)
}

Loading…
Cancel
Save