Explain incoming device linking and preserve self disconnect in properties
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
|
||||
+106
-17
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user