From 6d75232d8b5ffdb25075bc7114160c232a781092 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 3 Nov 2026 00:39:06 +0100 Subject: [PATCH] Explain incoming device linking and preserve self disconnect in properties --- .../java/com/utun/chat/data/ChatRepository.kt | 1 + .../utun/chat/ui/screens/MemberListScreen.kt | 123 +++++++++++++++--- .../com/utun/chat/viewmodel/ChatViewModel.kt | 19 +++ tools/chatgui/src/devgroupdialog.cpp | 11 +- tools/chatgui/tests/test_member_dialogs.cpp | 3 +- 5 files changed, 135 insertions(+), 22 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 002a37eb7..9f8dde676 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -48,6 +48,7 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = val bgpNodes: Int = 0, val connecting: Boolean = false, val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false, val devgroup: String = "", val devgroupName: String = "") { + val canLinkAsMyDevice: Boolean get() = !isSelf && !isDeleted && online && bgpUp && devgroup.isEmpty() val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index c3899bcb8..2dea826ca 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.* @@ -28,6 +30,7 @@ import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -74,6 +77,11 @@ fun MemberListScreen( var showEditDialog by remember(channel.id) { mutableStateOf(false) } var editMember by remember(channel.id) { mutableStateOf(null) } var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf()) } + var selectedGroup by rememberSaveable(channel.id) { mutableStateOf(null) } + var renamingGroup by rememberSaveable(channel.id) { mutableStateOf(null) } + var renameName by rememberSaveable(channel.id) { mutableStateOf("") } + var linkingDevice by rememberSaveable(channel.id) { mutableStateOf(null) } + var linkFailed by remember(linkingDevice) { mutableStateOf(false) } val devices by DevgroupRepository.state.collectAsState() val orderedMembers = remember(members) { members.sortedByDescending { it.isSelf } } val rows = remember(orderedMembers, expandedGroups) { @@ -89,6 +97,14 @@ fun MemberListScreen( } } val selection = selectedMember?.takeIf { it.channelId == channel.id } + val selectedGroupMembers = orderedMembers.filter { it.devgroup == selectedGroup } + LaunchedEffect(selectedGroup, orderedMembers) { + if (selectedGroup != null && selectedGroupMembers.isEmpty()) selectedGroup = null + if (renamingGroup != null && orderedMembers.none { it.isSelf && !it.isDeleted && it.devgroup == renamingGroup }) { + LogManager.addLog("WARN", "Members", "Device group rename cancelled: membership changed") + renamingGroup = null + } + } LaunchedEffect(channel.id) { LogManager.addLog("DEBUG", "Members", "Member list: channel=${channel.id} order=self-first actionWidth=36dp statusIconGap=2dp") @@ -104,6 +120,9 @@ fun MemberListScreen( val radioTalkState by viewModel.radioTalkState.collectAsState() val transmittingGroup by viewModel.radioAudio.transmittingGroup.collectAsState() val groupId = channel.id.toULongOrNull()?.toLong() + val isSpeaking: (ChatMember) -> Boolean = { member -> radioActive && + if (member.isSelf) transmittingGroup == groupId && groupId != null + else radioTalkState.groupId == groupId && member.nodeId in radioTalkState.speakers } LaunchedEffect(channel.id, selection?.nodeId) { val nodeId = selection?.nodeId @@ -148,22 +167,50 @@ fun MemberListScreen( phoneBusy = groupPhoneBusy, onToggleGroup = { expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup + selectedGroup = m.devgroup; viewModel.clearMemberDetail() LogManager.addLog("DEBUG", "Members", "Device group: toggled group=${m.devgroup} expanded=${m.devgroup in expandedGroups}") }, group = channel.id, - speaking = target != null && radioActive && if (device.isSelf) transmittingGroup == groupId && groupId != null - else radioTalkState.groupId == groupId && device.nodeId in radioTalkState.speakers, - isSelected = !header && selection?.nodeId == device.nodeId, - onClick = { viewModel.showMemberDetail(channel.id, device.nodeId) }, + speaking = target != null && isSpeaking(device), + isSelected = if (header) selectedGroup == m.devgroup else selectedGroup == null && selection?.nodeId == device.nodeId, + onClick = { + if (header) { + selectedGroup = m.devgroup; viewModel.clearMemberDetail() + LogManager.addLog("DEBUG", "Members", "Device group selected group=${m.devgroup} devices=${accounts.size}") + } else { selectedGroup = null; viewModel.showMemberDetail(channel.id, device.nodeId) } + }, onCall = { target?.let { viewModel.startCall(channel.id, it.nodeId, if (header) m.devgroupName else it.name, devices = header) } }, onRadioCall = { target?.let { com.utun.chat.data.RadioCallController.start(channel.id, it.nodeId, devices = header) } }, onRadioMute = { target?.let { viewModel.toggleMemberRadioMute(channel.id, it.nodeId) } } + , onMyDevice = if (!header && device.canLinkAsMyDevice) ({ + linkingDevice = device.nodeId + LogManager.addLog("INFO", "Devgroup", "Link explanation opened channel=${channel.id} node=${device.nodeId}") + }) else null ) } } + if (selectedGroupMembers.isNotEmpty()) Card(Modifier.fillMaxWidth().padding(8.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(selectedGroupMembers.first().devgroupName, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + if (selectedGroupMembers.any { it.isSelf && !it.isDeleted }) TextButton( + enabled = !devices.renaming && !devices.leaving, + onClick = { renamingGroup = selectedGroup; renameName = selectedGroupMembers.first().devgroupName } + ) { Text(stringResource(R.string.devgroup_rename)) } + } + if (selectedGroup !in expandedGroups) LazyColumn(Modifier.heightIn(max = 240.dp)) { + itemsIndexed(selectedGroupMembers, key = { _, member -> member.nodeId }) { _, member -> + MemberListItem(member = member, group = channel.id, speaking = isSpeaking(member), + onClick = { selectedGroup = null; viewModel.showMemberDetail(channel.id, member.nodeId) }, + onCall = { viewModel.startCall(channel.id, member.nodeId, member.name) }, + onRadioCall = { com.utun.chat.data.RadioCallController.start(channel.id, member.nodeId) }, + onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, member.nodeId) }) + } + } + } + AnimatedVisibility( - visible = selection != null, + visible = selectedGroup == null && selection != null, enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }) ) { @@ -176,9 +223,8 @@ fun MemberListScreen( isSelf = member?.isSelf == true, canEdit = member != null, onFiles = { onFiles(detail.nodeId) }, - deviceActionEnabled = member != null && !member.isDeleted && !devices.leaving && (!member.isSelf || devices.member), - onDeviceAction = { if (member?.isSelf == true) DevgroupRepository.command(4) - else DevgroupRepository.command(1, channel.id.toULong().toLong(), detail.nodeId) }, + deviceActionEnabled = member?.isSelf == true && !member.isDeleted && !devices.leaving && devices.member, + onDeviceAction = { DevgroupRepository.command(4) }, onInvite = { viewModel.showPmInvite(detail.nodeId, member?.name ?: detail.name, channel.id) }, onMessage = { viewModel.startDm(channel.id, detail.nodeId, member?.name ?: detail.name) }, onConnect = { @@ -197,6 +243,38 @@ fun MemberListScreen( } } + if (linkingDevice != null) { + val target = members.firstOrNull { it.nodeId == linkingDevice } + AlertDialog( + onDismissRequest = { linkingDevice = null }, + title = { Text(stringResource(R.string.devgroup_this_is_my_device)) }, + text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.devgroup_link_explanation, target?.name ?: "0x${linkingDevice?.toULong()?.toString(16)}")) + if (linkFailed || target?.canLinkAsMyDevice != true) + Text(stringResource(R.string.devgroup_link_unavailable), color = MaterialTheme.colorScheme.error) + } }, + confirmButton = { TextButton(enabled = target?.canLinkAsMyDevice == true && !devices.leaving, onClick = { + val node = linkingDevice ?: return@TextButton + if (viewModel.linkMyDevice(channel.id, node)) linkingDevice = null else linkFailed = true + }) { Text(stringResource(R.string.devgroup_start_linking)) } }, + dismissButton = { TextButton(onClick = { linkingDevice = null }) { Text(stringResource(android.R.string.cancel)) } } + ) + } + + if (renamingGroup != null) AlertDialog( + onDismissRequest = { renamingGroup = null }, + title = { Text(stringResource(R.string.devgroup_rename)) }, + text = { OutlinedTextField(value = renameName, onValueChange = { renameName = it }, singleLine = true, + label = { Text(stringResource(R.string.devgroup_name)) }) }, + confirmButton = { TextButton( + enabled = !devices.leaving && !devices.renaming && renameName.trim().isNotEmpty() && + renameName.trim().toByteArray(Charsets.UTF_8).size <= 63 && + '\u0000' !in renameName && orderedMembers.any { it.isSelf && !it.isDeleted && it.devgroup == renamingGroup }, + onClick = { DevgroupRepository.command(3, text = renameName.trim()); renamingGroup = null } + ) { Text(stringResource(android.R.string.ok)) } }, + dismissButton = { TextButton(onClick = { renamingGroup = null }) { Text(stringResource(android.R.string.cancel)) } } + ) + if (radioMuteFailed) { AlertDialog( onDismissRequest = viewModel::dismissRadioMuteError, @@ -224,8 +302,7 @@ fun MemberListScreen( showEditDialog = false }, canDisconnect = devices.member && !devices.leaving, - onDeviceAction = { if (editingMember.isSelf) DevgroupRepository.command(4) - else DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) }, + onDeviceAction = { DevgroupRepository.command(4) }, onTransferAdmin = { viewModel.transferAdmin(channel.id, editingMember.nodeId) showEditDialog = false @@ -234,7 +311,7 @@ fun MemberListScreen( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun MemberListItem( member: ChatMember, @@ -254,9 +331,11 @@ private fun MemberListItem( onClick: () -> Unit, onCall: () -> Unit, onRadioCall: () -> Unit, - onRadioMute: () -> Unit + onRadioMute: () -> Unit, + onMyDevice: (() -> Unit)? = null ) { val header = groupName != null + var menuExpanded by remember(member.nodeId, group, onMyDevice != null) { mutableStateOf(false) } val onlineColor = when { member.connecting -> Color(0xFFFFEB3B) // yellow: connecting member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay) @@ -268,7 +347,8 @@ private fun MemberListItem( Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = if (header) onToggleGroup else onClick), + .combinedClickable(onClick = onClick, onLongClickLabel = stringResource(R.string.options), + onLongClick = if (onMyDevice != null) ({ onClick(); menuExpanded = true }) else null), color = bgColor, tonalElevation = 0.dp ) { @@ -348,6 +428,15 @@ private fun MemberListItem( ) } + if (onMyDevice != null) Box { + IconButton(onClick = { onClick(); menuExpanded = true }, modifier = Modifier.size(48.dp)) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.options)) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.devgroup_this_is_my_device)) }, + onClick = { menuExpanded = false; onMyDevice() }) + } + } val radioStatus = stringResource(if (speaking) R.string.radio_member_speaking else R.string.radio_member_silent) + if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else "" CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { @@ -493,8 +582,8 @@ private fun MemberDetailCard( } } - TextButton(onClick = onDeviceAction, enabled = deviceActionEnabled) { - Text(stringResource(if (isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device)) + if (isSelf) TextButton(onClick = onDeviceAction, enabled = deviceActionEnabled) { + Text(stringResource(R.string.devgroup_disconnect)) } if (!isSelf) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -662,8 +751,8 @@ private fun MemberEditDialog( modifier = Modifier.fillMaxWidth(), singleLine = true ) - TextButton(onClick = onDeviceAction, enabled = !member.isDeleted && (!member.isSelf || canDisconnect)) { - Text(stringResource(if (member.isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device)) + if (member.isSelf) TextButton(onClick = onDeviceAction, enabled = !member.isDeleted && canDisconnect) { + Text(stringResource(R.string.devgroup_disconnect)) } Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 567514920..a256a9ecd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -1483,6 +1483,25 @@ class ChatViewModel : ViewModel() { refreshSelectedMemberDetail(channelId) } + /** Повторная проверка живого снимка при подтверждении связывания; ViewModel не хранит окна/Context. */ + fun linkMyDevice(channelId: String, nodeId: Long): Boolean { + val member = membersRepository.members.value[MemberKey(channelId, nodeId)] + val group = channelId.toULongOrNull()?.toLong() + if (_currentChannel.value?.id != channelId || group == null || group == 0L || + member?.canLinkAsMyDevice != true || !NativeLib.isReady()) { + LogManager.addLog("WARN", "Devgroup", "Link cancelled: target changed/unavailable channel=$channelId node=$nodeId") + return false + } + LogManager.addLog("INFO", "Devgroup", "Link confirmed channel=$channelId node=$nodeId") + DevgroupRepository.command(1, group, nodeId) + return true + } + + fun clearMemberDetail() { + memberDetailJob?.cancel(); memberDetailTarget = null + _selectedMember.value = null; _memberLinks.value = MemberLinks() + } + /* Перечитываем родителя при изменении участника или его инвайтера, сохраняя актуальный live-статус. */ private fun refreshSelectedMemberDetail(channelId: String, changedNodeId: Long? = null) { val target = memberDetailTarget ?: return diff --git a/tools/chatgui/src/devgroupdialog.cpp b/tools/chatgui/src/devgroupdialog.cpp index 1dcbd3dc2..9b3e84ad4 100644 --- a/tools/chatgui/src/devgroupdialog.cpp +++ b/tools/chatgui/src/devgroupdialog.cpp @@ -40,7 +40,7 @@ public: static QString tr(const char* text) { return QCoreApplication::translate("DevgroupDialog", text); } void refresh() { send(CHAT_DEVGROUP_STATUS); } DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) : QDialog(parent), channel(channel), peer(peer) { - setAttribute(Qt::WA_DeleteOnClose); setModal(true); setWindowTitle(tr("Add device")); resize(420, 140); + setAttribute(Qt::WA_DeleteOnClose); setModal(true); setWindowTitle(tr("Linking devices")); resize(420, 140); auto* layout = new QVBoxLayout(this); status = new QLabel(tr("Waiting for PIN"), this); status->setWordWrap(true); status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status); @@ -147,9 +147,14 @@ static void automaticInvitations(const QJsonObject& snapshot) { if (pinDialogs.value(id) || dismissedInvites.contains(id) || awaitingPins.contains(id)) continue; auto* dialog = new QInputDialog(invitationWindow); pinDialogs.insert(id, dialog); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setObjectName("devgroupIncomingPin"); - dialog->setWindowTitle(DevgroupDialog::tr("Add device")); - dialog->setLabelText(entry["peer"].toString() + "\n" + DevgroupDialog::tr(attemptedPins.contains(id) ? + dialog->setWindowTitle(DevgroupDialog::tr("Linking devices")); + dialog->setLabelText(entry["peer"].toString() + "\n\n" + DevgroupDialog::tr( + "Linking joins this device to your personal device subgroup and gives it the shared subgroup keys.") + "\n\n" + + DevgroupDialog::tr("If you are not linking your own personal devices, simply close this window.") + "\n\n" + + DevgroupDialog::tr(attemptedPins.contains(id) ? "PIN was not accepted. Try again." : "PIN shown on the inviting device")); + for (auto* label : dialog->findChildren()) { label->setTextFormat(Qt::PlainText); label->setWordWrap(true); } + dialog->resize(480, 260); dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal); if (auto* edit = dialog->findChild()) { edit->setMaxLength(4); edit->setValidator(new QRegularExpressionValidator(QRegularExpression("[0-9]{0,4}"), edit)); diff --git a/tools/chatgui/tests/test_member_dialogs.cpp b/tools/chatgui/tests/test_member_dialogs.cpp index 0510f710b..9efb65ba0 100644 --- a/tools/chatgui/tests/test_member_dialogs.cpp +++ b/tools/chatgui/tests/test_member_dialogs.cpp @@ -152,8 +152,7 @@ private slots: auto ungrouped = makeDialog(core, true); QVERIFY(!ungrouped->findChild("memberAddMyDevice")->isEnabled()); core.db.setMyNodeId(0); auto other = makeDialog(core, true); - button = other->findChild("memberAddMyDevice"); QVERIFY(button && button->isEnabled()); - QCOMPARE(button->text(), QString("Add device")); + QVERIFY(!other->findChild("memberAddMyDevice")); } void saves_data() {