diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index c35a4681..ea7dfb6a 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include extern "C" { @@ -36,8 +39,8 @@ MemberListModel::MemberListModel(QObject* parent) if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this, [this](const QString& channel, quint64 owner) { if (channel != m_channelId) return; - for (auto it = m_cache.begin(); it != m_cache.end(); ++it) if (it->snapshot.node_id == owner) { - applyPhoto(*it, channel); emit dataChanged(index(it.key()), index(it.key()), {Qt::DecorationRole}); + for (int row = 0; row < m_members.size(); ++row) if (m_members[row].snapshot.node_id == owner) { + applyPhoto(m_members[row], channel); emit dataChanged(index(row), index(row), {Qt::DecorationRole}); } }); m_rttTimer->setInterval(2000); @@ -46,55 +49,63 @@ MemberListModel::MemberListModel(QObject* parent) } int MemberListModel::rowCount(const QModelIndex& parent) const { - (void)parent; - return m_totalCount; + return parent.isValid() ? 0 : int(m_members.size()); } QVariant MemberListModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_totalCount) + if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 || index.row() >= m_members.size()) return {}; - auto it = m_cache.find(index.row()); - if (it != m_cache.end()) { - if (role == Qt::DisplayRole) return it->name; - if (role == Qt::DecorationRole) return QVariant::fromValue(QIcon(it->avatar)); - if (role == Qt::UserRole + 1) { - QString sub; - if (it->snapshot.rtt != 0xffff) sub += QString("rtt=%1ms").arg(it->snapshot.rtt / 10); - if (it->snapshot.nat_type != 0xff) { - static const char* nat[] = {"NET","DIR","EIM","DEP","NAT?","CONTACT","EIM?"}; - const char* nt = (it->snapshot.nat_type <= 6) ? nat[it->snapshot.nat_type] : "?"; - if (!sub.isEmpty()) sub += " "; - sub += nt; - } - return sub; + const auto& item = m_members.at(index.row()); + if (role == Qt::DisplayRole) return item.name; + if (role == Qt::DecorationRole) return QVariant::fromValue(QIcon(item.avatar)); + if (role == Qt::UserRole + 1) { + QString sub; + if (item.snapshot.rtt != 0xffff) sub += QString("rtt=%1ms").arg(item.snapshot.rtt / 10); + if (item.snapshot.nat_type != 0xff) { + static const char* nat[] = {"NET","DIR","EIM","DEP","NAT?","CONTACT","EIM?"}; + const char* nt = (item.snapshot.nat_type <= 6) ? nat[item.snapshot.nat_type] : "?"; + if (!sub.isEmpty()) sub += " "; + sub += nt; } - if (role == Qt::ToolTipRole) - return (it->snapshot.online ? QCoreApplication::translate("ChatGui", - "online") : QCoreApplication::translate("ChatGui", - "offline")) + " | 0x" + - QString("%1").arg(it->snapshot.node_id, 16, 16, QChar('0')) + - ((it->snapshot.flags & CHAT_MEMBER_FLAG_RADIO) ? QCoreApplication::translate("ChatGui", " · Radio enabled") : QString()); - if (role == Qt::UserRole + 2) return it->snapshot.is_self; - if (role == Qt::UserRole + 3) return it->snapshot.flags; - if (role == Qt::UserRole + 4) return (bool)it->snapshot.storage; - if (role == RadioSpeakingRole) return it->snapshot.is_self ? m_selfTransmitting : m_radioTalkers.contains(it->snapshot.node_id); - if (role == OnlineRole) return it->snapshot.online; - return {}; + return sub; } + if (role == Qt::ToolTipRole) + return QCoreApplication::translate("ChatGui", item.snapshot.online ? "online" : "offline") + " | 0x" + + QString("%1").arg(item.snapshot.node_id, 16, 16, QChar('0')) + + ((item.snapshot.flags & CHAT_MEMBER_FLAG_RADIO) ? QCoreApplication::translate("ChatGui", " · Radio enabled") : QString()); + if (role == Qt::UserRole + 2) return item.snapshot.is_self; + if (role == Qt::UserRole + 3) return item.snapshot.flags; + if (role == Qt::UserRole + 4) return bool(item.snapshot.storage); + if (role == RadioSpeakingRole) return item.snapshot.is_self ? m_selfTransmitting : m_radioTalkers.contains(item.snapshot.node_id); + if (role == OnlineRole) return item.snapshot.online; return {}; } void MemberListModel::setChannel(const QString& channelId, bool isOwner) { - if (m_channelId != channelId) { - beginResetModel(); - DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: channel %s -> %s cleared talkers=%d self_tx=%d", - qPrintable(m_channelId), qPrintable(channelId), int(m_radioTalkers.size()), m_selfTransmitting); - m_radioTalkers.clear(); m_radioActive = false; m_selfTransmitting = false; - m_totalCount = 0; m_cache.clear(); endResetModel(); - emit snapshotChanged(0); + const QByteArray channel = channelId.toUtf8(); + if (channel.size() >= int(sizeof(chat_member_list_req::ch_id)) || channel.contains('\0')) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member channel rejected: bytes=%lld embedded_nul=%d limit=63", + (long long)channel.size(), channel.contains('\0')); return; } - m_channelId = channelId; m_isOwner = isOwner; + const bool changed = m_channelId != channelId; + if (changed || (m_isOwner && !isOwner)) { + beginResetModel(); + if (changed) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: channel %s -> %s cleared talkers=%d self_tx=%d", + qPrintable(m_channelId), qPrintable(channelId), int(m_radioTalkers.size()), m_selfTransmitting); + m_channelId = channelId; + m_radioTalkers.clear(); m_radioActive = false; m_selfTransmitting = false; + m_members.clear(); + } else { + m_members.erase(std::remove_if(m_members.begin(), m_members.end(), [](const MemberListItem& item) { + return item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED; + }), m_members.end()); + } + m_isOwner = isOwner; + endResetModel(); + emit snapshotChanged(0); + } else m_isOwner = isOwner; refresh(); } @@ -104,7 +115,9 @@ void MemberListModel::refresh() { if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member list request allocation failed"); return; } req->inst = gui_bridge_get_inst(); strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1); - gui_bridge_post_uasync_fn(chat_core_request_member_list_trampoline, req); + if (gui_bridge_post_uasync_owned_fn(chat_core_request_member_list_trampoline, req) < 0) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member list request rejected ch=%s", qPrintable(m_channelId)); u_free(req); + } } } @@ -114,27 +127,28 @@ void MemberListModel::refreshRtt() { if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member RTT request allocation failed"); return; } req->inst = gui_bridge_get_inst(); strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1); - gui_bridge_post_uasync_fn(chat_core_request_member_rtt_trampoline, req); + if (gui_bridge_post_uasync_owned_fn(chat_core_request_member_rtt_trampoline, req) < 0) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member RTT request rejected ch=%s", qPrintable(m_channelId)); u_free(req); + } } quint64 MemberListModel::nodeIdAt(int row) const { - auto it = m_cache.find(row); - return it != m_cache.end() ? it->snapshot.node_id : 0; + return row >= 0 && row < m_members.size() ? m_members.at(row).snapshot.node_id : 0; } bool MemberListModel::isOnlineAt(int row) const { - auto it = m_cache.find(row); - return it != m_cache.end() ? it->snapshot.online : false; + return row >= 0 && row < m_members.size() ? m_members.at(row).snapshot.online : false; } std::optional MemberListModel::member(quint64 nodeId) const { - for (auto it = m_cache.cbegin(); it != m_cache.cend(); ++it) - if (it->snapshot.node_id == nodeId) return it->snapshot; + for (const auto& item : m_members) if (item.snapshot.node_id == nodeId) return item.snapshot; return std::nullopt; } -static void deserialize_member(const uint8_t* src, MemberListItem& item) { - chat_core_deserialize_member(src, &item.snapshot); +static bool deserialize_member(const uint8_t* src, MemberListItem& item) { + if (chat_core_deserialize_member(src, &item.snapshot) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot decode failed: missing data"); return false; + } item.name = QString::fromUtf8(item.snapshot.name); QColor c = item.snapshot.online ? QColor("#4CAF50") : QColor("#9E9E9E"); @@ -151,6 +165,7 @@ static void deserialize_member(const uint8_t* src, MemberListItem& item) { p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, item.name.left(1)); p.end(); item.avatar = pm; + return true; } void MemberListModel::onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen) { @@ -160,20 +175,20 @@ void MemberListModel::onMemberList(const QString& chId, int count, const uint8_t DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member list: invalid count=%d bytes=%d", count, dataLen); return; } - beginResetModel(); - m_totalCount = 0; - m_cache.clear(); - - int row = 0; + QVector members; + members.reserve(count); for (int i = 0; i < count; i++) { MemberListItem item; - deserialize_member(data + i * CHAT_MEMBER_DISPLAY_SIZE, item); + if (!deserialize_member(data + i * CHAT_MEMBER_DISPLAY_SIZE, item)) return; applyPhoto(item, chId); if ((item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) continue; - m_cache[row++] = item; + members.append(item); } - m_totalCount = row; + beginResetModel(); + m_members = std::move(members); endResetModel(); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "member list applied ch=%s count=%d visible=%d owner=%d", + qPrintable(chId), count, int(m_members.size()), m_isOwner); emit snapshotChanged(0); } @@ -181,13 +196,13 @@ void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data) if (chId != m_channelId) return; MemberListItem item; - deserialize_member(data, item); + if (!deserialize_member(data, item)) return; applyPhoto(item, chId); /* find existing row by nodeId */ int row = -1; - for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { - if (it->snapshot.node_id == item.snapshot.node_id) { row = it.key(); break; } + for (int i = 0; i < m_members.size(); ++i) { + if (m_members[i].snapshot.node_id == item.snapshot.node_id) { row = i; break; } } if ((item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) { @@ -197,14 +212,13 @@ void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data) } if (row >= 0) { - m_cache[row] = item; + m_members[row] = item; emit dataChanged(index(row), index(row)); } else { /* new member — add at end */ - row = m_totalCount; + row = int(m_members.size()); beginInsertRows(QModelIndex(), row, row); - m_cache[row] = item; - m_totalCount++; + m_members.append(item); endInsertRows(); } emit snapshotChanged(item.snapshot.node_id); @@ -216,13 +230,10 @@ void MemberListModel::onMemberRemoved(const QString& chId, uint64_t nodeId) { DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: removed talker ch=%s node=%016llx", qPrintable(chId), (unsigned long long)nodeId); - for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { - if (it->snapshot.node_id == nodeId) { - int row = it.key(); + for (int row = 0; row < m_members.size(); ++row) { + if (m_members[row].snapshot.node_id == nodeId) { beginRemoveRows(QModelIndex(), row, row); - m_cache.remove(row); - for (int next = row + 1; next < m_totalCount; ++next) m_cache[next - 1] = m_cache.take(next); - m_totalCount--; + m_members.removeAt(row); endRemoveRows(); emit snapshotChanged(nodeId); return; @@ -239,7 +250,7 @@ void MemberListModel::setRadioState(quint64 groupId, bool active, bool transmitt qPrintable(m_channelId), (unsigned long long)groupId, active, transmitting, int(m_radioTalkers.size())); m_radioActive = active; m_selfTransmitting = transmitting; if (!active) m_radioTalkers.clear(); - if (m_totalCount) emit dataChanged(index(0), index(m_totalCount - 1), {RadioSpeakingRole}); + if (!m_members.isEmpty()) emit dataChanged(index(0), index(m_members.size() - 1), {RadioSpeakingRole}); } /* START может прийти до списка участников; STOP снимает только указанный источник. */ @@ -259,8 +270,8 @@ void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) { else m_radioTalkers.remove(nodeId); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: talk ch=%s node=%016llx on=%d talkers=%d", qPrintable(m_channelId), (unsigned long long)nodeId, on, int(m_radioTalkers.size())); - for (auto it = m_cache.cbegin(); it != m_cache.cend(); ++it) if (it->snapshot.node_id == nodeId) { - emit dataChanged(index(it.key()), index(it.key()), {RadioSpeakingRole}); + for (int row = 0; row < m_members.size(); ++row) if (m_members[row].snapshot.node_id == nodeId) { + emit dataChanged(index(row), index(row), {RadioSpeakingRole}); return; } } diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index 0778bfea..552b9277 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -1,14 +1,16 @@ -/* Qt-модель участников выбранного канала: полные снимки CHAT_EVT_MEMBER_* в GUI-потоке. - * Список читает Qt roles, карточка — копию той же записи через member(nodeId). */ +/* Плоская Qt-модель участников одного канала, только GUI-поток с QApplication. + * Хранит полные снимки CHAT_EVT_MEMBER_*, без чтения живых структур ядра/БД. + * Последовательный массив совпадает с порядком строк; member(nodeId) возвращает независимую копию. + * Полный снимок проверяется/готовится до reset, обновление одной записи заменяет её целиком. + * Без isOwner удалённые записи скрыты, смена канала очищает снимки и эфемерную речь. + * Запросы идут через owned bridge API: остановка ядра отменяет их и освобождает аргументы. */ #ifndef MEMBERLISTMODEL_H #define MEMBERLISTMODEL_H #include -#include -#include +#include #include #include -#include #include #include #include @@ -30,20 +32,26 @@ public: static constexpr int RadioSpeakingRole = Qt::UserRole + 5; static constexpr int OnlineRole = Qt::UserRole + 6; /* живой статус BGP для кнопок вызова */ - explicit MemberListModel(QObject* parent = nullptr); + explicit MemberListModel(QObject* parent = nullptr); // parent владеет; собственный timer опрашивает RTT каждые 2 с int rowCount(const QModelIndex& parent = QModelIndex()) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + /* channelId — строковый ID до 63 байт UTF-8, пустой очищает модель. Смена owner-политики + * скрывает удалённых сразу; полный refresh асинхронный, при недоступном ядре сохраняет снимок. */ void setChannel(const QString& channelId, bool isOwner); - void refresh(); - void refreshRtt(); - quint64 nodeIdAt(int row) const; - bool isOnlineAt(int row) const; + void refresh(); // запрос полного списка, без очистки показанных строк + void refreshRtt(); // запрос полных MEMBER_UPDATED, включая все live-поля, не только RTT + quint64 nodeIdAt(int row) const; // 0 вне диапазона + bool isOnlineAt(int row) const; // false вне диапазона; online из последнего снимка, не из БД /* Копия записи: указатели в row-cache нельзя сохранять через reset/обновления. */ std::optional member(quint64 nodeId) const; - /* Called from bridge callbacks (GUI thread) */ + /* GUI callbacks, data заимствован на время вызова. Другой chId игнорируется. + * List требует ровно count*CHAT_MEMBER_DISPLAY_SIZE байтов; (0,NULL,0) очищает список. + * Updated требует доступный полный CHAT_MEMBER_DISPLAY_SIZE: длину проверяет caller/bridge codec. + * NULL/ошибка списка сохраняют прежнюю модель; неизвестный nodeId добавляется в конец. + * Removed сдвигает следующие строки с корректными Qt-уведомлениями; отсутствие допустимо. */ void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen); void onMemberUpdated(const QString& chId, const uint8_t* data); void onMemberRemoved(const QString& chId, uint64_t nodeId); @@ -58,14 +66,13 @@ signals: private: - QString m_channelId; - bool m_isOwner = false; - int m_totalCount = 0; - mutable QMap m_cache; - QSet m_radioTalkers; - bool m_radioActive = false; - bool m_selfTransmitting = false; - QTimer* m_rttTimer; + QString m_channelId; // выбранный канал, публикуется до modelReset/snapshotChanged + bool m_isOwner = false; // разрешает показывать удалённых участников + QVector m_members; // собственные снимки, индекс массива == номер строки + QSet m_radioTalkers; // START может прийти до загрузки строки; STOP/stop radio/смена канала очищают + bool m_radioActive = false; // приём START только для активной рации выбранного канала + bool m_selfTransmitting = false; // PTT/VAD из аудио-движка, не состояние удалённых источников + QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели }; #endif diff --git a/tools/chatgui/tests/test_member_detail.cpp b/tools/chatgui/tests/test_member_detail.cpp index 40f71e9a..f3051eea 100644 --- a/tools/chatgui/tests/test_member_detail.cpp +++ b/tools/chatgui/tests/test_member_detail.cpp @@ -4,6 +4,7 @@ #include "../transport/gui_bridge.h" #include #include +#include #include extern "C" { #include "utun_instance.h" @@ -17,6 +18,9 @@ static UTUN_INSTANCE instance{}; static std::vector requests; static gui_member_detail_fn detailCallback; static bool ready = true; +static bool rejectOwned = false; +static int ownedPosts = 0; +static std::vector memberRequests; extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; } extern "C" int gui_bridge_is_uasync_ready() { return ready; } @@ -25,8 +29,16 @@ extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {} extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; } extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast(arg)); + if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline) + memberRequests.push_back(*static_cast(arg)); u_free(arg); } +extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) { + ++ownedPosts; + if (rejectOwned) return -1; + gui_bridge_post_uasync_fn(fn, arg); + return 0; +} static QByteArray row(quint64 node, bool online = true, int up = 5, int rtt = 30) { chat_member_display member{}; @@ -65,7 +77,81 @@ class MemberDetailTest : public QObject { return QMetaObject::invokeMethod(&p, "onMemberClicked", Qt::DirectConnection, Q_ARG(QModelIndex, p.model()->index(rowNumber))); } private slots: - void init() { requests.clear(); ready = true; debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); } + void init() { + requests.clear(); memberRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true; + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + } + + void flatModelPreservesRowsSnapshotsAndPersistentIndexes() { + MemberListModel model; + QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest); + model.setChannel("123", false); + auto bytes = row(7) + row(8) + row(9); + model.onMemberList("123", 3, reinterpret_cast(bytes.constData()), bytes.size()); + QCOMPARE(model.rowCount(model.index(0)), 0); + const QPersistentModelIndex last = model.index(2); + const auto snapshot = model.member(9); QVERIFY(snapshot); + model.onMemberRemoved("123", 7); + QCOMPARE(last.row(), 1); QCOMPARE(model.nodeIdAt(last.row()), quint64(9)); + const auto changed = row(9, false, 0, 0xffff); + model.onMemberUpdated("123", reinterpret_cast(changed.constData())); + QVERIFY(snapshot->online); QVERIFY(!model.member(9)->online); + const auto added = row(10); + model.onMemberUpdated("123", reinterpret_cast(added.constData())); + QCOMPARE(model.rowCount(), 3); QCOMPARE(model.nodeIdAt(2), quint64(10)); + QVERIFY(!model.member(7)); QCOMPARE(model.nodeIdAt(-1), quint64(0)); + } + + void snapshotChangedSeesNewChannel() { + MemberListModel model; model.setChannel("123", false); + memberRequests.clear(); + connect(&model, &MemberListModel::snapshotChanged, &model, [&] { model.refresh(); }); + model.setChannel("456", false); + QCOMPARE(memberRequests.size(), size_t(2)); + for (const auto& request : memberRequests) QCOMPARE(QString::fromUtf8(request.ch_id), QString("456")); + } + + void losingOwnerPolicyHidesDeletedRowsWithoutCore() { + MemberListModel model; model.setChannel("123", true); + auto bytes = row(7) + row(8); + bytes[CHAT_MEMBER_DISPLAY_SIZE + 10] = CHAT_MEMBER_FLAG_DELETED; + model.onMemberList("123", 2, reinterpret_cast(bytes.constData()), bytes.size()); + QCOMPARE(model.rowCount(), 2); + ready = false; model.setChannel("123", false); + QCOMPARE(model.rowCount(), 1); QVERIFY(!model.member(8)); + } + + void malformedUpdatesKeepExistingSnapshot() { + MemberListModel model; model.setChannel("123", false); + const auto bytes = row(7); + model.onMemberList("123", 1, reinterpret_cast(bytes.constData()), bytes.size()); + model.onMemberList("123", 2, reinterpret_cast(bytes.constData()), bytes.size()); + model.onMemberList("123", -1, nullptr, -1); + model.onMemberUpdated("123", nullptr); + QCOMPARE(model.rowCount(), 1); QCOMPARE(model.nodeIdAt(0), quint64(7)); + } + + void rejectedMemberRequestsReleaseArguments() { + const auto before = u_get_allocated_count(); + rejectOwned = true; + MemberListModel model; model.setChannel("123", false); + model.refresh(); model.refreshRtt(); + QCOMPARE(ownedPosts, 3); + QCOMPARE(u_get_allocated_count(), before); + } + + void invalidChannelDoesNotReplaceVisibleSnapshotOrSendRequests() { + MemberListModel model; model.setChannel("123", false); + const auto bytes = row(7); + model.onMemberList("123", 1, reinterpret_cast(bytes.constData()), bytes.size()); + memberRequests.clear(); + model.setChannel(QString(64, '1'), false); + model.setChannel(QString::fromUtf8("123\0suffix", 10), false); + QCOMPARE(model.rowCount(), 1); QCOMPARE(model.nodeIdAt(0), quint64(7)); + QVERIFY(memberRequests.empty()); + model.refresh(); QCOMPARE(memberRequests.size(), size_t(1)); + QCOMPARE(QString::fromUtf8(memberRequests.back().ch_id), QString("123")); + } void modelOwnsPresenceAndRttAndRefreshPreservesSelection() { AccountList panel; panel.resize(600, 800); panel.show(); diff --git a/tools/chatgui/tests/test_radio_mute_bridge.cpp b/tools/chatgui/tests/test_radio_mute_bridge.cpp index 15e6a281..1237ca2b 100644 --- a/tools/chatgui/tests/test_radio_mute_bridge.cpp +++ b/tools/chatgui/tests/test_radio_mute_bridge.cpp @@ -7,6 +7,7 @@ #include #include #include +#include extern "C" { #include "utun_instance.h" @@ -16,6 +17,7 @@ extern "C" { #include "routing_layer/topo_node_sqlite.h" #include "u_async.h" #include "debug_config.h" +#include "mem.h" } struct Probe { @@ -81,6 +83,26 @@ static void mute_result(const uint8_t* data, int len) { class RadioMuteBridgeTest : public QObject { Q_OBJECT private slots: + void pendingMemberRequestsAreFreedBeforeCoreDestroy() { + gui_bridge_init(); + const auto before = u_get_allocated_count(); + { + UTUN_INSTANCE instance{}; + instance.ua = uasync_create(); QVERIFY(instance.ua); + const auto release = qScopeGuard([&] { + gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); uasync_destroy(instance.ua, 1); + }); + gui_bridge_set_uasync(instance.ua); gui_bridge_set_inst(&instance); + const auto coreOnly = u_get_allocated_count(); + MemberListModel model; model.setChannel("123", false); model.refreshRtt(); + QVERIFY(u_get_allocated_count() > coreOnly); + gui_bridge_set_inst(nullptr); + QCOMPARE(u_get_allocated_count(), coreOnly); + QVERIFY(!instance.ua->posted_tasks_head); + } + QCOMPARE(u_get_allocated_count(), before); + } + void cleanup() { gui_bridge_set_member_updated_cb(nullptr); gui_bridge_set_radio_mute_result_cb(nullptr); gui_bridge_set_member_list_cb(nullptr); gui_bridge_set_radio_talk_cb(nullptr); diff --git a/tools/chatgui/tests/test_radio_panel.cpp b/tools/chatgui/tests/test_radio_panel.cpp index 85a7c302..7ad56ba6 100644 --- a/tools/chatgui/tests/test_radio_panel.cpp +++ b/tools/chatgui/tests/test_radio_panel.cpp @@ -42,6 +42,11 @@ extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline) { u_free(arg); return; } uasync_post(instance.ua, fn, arg); } +extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) { + if (!instance.ua) return -1; + gui_bridge_post_uasync_fn(fn, arg); + return 0; +} extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { ++begins; return 0; } extern "C" int __wrap_radio_talk_end(UTUN_INSTANCE*, uint64_t) { ++ends; return 0; } extern "C" int __wrap_radio_talk_send(UTUN_INSTANCE*, uint64_t, const uint8_t*, int) { ++packets; return 0; }