chatgui-android: invite-ссылки с выбором узла входа в GUI + desktop dialog layout

This commit is contained in:
evgeny committed 2026-09-07 22:36:27 +03:00
1 parent ba557e2b9d
commit c8bd5975ae
8 files changed
+225 -52

No files matched your search

@@ -59,22 +59,17 @@ class MainActivity : ComponentActivity() {
var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteLinkText by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val showInvite: (Channel) -> Unit = { ch ->
val chId = ch.id.toLongOrNull()
if (chId == null) {
if (ch.id.toLongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
scope.launch { snackbarHostState.showSnackbar("Invalid channel id") }
} else {
val link = NativeLib.encodeInviteLink(chId)
if (link != null) inviteLinkText = link
else {
LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId")
scope.launch { snackbarHostState.showSnackbar("Could not generate invite link") }
}
inviteChannelId = ch.id
vm.requestInvite(ch.id)
}
}
@@ -181,8 +176,17 @@ class MainActivity : ComponentActivity() {
)
}
inviteLinkText?.let { link ->
InviteLinkDialog(link = link, onDismiss = { inviteLinkText = null })
inviteChannelId?.let { chId ->
val candidates by vm.inviteCandidates.collectAsState()
val link by vm.inviteLink.collectAsState()
val pending by vm.inviteLinkPending.collectAsState()
InviteLinkDialog(
candidates = candidates,
link = link,
pending = pending,
onSelectNode = { nodeId -> vm.requestInviteLink(chId, nodeId) },
onDismiss = { inviteChannelId = null; vm.clearInvite() }
)
}
}
}
@@ -73,6 +73,8 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean =
val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0)
}
data class InviteCandidate(val nodeId: Long, val name: String = "", val isAuto: Boolean = false, val isSelf: Boolean = false)
data class MemberDetail(val nodeId: Long, val name: String = "",
val online: Boolean = false, val lastSeen: Long = 0,
val created: Long = 0, val addresses: List<MemberAddr> = emptyList(),
@@ -48,7 +48,8 @@ object NativeLib {
fun getMyNodeId(): Long = nativeGetMyNodeId()
fun encodeInviteLink(channelId: Long): String? = nativeEncodeInviteLink(channelId)
fun requestInviteCandidates(channelId: String) { nativeRequestInviteCandidates(channelId) }
fun requestInviteLink(channelId: String, targetNodeId: Long) { nativeRequestInviteLink(channelId, targetNodeId) }
fun ping() { nativePing() }
fun isResponsive(): Boolean = nativeIsResponsive()
@@ -144,7 +145,8 @@ object NativeLib {
private external fun nativeRegenerateKeys()
private external fun nativeClearDatabase(): Boolean
private external fun nativeGetMyNodeId(): Long
private external fun nativeEncodeInviteLink(channelId: Long): String?
private external fun nativeRequestInviteCandidates(channelId: String)
private external fun nativeRequestInviteLink(channelId: String, targetNodeId: Long)
private external fun nativeRestart(configText: String): Boolean
private external fun nativePing()
private external fun nativeIsResponsive(): Boolean
@@ -20,35 +20,91 @@ import androidx.compose.ui.unit.sp
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.utun.chat.data.InviteCandidate
import com.utun.chat.data.LogManager
private fun candidateLabel(c: InviteCandidate): String =
c.name + when {
c.isAuto -> " (auto)"
c.isSelf -> " (self)"
else -> ""
}
@Composable
fun InviteLinkDialog(link: String, onDismiss: () -> Unit) {
fun InviteLinkDialog(
candidates: List<InviteCandidate>,
link: String?,
pending: Boolean,
onSelectNode: (Long) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
var copied by remember { mutableStateOf(false) }
val qrBitmap = remember(link) { generateQRCode(link) }
var selectedNodeId by remember { mutableStateOf<Long?>(null) }
var menuOpen by remember { mutableStateOf(false) }
val effectiveSelected = remember(candidates, selectedNodeId) {
candidates.firstOrNull { it.nodeId == selectedNodeId } ?: candidates.firstOrNull()
}
val qrBitmap = remember(link) { link?.let { generateQRCode(it) } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Invite Link", fontWeight = FontWeight.Bold) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
qrBitmap?.let {
Image(bitmap = it.asImageBitmap(), contentDescription = "QR",
modifier = Modifier.size(200.dp))
Spacer(modifier = Modifier.height(8.dp))
Box {
OutlinedButton(onClick = { menuOpen = true }) {
Text(
effectiveSelected?.let { candidateLabel(it) } ?: "Выбор узла...",
maxLines = 1
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
candidates.forEach { c ->
DropdownMenuItem(
text = { Text(candidateLabel(c)) },
onClick = {
menuOpen = false
if (selectedNodeId != c.nodeId) {
selectedNodeId = c.nodeId
copied = false
onSelectNode(c.nodeId)
}
}
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
pending -> Text("Генерация ссылки...", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
link != null -> {
qrBitmap?.let {
Image(bitmap = it.asImageBitmap(), contentDescription = "QR",
modifier = Modifier.size(200.dp))
Spacer(modifier = Modifier.height(8.dp))
}
Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.padding(8.dp))
}
else -> Text("Не удалось собрать ссылку", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
}
Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.padding(8.dp))
}
},
confirmButton = {
Button(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("invite", link))
copied = true
LogManager.addLog("INFO", "INVITE", "Link copied to clipboard")
}) { Text(if (copied) "Copied!" else "Copy Link") }
Button(
enabled = link != null,
onClick = {
link?.let {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("invite", it))
copied = true
LogManager.addLog("INFO", "INVITE", "Link copied to clipboard")
}
}
) { Text(if (copied) "Copied!" else "Copy Link") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Close") }
@@ -74,6 +74,15 @@ class ChatViewModel : ViewModel() {
private val _localSockets = MutableStateFlow<List<LocalSocketState>>(emptyList())
val localSockets: StateFlow<List<LocalSocketState>> = _localSockets
private val _inviteCandidates = MutableStateFlow<List<InviteCandidate>>(emptyList())
val inviteCandidates: StateFlow<List<InviteCandidate>> = _inviteCandidates
private val _inviteLink = MutableStateFlow<String?>(null)
val inviteLink: StateFlow<String?> = _inviteLink
private val _inviteLinkPending = MutableStateFlow(false)
val inviteLinkPending: StateFlow<Boolean> = _inviteLinkPending
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
@@ -335,6 +344,51 @@ class ChatViewModel : ViewModel() {
LogManager.addLog("INFO", "VM", "admin key received ch=$chId from=0x%016x — now co-owner".format(from))
if (_currentChannel.value?.id == chId) refreshMembers(chId)
}
30 -> { /* INVITE_CANDIDATES: [ch_id_len:1][ch_id][auto_node_id:8][count:2][node_id:8]* */
if (data == null || data.size < 11) { LogManager.addLog("WARN", "VM", "INVITE_CANDIDATES too short"); return }
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 10) return
val chId = String(data, 1, cl)
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, data.size - 1 - cl).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val autoId = buf.long
val count = buf.short.toInt() and 0xFFFF
val ids = mutableListOf<Long>()
for (i in 0 until count) {
if (buf.remaining() < 8) break
ids.add(buf.long)
}
LogManager.addLog("INFO", "VM", "INVITE_CANDIDATES ch=$chId auto=0x%016x count=%d".format(autoId, count))
viewModelScope.launch {
val myId = NativeLib.getMyNodeId()
val nameOf = withContext(Dispatchers.IO) {
(repo?.getMembers(chId) ?: emptyList()).associate { it.nodeId to it.name }
}
fun label(id: Long): String = nameOf[id]?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(id)
val list = mutableListOf<InviteCandidate>()
list.add(InviteCandidate(autoId, label(autoId), isAuto = true, isSelf = autoId == myId))
for (id in ids) {
if (id == autoId) continue
list.add(InviteCandidate(id, label(id), isSelf = id == myId))
}
_inviteCandidates.value = list
NativeLib.requestInviteLink(chId, autoId)
_inviteLink.value = null
_inviteLinkPending.value = true
}
}
29 -> { /* INVITE_LINK_READY: [ch_id_len:1][ch_id][link_len:2][link] — пустой link = ошибка */
if (data == null || data.size < 4) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 2) return
val chId = String(data, 1, cl)
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, data.size - 1 - cl).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val linkLen = buf.short.toInt() and 0xFFFF
val link = if (linkLen > 0 && data.size >= 1 + cl + 2 + linkLen)
String(data, 1 + cl + 2, linkLen) else null
LogManager.addLog(if (link != null) "INFO" else "ERROR", "VM", "INVITE_LINK_READY ch=$chId len=$linkLen")
_inviteLink.value = link
_inviteLinkPending.value = false
}
}
}
@@ -354,6 +408,27 @@ class ChatViewModel : ViewModel() {
if (dbReady && repo != null) refreshChannels()
}
fun requestInvite(channelId: String) {
_inviteCandidates.value = emptyList()
_inviteLink.value = null
_inviteLinkPending.value = true
NativeLib.requestInviteCandidates(channelId)
LogManager.addLog("INFO", "VM", "requestInvite ch=$channelId")
}
fun requestInviteLink(channelId: String, targetNodeId: Long) {
_inviteLink.value = null
_inviteLinkPending.value = true
NativeLib.requestInviteLink(channelId, targetNodeId)
LogManager.addLog("INFO", "VM", "requestInviteLink ch=$channelId node=0x%016x".format(targetNodeId))
}
fun clearInvite() {
_inviteCandidates.value = emptyList()
_inviteLink.value = null
_inviteLinkPending.value = false
}
fun refreshChannels() {
val r = repo ?: return
viewModelScope.launch {