Browse Source

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

v2
evgeny 4 weeks ago
parent
commit
c8bd5975ae
  1. 26
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  3. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  4. 84
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt
  5. 75
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  6. 55
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  7. 6
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  8. 23
      tools/chatgui/src/invitedialog.cpp

26
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -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() }
)
}
}
}

2
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<MemberAddr> = emptyList(),

6
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

84
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<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") }

75
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -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 {

55
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) ── */

6
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 ── */

23
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) {

Loading…
Cancel
Save