diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DmMembers.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DmMembers.kt new file mode 100644 index 000000000..0acf4ad71 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DmMembers.kt @@ -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 { + 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() + 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 { it.side }.thenByDescending { it.member.isSelf } + .thenByDescending { it.member.online }.thenBy { it.member.nodeId.toULong() }) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmMembersSheet.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmMembersSheet.kt new file mode 100644 index 000000000..95685bd68 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmMembersSheet.kt @@ -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>(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)) + } +} diff --git a/tools/chatgui-android/app/src/main/res/values/dm_groups.xml b/tools/chatgui-android/app/src/main/res/values/dm_groups.xml index 940ae4616..64e2a851f 100644 --- a/tools/chatgui-android/app/src/main/res/values/dm_groups.xml +++ b/tools/chatgui-android/app/src/main/res/values/dm_groups.xml @@ -1,5 +1,9 @@ + Conversation devices + My devices + Peer devices + Could not refresh the device list Devices for this conversation With device groups Only these devices