Explain incoming device linking and preserve self disconnect in properties

This commit is contained in:
evgeny committed 2026-11-03 00:39:06 +01:00
1 parent 18ae58a3d2
commit 6d75232d8b
5 files changed
+135 -22

No files matched your search

@@ -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)
}
@@ -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<ChatMember?>(null) }
var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf<String>()) }
var selectedGroup by rememberSaveable(channel.id) { mutableStateOf<String?>(null) }
var renamingGroup by rememberSaveable(channel.id) { mutableStateOf<String?>(null) }
var renameName by rememberSaveable(channel.id) { mutableStateOf("") }
var linkingDevice by rememberSaveable(channel.id) { mutableStateOf<Long?>(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) {
@@ -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
+8 -3
View File
@@ -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<QLabel*>()) { label->setTextFormat(Qt::PlainText); label->setWordWrap(true); }
dialog->resize(480, 260);
dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal);
if (auto* edit = dialog->findChild<QLineEdit*>()) {
edit->setMaxLength(4); edit->setValidator(new QRegularExpressionValidator(QRegularExpression("[0-9]{0,4}"), edit));
+1 -2
View File
@@ -152,8 +152,7 @@ private slots:
auto ungrouped = makeDialog(core, true);
QVERIFY(!ungrouped->findChild<QPushButton*>("memberAddMyDevice")->isEnabled());
core.db.setMyNodeId(0); auto other = makeDialog(core, true);
button = other->findChild<QPushButton*>("memberAddMyDevice"); QVERIFY(button && button->isEnabled());
QCOMPARE(button->text(), QString("Add device"));
QVERIFY(!other->findChild<QPushButton*>("memberAddMyDevice"));
}
void saves_data() {