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
This commit is contained in:
1 parent
3f5dd43109
commit
5a42741d06
5 files changed
+375
-123
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
+129
-85
@@ -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,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") }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user