diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt index 6252d47fe..4b79da574 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt @@ -6,14 +6,17 @@ import org.json.JSONObject import java.nio.ByteBuffer import java.nio.ByteOrder -data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int) +data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int, + val pin: String = "", val input: Int = 0, val pinError: Boolean = false) { + val pending: Boolean get() = state in listOf(1, 2, 3, 8, 9, 10) +} data class DevgroupState(val name: String = "", val member: Boolean = false, val invitations: List = emptyList(), val pin: String = "", val failed: Boolean = false, val outgoingVisible: Boolean = false, val leaving: Boolean = false, val leaveFailed: Boolean = false, val renaming: Boolean = false, val renameFailed: Boolean = false, - val outgoingId: String = "", val accepting: String = "", val dismissed: Set = emptySet()) + val outgoingId: String = "", val accepting: String = "", val choosing: Boolean = false) -/** Только main thread; PIN хранится до закрытия диалога, входящие/результаты не пишутся в лог. */ +/** Только main thread; входящие запросы обновляют меню self, диалог открывается явным действием. PIN не логируется. */ object DevgroupRepository { private val mutable = MutableStateFlow(DevgroupState()) val state = mutable.asStateFlow() @@ -21,7 +24,7 @@ object DevgroupRepository { private val pending = mutableMapOf() fun command(action: Int = 0, group: Long = 0, peer: Long = 0, text: String = "", pin: String = "") { if (action == 0 && pending.containsValue(0)) return - if (action == 1) mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = "", pin = "", failed = false) + if (action == 1) mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = "", pin = "", failed = false, choosing = false) if (action == 2) mutable.value = mutable.value.copy(accepting = text, failed = false) if (action == 3) mutable.value = mutable.value.copy(renaming = true, renameFailed = false) if (action == 4) mutable.value = mutable.value.copy(leaving = true, leaveFailed = false) @@ -33,7 +36,17 @@ object DevgroupRepository { fun dismissLeaveError() { mutable.value = mutable.value.copy(leaveFailed = false) } fun dismissRenameError() { mutable.value = mutable.value.copy(renameFailed = 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 openPairing(id: String = "") { + val invitations = mutable.value.invitations.filter { it.pending } + val selected = if (id.isNotEmpty()) invitations.firstOrNull { it.id == id } else invitations.singleOrNull() + if (invitations.isEmpty() || (id.isNotEmpty() && selected == null)) { + LogManager.addLog("WARN", "Devgroup", "No pending pairing to open"); return + } + mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = selected?.id ?: "", pin = "", + failed = false, choosing = selected == null) + LogManager.addLog("INFO", "Devgroup", "Pairing opened from self menu") + command() + } fun onEvent(type: Int, data: ByteArray?) { if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return } if (type == 12) { command(); return } @@ -49,13 +62,15 @@ object DevgroupRepository { 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) + command() return } val invitations = json.getJSONArray("invitations") if (action == 3) LogManager.addLog("INFO", "Devgroup", "Device group renamed") 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")) }, + DeviceInvitation(entry.getString("id"), entry.getString("peer"), entry.getInt("outgoing") != 0, entry.getInt("state"), + entry.optString("pin"), entry.optInt("input"), entry.optInt("pin_error") != 0) }, leaving = if (action == 4) false else mutable.value.leaving, renaming = if (action == 3) false else mutable.value.renaming, accepting = if (action == 2) "" else mutable.value.accepting, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt index a026a7e0d..b60248740 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt @@ -13,24 +13,18 @@ import kotlinx.coroutines.delay 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 + 8 -> R.string.devgroup_waiting_return; 9 -> R.string.devgroup_checking; 10 -> R.string.devgroup_transferring else -> R.string.devgroup_unknown } -/** Диалоги живут на уровне Activity, входящее приглашение видно с любого экрана. */ +/** Диалог живёт на уровне Activity, но входящее приглашение открывается только из меню self. */ @Composable fun DevicePairingDialogs() { val state by DevgroupRepository.state.collectAsState() - var current by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") } - var pin by androidx.compose.runtime.saveable.rememberSaveable(current) { mutableStateOf("") } - var submitted by androidx.compose.runtime.saveable.rememberSaveable(current) { mutableStateOf(false) } - LaunchedEffect(state.invitations, state.dismissed, current) { - if (current.isNotEmpty() && state.invitations.none { it.id == current }) current = "" - if (current.isEmpty()) current = state.invitations.firstOrNull { - !it.outgoing && it.state == 1 && it.id !in state.dismissed - }?.id ?: "" - } - LaunchedEffect(state.outgoingVisible, current) { - if (state.outgoingVisible || current.isNotEmpty()) while (true) { DevgroupRepository.command(); delay(1000) } + val invitation = state.invitations.firstOrNull { it.id == state.outgoingId } + var pin by remember(state.outgoingId, invitation?.input) { mutableStateOf("") } + LaunchedEffect(state.outgoingVisible, state.invitations.any { it.pending }) { + if (state.outgoingVisible || state.invitations.any { it.pending }) while (true) { DevgroupRepository.command(); delay(1000) } } if (state.renameFailed) { AlertDialog(onDismissRequest = DevgroupRepository::dismissRenameError, @@ -46,38 +40,39 @@ fun DevicePairingDialogs() { 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_linking)) }, - 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))) - if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error) - } }, confirmButton = { TextButton(onClick = DevgroupRepository::closeOutgoing) { Text(stringResource(R.string.close)) } }) - return - } - val invitation = state.invitations.firstOrNull { it.id == current } ?: return - val busy = invitation.state == 2 || state.accepting == current - val finished = invitation.state >= 4 - val close = { DevgroupRepository.dismissIncoming(current); current = ""; Unit } - AlertDialog(onDismissRequest = close, + if (!state.outgoingVisible) return + val busy = state.accepting.isNotEmpty() + val input = invitation?.input ?: 0 + val shownPin = invitation?.pin ?: state.pin + AlertDialog(onDismissRequest = DevgroupRepository::closeOutgoing, title = { Text(stringResource(R.string.devgroup_linking)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(invitation.peer) - Text(stringResource(R.string.devgroup_incoming_explanation)) - Text(stringResource(R.string.devgroup_unexpected_request)) - if (finished || busy) Text(stringResource(deviceInvitationState(invitation.state))) - else { - OutlinedTextField(value = pin, onValueChange = { if (it.length <= 4 && it.all { c -> c in '0'..'9' }) pin = it }, - label = { Text(stringResource(R.string.devgroup_pin_hint)) }, singleLine = true, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.NumberPassword)) - if (submitted || state.failed) Text(stringResource(R.string.devgroup_pin_retry), color = MaterialTheme.colorScheme.error) + if (state.choosing) { + Text(stringResource(R.string.devgroup_select_device)) + state.invitations.filter { it.pending }.forEach { entry -> + TextButton(onClick = { DevgroupRepository.openPairing(entry.id) }) { Text("${entry.peer} / ${entry.id.take(8)}") } + } + } else { + if (invitation != null) Text(invitation.peer) + if (invitation?.outgoing == false) { + Text(stringResource(R.string.devgroup_incoming_explanation)) + Text(stringResource(R.string.devgroup_unexpected_request)) + } + if (shownPin.isNotEmpty()) Text(stringResource( + if (invitation?.outgoing == false) R.string.devgroup_return_pin else R.string.devgroup_pin, shownPin)) + Text(stringResource(deviceInvitationState(invitation?.state ?: if (state.outgoingId.isEmpty()) 1 else 6))) } + if (input != 0) { + OutlinedTextField(value = pin, onValueChange = { if (it.length <= 4 && it.all { c -> c in '0'..'9' }) pin = it }, + label = { Text(stringResource(if (input == 1) R.string.devgroup_pin_hint else R.string.devgroup_return_hint)) }, + enabled = !busy, singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.NumberPassword)) + if (invitation?.pinError == true) Text(stringResource(R.string.devgroup_pin_retry), color = MaterialTheme.colorScheme.error) + } + if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error) } }, confirmButton = { - if (finished) TextButton(onClick = close) { Text(stringResource(android.R.string.ok)) } - else TextButton(enabled = !busy && pin.length == 4, onClick = { - submitted = true; DevgroupRepository.command(2, text = current, pin = pin); pin = "" - }) { Text(stringResource(android.R.string.ok)) } - }, dismissButton = { TextButton(onClick = close) { Text(stringResource(R.string.close)) } }) + if (input != 0) TextButton(enabled = !busy && pin.length == 4, onClick = { + DevgroupRepository.command(2, text = state.outgoingId, pin = pin); pin = "" + }) { Text(stringResource(R.string.devgroup_check_pin)) } + }, dismissButton = { TextButton(onClick = DevgroupRepository::closeOutgoing) { Text(stringResource(R.string.close)) } }) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 209f3d334..a0ae618f1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -185,7 +185,9 @@ fun MemberListScreen( onMyDevice = if (!header && device.canLinkAsMyDevice) ({ linkingDevice = device.nodeId LogManager.addLog("INFO", "Devgroup", "Link explanation opened channel=${channel.id} node=${device.nodeId}") - }) else null + }) else null, + onFinishPairing = if ((if (header) accounts.any { it.isSelf } else device.isSelf) && devices.invitations.any { it.pending }) + ({ DevgroupRepository.openPairing() }) else null ) } } @@ -204,7 +206,9 @@ fun MemberListScreen( 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) }) + onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, member.nodeId) }, + onFinishPairing = if (member.isSelf && devices.invitations.any { it.pending }) + ({ DevgroupRepository.openPairing() }) else null) } } } @@ -332,10 +336,11 @@ private fun MemberListItem( onCall: () -> Unit, onRadioCall: () -> Unit, onRadioMute: () -> Unit, - onMyDevice: (() -> Unit)? = null + onMyDevice: (() -> Unit)? = null, + onFinishPairing: (() -> Unit)? = null ) { val header = groupName != null - var menuExpanded by remember(member.nodeId, group, onMyDevice != null) { mutableStateOf(false) } + var menuExpanded by remember(member.nodeId, group, onMyDevice != null, onFinishPairing != null) { mutableStateOf(false) } val onlineColor = when { member.connecting -> Color(0xFFFFEB3B) // yellow: connecting member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay) @@ -348,7 +353,7 @@ private fun MemberListItem( modifier = Modifier .fillMaxWidth() .combinedClickable(onClick = onClick, onLongClickLabel = stringResource(R.string.options), - onLongClick = if (onMyDevice != null) ({ onClick(); menuExpanded = true }) else null), + onLongClick = if (onMyDevice != null || onFinishPairing != null) ({ onClick(); menuExpanded = true }) else null), color = bgColor, tonalElevation = 0.dp ) { @@ -428,13 +433,15 @@ private fun MemberListItem( ) } - if (onMyDevice != null) Box { + if (onMyDevice != null || onFinishPairing != 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)) }, + if (onMyDevice != null) DropdownMenuItem(text = { Text(stringResource(R.string.devgroup_this_is_my_device)) }, onClick = { menuExpanded = false; onMyDevice() }) + if (onFinishPairing != null) DropdownMenuItem(text = { Text(stringResource(R.string.devgroup_finish_pairing)) }, + onClick = { menuExpanded = false; onFinishPairing() }) } } val radioStatus = stringResource(if (speaking) R.string.radio_member_speaking else R.string.radio_member_silent) +