chat: user-friendly INFO logs for invite/connect flow

- chat_sync.c: invite entry, JOINED/TIMEOUT messages
- conn_mgr_core.c: invite start (addrs+udp+tcp), NCD UP/DOWN/TIMEOUT,
  TCP connect/ready/close, membership check send/receive/confirm/deny,
  overall timeout
- node_conn_direct.c: NCD event names instead of numbers
This commit is contained in:
evgeny committed 2026-08-02 20:32:01 +03:00
1 parent c35135cb52
commit c2191aad3b
34 files changed
+597 -230

No files matched your search

@@ -3,6 +3,47 @@ package com.utun.chat.data
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
const val EVT_NODEINFO_UPDATED = 16
data class NodeStatus(
val nodeId: Long = 0,
val directPresence: Boolean = false,
val directUp: Boolean = false,
val indirectPresence: Boolean = false,
val indirectUp: Boolean = false,
val bgpPresence: Boolean = false,
val bgpUp: Boolean = false,
val bestRtt: Int = 0,
val rttType: Int = 0 // 0=interface, 1=nat, 2=real
) {
val online: Boolean get() = (directPresence && directUp) || (indirectPresence && indirectUp)
val hasPresence: Boolean get() = directPresence || indirectPresence || bgpPresence
}
fun parseNodeStatus(data: ByteArray): NodeStatus? {
if (data.size < 12) return null
val buf = ByteBuffer.wrap(data, 0, 12).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
return NodeStatus(
nodeId = nodeId,
directPresence = (presence and 1) != 0,
directUp = (up and 1) != 0,
indirectPresence = (presence and 2) != 0,
indirectUp = (up and 2) != 0,
bgpPresence = (presence and 4) != 0,
bgpUp = (up and 4) != 0,
bestRtt = bestRtt,
rttType = rttType
)
}
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
val peersOnline: Int = 0)
@@ -15,11 +56,18 @@ 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 isSelf: Boolean = false)
val rtt: Int = 0, val rttType: 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)
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 created: Long = 0, val addresses: List<MemberAddr> = emptyList(),
val bestRtt: Int = 0, val rttType: Int = 0,
val directPresence: Boolean = false, val directUp: Boolean = false,
val indirectPresence: Boolean = false, val indirectUp: Boolean = false,
val bgpPresence: Boolean = false)
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)
@@ -121,7 +121,17 @@ private fun MemberListItem(
onClick: () -> Unit,
onEdit: () -> Unit
) {
val onlineColor = if (member.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
val onlineColor = when {
member.directUp || member.indirectUp -> Color(0xFF4CAF50) // green: up
member.directPresence || member.indirectPresence || member.bgpPresence -> Color(0xFFFFA726) // orange: presence but down
else -> Color(0xFF9E9E9E) // gray: offline
}
val connLabel = when {
member.directUp -> "direct"
member.indirectUp -> "indirect"
member.directPresence || member.indirectPresence || member.bgpPresence -> "offline"
else -> ""
}
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
Surface(
@@ -152,27 +162,22 @@ private fun MemberListItem(
overflow = TextOverflow.Ellipsis,
color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified
)
val roles = buildList {
if (member.isAdmin) add("Admin")
if (member.isSuper) add("Super")
if (member.isStorage) add("Storage")
}
if (roles.isNotEmpty() || member.rtt > 0) {
val parts = mutableListOf<String>()
if (member.isAdmin) parts.add("Admin")
if (member.isSuper) parts.add("Super")
if (member.isStorage) parts.add("Storage")
if (member.directUp) parts.add("direct↑")
else if (member.indirectUp) parts.add("indirect↑")
else if (member.directPresence || member.indirectPresence || member.bgpPresence) parts.add("down")
if (member.rtt > 0) parts.add("${member.rtt}ms")
if (parts.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (roles.isNotEmpty())
Text(
text = roles.joinToString(", "),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.primary
)
if (member.rtt > 0) {
if (roles.isNotEmpty()) Text(" ", fontSize = 11.sp)
Text(
text = "${member.rtt / 10}ms",
fontSize = 11.sp,
color = Color.Gray
)
}
Text(
text = parts.joinToString(" "),
fontSize = 11.sp,
color = if (member.directUp || member.indirectUp)
Color(0xFF4CAF50) else Color.Gray
)
}
}
}
@@ -266,6 +271,38 @@ private fun MemberDetailCard(
}
}
}
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 (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.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)
}
}
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.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 12.sp, color = Color.Gray)
}
}
}
}
@@ -57,6 +57,9 @@ class ChatViewModel : ViewModel() {
private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow<Boolean> = _isStuck
private val _nodeStatuses = MutableStateFlow<Map<Long, NodeStatus>>(emptyMap())
val nodeStatuses: StateFlow<Map<Long, NodeStatus>> = _nodeStatuses
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
@@ -186,6 +189,13 @@ class ChatViewModel : ViewModel() {
4 -> if (repo != null) refreshChannels() /* CHANNEL_UPDATED */
12 -> LogManager.addLog("INFO", "VM", "service started")
13 -> LogManager.addLog("INFO", "VM", "service stopped")
16 -> { /* NODEINFO_UPDATED: [node_id:8][presence:1][up:1][best_rtt:2] */
if (data == null || data.size < 12) return
val ns = parseNodeStatus(data) ?: return
_nodeStatuses.value = _nodeStatuses.value + (ns.nodeId to ns)
val cur = _currentChannel.value
if (cur != null) refreshMembers(cur.id)
}
}
}
@@ -228,15 +238,33 @@ class ChatViewModel : ViewModel() {
fun refreshMembers(chId: String) {
val r = repo ?: return
val statuses = _nodeStatuses.value
viewModelScope.launch {
_members.value = withContext(Dispatchers.IO) { r.getMembers(chId) }
val raw = withContext(Dispatchers.IO) { r.getMembers(chId) }
_members.value = raw.map { m ->
val ns = statuses[m.nodeId]
if (ns != null) m.copy(
online = ns.online, rtt = ns.bestRtt, rttType = ns.rttType,
directUp = ns.directUp, indirectUp = ns.indirectUp,
directPresence = ns.directPresence, indirectPresence = ns.indirectPresence,
bgpPresence = ns.bgpPresence
) else m
}
}
}
fun showMemberDetail(channelId: String, nodeId: Long) {
val r = repo ?: return
val ns = _nodeStatuses.value[nodeId]
viewModelScope.launch {
_selectedMember.value = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) }
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,
directPresence = ns.directPresence, directUp = ns.directUp,
indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp,
bgpPresence = ns.bgpPresence
)
else _selectedMember.value = d
}
}