Support explicit pairing menu and return PIN on Android
This commit is contained in:
1 parent
10bafe3ca3
commit
18f1c6aaf0
3 files changed
+72
-55
No files matched your search
@@ -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<DeviceInvitation> = 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<String> = 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<Long, Int>()
|
||||
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,
|
||||
|
||||
+37
-42
@@ -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)) } })
|
||||
}
|
||||
+14
-7
@@ -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) +
|
||||
|
||||
Reference in new issue
Block a user