Add an Android panel for own and peer DM devices

This commit is contained in:
evgeny committed 2026-11-03 14:15:48 +01:00
1 parent cb354c4474
commit 317ea6565f
3 files changed
+103

No files matched your search

@@ -0,0 +1,29 @@
package com.utun.chat.data
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Текущий состав закреплённых сторон PM; собственная копия общего member snapshot. */
data class DmMember(val side: Int, val member: ChatMember)
/** Атомарный decode CHAT_EVT_DM_MEMBERS; чужое поколение и неполный/повторный список отклоняются. */
fun parseDmMembers(data: ByteArray, target: ConversationTarget.Pm): List<DmMember> {
require(data.size >= 12) { "Missing DM member header" }
val length = data[0].toInt() and 255
require(length in 1..63 && data.size >= 11 + length) { "Invalid DM member conversation length" }
require(String(data, 1, length, Charsets.UTF_8) == target.id) { "Different DM member conversation" }
val header = ByteBuffer.wrap(data, 1 + length, 10).order(ByteOrder.LITTLE_ENDIAN)
require(header.long == target.generation && target.generation > 0) { "Stale DM member generation" }
val count = header.short.toInt() and 65535
require(count <= 128 && data.size == 11 + length + count * (1 + MEMBER_WIRE_SIZE)) { "Invalid DM member count/size" }
val nodes = mutableSetOf<Long>()
return List(count) { index ->
val offset = 11 + length + index * (1 + MEMBER_WIRE_SIZE)
val side = data[offset].toInt() and 255
require(side in 0..1) { "Invalid DM member side" }
val wire = requireNotNull(parseMemberWire(data, offset + 1))
require(wire.nodeId != 0L && nodes.add(wire.nodeId)) { "Invalid/duplicate DM member node" }
DmMember(side, wire.toChatMember())
}.sortedWith(compareBy<DmMember> { it.side }.thenByDescending { it.member.isSelf }
.thenByDescending { it.member.online }.thenBy { it.member.nodeId.toULong() })
}
@@ -0,0 +1,70 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.utun.chat.R
import com.utun.chat.data.*
import com.utun.chat.ui.components.PeerStatusDot
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** Панель GROUPS: опрос живёт ровно столько, сколько открыта панель выбранного поколения. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DmMembersSheet(conv: DmConversation, onDismiss: () -> Unit) {
val target = ConversationTarget.Pm(conv.convId, conv.generation)
var members by remember(target) { mutableStateOf<List<DmMember>>(emptyList()) }
var unavailable by remember(target) { mutableStateOf(false) }
LaunchedEffect(target, conv.closed) {
if (conv.closed) { members = emptyList(); return@LaunchedEffect }
while (true) {
try {
val snapshot = withContext(Dispatchers.IO) {
parseDmMembers(checkNotNull(NativeLib.dmMembers(target.id, target.generation)), target)
}
if (snapshot != members) LogManager.addLog("DEBUG", "PM", "Members updated conv=${target.id} count=${snapshot.size}")
members = snapshot; unavailable = false
} catch (e: CancellationException) { throw e }
catch (e: Exception) {
LogManager.addLog("WARN", "PM", "Members unavailable conv=${target.id}: $e"); unavailable = true
}
delay(2000)
}
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Text(stringResource(R.string.dm_members_title), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp))
if (unavailable) Text(stringResource(R.string.dm_members_unavailable), modifier = Modifier.padding(horizontal = 16.dp))
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
for (side in 0..1) {
item(key = "side:$side") {
HorizontalDivider()
Text(stringResource(if (side == 0) R.string.dm_my_devices else R.string.dm_peer_devices),
fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp))
}
items(members.filter { it.side == side }, key = { "node:${it.member.nodeId}" }) { entry ->
val member = entry.member
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
PeerStatusDot(member.online)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(member.name, fontWeight = if (member.isSelf) FontWeight.Bold else FontWeight.Normal)
Text("0x%016x".format(member.nodeId), style = MaterialTheme.typography.bodySmall)
}
Text(stringResource(if (member.online) R.string.online else R.string.offline))
}
}
}
}
Spacer(Modifier.height(16.dp))
}
}
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="dm_members_title">Conversation devices</string>
<string name="dm_my_devices">My devices</string>
<string name="dm_peer_devices">Peer devices</string>
<string name="dm_members_unavailable">Could not refresh the device list</string>
<string name="dm_mode_title">Devices for this conversation</string>
<string name="dm_mode_groups">With device groups</string>
<string name="dm_mode_device">Only these devices</string>