diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt index ec19e745f..a5a7e85f3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* @@ -42,6 +43,8 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit, onCapturePhoto: () -> Unit, onViewPhoto: (Message) -> Unit) { val target = ConversationTarget.Pm(conv.convId, conv.generation) + var showDevices by rememberSaveable(conv.convId, conv.generation) { mutableStateOf(false) } + if (showDevices && conv.mode == 1) DmMembersSheet(conv) { showDevices = false } val playback by viewModel.playbackState.collectAsState() val callAvailable by viewModel.dmCallAvailable.collectAsState() val callState by viewModel.callState.collectAsState() @@ -118,6 +121,9 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }, actions = { + if (conv.mode == 1) IconButton(onClick = { showDevices = true }) { + Icon(Icons.Filled.Group, stringResource(R.string.dm_members_title)) + } IconButton(onClick = { viewModel.showPmInvite(conv.peerNodeId, conv.displayName, target.nativeId) }, enabled = !conv.closed) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/dm_groups.xml b/tools/chatgui-android/app/src/main/res/values-ru/dm_groups.xml index 17e2c26d1..2b2dd639a 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/dm_groups.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/dm_groups.xml @@ -1,5 +1,9 @@ + Устройства беседы + Мои устройства + Устройства собеседника + Не удалось обновить список устройств Устройства для этой беседы С подгруппами устройств Только эти устройства diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/DmMembersTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/DmMembersTest.kt new file mode 100644 index 000000000..67f955c6a --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/DmMembersTest.kt @@ -0,0 +1,47 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DmMembersTest { + private val target = ConversationTarget.Pm("42", 7) + private fun frame(vararg entries: Triple): ByteArray { + val data = ByteArray(13 + entries.size * (MEMBER_WIRE_SIZE + 1)) + data[0] = 2; data[1] = '4'.code.toByte(); data[2] = '2'.code.toByte() + ByteBuffer.wrap(data, 3, 10).order(ByteOrder.LITTLE_ENDIAN).putLong(7).putShort(entries.size.toShort()) + entries.forEachIndexed { i, (side, node, self) -> + val offset = 13 + i * (MEMBER_WIRE_SIZE + 1); data[offset] = side.toByte() + ByteBuffer.wrap(data, offset + 1, 8).order(ByteOrder.LITTLE_ENDIAN).putLong(node) + data[offset + 9] = if (self) 1 else 0; data[offset + 10] = if (self) 1 else 0 + "node$node".toByteArray().copyInto(data, offset + 16) + } + return data + } + + @Test fun separatesOwnAndPeerDevicesIncludingOffline() { + val members = parseDmMembers(frame(Triple(1, 9L, false), Triple(0, 7L, false), Triple(0, 21L, true)), target) + assertEquals(listOf(21L, 7L, 9L), members.map { it.member.nodeId }) + assertEquals(listOf(0, 0, 1), members.map { it.side }) + assertEquals(listOf(true, false, false), members.map { it.member.online }) + assertTrue(members.first().member.isSelf) + } + + @Test fun emptyOrFallbackSideDoesNotRequireAGroup() { + assertTrue(parseDmMembers(frame(), target).isEmpty()) + val members = parseDmMembers(frame(Triple(1, 9L, false)), target) + assertEquals(1, members.size); assertEquals("", members.single().member.devgroup) + } + + @Test fun rejectsOtherConversationGenerationAndMalformedRecords() { + val data = frame(Triple(0, 7L, true), Triple(1, 9L, false)) + fun rejected(bytes: ByteArray, selected: ConversationTarget.Pm = target) { + assertThrows(IllegalArgumentException::class.java) { parseDmMembers(bytes, selected) } + } + rejected(data, target.copy(id = "43")); rejected(data, target.copy(generation = 8)) + rejected(data.copyOf(data.size - 1)); rejected(data + 0.toByte()) + rejected(frame(Triple(0, 7L, true), Triple(1, 7L, false))) + rejected(frame(Triple(2, 9L, false))); rejected(frame(Triple(1, 0L, false))) + } +}