diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index f88feab1..ac17ad52 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -59,22 +59,17 @@ class MainActivity : ComponentActivity() { var qrResult by remember { mutableStateOf(null) } var showJoinDialog by remember { mutableStateOf(false) } var joinAutoLink by remember { mutableStateOf(null) } - var inviteLinkText by remember { mutableStateOf(null) } + var inviteChannelId by remember { mutableStateOf(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() } + ) } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index e9b5495e..7709b726 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -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 = emptyList(), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index c58bf26c..c458f3ad 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt index fcb9da4f..57094318 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt @@ -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, + 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(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") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 73b44fd2..a150acbd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -74,6 +74,15 @@ class ChatViewModel : ViewModel() { private val _localSockets = MutableStateFlow>(emptyList()) val localSockets: StateFlow> = _localSockets + private val _inviteCandidates = MutableStateFlow>(emptyList()) + val inviteCandidates: StateFlow> = _inviteCandidates + + private val _inviteLink = MutableStateFlow(null) + val inviteLink: StateFlow = _inviteLink + + private val _inviteLinkPending = MutableStateFlow(false) + val inviteLinkPending: StateFlow = _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() + 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() + 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 { diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index d591d309..49984f04 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -230,14 +230,33 @@ void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id, chat_sync_connect_from_invite(channel_id, real_node_id, pubkey_bin, addrs_data, addr_count, addrs_data_len, NULL); } -char* utun_bridge_encode_invite_link(uint64_t channel_id, const char* password) { - char out[1024]; - if (chat_invite_build_link(channel_id, 0, password, out, sizeof(out)) < 0) { - bridge_log(BLEV_ERROR, "encode_invite_link: chat_invite_build_link failed ch=%llu", - (unsigned long long)channel_id); - return NULL; +void utun_bridge_request_invite_candidates(const char* channel_id) { + if (!channel_id || !channel_id[0]) { + bridge_log(BLEV_ERROR, "request_invite_candidates: null ch_id"); + return; } - return u_strdup(out); + struct UASYNC* ua = instance_lite_get_uasync(); + if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "request_invite_candidates: uasync not running"); return; } + char* cid = u_strdup(channel_id); + if (!cid) { bridge_log(BLEV_ERROR, "request_invite_candidates: OOM"); return; } + bridge_log(BLEV_INFO, "request_invite_candidates ch=%s", channel_id); + uasync_post(ua, chat_invite_candidates_trampoline, cid); +} + +void utun_bridge_request_invite_link(const char* channel_id, uint64_t target_node_id) { + if (!channel_id || !channel_id[0]) { + bridge_log(BLEV_ERROR, "request_invite_link: null ch_id"); + return; + } + struct UASYNC* ua = instance_lite_get_uasync(); + if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "request_invite_link: uasync not running"); return; } + struct chat_invite_build_req* req = u_calloc(1, sizeof(*req)); + if (!req) { bridge_log(BLEV_ERROR, "request_invite_link: OOM"); return; } + strncpy(req->ch_id, channel_id, sizeof(req->ch_id) - 1); + req->target_node_id = target_node_id; + bridge_log(BLEV_INFO, "request_invite_link ch=%s target=0x%016llx", + channel_id, (unsigned long long)target_node_id); + uasync_post(ua, chat_invite_build_link_trampoline, req); } void utun_bridge_connect_channel(const char* channel_id) { @@ -1374,16 +1393,22 @@ JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeDeriveNodeId( return (jlong)node_id; } -/* ── nativeEncodeInviteLink ── */ +/* ── nativeRequestInviteCandidates / nativeRequestInviteLink ── */ -JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeEncodeInviteLink( - JNIEnv* env, jobject thiz, jlong channelId) { +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestInviteCandidates( + JNIEnv* env, jobject thiz, jstring channelId) { (void)thiz; - char* link = utun_bridge_encode_invite_link((uint64_t)channelId, NULL); - if (!link) return NULL; - jstring result = (*env)->NewStringUTF(env, link); - u_free(link); - return result; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + utun_bridge_request_invite_candidates(ch); + if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestInviteLink( + JNIEnv* env, jobject thiz, jstring channelId, jlong targetNodeId) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + utun_bridge_request_invite_link(ch, (uint64_t)targetNodeId); + if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); } /* ── nativeSetEventCallback(eventCb) ── */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 82527bcf..fd01e249 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -103,8 +103,10 @@ void* utun_bridge_voice_decode_open(const char* file_path, int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples); void utun_bridge_voice_decode_close(void* handle); -/* ── Invite link encode (for sharing) ── */ -char* utun_bridge_encode_invite_link(uint64_t channel_id, const char* password); +/* ── Invite link (for sharing) — асинхронно через uasync-поток. + Результат приходит событиями CHAT_EVT_INVITE_CANDIDATES / CHAT_EVT_INVITE_LINK_READY. ── */ +void utun_bridge_request_invite_candidates(const char* channel_id); +void utun_bridge_request_invite_link(const char* channel_id, uint64_t target_node_id); /* ── Debug ── */ diff --git a/tools/chatgui/src/invitedialog.cpp b/tools/chatgui/src/invitedialog.cpp index 59c2e381..34ac6af7 100644 --- a/tools/chatgui/src/invitedialog.cpp +++ b/tools/chatgui/src/invitedialog.cpp @@ -26,7 +26,7 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW , m_db(db) { setWindowTitle(QString::fromUtf8("Пригласительная ссылка")); - setMinimumWidth(420); + setMinimumSize(420, 560); setModal(true); auto *layout = new QVBoxLayout(this); @@ -34,21 +34,23 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW auto *nodeLabel = new QLabel(QString::fromUtf8("Узел входа:"), this); nodeLabel->setStyleSheet("font-size: 12px; color: palette(window-text);"); - layout->addWidget(nodeLabel); m_nodeCombo = new QComboBox(this); m_nodeCombo->setStyleSheet( "QComboBox { border: 1px solid palette(mid); border-radius: 4px;" " padding: 6px 10px; font-size: 13px; background: palette(base); }"); - layout->addWidget(m_nodeCombo); + + auto *nodeRow = new QHBoxLayout(); + nodeRow->setSpacing(8); + nodeRow->addWidget(nodeLabel); + nodeRow->addWidget(m_nodeCombo, 1); + layout->addLayout(nodeRow); m_linkEdit = new QLineEdit(this); m_linkEdit->setReadOnly(true); m_linkEdit->setPlaceholderText(QString::fromUtf8("Генерация ссылки...")); m_linkEdit->setStyleSheet("padding: 6px; font-family: monospace;"); - layout->addWidget(m_linkEdit); - auto *btnLayout = new QHBoxLayout(); auto *copyBtn = new QPushButton(QString::fromUtf8("Копировать"), this); connect(copyBtn, &QPushButton::clicked, this, [this]() { QClipboard* cb = QApplication::clipboard(); @@ -57,12 +59,16 @@ ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QW cb->setText(m_linkEdit->text(), QClipboard::Selection); m_linkEdit->setFocus(); }); - btnLayout->addWidget(copyBtn); - btnLayout->addStretch(); - layout->addLayout(btnLayout); + + auto *linkRow = new QHBoxLayout(); + linkRow->setSpacing(8); + linkRow->addWidget(m_linkEdit, 1); + linkRow->addWidget(copyBtn); + layout->addLayout(linkRow); m_qrLabel = new QLabel(this); m_qrLabel->setAlignment(Qt::AlignCenter); + m_qrLabel->setMinimumSize(180, 180); layout->addWidget(m_qrLabel, 0, Qt::AlignCenter); auto *closeBtn = new QPushButton("OK", this); @@ -120,6 +126,7 @@ void ShareInviteDialog::setLink(const QString& link) { if (!qrImage.isNull()) m_qrLabel->setPixmap(QPixmap::fromImage(qrImage).scaled( 180, 180, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + adjustSize(); } void ShareInviteDialog::onCandidates(const char* chId, int chIdLen, const uint8_t* data, int len) {