Files
utun2/tools/chatgui/src/invitedialog.cpp
T
evgeny f84622966e Add BLE ETCP links and GATT invitations for desktop and Android
Attach packet drivers to initially empty ETCP connections. Share BLE neighbor
tracking and group fallback between desktop and Android in cgathui_common.

Deliver join links through a dedicated Write/Indicate characteristic with
independent fragmentation queues, application acknowledgements, deduplication
and bounded retries. Merge LAN/BLE discovery and add a separate Bluetooth
invitation reception setting to both clients.

Validation: 230 core tests, BLE transport/BlueZ/UI tests, ASAN/UBSAN,
Linux GUI and Android builds, and Windows backend cross-compilation.
Physical Android/Linux invitation exchange passed with either GATT initiator.
The Android-initiated run also verified all 32 ETCP packets of 1500 bytes;
the desktop-initiated load run reached 7/32 within its 45-second time limit.
2026-10-29 16:41:04 +01:00

304 lines
13 KiB
C++

#include <QCoreApplication>
#include "invitedialog.h"
#include "qrcode_utils.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QComboBox>
#include <QClipboard>
#include <QApplication>
#include <QSignalBlocker>
#include <QPointer>
#include <QtEndian>
#include <cstring>
extern "C" {
#include "../../../src/chat/invite_build.h"
#include "../../../src/chat/chat_join.h"
#include "../../../src/chat/chat_local.h"
#include "../../../lib/mem.h"
}
static QPointer<ShareInviteDialog> s_shareInviteDialog;
ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent)
: QDialog(parent)
, m_channelId(channelId)
, m_db(db)
{
setWindowTitle(QCoreApplication::translate("ChatGui", "Invite link"));
setMinimumSize(420, 560);
setModal(true);
s_shareInviteDialog = this;
auto *layout = new QVBoxLayout(this);
layout->setSpacing(12);
auto* expiry = new QLabel(QCoreApplication::translate("ChatGui",
"The link expires %1 minutes after creation.").arg(JOIN_KEY_TTL_SECONDS / 60), this);
expiry->setWordWrap(true);
layout->addWidget(expiry);
auto *nodeLabel = new QLabel(QCoreApplication::translate("ChatGui", "Connect via"), this);
nodeLabel->setStyleSheet("font-size: 12px; color: palette(window-text);");
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); }");
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(QCoreApplication::translate("ChatGui", "Generating link..."));
m_linkEdit->setStyleSheet("padding: 6px; font-family: monospace;");
auto *copyBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Copy"), this);
connect(copyBtn, &QPushButton::clicked, this, [this]() {
QClipboard* cb = QApplication::clipboard();
cb->setText(m_linkEdit->text(), QClipboard::Clipboard);
if (cb->supportsSelection())
cb->setText(m_linkEdit->text(), QClipboard::Selection);
m_linkEdit->setFocus();
});
auto *linkRow = new QHBoxLayout();
linkRow->setSpacing(8);
linkRow->addWidget(m_linkEdit, 1);
linkRow->addWidget(copyBtn);
layout->addLayout(linkRow);
m_localRow = new QWidget(this);
auto* localLayout = new QVBoxLayout(m_localRow);
localLayout->setContentsMargins(0, 0, 0, 0);
localLayout->addWidget(new QLabel(QCoreApplication::translate("ChatGui", "Nearby devices (LAN / Bluetooth):"), m_localRow));
auto* deviceRow = new QHBoxLayout();
m_devices = new QComboBox(m_localRow);
m_devices->setObjectName(QStringLiteral("localDevices"));
m_sendLocal = new QPushButton(QCoreApplication::translate("ChatGui", "Send to device"), m_localRow);
m_sendLocal->setObjectName(QStringLiteral("sendLocalInvite"));
m_sendLocal->setEnabled(false);
deviceRow->addWidget(m_devices, 1); deviceRow->addWidget(m_sendLocal);
localLayout->addLayout(deviceRow);
layout->addWidget(m_localRow); m_localRow->hide();
m_delivery = new QLabel(this);
m_delivery->setWordWrap(true); layout->addWidget(m_delivery);
connect(m_sendLocal, &QPushButton::clicked, this, &ShareInviteDialog::sendLocalInvite);
connect(m_devices, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this] { updateLocalSend(); });
gui_bridge_set_local_devices_cb([](const uint8_t* data, int len) {
if (s_shareInviteDialog) s_shareInviteDialog->onLocalDevices(data, len);
});
gui_bridge_set_local_delivery_cb([](const uint8_t* data, int len) {
if (s_shareInviteDialog) s_shareInviteDialog->onLocalDelivery(data, len);
});
if (gui_bridge_is_uasync_ready() && gui_bridge_get_inst())
gui_bridge_post_uasync_fn(chat_local_peers_trampoline, gui_bridge_get_inst());
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);
connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept);
layout->addWidget(closeBtn, 0, Qt::AlignRight);
connect(m_nodeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ShareInviteDialog::onNodeSelected);
/* колбэки результатов (по образцу JoinDialog) */
gui_bridge_set_invite_link_ready_cb([](const char* chId, int chIdLen, uint64_t requestId, const char* link) {
ShareInviteDialog* dlg = s_shareInviteDialog;
if (dlg) dlg->onLinkReady(chId, chIdLen, requestId, link);
});
gui_bridge_set_invite_candidates_cb([](const char* chId, int chIdLen, const uint8_t* data, int len) {
ShareInviteDialog* dlg = s_shareInviteDialog;
if (dlg) dlg->onCandidates(chId, chIdLen, data, len);
});
/* запросить кандидатов; линк строится после их прихода */
struct chat_invite_cand_req* creq = (struct chat_invite_cand_req*)u_calloc(1, sizeof(*creq));
if (!creq) return;
creq->inst = gui_bridge_get_inst();
strncpy(creq->ch_id, m_channelId.toUtf8().constData(), sizeof(creq->ch_id) - 1);
gui_bridge_post_uasync_fn(chat_invite_candidates_trampoline, creq);
}
ShareInviteDialog::~ShareInviteDialog() {
s_shareInviteDialog = nullptr;
gui_bridge_set_local_devices_cb(nullptr);
gui_bridge_set_local_delivery_cb(nullptr);
gui_bridge_set_invite_link_ready_cb(nullptr);
gui_bridge_set_invite_candidates_cb(nullptr);
}
void ShareInviteDialog::onNodeSelected(int index) {
if (index < 0) return;
qulonglong nodeId = m_nodeCombo->itemData(index).toULongLong();
requestLink((uint64_t)nodeId);
}
void ShareInviteDialog::requestLink(uint64_t targetNodeId) {
struct chat_invite_build_req* req = (struct chat_invite_build_req*)u_malloc(sizeof(*req));
if (!req) {
GUI_ERROR("invite: requestLink — alloc failed");
return;
}
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
QByteArray chBytes = m_channelId.toUtf8();
strncpy(req->ch_id, chBytes.constData(), sizeof(req->ch_id) - 1);
static uint64_t nextRequestId = 0;
req->request_id = m_requestId = ++nextRequestId;
req->target_node_id = targetNodeId;
m_linkEdit->setText(QString());
updateLocalSend();
m_linkEdit->setPlaceholderText(QCoreApplication::translate("ChatGui", "Generating link..."));
m_qrLabel->clear();
gui_bridge_post_uasync_fn(chat_invite_build_link_trampoline, req);
}
void ShareInviteDialog::setLink(const QString& link) {
m_linkEdit->setText(link);
updateLocalSend();
QImage qrImage = generateQRCode(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) {
QString cid = QString::fromUtf8(chId, chIdLen);
if (cid != m_channelId) return;
if (!data || len < 10) return;
uint64_t autoId = 0;
memcpy(&autoId, data, 8);
uint16_t count = 0;
memcpy(&count, data + 8, 2);
if (autoId == 0) {
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QCoreApplication::translate("ChatGui", "Failed to identify the node (see log)"));
return;
}
auto nodeLabel = [this](uint64_t nid) {
QString name = m_db ? m_db->getDisplayName(nid) : QString();
if (name.isEmpty())
name = QString("0x%1").arg(nid, 16, 16, QChar('0'));
return name;
};
/* блокируем сигналы, чтобы не перезапрашивать линк при добавлении пунктов */
QSignalBlocker blocker(m_nodeCombo);
m_nodeCombo->clear();
/* пункт 0 — узел, найденный авто-выбором */
m_nodeCombo->addItem(nodeLabel(autoId) + QCoreApplication::translate("ChatGui", " (auto)"),
QVariant::fromValue((qulonglong)autoId));
/* остальные узлы (пропуская авто), свой помечаем (self) */
int off = 10;
for (int i = 0; i < (int)count && off + 8 <= len; i++) {
uint64_t nid = 0;
memcpy(&nid, data + off, 8);
off += 8;
if (nid == autoId) continue;
QString label = nodeLabel(nid);
if (m_db && nid == m_db->myNodeId())
label += QCoreApplication::translate("ChatGui", " (self)");
m_nodeCombo->addItem(label, QVariant::fromValue((qulonglong)nid));
}
/* строим линк для авто-узла (сигнал заблокирован) */
requestLink(autoId);
}
void ShareInviteDialog::onLinkReady(const char* chId, int chIdLen, uint64_t requestId, const char* link) {
if (requestId != m_requestId) return;
QString cid = QString::fromUtf8(chId, chIdLen);
if (cid != m_channelId) return;
if (!link || link[0] == '\0') {
m_linkEdit->setText(QString());
updateLocalSend();
m_linkEdit->setPlaceholderText(QCoreApplication::translate("ChatGui", "Failed to build the link (see log)"));
m_qrLabel->clear();
return;
}
setLink(QString::fromUtf8(link));
}
void ShareInviteDialog::updateLocalSend() {
m_sendLocal->setEnabled(!m_localRequest && m_devices->currentIndex() >= 0 && !m_linkEdit->text().isEmpty());
}
void ShareInviteDialog::onLocalDevices(const uint8_t* data, int len) {
if (len < 2) { GUI_ERROR("discovery: invalid device list bytes=%d", len); return; }
struct Device { quint64 id; QString name; };
QList<Device> devices;
int count = qFromBigEndian<quint16>(data), offset = 2;
for (int i = 0; i < count; i++) {
if (offset + 9 > len || offset + 9 + data[offset + 8] > len) {
GUI_ERROR("discovery: truncated device list bytes=%d offset=%d", len, offset); return;
}
int nameLen = data[offset + 8];
devices.append({qFromBigEndian<quint64>(data + offset), QString::fromUtf8((const char*)data + offset + 9, nameLen)});
offset += 9 + nameLen;
}
if (offset != len) { GUI_ERROR("discovery: trailing device list bytes=%d", len - offset); return; }
auto selected = m_devices->currentData();
QSignalBlocker blocker(m_devices);
m_devices->clear();
for (const auto& device : devices) {
const QString suffix = QString::number(device.id, 16).right(6);
m_devices->addItem(device.name + QStringLiteral(" [%1]").arg(suffix), QVariant::fromValue(device.id));
}
int index = m_devices->findData(selected);
if (index >= 0) m_devices->setCurrentIndex(index);
m_localRow->setVisible(!devices.isEmpty()); updateLocalSend();
}
void ShareInviteDialog::sendLocalInvite() {
QByteArray link = m_linkEdit->text().toUtf8();
if (m_localRequest || m_devices->currentIndex() < 0 || link.isEmpty()) return;
if (!gui_bridge_is_uasync_ready() || !gui_bridge_get_inst()) {
GUI_ERROR("discovery: core stopped before local send");
m_delivery->setText(QCoreApplication::translate("ChatGui", "Could not deliver invitation")); return;
}
if (link.size() > LOCAL_TRANSPORT_DATA_MAX) {
GUI_ERROR("discovery: invitation too long bytes=%d", (int)link.size());
m_delivery->setText(QCoreApplication::translate("ChatGui", "Could not deliver invitation")); return;
}
auto* req = (struct chat_local_send_request*)u_calloc(1, sizeof(struct chat_local_send_request));
if (!req) { GUI_ERROR("discovery: send request allocation failed"); return; }
static uint64_t nextRequest = 0;
req->inst = gui_bridge_get_inst(); req->peer_id = m_devices->currentData().toULongLong();
req->request_id = m_localRequest = ++nextRequest;
memcpy(req->link, link.constData(), (size_t)link.size());
m_delivery->setText(QCoreApplication::translate("ChatGui", "Sending invitation…")); updateLocalSend();
gui_bridge_post_uasync_fn(chat_local_send_trampoline, req);
}
void ShareInviteDialog::onLocalDelivery(const uint8_t* data, int len) {
if (len != 17 || data[16] > LOCAL_DELIVERY_FORBIDDEN) { GUI_ERROR("discovery: invalid delivery response bytes=%d", len); return; }
if (!m_localRequest || qFromBigEndian<quint64>(data) != m_localRequest) return;
m_localRequest = 0;
if (data[16] == LOCAL_DELIVERY_FORBIDDEN) m_delivery->setText(QCoreApplication::translate("ChatGui",
"Receiving invitations is forbidden. Enable it in the recipient device's settings."));
else m_delivery->setText(QCoreApplication::translate("ChatGui",
data[16] == LOCAL_DELIVERY_RECEIVED ? "Invitation delivered" : "Could not deliver invitation"));
updateLocalSend();
}