chatgui-android: invite-ссылки с выбором узла входа в GUI + desktop dialog layout
This commit is contained in:
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
|
||||
|
||||
+70
-14
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user