Refine device group controls and aggregate status indicators

This commit is contained in:
evgeny committed 2026-11-02 21:02:14 +01:00
1 parent 03fea5eb74
commit 24cb0eb463
20 files changed
+442 -283

No files matched your search

+2 -2
View File
@@ -14,7 +14,7 @@ X25519 для шифрования и Ed25519 для подписей. Публ
Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте.
2. Приглашение.
A нажимает «Добавить моё устройство» в свойствах B: приглашение отправляется B, на A показывается PIN из 4 цифр.
A нажимает «Добавить устройство» в свойствах B: приглашение отправляется B, на A показывается PIN из 4 цифр.
На B автоматически появляется диалог ввода PIN с экрана A; введённый PIN возвращается A для проверки.
Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут.
Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки.
@@ -41,7 +41,7 @@ A передаёт ключи и профиль группы. B проверяе
После восстановления связи и при вступлении в новую группу используется штатный обмен NODEINFO.
5. Выход.
Добровольный выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp.
Кнопка «Отключить от моих устройств» доступна в свойствах self. Выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp.
Локальное состояние, снимок node_snapshots и поля nodes меняются атомарно; уведомления и распространение выполняются после commit.
Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап.
@@ -9,6 +9,7 @@ import java.nio.ByteOrder
data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int)
data class DevgroupState(val name: String = "", val member: Boolean = false, val invitations: List<DeviceInvitation> = emptyList(),
val pin: String = "", val failed: Boolean = false, val outgoingVisible: Boolean = false,
val leaving: Boolean = false, val leaveFailed: Boolean = false,
val outgoingId: String = "", val accepting: String = "", val dismissed: Set<String> = emptySet())
/** Только main thread; PIN хранится до закрытия диалога, входящие/результаты не пишутся в лог. */
@@ -21,14 +22,14 @@ object DevgroupRepository {
if (action == 0 && pending.containsValue(0)) return
if (action == 1) mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = "", pin = "", failed = false)
if (action == 2) mutable.value = mutable.value.copy(accepting = text, failed = false)
if (action == 4) mutable.value = mutable.value.copy(leaving = true, leaveFailed = false)
val id = ++nextId
if (NativeLib.devgroupCommand(action, group, peer, text, pin, id)) pending[id] = action
else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true, accepting = "") }
else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true, accepting = "", leaving = false, leaveFailed = action == 4) }
}
fun dismiss() { mutable.value = mutable.value.copy(failed = false) }
fun dismissLeaveError() { mutable.value = mutable.value.copy(leaveFailed = false) }
fun closeOutgoing() { mutable.value = mutable.value.copy(outgoingVisible = false, outgoingId = "", pin = "", failed = false) }
fun dismissIncoming(id: String) { mutable.value = mutable.value.copy(dismissed = mutable.value.dismissed + id, failed = false) }
fun reopenIncoming(id: String) { mutable.value = mutable.value.copy(dismissed = mutable.value.dismissed - id, failed = false) }
fun onEvent(type: Int, data: ByteArray?) {
if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return }
if (type == 12) { command(); return }
@@ -36,7 +37,7 @@ object DevgroupRepository {
if (type != 71 || data == null || data.size < 9) return
val id = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).long
val action = pending.remove(id) ?: return
if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true, accepting = ""); return }
if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true, accepting = "", leaving = false, leaveFailed = action == 4); return }
val json = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8))
if (action == 1) {
if (mutable.value.outgoingVisible) mutable.value = mutable.value.copy(pin = json.getString("pin"), outgoingId = json.getString("id"), failed = false)
@@ -46,6 +47,7 @@ object DevgroupRepository {
mutable.value = mutable.value.copy(name = json.getString("name"), member = json.getString("group").isNotEmpty(),
invitations = List(invitations.length()) { i -> val entry = invitations.getJSONObject(i)
DeviceInvitation(entry.getString("id"), entry.getString("peer"), entry.getInt("outgoing") != 0, entry.getInt("state")) },
leaving = if (action == 4) false else mutable.value.leaving,
accepting = if (action == 2) "" else mutable.value.accepting,
failed = if (action == 0) mutable.value.failed else false)
}
@@ -1,8 +1,6 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -12,36 +10,6 @@ import com.utun.chat.R
import com.utun.chat.data.DevgroupRepository
import kotlinx.coroutines.delay
@Composable
fun DevgroupDialog(channel: String, peer: Long = 0, onDismiss: () -> Unit) {
val state by DevgroupRepository.state.collectAsState()
var name by remember { mutableStateOf("") }
LaunchedEffect(Unit) { while (true) { DevgroupRepository.command(); delay(1000) } }
DisposableEffect(Unit) { onDispose { DevgroupRepository.dismiss() } }
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.devgroup_title)) },
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (state.member) state.name else stringResource(R.string.devgroup_none))
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.devgroup_name)) })
TextButton(onClick = { DevgroupRepository.command(3, text = name) }) { Text(stringResource(R.string.devgroup_rename)) }
if (peer != 0L) TextButton(onClick = { DevgroupRepository.command(1, channel.toULongOrNull()?.toLong() ?: 0, peer) }) {
Text(stringResource(R.string.devgroup_add, peer.toULong().toString(16)))
}
if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error)
for (entry in state.invitations) {
val label = when (entry.state) {
1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring
4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired
else -> R.string.devgroup_unknown
}
Text(entry.peer + " — " + stringResource(label))
if (!entry.outgoing && entry.state == 1) TextButton(onClick = { DevgroupRepository.reopenIncoming(entry.id) }) {
Text(stringResource(R.string.devgroup_enter_pin))
}
}
if (state.member) TextButton(onClick = { DevgroupRepository.command(4) }) { Text(stringResource(R.string.devgroup_leave)) }
} }, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } })
}
private fun deviceInvitationState(state: Int): Int = when (state) {
1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring
4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired
@@ -64,10 +32,17 @@ fun DevicePairingDialogs() {
LaunchedEffect(state.outgoingVisible, current) {
if (state.outgoingVisible || current.isNotEmpty()) while (true) { DevgroupRepository.command(); delay(1000) }
}
if (state.leaveFailed) {
AlertDialog(onDismissRequest = DevgroupRepository::dismissLeaveError,
title = { Text(stringResource(R.string.devgroup_disconnect)) },
text = { Text(stringResource(R.string.devgroup_failed)) },
confirmButton = { TextButton(onClick = DevgroupRepository::dismissLeaveError) { Text(stringResource(android.R.string.ok)) } })
return
}
if (state.outgoingVisible) {
val invitation = state.invitations.firstOrNull { it.id == state.outgoingId }
AlertDialog(onDismissRequest = DevgroupRepository::closeOutgoing,
title = { Text(stringResource(R.string.devgroup_add_my_device)) },
title = { Text(stringResource(R.string.devgroup_add_device)) },
text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.pin.isNotEmpty() && (invitation?.state ?: 1) < 4) Text(stringResource(R.string.devgroup_pin, state.pin))
Text(stringResource(deviceInvitationState(invitation?.state ?: 1)))
@@ -80,7 +55,7 @@ fun DevicePairingDialogs() {
val finished = invitation.state >= 4
val close = { DevgroupRepository.dismissIncoming(current); current = ""; Unit }
AlertDialog(onDismissRequest = close,
title = { Text(stringResource(R.string.devgroup_add_my_device)) },
title = { Text(stringResource(R.string.devgroup_add_device)) },
text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(invitation.peer)
if (finished || busy) Text(stringResource(deviceInvitationState(invitation.state)))
@@ -22,6 +22,7 @@ import com.utun.chat.data.LogManager
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Bolt
@@ -69,7 +70,6 @@ fun MemberListScreen(
) {
var showEditDialog by remember(channel.id) { mutableStateOf(false) }
var editMember by remember(channel.id) { mutableStateOf<ChatMember?>(null) }
var devicesDialog by remember { mutableStateOf(false) }
var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf<String>()) }
val devices by DevgroupRepository.state.collectAsState()
val rows = remember(members, expandedGroups) {
@@ -115,33 +115,42 @@ fun MemberListScreen(
}
)
TextButton(onClick = { devicesDialog = true }) {
Text(stringResource(R.string.devgroup_title) + if (devices.invitations.any { !it.outgoing && it.state == 1 }) " •" else "")
}
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
items(rows, key = { if (it.second) "group:" + it.first.devgroup else "node:" + it.first.nodeId }) { (m, header) ->
if (header) {
val online = members.any { it.devgroup == m.devgroup && it.online && !it.isDeleted }
Text((if (m.devgroup in expandedGroups) "[−] " else "[+] ") + m.devgroupName,
color = if (online) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth().clickable {
expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup
}.padding(16.dp))
} else MemberListItem(
member = m,
val accounts = if (header) members.filter { it.devgroup == m.devgroup && !it.isDeleted } else listOf(m)
val target = if (header) accounts.firstOrNull { it.online } else m
val device = target ?: m
val groupRadio = accounts.any { it.radioActive }
val groupPhoneKnown = accounts.any { it.phoneStatusKnown }
val groupPhoneBusy = accounts.any { it.phoneBusy }
LaunchedEffect(header, target?.nodeId, groupRadio, groupPhoneKnown, groupPhoneBusy) {
if (header) LogManager.addLog("DEBUG", "Members", "Device group: group=${m.devgroup} " +
"target=${target?.nodeId} radio=$groupRadio phoneKnown=$groupPhoneKnown phoneBusy=$groupPhoneBusy speakerColumn=right")
}
MemberListItem(
member = device,
avatarMember = m,
groupName = if (header) m.devgroupName else null,
expanded = m.devgroup in expandedGroups,
actionsEnabled = target != null,
radioEnabled = groupRadio,
phoneStatusKnown = groupPhoneKnown,
phoneBusy = groupPhoneBusy,
onToggleGroup = {
expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup
},
group = channel.id,
speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null
else radioTalkState.groupId == groupId && m.nodeId in radioTalkState.speakers,
isSelected = selection?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onRadioCall = { com.utun.chat.data.RadioCallController.start(channel.id, m.nodeId) },
onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, m.nodeId) }
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) },
onCall = { target?.let { viewModel.startCall(channel.id, it.nodeId, it.name) } },
onRadioCall = { target?.let { com.utun.chat.data.RadioCallController.start(channel.id, it.nodeId) } },
onRadioMute = { target?.let { viewModel.toggleMemberRadioMute(channel.id, it.nodeId) } }
)
}
}
@@ -160,7 +169,9 @@ fun MemberListScreen(
isSelf = member?.isSelf == true,
canEdit = member != null,
onFiles = { onFiles(detail.nodeId) },
onAddDevice = { DevgroupRepository.command(1, channel.id.toULong().toLong(), 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) },
onInvite = { viewModel.showPmInvite(detail.nodeId, member?.name ?: detail.name, channel.id) },
onMessage = { viewModel.startDm(channel.id, detail.nodeId, member?.name ?: detail.name) },
onConnect = {
@@ -179,9 +190,6 @@ fun MemberListScreen(
}
}
if (devicesDialog) DevgroupDialog(channel.id, selection?.nodeId?.takeIf { id -> members.any { it.nodeId == id && !it.isSelf } } ?: 0) {
devicesDialog = false
}
if (radioMuteFailed) {
AlertDialog(
onDismissRequest = viewModel::dismissRadioMuteError,
@@ -208,7 +216,9 @@ fun MemberListScreen(
viewModel.setMemberFlags(channel.id, editingMember.nodeId, admin, supernode, moder, storage, verified, deleted)
showEditDialog = false
},
onAddDevice = { DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) },
canDisconnect = devices.member && !devices.leaving,
onDeviceAction = { if (editingMember.isSelf) DevgroupRepository.command(4)
else DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) },
onTransferAdmin = {
viewModel.transferAdmin(channel.id, editingMember.nodeId)
showEditDialog = false
@@ -221,6 +231,14 @@ fun MemberListScreen(
@Composable
private fun MemberListItem(
member: ChatMember,
avatarMember: ChatMember = member,
groupName: String? = null,
expanded: Boolean = false,
actionsEnabled: Boolean = true,
radioEnabled: Boolean = member.radioActive,
phoneStatusKnown: Boolean = member.phoneStatusKnown,
phoneBusy: Boolean = member.phoneBusy,
onToggleGroup: () -> Unit = {},
group: String,
speaking: Boolean,
isSelected: Boolean = false,
@@ -229,6 +247,7 @@ private fun MemberListItem(
onRadioCall: () -> Unit,
onRadioMute: () -> Unit
) {
val header = groupName != null
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay)
@@ -239,7 +258,7 @@ private fun MemberListItem(
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
.clickable(onClick = if (header) onToggleGroup else onClick),
color = bgColor,
tonalElevation = 0.dp
) {
@@ -247,9 +266,15 @@ private fun MemberListItem(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
if (header) IconButton(onClick = onToggleGroup, modifier = Modifier.size(width = 24.dp, height = 48.dp)) {
Icon(if (expanded) Icons.Filled.Remove else Icons.Filled.Add,
stringResource(if (expanded) R.string.devgroup_collapse else R.string.devgroup_expand), modifier = Modifier.size(18.dp))
} else Spacer(Modifier.width(24.dp))
}
Box {
UserAvatar(group, member.nodeId, member.name)
if (member.isSelf) Text("✓", modifier = Modifier.align(Alignment.BottomEnd),
UserAvatar(group, avatarMember.nodeId, avatarMember.name)
if (member.isSelf && !header) Text("✓", modifier = Modifier.align(Alignment.BottomEnd),
fontSize = 12.sp, color = Color(0xFF81C784))
else Box(Modifier.align(Alignment.BottomEnd).size(10.dp).clip(CircleShape).background(onlineColor))
}
@@ -257,33 +282,33 @@ private fun MemberListItem(
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) {
if (!header && member.isSuper) {
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isAdmin) {
if (!header && member.isAdmin) {
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isModer) {
if (!header && member.isModer) {
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isStorage) {
if (!header && member.isStorage) {
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (!member.isVerified) {
if (!header && !member.isVerified) {
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
Text(
text = member.name,
fontWeight = FontWeight.Medium,
text = groupName ?: member.name,
fontWeight = if (header) FontWeight.Bold else FontWeight.Medium,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
textDecoration = if (!header && member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
color = if (member.isSelf) Color(0xFF81C784) else Color.Unspecified,
modifier = Modifier.weight(1f)
)
@@ -297,7 +322,7 @@ private fun MemberListItem(
else -> stringResource(R.string.offline)
}
Text(
text = connectionLabel, fontSize = 11.sp,
text = if (header && actionsEnabled) member.name + " · " + connectionLabel else connectionLabel, fontSize = 11.sp,
color = if (member.isSelf || member.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
)
}
@@ -306,27 +331,29 @@ private fun MemberListItem(
if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else ""
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
val actionModifier = Modifier.size(width = 36.dp, height = 48.dp)
if (if (header) actionsEnabled else !member.isSelf) {
IconButton(onClick = onRadioCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) {
val label = stringResource(R.string.radio_call_start) +
if (radioEnabled) " · " + stringResource(R.string.radio_active) else ""
PortableRadioIcon(label, tint = if (radioEnabled) Color(0xFF4CAF50) else LocalContentColor.current)
}
IconButton(onClick = onCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) {
val status = if (!member.online || !phoneStatusKnown) R.string.phone_status_unknown
else if (phoneBusy) R.string.phone_status_busy else R.string.phone_status_free
Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status),
tint = if (!member.online || !phoneStatusKnown) LocalContentColor.current
else if (phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50))
}
}
if (member.isSelf) {
Box(actionModifier, contentAlignment = Alignment.Center) {
RadioSpeakerIcon(speaking, false, radioStatus)
}
} else {
IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
IconToggleButton(checked = member.radioMuted, enabled = actionsEnabled, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " +
stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute))
}
IconButton(onClick = onRadioCall, enabled = member.online, modifier = actionModifier) {
val label = stringResource(R.string.radio_call_start) +
if (member.radioActive) " · " + stringResource(R.string.radio_active) else ""
PortableRadioIcon(label, tint = if (member.radioActive) Color(0xFF4CAF50) else LocalContentColor.current)
}
IconButton(onClick = onCall, enabled = member.online, modifier = actionModifier) {
val status = if (!member.online || !member.phoneStatusKnown) R.string.phone_status_unknown
else if (member.phoneBusy) R.string.phone_status_busy else R.string.phone_status_free
Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status),
tint = if (!member.online || !member.phoneStatusKnown) LocalContentColor.current
else if (member.phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50))
}
}
}
}
@@ -347,7 +374,8 @@ private fun MemberDetailCard(
localSocketStates: List<LocalSocketState>,
isSelf: Boolean = false,
canEdit: Boolean,
onAddDevice: () -> Unit,
deviceActionEnabled: Boolean,
onDeviceAction: () -> Unit,
onFiles: () -> Unit,
onInvite: () -> Unit,
onMessage: () -> Unit,
@@ -444,8 +472,10 @@ 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 = onAddDevice) { Text(stringResource(R.string.devgroup_add_my_device)) }
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onMessage) {
Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message))
@@ -584,7 +614,8 @@ private fun MemberEditDialog(
onDismiss: () -> Unit,
onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) -> Unit,
onAddDevice: () -> Unit,
canDisconnect: Boolean,
onDeviceAction: () -> Unit,
onTransferAdmin: () -> Unit
) {
var nick by remember { mutableStateOf(member.name) }
@@ -610,8 +641,8 @@ private fun MemberEditDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
if (!member.isSelf && !member.isDeleted) TextButton(onClick = onAddDevice) {
Text(stringResource(R.string.devgroup_add_my_device))
TextButton(onClick = onDeviceAction, enabled = !member.isDeleted && (!member.isSelf || canDisconnect)) {
Text(stringResource(if (member.isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device))
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -673,11 +673,6 @@
<string name="audio_graph_kib">КиБ</string>
<string name="audio_graph_bps">байт/с</string>
<string name="audio_graph_kibps">КиБ/с</string>
<string name="devgroup_title">Мои устройства</string>
<string name="devgroup_none">Нет группы устройств</string>
<string name="devgroup_name">Имя группы устройств</string>
<string name="devgroup_rename">Создать / переименовать</string>
<string name="devgroup_add">Добавить устройство %1$s</string>
<string name="devgroup_pin">PIN: %1$s — введите его на другом устройстве в течение 2 минут.</string>
<string name="devgroup_failed">Операция не выполнена. Проверьте соединение и состояние приглашения.</string>
<string name="devgroup_waiting">Ожидается PIN</string>
@@ -687,9 +682,10 @@
<string name="devgroup_rejected">Отказ</string>
<string name="devgroup_expired">Истёк срок</string>
<string name="devgroup_unknown">Результат неизвестен</string>
<string name="devgroup_enter_pin">Ввести PIN</string>
<string name="devgroup_pin_hint">PIN с экрана приглашающего устройства</string>
<string name="devgroup_leave">Выйти из группы устройств</string>
<string name="devgroup_add_my_device">Добавить моё устройство</string>
<string name="devgroup_add_device">Добавить устройство</string>
<string name="devgroup_pin_retry">PIN не принят. Попробуйте ещё раз.</string>
<string name="devgroup_disconnect">Отключить от моих устройств</string>
<string name="devgroup_expand">Развернуть устройства</string>
<string name="devgroup_collapse">Свернуть устройства</string>
</resources>
@@ -672,11 +672,6 @@
<string name="audio_graph_kib">KiB</string>
<string name="audio_graph_bps">B/s</string>
<string name="audio_graph_kibps">KiB/s</string>
<string name="devgroup_title">My devices</string>
<string name="devgroup_none">No device group</string>
<string name="devgroup_name">Device group name</string>
<string name="devgroup_rename">Create / rename</string>
<string name="devgroup_add">Add device %1$s</string>
<string name="devgroup_pin">PIN: %1$s — enter it on the other device within 2 minutes.</string>
<string name="devgroup_failed">Operation failed. Check connection and invitation status.</string>
<string name="devgroup_waiting">Waiting for PIN</string>
@@ -686,9 +681,10 @@
<string name="devgroup_rejected">Rejected</string>
<string name="devgroup_expired">Expired</string>
<string name="devgroup_unknown">Result unknown</string>
<string name="devgroup_enter_pin">Enter PIN</string>
<string name="devgroup_pin_hint">PIN shown on the inviting device</string>
<string name="devgroup_leave">Leave device group</string>
<string name="devgroup_add_my_device">Add my device</string>
<string name="devgroup_add_device">Add device</string>
<string name="devgroup_pin_retry">PIN was not accepted. Try again.</string>
<string name="devgroup_disconnect">Disconnect from my devices</string>
<string name="devgroup_expand">Expand devices</string>
<string name="devgroup_collapse">Collapse devices</string>
</resources>
+49 -25
View File
@@ -16,7 +16,7 @@ extern "C" {
}
QRect AccountDelegate::actionRect(const QRect& row, Action action) {
const int offset = action == Action::RadioMute ? 95 : action == Action::RadioCall ? 63 : 31;
const int offset = action == Action::RadioMute ? 31 : action == Action::RadioCall ? 95 : 63;
return QRect(row.right() - offset, row.center().y() - 14, 28, 28);
}
@@ -59,12 +59,13 @@ static void drawRadioMuteButton(QPainter* painter, const QRect& button, bool mut
static QRect avatarRect(const QRect& row) {
const int size = MemberListModel::kAvatarSize;
return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size);
return QRect(row.left() + GroupedMemberModel::DisclosureWidth + 4, row.top() + (row.height() - size) / 2, size, size);
}
AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool self) {
AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool showCalls) {
if (avatarRect(row).contains(position)) return Action::Avatar;
if (!self) for (Action action : {Action::RadioMute, Action::RadioCall, Action::Call})
if (actionRect(row, Action::RadioMute).contains(position)) return Action::RadioMute;
if (showCalls) for (Action action : {Action::RadioCall, Action::Call})
if (actionRect(row, action).contains(position)) return action;
return Action::None;
}
@@ -86,11 +87,14 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
else
painter->fillRect(option.rect, option.palette.alternateBase());
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) {
QFont font = painter->font(); font.setBold(true); painter->setFont(font);
painter->setPen(index.data(MemberListModel::OnlineRole).toBool() ? QColor("#4CAF50") : option.palette.color(QPalette::Text));
painter->drawText(option.rect.adjusted(8, 0, -8, 0), Qt::AlignVCenter, index.data().toString());
painter->restore(); return;
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
if (header) {
const QRectF box(option.rect.left() + 5, option.rect.center().y() - 7, 14, 14);
painter->setRenderHint(QPainter::Antialiasing); painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(option.palette.color(QPalette::Text), 1.3)); painter->drawRoundedRect(box, 2, 2);
painter->drawLine(QPointF(box.left() + 3, box.center().y()), QPointF(box.right() - 3, box.center().y()));
if (!index.data(GroupedMemberModel::GroupExpandedRole).toBool())
painter->drawLine(QPointF(box.center().x(), box.top() + 3), QPointF(box.center().x(), box.bottom() - 3));
}
const int margin = 4;
const int iconSz = MemberListModel::kAvatarSize;
@@ -98,10 +102,15 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QIcon icon = qvariant_cast<QIcon>(index.data(Qt::DecorationRole));
if (!icon.isNull())
icon.paint(painter, avatarRect(option.rect));
if (header) {
const QRect avatar = avatarRect(option.rect); const QColor color(index.data(MemberListModel::OnlineRole).toBool() ? "#4CAF50" : "#9E9E9E");
painter->setPen(QPen(color, 1)); painter->setBrush(color); painter->drawEllipse(QRect(avatar.right() - 8, avatar.bottom() - 8, 8, 8));
}
const int x = option.rect.left() + iconSz + margin * 3;
const int x = option.rect.left() + GroupedMemberModel::DisclosureWidth + iconSz + margin * 3;
const bool self = index.data(Qt::UserRole + 2).toBool();
const int w = qMax(0, option.rect.right() - x - margin - (self ? 32 : 96));
const bool online = index.data(MemberListModel::OnlineRole).toBool(), showCalls = header ? online : !self;
const int w = qMax(0, option.rect.right() - x - margin - (showCalls ? 96 : 32));
const int yMid = option.rect.top() + option.rect.height() / 2;
uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF);
@@ -109,7 +118,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QFont nameFont = painter->font();
nameFont.setBold(true);
nameFont.setPixelSize(13);
if (flags & MEMBER_FLAG_DELETED) nameFont.setStrikeOut(true);
if (!header && (flags & MEMBER_FLAG_DELETED)) nameFont.setStrikeOut(true);
painter->setFont(nameFont);
QColor textColor = option.palette.color(QPalette::Text);
@@ -119,7 +128,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->save();
painter->setClipRect(QRect(x, option.rect.top(), w, option.rect.height()), Qt::IntersectClip);
const uint8_t visualFlags = flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER);
const uint8_t visualFlags = header ? 0 : flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER);
int flagX = x;
if (visualFlags) {
FlagPainter::draw(painter, flags, flagX, option.rect.top() + (option.rect.height() - nameFont.pixelSize()) / 2, nameFont.pixelSize());
@@ -142,10 +151,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
Qt::AlignLeft | Qt::AlignTop, sub);
painter->restore();
drawRadioMuteButton(painter, actionRect(option.rect, self ? Action::Call : Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED,
const bool available = !header || index.data(MemberListModel::OnlineRole).toBool();
painter->save(); if (!available) painter->setOpacity(0.35);
drawRadioMuteButton(painter, actionRect(option.rect, Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED,
index.data(MemberListModel::RadioSpeakingRole).toBool());
if (!self) {
const QColor color = index.data(MemberListModel::OnlineRole).toBool() ? textColor :
if (showCalls) {
const QColor color = online ? option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text) :
option.palette.color(QPalette::Disabled, QPalette::Text);
const QRect radio = actionRect(option.rect, Action::RadioCall);
const QColor radioColor = flags & CHAT_MEMBER_FLAG_RADIO ? QColor("#4CAF50") : color;
@@ -155,12 +166,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
drawCallButton(painter, actionRect(option.rect, Action::Call), phoneColor);
}
painter->restore();
painter->restore();
}
bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option,
const QModelIndex& index) {
Q_UNUSED(model);
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false;
if (event->type() == QEvent::MouseMove) {
const auto* mouse = static_cast<QMouseEvent*>(event);
if (m_pressedIndex.isValid() && (!(mouse->buttons() & Qt::LeftButton) ||
@@ -175,44 +186,57 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
event->type() != QEvent::MouseButtonDblClick) return false;
auto* mouse = static_cast<QMouseEvent*>(event);
if (mouse->button() != Qt::LeftButton) return false;
const Action action = actionAt(mouse->pos(), option.rect, index.data(Qt::UserRole + 2).toBool());
const bool enabled = action == Action::Avatar || action == Action::RadioMute || index.data(MemberListModel::OnlineRole).toBool();
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
const bool self = index.data(Qt::UserRole + 2).toBool(), online = index.data(MemberListModel::OnlineRole).toBool();
Action action = actionAt(mouse->pos(), option.rect, header ? online : !self);
if (header && action == Action::None) action = Action::ToggleGroup;
const bool enabled = action == Action::Avatar || action == Action::ToggleGroup ||
(!self && (!header || online) && (action == Action::RadioMute || online));
if (event->type() == QEvent::MouseButtonPress) {
m_pressedIndex = action != Action::None && enabled ? index : QModelIndex();
m_pressedNodeId = index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong();
m_pressedAction = action; m_pressedPosition = mouse->pos();
return action != Action::None;
}
if (event->type() == QEvent::MouseButtonDblClick) return action != Action::None;
const bool handled = action != Action::None || m_pressedIndex.isValid();
const bool activate = enabled && action != Action::None && action == m_pressedAction && m_pressedIndex == index &&
m_pressedNodeId == index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong() &&
(mouse->pos() - m_pressedPosition).manhattanLength() < ClickDistance;
m_pressedIndex = QPersistentModelIndex(); m_pressedAction = Action::None;
if (activate) {
GUI_DEBUG("AccountDelegate: action activated row=%d action=%d", index.row(), int(action));
GUI_DEBUG("AccountDelegate: action activated row=%d group=%d action=%d target=%016llx online=%d flags=%02x",
index.row(), header, int(action), (unsigned long long)m_pressedNodeId, online, index.data(Qt::UserRole + 3).toInt());
if (action == Action::Avatar) emit avatarRequested(index);
else if (action == Action::RadioMute) emit radioMuteRequested(index);
else if (action == Action::RadioCall) emit radioCallRequested(index);
else if (action == Action::Call) emit callRequested(index);
else if (action == Action::ToggleGroup) emit groupToggleRequested(index);
}
return handled;
}
bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
const QModelIndex& index) {
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false;
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool() && event &&
event->pos().x() < option.rect.left() + GroupedMemberModel::DisclosureWidth) {
QToolTip::showText(event->globalPos(), QCoreApplication::translate("ChatGui",
index.data(GroupedMemberModel::GroupExpandedRole).toBool() ? "Collapse devices" : "Expand devices"), view); return true;
}
const bool self = index.data(Qt::UserRole + 2).toBool();
if (event && actionRect(option.rect, self ? Action::Call : Action::RadioMute).contains(event->pos())) {
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool(), online = index.data(MemberListModel::OnlineRole).toBool();
if (event && actionRect(option.rect, Action::RadioMute).contains(event->pos())) {
const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED;
const bool speaking = index.data(MemberListModel::RadioSpeakingRole).toBool();
QString text = QCoreApplication::translate("ChatGui", speaking ? "Radio: speaking" : "Radio: silent");
if (muted) text += " · " + QCoreApplication::translate("ChatGui", "Radio: muted");
if (!index.data(Qt::UserRole + 2).toBool()) text += '\n' + QCoreApplication::translate("ChatGui",
if (!self && (!header || online)) text += '\n' + QCoreApplication::translate("ChatGui",
muted ? "Listen to this member in radio" : "Mute this member in radio");
QToolTip::showText(event->globalPos(), text, view);
return true;
}
if (event && !self) {
const Action action = actionAt(event->pos(), option.rect, false);
if (event && (header ? online : !self)) {
const Action action = actionAt(event->pos(), option.rect, true);
if (action == Action::RadioCall || action == Action::Call) {
QString text = QCoreApplication::translate("ChatGui", action == Action::RadioCall ? "Call by radio" : "Call peer");
if (action == Action::RadioCall && (index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO))
+8 -3
View File
@@ -3,7 +3,10 @@
* UserRole+1 — подпись, +2 — self, +3 — CHAT_MEMBER_FLAG_*, +4 — storage;
* MemberListModel::OnlineRole/RadioSpeakingRole определяют доступность и индикацию.
* Аватар доступен всем, mute — чужим участникам независимо от online, вызовы — online-пирам.
* Собственная строка показывает состояние рации без кнопок mute/вызова.
* Динамик всегда справа. Собственная строка показывает состояние рации без кнопок mute/вызова.
* Под раскрытие зарезервирована общая колонка; заголовок группы получает аватар и действия
* через GroupedMemberModel. Трубка/рация группы видны при наличии online-устройства,
* в том числе self; вызовы себе недоступны. Их состояния общие для группы.
* Сигнал только при LeftButton release в том же действии/строке после press:
* MouseMove со смещением >=5 по Manhattan или без LeftButton отменяет жест, включая возврат курсора назад. */
#pragma once
@@ -30,12 +33,14 @@ signals:
void callRequested(const QModelIndex& index);
void avatarRequested(const QModelIndex& index);
void radioMuteRequested(const QModelIndex& index);
void groupToggleRequested(const QModelIndex& index);
private:
static constexpr int ClickDistance = 5;
enum class Action { None, Avatar, RadioMute, RadioCall, Call };
enum class Action { None, Avatar, RadioMute, RadioCall, Call, ToggleGroup };
static QRect actionRect(const QRect& row, Action action);
static Action actionAt(const QPoint& position, const QRect& row, bool self);
static Action actionAt(const QPoint& position, const QRect& row, bool showCalls);
QPersistentModelIndex m_pressedIndex; /* строка, на которой начался клик */
QPoint m_pressedPosition; // координаты viewport при press
quint64 m_pressedNodeId = 0; // смена online-устройства между press/release отменяет действие
Action m_pressedAction = Action::None; // ожидаемое действие; сбрасывается при release/отмене жеста
};
+16 -16
View File
@@ -84,41 +84,44 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
m_header->setAlignment(Qt::AlignCenter);
m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);");
layout->addWidget(m_header);
auto* devices = new QPushButton(tr("My devices"), this); layout->addWidget(devices);
watchDevgroupButton(devices);
connect(devices, &QPushButton::clicked, this, [this, devices] { devices->setText(tr("My devices")); showDevgroupDialog(this, m_channelId); });
m_grouped = new GroupedMemberModel(m_model, this);
m_listView->setModel(m_grouped);
auto* delegate = new AccountDelegate(m_listView);
m_listView->setItemDelegate(delegate);
connect(delegate, &AccountDelegate::groupToggleRequested, this, [this](const QModelIndex& index) {
if (m_grouped->toggle(index.row())) clearMemberDetail();
});
connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) {
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(m_grouped->sourceRow(index.row()))) < 0)
const quint64 peer = m_model->nodeIdAt(m_grouped->actionSourceRow(index.row()));
if (!peer) return;
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), peer) < 0)
QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"),
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
});
connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) {
showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->sourceRow(index.row())));
showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->avatarSourceRow(index.row())));
});
connect(delegate, &AccountDelegate::radioCallRequested, this, [this](const QModelIndex& index) {
const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) {
const int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source);
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(source)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "member radio call ignored: peer=%016llx online=%d",
(unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return;
(unsigned long long)peer, m_model->isOnlineAt(source)); return;
}
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "member radio call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
emit radioCallRequested(m_channelId, peer);
});
connect(delegate, &AccountDelegate::callRequested, this, [this](const QModelIndex& index) {
const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) {
const int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source);
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(source)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "member call ignored: peer=%016llx online=%d",
(unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return;
(unsigned long long)peer, m_model->isOnlineAt(source)); return;
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
emit callRequested(m_channelId, peer, index.data(Qt::DisplayRole).toString());
const auto member = m_model->member(peer);
emit callRequested(m_channelId, peer, member ? QString::fromUtf8(member->name) : QString());
});
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_listView->setSelectionMode(QAbstractItemView::SingleSelection);
@@ -223,8 +226,7 @@ void AccountList::setChannel(const QString& channelId) {
void AccountList::refresh() { m_model->refresh(); }
void AccountList::onMemberClicked(const QModelIndex& index) {
if (!index.isValid()) return;
if (m_grouped->toggle(index.row())) { clearMemberDetail(); return; }
if (!index.isValid() || index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return;
quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (nodeId) showMemberDetail(nodeId);
}
@@ -537,7 +539,6 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QPointer<AccountList> alive(this);
QMenu menu;
connect(this, &QObject::destroyed, &menu, &QMenu::close);
QAction* deviceAction = menu.addAction(tr("Add to my devices")); deviceAction->setEnabled(!self);
QAction* pmAction = menu.addAction(QCoreApplication::translate("ChatGui", "Send message"));
pmAction->setEnabled(!self);
QAction* inviteAction = menu.addAction(QCoreApplication::translate("ChatGui", "Invite to group"));
@@ -575,7 +576,6 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
if (chosen == filesAction) { emit filesRequested(channel, nodeId); return; }
if (chosen == pmAction) { emit pmRequested(channel, nodeId, name); return; }
if (chosen == callAction) { emit callRequested(channel, nodeId, name); return; }
if (chosen == deviceAction) { inviteMyDevice(this, channel, nodeId); return; }
if (chosen == connectAction) {
auto* request = static_cast<chat_connect_node_req*>(u_calloc(1, sizeof(chat_connect_node_req)));
if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member connect request allocation failed"); return; }
+44 -65
View File
@@ -1,5 +1,4 @@
#include <QCoreApplication>
#include <algorithm>
#include "devgroupdialog.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
@@ -8,7 +7,6 @@
#include <QLabel>
#include <QPushButton>
#include <QLineEdit>
#include <QListWidget>
#include <QInputDialog>
#include <QJsonDocument>
#include <QJsonObject>
@@ -16,6 +14,7 @@
#include <QTimer>
#include <QPointer>
#include <QHash>
#include <QMessageBox>
#include <QSet>
#include <QDialogButtonBox>
#include <QRegularExpressionValidator>
@@ -25,7 +24,7 @@ extern "C" {
}
class DevgroupDialog;
static QPointer<DevgroupDialog> active;
static QList<QPointer<QPushButton>> buttons;
static QHash<quint64, QPointer<QPushButton>> leaveRequests;
static quint64 nextRequest = 0;
static QPointer<QWidget> invitationWindow;
static quint64 statusRequest = 0, lastAutomaticSnapshot = 0;
@@ -39,35 +38,19 @@ class DevgroupDialog : public QDialog {
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, bool inviting = false) : QDialog(parent), channel(channel), peer(peer), inviting(inviting) {
setAttribute(Qt::WA_DeleteOnClose); setModal(inviting); setWindowTitle(inviting ? tr("Add my device") : tr("My devices")); resize(480, 360);
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);
auto* layout = new QVBoxLayout(this);
title = new QLabel(this); layout->addWidget(title);
name = new QLineEdit(this); name->setPlaceholderText(tr("Device group name")); layout->addWidget(name);
auto* rename = new QPushButton(tr("Create / rename"), this); layout->addWidget(rename);
connect(rename, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_RENAME, name->text()); });
if (peer && !inviting) {
auto* invite = new QPushButton(tr("Add device %1").arg(QString::number(peer, 16)), this); layout->addWidget(invite);
connect(invite, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_INVITE); });
}
status = new QLabel(this); status->setWordWrap(true); status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status);
list = new QListWidget(this); layout->addWidget(list);
auto* accept = new QPushButton(tr("Enter PIN for selected invitation"), this); layout->addWidget(accept);
connect(accept, &QPushButton::clicked, this, [this] {
const auto* item = list->currentItem(); if (!item || item->data(Qt::UserRole).toString().isEmpty()) return;
const QString id = item->data(Qt::UserRole).toString(); bool ok = false;
const QString pin = QInputDialog::getText(this, tr("Add device"), tr("PIN shown on the inviting device"), QLineEdit::Normal, {}, &ok);
if (ok) send(CHAT_DEVGROUP_ACCEPT, id, pin);
});
auto* leave = new QPushButton(tr("Leave device group"), this); layout->addWidget(leave);
connect(leave, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_LEAVE); });
status = new QLabel(tr("Waiting for PIN"), this); status->setWordWrap(true);
status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status);
auto* close = new QDialogButtonBox(QDialogButtonBox::Close, this); layout->addWidget(close);
connect(close, &QDialogButtonBox::rejected, this, &QDialog::close);
auto* timer = new QTimer(this); timer->setInterval(1000);
connect(timer, &QTimer::timeout, this, [this] { if (!gui_bridge_is_uasync_ready()) pending.clear();
else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) send(CHAT_DEVGROUP_STATUS); });
if (inviting) {
title->hide(); name->hide(); rename->hide(); list->hide(); accept->hide(); leave->hide();
}
timer->start(); send(inviting ? CHAT_DEVGROUP_INVITE : CHAT_DEVGROUP_STATUS);
connect(timer, &QTimer::timeout, this, [this] {
if (!gui_bridge_is_uasync_ready()) { pending.clear(); status->setText(tr("Core unavailable")); }
else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) refresh();
});
timer->start(); send(CHAT_DEVGROUP_INVITE);
}
void result(const uint8_t* data, int len) {
if (len < 9) return;
@@ -80,52 +63,45 @@ public:
outgoingId = json["id"].toString(); pinText = tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString());
status->setText(pinText); return;
}
title->setText(json["group"].toString().isEmpty() ? tr("No device group") : json["name"].toString());
const QString selected = list->currentItem() ? list->currentItem()->data(Qt::UserRole).toString() : QString();
list->clear();
const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"),
tr("Rejected"), tr("Expired"), tr("Result unknown")};
for (const auto value : json["invitations"].toArray()) {
const auto invitation = value.toObject(); const int state = invitation["state"].toInt();
if (inviting && invitation["id"].toString() == outgoingId) {
status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state));
}
auto* item = new QListWidgetItem(invitation["peer"].toString() + " — " + states.value(state), list);
if (!invitation["outgoing"].toInt() && state == 1) {
item->setData(Qt::UserRole, invitation["id"].toString());
if (item->data(Qt::UserRole).toString() == selected) list->setCurrentItem(item);
}
if (invitation["id"].toString() == outgoingId) status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state));
}
}
private:
void send(chat_devgroup_action action, const QString& text = {}, const QString& pin = {}) {
const auto bytes = text.toUtf8(), code = pin.toUtf8();
if (bytes.size() >= 128 || bytes.contains('\0') || code.size() >= 5 || code.contains('\0')) {
status->setText(tr("Invalid name or PIN")); return;
}
void send(chat_devgroup_action action) {
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->request_id = ++nextRequest; req->group = channel.toULongLong(); req->peer = peer;
req->action = action; memcpy(req->text, bytes.constData(), bytes.size()); memcpy(req->pin, code.constData(), code.size());
const quint64 id = req->request_id;
req->action = action; const quint64 id = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
memset(req, 0, sizeof(*req)); u_free(req); status->setText(tr("Core unavailable"));
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return;
u_free(req); status->setText(tr("Core unavailable")); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return;
}
pending.insert(id, action);
}
QString channel, outgoingId, pinText; quint64 peer; bool inviting;
QLabel *title, *status; QLineEdit* name; QListWidget* list;
QString channel, outgoingId, pinText; quint64 peer;
QLabel* status;
QHash<quint64, int> pending;
};
static void devgroupResult(const uint8_t* data, int len) {
if (!len) {
refreshInvitations();
for (const auto& button : buttons) if (button) button->setText(QCoreApplication::translate("AccountList", "My devices") + " •");
if (active) active->refresh();
} else {
if (len >= 9) {
quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i);
if (leaveRequests.contains(id)) {
const auto button = leaveRequests.take(id);
if (!data[8]) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect refused");
if (button) { button->setEnabled(true); QMessageBox::warning(button->window(),
QCoreApplication::translate("ChatGui", "Disconnect from my devices"),
DevgroupDialog::tr("Operation failed. Check connection and invitation status.")); }
} else DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: own device disconnected");
return;
}
const bool automatic = id == statusRequest || acceptRequests.contains(id);
if (id == statusRequest) statusRequest = 0;
if (acceptRequests.contains(id)) awaitingPins.remove(acceptRequests.take(id));
@@ -140,16 +116,6 @@ static void devgroupResult(const uint8_t* data, int len) {
if (active) active->result(data, len);
}
}
void watchDevgroupButton(QPushButton* button) {
buttons.erase(std::remove_if(buttons.begin(), buttons.end(), [](const QPointer<QPushButton>& b) { return b.isNull(); }), buttons.end());
buttons.append(button); gui_bridge_set_devgroup_result_cb(devgroupResult);
}
void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) {
if (active) { active->close(); active = nullptr; }
gui_bridge_set_devgroup_result_cb(devgroupResult);
active = new DevgroupDialog(parent, channel, peer); active->show();
}
static void refreshInvitations() {
if (!invitationWindow || statusRequest || !gui_bridge_is_uasync_ready()) return;
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
@@ -171,7 +137,7 @@ 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 my device"));
dialog->setWindowTitle(DevgroupDialog::tr("Add device"));
dialog->setLabelText(entry["peer"].toString() + "\n" + DevgroupDialog::tr(attemptedPins.contains(id) ?
"PIN was not accepted. Try again." : "PIN shown on the inviting device"));
dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal);
@@ -222,5 +188,18 @@ void initDevgroupUi(QWidget* window) {
void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer) {
if (active) { active->close(); active = nullptr; }
gui_bridge_set_devgroup_result_cb(devgroupResult);
active = new DevgroupDialog(parent, channel, peer, true); active->show();
active = new DevgroupDialog(parent, channel, peer); active->show();
}
void disconnectMyDevice(QPushButton* button) {
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_LEAVE; req->request_id = ++nextRequest;
const quint64 id = req->request_id; gui_bridge_set_devgroup_result_cb(devgroupResult);
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect enqueue failed");
QMessageBox::warning(button->window(), QCoreApplication::translate("ChatGui", "Disconnect from my devices"),
DevgroupDialog::tr("Core unavailable")); return;
}
leaveRequests.insert(id, button); button->setEnabled(false);
}
+3 -4
View File
@@ -1,4 +1,4 @@
/* Локальное управление devgroup. Все операции передаются в uasync; секреты не читаются GUI. */
/* Диалоги сопряжения устройств и отключение self. Все операции передаются в uasync; секреты не читаются GUI. */
#ifndef DEVGROUPDIALOG_H
#define DEVGROUPDIALOG_H
#include <QtGlobal>
@@ -9,7 +9,6 @@ class QPushButton;
void initDevgroupUi(QWidget* window);
/* Сразу создать приглашение выбранному устройству и показать PIN. */
void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer);
/* Помечать кнопку при изменении приглашений; указатель отслеживается через QPointer. */
void watchDevgroupButton(QPushButton* button);
void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer = 0);
/* Отключить self; кнопка блокируется до результата, при отказе восстанавливается и показывается ошибка. */
void disconnectMyDevice(QPushButton* button);
#endif
+34 -14
View File
@@ -350,24 +350,44 @@ bool GroupedMemberModel::toggle(int row) {
if (row < 0 || row >= m_rows.size() || m_rows[row] >= 0) return false;
const auto group = memberGroup(m_source, -m_rows[row]-1);
if (!m_expanded.remove(group)) m_expanded.insert(group);
GUI_DEBUG("Device group: toggled group=%s expanded=%d", group.toHex().constData(), m_expanded.contains(group));
rebuild(); return true;
}
int GroupedMemberModel::avatarSourceRow(int row) const {
if (row < 0 || row >= m_rows.size()) return -1;
return m_rows[row] >= 0 ? m_rows[row] : -m_rows[row]-1;
}
int GroupedMemberModel::actionSourceRow(int row) const {
if (row < 0 || row >= m_rows.size()) return -1;
if (m_rows[row] >= 0) return m_rows[row];
const auto group = memberGroup(m_source, -m_rows[row]-1);
for (int i = 0; i < m_source->rowCount(); ++i)
if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i) &&
!(m_source->index(i).data(Qt::UserRole + 3).toInt() & MEMBER_FLAG_DELETED)) return i;
return -1;
}
QVariant GroupedMemberModel::data(const QModelIndex& idx, int role) const {
if (!idx.isValid() || idx.model() != this || idx.row() < 0 || idx.row() >= m_rows.size()) return {};
const int row = m_rows[idx.row()];
const int row = m_rows[idx.row()], target = actionSourceRow(idx.row());
if (role == GroupHeaderRole) return row < 0;
if (row >= 0) {
const auto value = m_source->data(m_source->index(row), role);
return role == Qt::DisplayRole && !memberGroup(m_source, row).isEmpty() ? QVariant(" " + value.toString()) : value;
if (role == ActionNodeIdRole) return QVariant::fromValue(m_source->nodeIdAt(target));
if (row >= 0) return m_source->data(m_source->index(row), role);
const int avatar = avatarSourceRow(idx.row()); const auto group = memberGroup(m_source, avatar);
const auto member = m_source->member(m_source->nodeIdAt(avatar)); if (!member) return {};
if (role == GroupExpandedRole) return m_expanded.contains(group);
if (role == Qt::DisplayRole) return QString::fromUtf8(member->devgroup_name);
if (role == Qt::DecorationRole) return m_source->data(m_source->index(avatar), role);
if (role == MemberListModel::OnlineRole) return target >= 0;
if (role == Qt::UserRole + 3) {
const int shared = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY;
int flags = target < 0 ? 0 : m_source->index(target).data(role).toInt() & ~shared;
for (int i = 0; i < m_source->rowCount(); ++i) if (memberGroup(m_source, i) == group) {
const int deviceFlags = m_source->index(i).data(role).toInt();
if (!(deviceFlags & MEMBER_FLAG_DELETED)) flags |= deviceFlags & shared;
}
return flags;
}
const auto member = m_source->member(m_source->nodeIdAt(-row-1)); if (!member) return {};
const auto group = memberGroup(m_source, -row-1);
if (role == Qt::DisplayRole) return (m_expanded.contains(group) ? QStringLiteral("[−] ") : QStringLiteral("[+] ")) +
QString::fromUtf8(member->devgroup_name);
if (role == MemberListModel::OnlineRole) {
for (int i = 0; i < m_source->rowCount(); ++i)
if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i)) return true;
return false;
}
return {};
if (role == Qt::UserRole + 1) return target < 0 ? QCoreApplication::translate("ChatGui", "offline") :
m_source->data(m_source->index(target), Qt::DisplayRole).toString() + " · " + m_source->data(m_source->index(target), role).toString();
return target < 0 ? QVariant() : m_source->data(m_source->index(target), role);
}
+8 -1
View File
@@ -78,15 +78,22 @@ private:
QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели
};
/* Представление поверх снимков: заголовки не имеют node_id, действия доступны только детям. */
/* Представление поверх снимков: заголовки не являются устройствами; аватар берётся у первого
* участника группы, кнопки действуют на первое online-устройство. RADIO/PHONE-флаги заголовка
* объединяют состояния всех неудалённых участников; mute/self относятся к цели кнопок. Источник заимствован. */
class GroupedMemberModel : public QAbstractListModel {
Q_OBJECT
public:
static constexpr int GroupHeaderRole = Qt::UserRole + 7;
static constexpr int GroupExpandedRole = Qt::UserRole + 8;
static constexpr int ActionNodeIdRole = Qt::UserRole + 9; // конкретное устройство для кнопок; 0 если в группе нет online
static constexpr int DisclosureWidth = 24; // одинаковая левая колонка во всех строках
explicit GroupedMemberModel(MemberListModel* source, QObject* parent = nullptr);
int rowCount(const QModelIndex& parent = {}) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
int sourceRow(int row) const; // -1 для заголовка/неверной строки
int actionSourceRow(int row) const; // заголовок: первое online-устройство, -1 если их нет
int avatarSourceRow(int row) const; // заголовок: первое устройство независимо от online
bool toggle(int row); // true — обработан заголовок
private:
void rebuild(bool forceReset = false);
+9 -7
View File
@@ -41,14 +41,14 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
QString curNick;
bool curVerified = false;
bool curDeleted = false;
bool curStorage = false;
bool curStorage = false, inDeviceGroup = false;
m_currentVer = 0;
bool loaded = false;
if (m_db && m_db->m_db) {
QString tbl = m_db->peersTableName(channelId);
QByteArray sql = QString(
"SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey) FROM \"%1\" p "
"JOIN channels c ON c.channel_id=? WHERE p.node_id=?"
"SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey),length(n.devgroup_ed) FROM \"%1\" p "
"JOIN channels c ON c.channel_id=? LEFT JOIN nodes n ON n.node_id=p.node_id WHERE p.node_id=?"
).arg(tbl).toUtf8();
sqlite3_stmt* st = nullptr;
int rc = sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &st, nullptr);
@@ -66,7 +66,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
curDeleted = tags.value("deleted").toString() == "yes";
curStorage = tags.value("storage").toString() == "yes";
curNick = DbManager::colText(st, 1);
m_haveAdminKey = sqlite3_column_int(st, 2) == 32;
m_haveAdminKey = sqlite3_column_int(st, 2) == 32; inDeviceGroup = sqlite3_column_int(st, 3) == 32;
}
int finish = sqlite3_finalize(st); loaded = rc == SQLITE_ROW && finish == SQLITE_OK;
if (!loaded) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member properties: read ch=%s node=%016llx rc=%d finalize=%d: %s",
@@ -124,9 +124,11 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
layout->addWidget(hint);
}
auto* addDevice = new QPushButton(QCoreApplication::translate("ChatGui", "Add my device"), this);
addDevice->setObjectName("memberAddMyDevice"); addDevice->setEnabled(loaded && !isOwn && !curDeleted);
connect(addDevice, &QPushButton::clicked, this, [this] { inviteMyDevice(this, m_channelId, m_nodeId); });
auto* addDevice = new QPushButton(QCoreApplication::translate("ChatGui", isOwn ? "Disconnect from my devices" : "Add device"), this);
addDevice->setObjectName("memberAddMyDevice"); addDevice->setEnabled(loaded && !curDeleted && (!isOwn || inDeviceGroup));
connect(addDevice, &QPushButton::clicked, this, [this, addDevice] {
if (m_isOwn) disconnectMyDevice(addDevice); else inviteMyDevice(this, m_channelId, m_nodeId);
});
layout->addWidget(addDevice);
m_transferBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Transfer admin rights"), this);
+6 -5
View File
@@ -468,7 +468,7 @@ private slots:
QSignalSpy radios(&delegate, &AccountDelegate::radioCallRequested);
QSignalSpy calls(&delegate, &AccountDelegate::callRequested);
const QRect row = view.visualRect(model.index(0, 0));
const QPoint radio(row.right() - 50, row.center().y()), phone(row.right() - 17, row.center().y());
const QPoint radio(row.right() - 82, row.center().y()), phone(row.right() - 50, row.center().y());
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio);
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, phone);
QCOMPARE(radios.count(), 0); QCOMPARE(calls.count(), 0);
@@ -507,7 +507,7 @@ private slots:
QPainter painter(&image); delegate.paint(&painter, option, model.index(0, 0)); painter.end();
int count = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = image.width() - 31; x < image.width(); ++x) count += image.pixelColor(x, y) == color;
for (int x = image.width() - 64; x < image.width() - 36; ++x) count += image.pixelColor(x, y) == color;
return count;
};
const int known = CHAT_MEMBER_FLAG_PHONE_KNOWN, busy = CHAT_MEMBER_FLAG_PHONE_BUSY;
@@ -540,7 +540,8 @@ private slots:
action == 2 ? &AccountDelegate::callRequested : &AccountDelegate::radioMuteRequested;
QSignalSpy activated(&delegate, signal);
const QRect row = view.visualRect(model.index(0, 0));
const QPoint radio(action == 0 ? row.left() + 21 : row.right() - (action == 1 ? 50 : action == 2 ? 17 : 82), row.center().y());
const QPoint radio(action == 0 ? row.left() + GroupedMemberModel::DisclosureWidth + 21 :
row.right() - (action == 1 ? 82 : action == 2 ? 50 : 18), row.center().y());
QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio);
for (QPoint point : {radio + QPoint(-15, 0), radio}) {
QMouseEvent move(QEvent::MouseMove, point, view.viewport()->mapToGlobal(point), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
@@ -566,14 +567,14 @@ private slots:
});
const QModelIndex index = model.index(0, 0);
const QRect row = view.visualRect(index);
const QPoint button(row.right() - 82, row.center().y());
const QPoint button(row.right() - 18, row.center().y());
auto colorCount = [&](QRgb color) {
QImage image(view.viewport()->size(), QImage::Format_RGB32); image.fill(Qt::white);
QStyleOptionViewItem option; option.initFrom(&view); option.rect = row;
QPainter painter(&image); delegate.paint(&painter, option, index); painter.end();
int count = 0;
for (int y = row.top(); y <= row.bottom(); ++y)
for (int x = row.right() - 95; x < row.right() - 67; ++x) count += image.pixel(x, y) == color;
for (int x = row.right() - 31; x < row.right() - 3; ++x) count += image.pixel(x, y) == color;
return count;
};
QVERIFY(colorCount(QColor("#9E9E9E").rgb()) > 0); // Молчащий участник серый; зелёный означает передачу.
+120 -2
View File
@@ -1,5 +1,6 @@
/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */
#include "../src/accountlist.h"
#include "../src/accountdelegate.h"
#include "../src/devgroupdialog.h"
#include <QInputDialog>
#include <QDialogButtonBox>
@@ -13,6 +14,8 @@
#include <QListView>
#include <QAbstractItemModelTester>
#include <QMenu>
#include <QPainter>
#include <QMouseEvent>
#include <QScopeGuard>
#include <memory>
#include <functional>
@@ -37,12 +40,13 @@ static bool rejectOwned = false;
static int ownedPosts = 0;
static std::vector<chat_member_list_req> memberRequests;
static std::vector<chat_connect_node_req> connectRequests;
static std::vector<uint64_t> muteRequests;
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
extern "C" int gui_bridge_is_uasync_ready() { return ready; }
extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; }
extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {}
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; }
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t node) { muteRequests.push_back(node); return 0; }
extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) {
if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast<chat_member_detail_req*>(arg));
if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline)
@@ -132,7 +136,7 @@ class MemberDetailTest : public QObject {
}
private slots:
void init() {
requests.clear(); memberRequests.clear(); connectRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
requests.clear(); memberRequests.clear(); connectRequests.clear(); muteRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
}
@@ -173,6 +177,17 @@ private slots:
QVERIFY(shown);
}
void disconnectSelfUsesLeaveCommandAndDisablesButton() {
QWidget parent; QPushButton button("Disconnect from my devices", &parent); devgroupRequests.clear();
disconnectMyDevice(&button);
QVERIFY(!button.isEnabled()); QVERIFY(!devgroupRequests.empty());
QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_LEAVE);
QCOMPARE(devgroupRequests.back().peer, uint64_t(0));
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), devgroupRequests.back().request_id); bytes[8] = 1;
bytes += "{}"; devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
QVERIFY(!button.isEnabled());
}
void actualRouteText_data() {
QTest::addColumn<int>("up"); QTest::addColumn<int>("hops"); QTest::addColumn<int>("localNat"); QTest::addColumn<int>("peerNat");
QTest::addColumn<int>("rtt"); QTest::addColumn<bool>("online"); QTest::addColumn<QString>("compact"); QTest::addColumn<QString>("detail");
@@ -224,6 +239,109 @@ private slots:
QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 2);
}
void devgroupControlsFollowFirstOnlineDeviceAndKeepFirstAvatar() {
AccountList panel; panel.resize(380, 350); panel.show();
auto first = row(7, false), second = row(8), third = row(9);
for (auto* bytes : {&first, &second, &third}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
fill(panel, "123", first + second + third + row(10));
auto* view = panel.findChild<QListView*>();
auto* grouped = qobject_cast<GroupedMemberModel*>(view->model()); QVERIFY(grouped);
QSignalSpy calls(&panel, &AccountList::callRequested), radioCalls(&panel, &AccountList::radioCallRequested);
const auto group = grouped->index(0);
QCOMPARE(group.data(Qt::DisplayRole).toString(), QString("My devices"));
QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(8));
QCOMPARE(grouped->avatarSourceRow(0), 0); QCOMPARE(grouped->sourceRow(0), -1);
const auto avatar = qvariant_cast<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage();
QCOMPARE(avatar, qvariant_cast<QIcon>(panel.model()->index(0).data(Qt::DecorationRole)).pixmap(34, 34).toImage());
QTest::qWait(10);
const QRect rect = view->visualRect(group);
const QPoint call(rect.right() - 49, rect.center().y()), radio(rect.right() - 81, rect.center().y());
const QPoint mute(rect.right() - 17, rect.center().y());
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 1); QCOMPARE(calls.last()[1].toULongLong(), quint64(8));
QCOMPARE(calls.last()[2].toString(), QString("peer8"));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, radio);
QCOMPARE(radioCalls.size(), 1); QCOMPARE(radioCalls.last()[1].toULongLong(), quint64(8));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute);
QCOMPARE(muteRequests.size(), size_t(1)); QCOMPARE(muteRequests.back(), uint64_t(8));
QCOMPARE(grouped->rowCount(), 2); // Кнопки не раскрывают группу.
QTest::mousePress(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
second[8] = 0; // Устройство уходит offline между press и release.
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(second.constData()));
QTest::mouseRelease(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 1);
QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(9));
QCOMPARE(qvariant_cast<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(), avatar);
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 2); QCOMPARE(calls.last()[1].toULongLong(), quint64(9));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 12, rect.center().y()));
QCOMPARE(grouped->rowCount(), 5); QVERIFY(grouped->index(0).data(GroupedMemberModel::GroupExpandedRole).toBool());
const QString screenshot = qEnvironmentVariable("UTUN_TEST_GROUP_SCREENSHOT");
if (!screenshot.isEmpty()) QVERIFY(panel.grab().save(screenshot));
third[8] = 0; panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(third.constData()));
QCOMPARE(grouped->index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(0));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute);
QCOMPARE(calls.size(), 2); QCOMPARE(muteRequests.size(), size_t(1));
}
void devgroupIndicatorsCombineDevicesEvenWhenFirstOnlineIsSelf() {
MemberListModel source; source.setChannel("123", true);
GroupedMemberModel grouped(&source);
auto self = row(7, true, 0, 0xffff, 255, 0, true), busy = row(8), removed = row(9);
for (auto* bytes : {&self, &busy, &removed}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
const int active = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY;
busy[10] = char(active | CHAT_MEMBER_FLAG_RADIO_MUTED);
removed[10] = char(active | CHAT_MEMBER_FLAG_DELETED);
const auto bytes = self + busy + removed;
source.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
const auto header = grouped.index(0);
QVERIFY(header.data(Qt::UserRole + 2).toBool());
QCOMPARE(header.data(Qt::UserRole + 3).toInt(), active); // mute относится только к цели кнопок.
AccountDelegate delegate;
QStyleOptionViewItem option; option.rect = QRect(0, 0, 340, MemberListModel::kRowHeight);
const QColor background("#FFFFFF");
option.palette.setColor(QPalette::AlternateBase, background);
auto image = [&] {
QImage result(option.rect.size(), QImage::Format_RGB32); result.fill(background);
QPainter painter(&result); delegate.paint(&painter, option, header); painter.end(); return result;
};
auto colors = [](const QImage& image, int rightOffset, const QColor& color) {
int count = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = image.width() - rightOffset - 1; x < image.width() - rightOffset + 27; ++x)
count += image.pixelColor(x, y) == color;
return count;
};
const auto activeImage = image();
QVERIFY(colors(activeImage, 95, QColor("#4CAF50")) > 0);
QVERIFY(colors(activeImage, 63, QColor("#E53935")) > 0);
QVERIFY(colors(activeImage, 31, QColor("#9E9E9E")) > 0);
QSignalSpy calls(&delegate, &AccountDelegate::callRequested), toggles(&delegate, &AccountDelegate::groupToggleRequested);
for (int offset : {81, 49, 17}) {
const QPoint point(option.rect.right() - offset, option.rect.center().y());
QMouseEvent press(QEvent::MouseButtonPress, point, point, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
QMouseEvent release(QEvent::MouseButtonRelease, point, point, Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
delegate.editorEvent(&press, &grouped, option, header); delegate.editorEvent(&release, &grouped, option, header);
}
QCOMPARE(calls.size(), 0); QCOMPARE(toggles.size(), 0); // Индикаторы self не вызывают себя и не раскрывают группу.
busy[10] = CHAT_MEMBER_FLAG_PHONE_KNOWN;
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(busy.constData()));
QCOMPARE(header.data(Qt::UserRole + 3).toInt(), int(CHAT_MEMBER_FLAG_PHONE_KNOWN));
QCOMPARE(colors(image(), 63, QColor("#E53935")), 0); // Удалённый busy-аккаунт не влияет на группу.
QCOMPARE(colors(image(), 95, QColor("#4CAF50")), 0); // self не окрашивает выключенную рацию в зелёный.
self[8] = 0; busy[8] = 0;
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(self.constData()));
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(busy.constData()));
QVERIFY(!header.data(MemberListModel::OnlineRole).toBool());
const auto offlineImage = image();
for (int offset : {95, 63}) QCOMPARE(colors(offlineImage, offset, background), 28 * offlineImage.height());
}
void flatModelPreservesRowsSnapshotsAndPersistentIndexes() {
MemberListModel model;
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
@@ -15,6 +15,7 @@
extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_devgroup.h"
#include "debug_config.h"
#include "mem.h"
}
@@ -138,6 +139,23 @@ private slots:
}
void init() { allocationBaseline = u_get_allocated_count(); }
void cleanup() { QCoreApplication::processEvents(); QCOMPARE(u_get_allocated_count(), allocationBaseline); }
void deviceActionMatchesSelfAndDisconnectsCommittedGroup() {
MemberCore core; QVERIFY(core.start());
QVERIFY(!core.run([&] { return chat_devgroup_rename(core.instance, "Devices"); }));
auto dialog = makeDialog(core, true);
auto* button = dialog->findChild<QPushButton*>("memberAddMyDevice"); QVERIFY(button && button->isEnabled());
QCOMPARE(button->text(), QString("Disconnect from my devices")); button->click(); QVERIFY(!button->isEnabled());
QCOMPARE(core.run([&] {
devgroup_record record; uint8_t ed[32], x[32]; return devgroup_load_local(core.instance, &record, ed, x);
}), 0);
QCoreApplication::processEvents();
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"));
}
void saves_data() {
QTest::addColumn<bool>("properties"); QTest::addColumn<QString>("failure");
for (bool properties : {false, true}) for (const char* failure : {"none", "prepare", "step", "commit", "missing"})
@@ -150,14 +150,14 @@ private slots:
connect(&delegate, &AccountDelegate::radioMuteRequested, &view, [&](const QModelIndex&) {
accepted = gui_bridge_toggle_member_radio_mute(probe.channel.toUtf8().constData(), 42);
});
const QRect row = view.visualRect(source); const QPoint button(row.right() - 82, row.center().y());
const QRect row = view.visualRect(source); const QPoint button(row.right() - 18, row.center().y());
auto iconImage = [&] {
return view.viewport()->grab().toImage().copy(QRect(row.right() - 95, row.center().y() - 14, 28, 28));
return view.viewport()->grab().toImage().copy(QRect(row.right() - 31, row.center().y() - 14, 28, 28));
};
auto iconHasColor = [&](const QColor& color) {
const QImage image = view.viewport()->grab().toImage();
for (int y = row.top(); y <= row.bottom(); ++y)
for (int x = row.right() - 95; x < row.right() - 67; ++x) if (image.pixelColor(x, y) == color) return true;
for (int x = row.right() - 31; x < row.right() - 3; ++x) if (image.pixelColor(x, y) == color) return true;
return false;
};
QVERIFY(iconHasColor(QColor("#9E9E9E"))); QVERIFY(!iconHasColor(QColor("#4CAF50")));
+6 -13
View File
@@ -3,7 +3,10 @@
<TS version="2.1" language="en_US" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Add my device</source><translation>Add my device</translation></message>
<message><source>Expand devices</source><translation>Expand devices</translation></message>
<message><source>Collapse devices</source><translation>Collapse devices</translation></message>
<message><source>Disconnect from my devices</source><translation>Disconnect from my devices</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message>
<source>Lookback time:</source>
<translation>Lookback time:</translation>
@@ -2861,19 +2864,12 @@ See log:
</context>
<context>
<name>DevgroupDialog</name>
<message><source>Add my device</source><translation>Add my device</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN was not accepted. Try again.</translation></message>
<message><source>My devices</source><translation>My devices</translation></message>
<message><source>Device group name</source><translation>Device group name</translation></message>
<message><source>Create / rename</source><translation>Create / rename</translation></message>
<message><source>Add device %1</source><translation>Add device %1</translation></message>
<message><source>Enter PIN for selected invitation</source><translation>Enter PIN for selected invitation</translation></message>
<message><source>Disconnect from my devices</source><translation>Disconnect from my devices</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN was not accepted. Try again.</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN shown on the inviting device</translation></message>
<message><source>Leave device group</source><translation>Leave device group</translation></message>
<message><source>Operation failed. Check connection and invitation status.</source><translation>Operation failed. Check connection and invitation status.</translation></message>
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — enter it on the other device within 2 minutes.</translation></message>
<message><source>No device group</source><translation>No device group</translation></message>
<message><source>Waiting for PIN</source><translation>Waiting for PIN</translation></message>
<message><source>Checking PIN</source><translation>Checking PIN</translation></message>
<message><source>Transferring keys</source><translation>Transferring keys</translation></message>
@@ -2881,12 +2877,9 @@ See log:
<message><source>Rejected</source><translation>Rejected</translation></message>
<message><source>Expired</source><translation>Expired</translation></message>
<message><source>Result unknown</source><translation>Result unknown</translation></message>
<message><source>Invalid name or PIN</source><translation>Invalid name or PIN</translation></message>
<message><source>Core unavailable</source><translation>Core unavailable</translation></message>
</context>
<context>
<name>AccountList</name>
<message><source>My devices</source><translation>My devices</translation></message>
<message><source>Add to my devices</source><translation>Add to my devices</translation></message>
</context>
</TS>
+6 -13
View File
@@ -3,7 +3,10 @@
<TS version="2.1" language="ru_RU" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Add my device</source><translation>Добавить моё устройство</translation></message>
<message><source>Expand devices</source><translation>Развернуть устройства</translation></message>
<message><source>Collapse devices</source><translation>Свернуть устройства</translation></message>
<message><source>Disconnect from my devices</source><translation>Отключить от моих устройств</translation></message>
<message><source>Add device</source><translation>Добавить устройство</translation></message>
<message>
<source>Lookback time:</source>
<translation>Время анализа:</translation>
@@ -2871,19 +2874,12 @@ See log:
</context>
<context>
<name>DevgroupDialog</name>
<message><source>Add my device</source><translation>Добавить моё устройство</translation></message>
<message><source>Disconnect from my devices</source><translation>Отключить от моих устройств</translation></message>
<message><source>Add device</source><translation>Добавить устройство</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN не принят. Попробуйте ещё раз.</translation></message>
<message><source>My devices</source><translation>Мои устройства</translation></message>
<message><source>Device group name</source><translation>Имя группы устройств</translation></message>
<message><source>Create / rename</source><translation>Создать / переименовать</translation></message>
<message><source>Add device %1</source><translation>Добавить устройство %1</translation></message>
<message><source>Enter PIN for selected invitation</source><translation>Ввести PIN выбранного приглашения</translation></message>
<message><source>Add device</source><translation>Добавление устройства</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN с экрана приглашающего устройства</translation></message>
<message><source>Leave device group</source><translation>Выйти из группы устройств</translation></message>
<message><source>Operation failed. Check connection and invitation status.</source><translation>Операция не выполнена. Проверьте соединение и состояние приглашения.</translation></message>
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — введите его на другом устройстве в течение 2 минут.</translation></message>
<message><source>No device group</source><translation>Нет группы устройств</translation></message>
<message><source>Waiting for PIN</source><translation>Ожидается PIN</translation></message>
<message><source>Checking PIN</source><translation>Проверка PIN</translation></message>
<message><source>Transferring keys</source><translation>Передача ключей</translation></message>
@@ -2891,12 +2887,9 @@ See log:
<message><source>Rejected</source><translation>Отказ</translation></message>
<message><source>Expired</source><translation>Истёк срок</translation></message>
<message><source>Result unknown</source><translation>Результат неизвестен</translation></message>
<message><source>Invalid name or PIN</source><translation>Неверное имя или PIN</translation></message>
<message><source>Core unavailable</source><translation>Ядро недоступно</translation></message>
</context>
<context>
<name>AccountList</name>
<message><source>My devices</source><translation>Мои устройства</translation></message>
<message><source>Add to my devices</source><translation>Добавить в мои устройства</translation></message>
</context>
</TS>