Add local per-member radio mute controls on desktop and Android
This commit is contained in:
1 parent
a069d11ef9
commit
7e19cbaf3a
33 files changed
+735
-27
No files matched your search
@@ -350,6 +350,17 @@ target_include_directories(test_radio_audio PRIVATE ${CMAKE_SOURCE_DIR}/../../sr
|
||||
target_link_libraries(test_radio_audio PRIVATE utun_voice utun pthread)
|
||||
add_test(NAME test_radio_audio COMMAND test_radio_audio)
|
||||
|
||||
add_executable(test_radio_member_mute tests/test_radio_member_mute.cpp)
|
||||
target_include_directories(test_radio_member_mute PRIVATE ${UTUN_INCLUDE_DIRS})
|
||||
target_link_libraries(test_radio_member_mute PRIVATE ${QT_CORE} utun_voice utun pthread)
|
||||
add_test(NAME test_radio_member_mute COMMAND test_radio_member_mute)
|
||||
set_tests_properties(test_radio_member_mute PROPERTIES TIMEOUT 20)
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_compile_definitions(test_radio_member_mute PRIVATE UTUN_TEST_WRAP_VAD)
|
||||
target_link_options(test_radio_member_mute PRIVATE "-Wl,--wrap=silero_vad_create_default" "-Wl,--wrap=silero_vad_destroy"
|
||||
"-Wl,--wrap=silero_vad_reset" "-Wl,--wrap=silero_vad_process")
|
||||
endif()
|
||||
|
||||
add_executable(test_audio_event_queue tests/test_audio_event_queue.cpp)
|
||||
target_link_libraries(test_audio_event_queue PRIVATE pthread)
|
||||
add_test(NAME test_audio_event_queue COMMAND test_audio_event_queue)
|
||||
|
||||
@@ -5,11 +5,39 @@
|
||||
#include <QPainter>
|
||||
#include <QFont>
|
||||
#include <QMouseEvent>
|
||||
#include <QHelpEvent>
|
||||
#include <QToolTip>
|
||||
#include <QPainterPath>
|
||||
#include <QCoreApplication>
|
||||
#include <QAbstractItemView>
|
||||
|
||||
extern "C" {
|
||||
#include "chat/chat_member.h"
|
||||
}
|
||||
|
||||
static QRect inviteButtonRect(const QRect& row) {
|
||||
return QRect(row.right() - 31, row.center().y() - 14, 28, 28);
|
||||
}
|
||||
|
||||
static QRect radioMuteButtonRect(const QRect& row) {
|
||||
return QRect(row.right() - 63, row.center().y() - 14, 28, 28);
|
||||
}
|
||||
|
||||
static void drawRadioMuteButton(QPainter* painter, const QRect& button, bool muted) {
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing);
|
||||
painter->translate(button.center());
|
||||
QColor color(muted ? "#EF5350" : "#4CAF50");
|
||||
QPainterPath speaker;
|
||||
speaker.moveTo(-8, -3); speaker.lineTo(-4, -3); speaker.lineTo(1, -7);
|
||||
speaker.lineTo(1, 7); speaker.lineTo(-4, 3); speaker.lineTo(-8, 3); speaker.closeSubpath();
|
||||
painter->fillPath(speaker, color);
|
||||
painter->setPen(QPen(color, 1.8, Qt::SolidLine, Qt::RoundCap)); painter->setBrush(Qt::NoBrush);
|
||||
painter->drawArc(QRectF(-3, -5, 10, 10), -60 * 16, 120 * 16);
|
||||
painter->drawArc(QRectF(-6, -8, 16, 16), -60 * 16, 120 * 16);
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
static QRect avatarRect(const QRect& row) {
|
||||
const int size = MemberListModel::kAvatarSize;
|
||||
return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size);
|
||||
@@ -41,7 +69,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
|
||||
|
||||
const int x = option.rect.left() + iconSz + margin * 3;
|
||||
const bool canInvite = !index.data(Qt::UserRole + 2).toBool();
|
||||
const int w = option.rect.right() - x - margin - (canInvite ? 32 : 0);
|
||||
const int w = option.rect.right() - x - margin - (canInvite ? 64 : 0);
|
||||
const int yMid = option.rect.top() + option.rect.height() / 2;
|
||||
|
||||
uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF);
|
||||
@@ -90,6 +118,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
|
||||
Qt::AlignLeft | Qt::AlignTop, sub);
|
||||
|
||||
if (canInvite) {
|
||||
drawRadioMuteButton(painter, radioMuteButtonRect(option.rect), flags & CHAT_MEMBER_FLAG_RADIO_MUTED);
|
||||
QFont plusFont = nameFont; plusFont.setPixelSize(22); plusFont.setStrikeOut(false);
|
||||
painter->setFont(plusFont);
|
||||
painter->setPen(option.palette.color(QPalette::Link));
|
||||
@@ -105,6 +134,18 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
|
||||
event->type() != QEvent::MouseButtonDblClick) return false;
|
||||
auto* mouse = static_cast<QMouseEvent*>(event);
|
||||
if (mouse->button() != Qt::LeftButton) return false;
|
||||
const bool onMute = !index.data(Qt::UserRole + 2).toBool() && radioMuteButtonRect(option.rect).contains(mouse->pos());
|
||||
if (event->type() == QEvent::MouseButtonPress) {
|
||||
m_radioMutePress = onMute ? index : QModelIndex(); m_radioMutePosition = mouse->pos();
|
||||
}
|
||||
if (onMute || m_radioMutePress.isValid()) {
|
||||
if (event->type() == QEvent::MouseButtonRelease) {
|
||||
const bool activate = onMute && m_radioMutePress == index && (mouse->pos() - m_radioMutePosition).manhattanLength() < 5;
|
||||
m_radioMutePress = QPersistentModelIndex();
|
||||
if (activate) emit radioMuteRequested(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const bool onAvatar = avatarRect(option.rect).contains(mouse->pos());
|
||||
if (event->type()==QEvent::MouseButtonPress) {
|
||||
m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPosition=mouse->pos();
|
||||
@@ -125,6 +166,17 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
|
||||
return activate;
|
||||
}
|
||||
|
||||
bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) {
|
||||
if (event && !index.data(Qt::UserRole + 2).toBool() && radioMuteButtonRect(option.rect).contains(event->pos())) {
|
||||
const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED;
|
||||
QToolTip::showText(event->globalPos(), QCoreApplication::translate("ChatGui",
|
||||
muted ? "Listen to this member in radio" : "Mute this member in radio"), view);
|
||||
return true;
|
||||
}
|
||||
return QStyledItemDelegate::helpEvent(event, view, option, index);
|
||||
}
|
||||
|
||||
QSize AccountDelegate::sizeHint(const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const {
|
||||
(void)option; (void)index;
|
||||
|
||||
@@ -15,11 +15,16 @@ public:
|
||||
const QModelIndex &index) const override;
|
||||
bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) override;
|
||||
bool helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) override;
|
||||
signals:
|
||||
void inviteRequested(const QModelIndex& index);
|
||||
void avatarRequested(const QModelIndex& index);
|
||||
void radioMuteRequested(const QModelIndex& index);
|
||||
private:
|
||||
QPersistentModelIndex m_invitePress;
|
||||
QPersistentModelIndex m_avatarPress;
|
||||
QPoint m_avatarPosition;
|
||||
QPersistentModelIndex m_radioMutePress;
|
||||
QPoint m_radioMutePosition;
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <QDateTime>
|
||||
#include <QFont>
|
||||
#include <QSet>
|
||||
#include <QMessageBox>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include "../../../lib/platform_compat.h"
|
||||
@@ -85,6 +86,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
|
||||
m_listView->setModel(m_model);
|
||||
auto* delegate = new AccountDelegate(m_listView);
|
||||
m_listView->setItemDelegate(delegate);
|
||||
connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) {
|
||||
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(index.row())) < 0)
|
||||
QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"),
|
||||
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
|
||||
});
|
||||
connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) {
|
||||
showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row()));
|
||||
});
|
||||
@@ -143,6 +149,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
|
||||
});
|
||||
|
||||
g_detailTarget = this;
|
||||
gui_bridge_set_radio_mute_result_cb([](const uint8_t* data, int len) {
|
||||
if (len != 17) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: invalid GUI mute result bytes=%d", len); return; }
|
||||
if (g_detailTarget && !data[16]) QMessageBox::warning(g_detailTarget, QCoreApplication::translate("ChatGui", "Error"),
|
||||
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
|
||||
});
|
||||
gui_bridge_set_member_detail_cb(memberDetailCb);
|
||||
|
||||
static bool nodeinfoRegistered = false;
|
||||
@@ -160,7 +171,7 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
|
||||
}
|
||||
|
||||
AccountList::~AccountList() {
|
||||
if (g_detailTarget == this) g_detailTarget = nullptr;
|
||||
if (g_detailTarget == this) { g_detailTarget = nullptr; gui_bridge_set_radio_mute_result_cb(nullptr); }
|
||||
}
|
||||
|
||||
void AccountList::setChannel(const QString& channelId) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <QDir>
|
||||
extern "C" {
|
||||
#include "chat/invite_link.h"
|
||||
#include "chat/chat_member.h"
|
||||
}
|
||||
|
||||
class InviteLinksTest : public QObject {
|
||||
@@ -305,6 +306,44 @@ private slots:
|
||||
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, row.center().y()));
|
||||
QCOMPARE(invites.count(), 1);
|
||||
}
|
||||
|
||||
void radioMuteButtonTargetsMemberAndChangesColor() {
|
||||
QStandardItemModel model;
|
||||
auto* other = new QStandardItem("other"); other->setData(false, Qt::UserRole + 2); model.appendRow(other);
|
||||
auto* self = new QStandardItem("self"); self->setData(true, Qt::UserRole + 2); model.appendRow(self);
|
||||
QListView view; AccountDelegate delegate;
|
||||
view.setModel(&model); view.setItemDelegate(&delegate); view.resize(280, 140); view.show(); QTest::qWait(30);
|
||||
QSignalSpy toggles(&delegate, &AccountDelegate::radioMuteRequested);
|
||||
QSignalSpy invites(&delegate, &AccountDelegate::inviteRequested);
|
||||
connect(&delegate, &AccountDelegate::radioMuteRequested, &model, [&](const QModelIndex& index) {
|
||||
model.setData(index, index.data(Qt::UserRole + 3).toInt() ^ CHAT_MEMBER_FLAG_RADIO_MUTED, Qt::UserRole + 3);
|
||||
});
|
||||
const QModelIndex index = model.index(0, 0);
|
||||
const QRect row = view.visualRect(index);
|
||||
const QPoint button(row.right() - 50, row.center().y());
|
||||
auto colorCount = [&](QRgb color) {
|
||||
QImage image(view.viewport()->size(), QImage::Format_RGB32); image.fill(Qt::white);
|
||||
QStyleOptionViewItem option; option.initFrom(&view); option.rect = row;
|
||||
QPainter painter(&image); delegate.paint(&painter, option, index); painter.end();
|
||||
int count = 0;
|
||||
for (int y = row.top(); y <= row.bottom(); ++y)
|
||||
for (int x = row.right() - 63; x < row.right() - 35; ++x) count += image.pixel(x, y) == color;
|
||||
return count;
|
||||
};
|
||||
QVERIFY(colorCount(QColor("#4CAF50").rgb()) > 0);
|
||||
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, button);
|
||||
QCOMPARE(toggles.count(), 1); QCOMPARE(qvariant_cast<QModelIndex>(toggles.first().first()).row(), 0);
|
||||
QVERIFY(colorCount(QColor("#EF5350").rgb()) > 0); QCOMPARE(colorCount(QColor("#4CAF50").rgb()), 0);
|
||||
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, button);
|
||||
QCOMPARE(toggles.count(), 2); QVERIFY(colorCount(QColor("#4CAF50").rgb()) > 0);
|
||||
const QRect own = view.visualRect(model.index(1, 0));
|
||||
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(own.right() - 50, own.center().y()));
|
||||
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(65, row.center().y()));
|
||||
QCOMPARE(toggles.count(), 2); QCOMPARE(invites.count(), 0);
|
||||
QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, button);
|
||||
QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(65, row.center().y()));
|
||||
QCOMPARE(toggles.count(), 2);
|
||||
}
|
||||
};
|
||||
QTEST_MAIN(InviteLinksTest)
|
||||
#include "test_invite_links.moc"
|
||||
@@ -0,0 +1,201 @@
|
||||
/* Настоящее ядро, SQLite и Opus: локальное отключение источника, события и восстановление после restart чата. */
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
extern "C" {
|
||||
#include "utun_instance.h"
|
||||
#include "config_parser.h"
|
||||
#include "chat/chat_core.h"
|
||||
#include "chat/chat_member.h"
|
||||
#include "chat/chat_event.h"
|
||||
#include "routing_layer/topo_group.h"
|
||||
#include "routing_layer/topo_node_sqlite.h"
|
||||
#include "radio/radio.h"
|
||||
#include "radio/radio_audio.h"
|
||||
#include "u_async.h"
|
||||
#include "debug_config.h"
|
||||
#include "opus_codec.h"
|
||||
#include "mem.h"
|
||||
#include "silero_vad.h"
|
||||
}
|
||||
|
||||
#ifdef UTUN_TEST_WRAP_VAD
|
||||
/* Детерминированная речь; автомат VAD, кодер и признак занятого эфира остаются настоящими. */
|
||||
extern "C" silero_vad_t* __wrap_silero_vad_create_default() { return reinterpret_cast<silero_vad_t*>(1); }
|
||||
extern "C" void __wrap_silero_vad_destroy(silero_vad_t*) {}
|
||||
extern "C" void __wrap_silero_vad_reset(silero_vad_t*) {}
|
||||
extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float*, float* probability) { *probability = 1.0f; return 0; }
|
||||
#endif
|
||||
|
||||
#define REQUIRE(condition) do { if (!(condition)) { std::fprintf(stderr, "radio mute line %d: %s\n", __LINE__, #condition); std::abort(); } } while (0)
|
||||
static constexpr uint64_t SOURCE_A = 42, SOURCE_B = 43;
|
||||
static int updates, results, successes;
|
||||
static uint8_t last_flags;
|
||||
|
||||
static void event(UTUN_INSTANCE*, int type, const uint8_t* data, int len) {
|
||||
if (type == CHAT_EVT_MEMBER_UPDATED && len == data[0] + 2 + CHAT_MEMBER_DISPLAY_SIZE) {
|
||||
chat_member_display m{}; REQUIRE(chat_core_deserialize_member(data + data[0] + 2, &m) == 0);
|
||||
if (m.node_id == SOURCE_A) { ++updates; last_flags = m.flags; }
|
||||
}
|
||||
if (type == CHAT_EVT_RADIO_MUTE_RESULT) { REQUIRE(len == 17); ++results; successes += data[16] != 0; }
|
||||
}
|
||||
|
||||
static TOPO_GROUP* first_channel(UTUN_INSTANCE* inst) {
|
||||
for (ll_entry* e = inst->topo_groups->group_list->head; e; e = e->next) {
|
||||
auto* group = reinterpret_cast<TOPO_GROUP*>(e);
|
||||
if (group->group_type == TOPO_GROUP_TYPE_CHAT) return group;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void frames(UTUN_INSTANCE* inst, uint64_t group, uint64_t source, uint16_t first_seq = 0) {
|
||||
auto* enc = opus_codec_encoder_create(48000, 1); REQUIRE(enc);
|
||||
int16_t pcm[960];
|
||||
for (int i = 0; i < 960; ++i) pcm[i] = 7000;
|
||||
for (uint16_t i = 0; i < 8; ++i) {
|
||||
uint8_t packet[256]; int bytes = opus_codec_encode(enc, pcm, 960, packet, sizeof(packet)); REQUIRE(bytes > 0);
|
||||
radio_audio_on_frame(inst, group, source, 1, first_seq + i, 0, packet, bytes, nullptr);
|
||||
}
|
||||
opus_codec_encoder_destroy(enc);
|
||||
}
|
||||
|
||||
static std::vector<int16_t> pull(uint64_t group) {
|
||||
std::vector<int16_t> pcm(16 * 1920);
|
||||
for (int i = 0; i < 16; ++i) REQUIRE(radio_audio_pull_pcm(group, pcm.data() + i * 1920, 1920) == 1920);
|
||||
return pcm;
|
||||
}
|
||||
|
||||
static bool audible(const std::vector<int16_t>& pcm) {
|
||||
for (int16_t sample : pcm) if (sample > 1000 || sample < -1000) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool flag(UTUN_INSTANCE* inst, const std::string& channel, uint64_t source) {
|
||||
uint8_t member[CHAT_MEMBER_DISPLAY_SIZE];
|
||||
REQUIRE(chat_core_get_single_member(inst, channel.c_str(), source, member) == 0);
|
||||
uint8_t* members = nullptr; int count = 0;
|
||||
REQUIRE(chat_core_get_member_list(inst, channel.c_str(), &members, &count) == 0);
|
||||
bool found = false;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
chat_member_display m{}; REQUIRE(chat_core_deserialize_member(members + i * CHAT_MEMBER_DISPLAY_SIZE, &m) == 0);
|
||||
if (m.node_id == source) { REQUIRE(m.flags == member[10]); found = true; }
|
||||
}
|
||||
u_free(members); REQUIRE(found);
|
||||
return member[10] & CHAT_MEMBER_FLAG_RADIO_MUTED;
|
||||
}
|
||||
|
||||
int main() {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level_by_name("radio", "debug");
|
||||
UASYNC* ua = uasync_create(); REQUIRE(ua);
|
||||
QTemporaryDir directory; REQUIRE(directory.isValid());
|
||||
std::string config =
|
||||
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
|
||||
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"
|
||||
"db_path=" + directory.path().toStdString() + "\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n";
|
||||
UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config.c_str());
|
||||
REQUIRE(inst); inst->config->global.db_sync_enabled = 0;
|
||||
REQUIRE(utun_core_start(inst) == 0 && chat_service_start(inst) == 0);
|
||||
chat_core_create_channel_auto(inst, "mute test");
|
||||
TOPO_GROUP* group = first_channel(inst); REQUIRE(group);
|
||||
uint64_t gid = group->group_id; std::string channel = group->channel_id;
|
||||
uint8_t key[32] = {1};
|
||||
REQUIRE(topo_node_sqlite_member_placeholder_put(inst->topo_sqlite_db, channel.c_str(), SOURCE_A, key, key) == 0);
|
||||
REQUIRE(topo_node_sqlite_member_placeholder_put(inst->topo_sqlite_db, channel.c_str(), SOURCE_B, key, key) == 0);
|
||||
chat_event_set_handler(inst, event);
|
||||
REQUIRE(radio_audio_init(inst) == 0); radio_set_active(inst, gid, 1);
|
||||
REQUIRE(radio_audio_start(gid, 0) == 0);
|
||||
frames(inst, gid, SOURCE_B); auto reference = pull(gid); REQUIRE(audible(reference));
|
||||
radio_audio_stop(); REQUIRE(radio_audio_start(gid, 0) == 0);
|
||||
|
||||
/* Отключение посреди передачи удаляет уже накопленный A, сохраняя точный PCM источника B. */
|
||||
frames(inst, gid, SOURCE_A); frames(inst, gid, SOURCE_B);
|
||||
REQUIRE(chat_core_toggle_member_radio_mute(inst, channel.c_str(), SOURCE_A) == 0);
|
||||
chat_core_create_channel_auto(inst, "independent mute");
|
||||
TOPO_GROUP* other = nullptr;
|
||||
for (ll_entry* e = inst->topo_groups->group_list->head; e; e = e->next) {
|
||||
auto* candidate = reinterpret_cast<TOPO_GROUP*>(e);
|
||||
if (candidate->group_id != gid && candidate->group_type == TOPO_GROUP_TYPE_CHAT) other = candidate;
|
||||
}
|
||||
REQUIRE(other);
|
||||
REQUIRE(topo_node_sqlite_member_placeholder_put(inst->topo_sqlite_db, other->channel_id, SOURCE_A, key, key) == 0);
|
||||
radio_set_active(inst, other->group_id, 1);
|
||||
REQUIRE(!flag(inst, other->channel_id, SOURCE_A) && !radio_member_muted(inst, other->group_id, SOURCE_A));
|
||||
REQUIRE(flag(inst, channel, SOURCE_A) && radio_member_muted(inst, gid, SOURCE_A));
|
||||
REQUIRE(last_flags & CHAT_MEMBER_FLAG_RADIO_MUTED);
|
||||
REQUIRE(pull(gid) == reference);
|
||||
frames(inst, gid, SOURCE_A, 8); REQUIRE(!audible(pull(gid)));
|
||||
REQUIRE(chat_core_toggle_member_radio_mute(inst, channel.c_str(), SOURCE_A) == 0);
|
||||
frames(inst, gid, SOURCE_A, 16); REQUIRE(audible(pull(gid)));
|
||||
REQUIRE(!flag(inst, channel, SOURCE_A) && !radio_member_muted(inst, gid, SOURCE_A));
|
||||
|
||||
/* Два подряд нажатия обрабатываются по актуальному состоянию, каждое получает результат. */
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
auto* req = static_cast<chat_member_radio_mute_req*>(u_calloc(1, sizeof(chat_member_radio_mute_req))); REQUIRE(req);
|
||||
req->inst = inst; req->node_id = SOURCE_A; std::strcpy(req->ch_id, channel.c_str());
|
||||
chat_core_toggle_member_radio_mute_trampoline(req);
|
||||
}
|
||||
REQUIRE(results == 2 && successes == 2 && updates == 4 && !flag(inst, channel, SOURCE_A));
|
||||
REQUIRE(chat_core_toggle_member_radio_mute(inst, channel.c_str(), inst->node_id) < 0);
|
||||
REQUIRE(chat_core_toggle_member_radio_mute(inst, channel.c_str(), 999) < 0);
|
||||
REQUIRE(radio_set_member_muted(inst, gid, SOURCE_A, 2) < 0);
|
||||
REQUIRE(sqlite3_exec(inst->topo_sqlite_db, "PRAGMA query_only=ON", nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
auto* rejected = static_cast<chat_member_radio_mute_req*>(u_calloc(1, sizeof(chat_member_radio_mute_req))); REQUIRE(rejected);
|
||||
rejected->inst = inst; rejected->node_id = SOURCE_A; std::strcpy(rejected->ch_id, channel.c_str());
|
||||
chat_core_toggle_member_radio_mute_trampoline(rejected);
|
||||
REQUIRE(results == 3 && successes == 2 && updates == 4);
|
||||
REQUIRE(!flag(inst, channel, SOURCE_A) && !radio_member_muted(inst, gid, SOURCE_A));
|
||||
REQUIRE(sqlite3_exec(inst->topo_sqlite_db, "PRAGMA query_only=OFF", nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
|
||||
/* Локальные поля сохраняются при замене подписанного блока участника. */
|
||||
REQUIRE(chat_core_toggle_member_radio_mute(inst, channel.c_str(), SOURCE_A) == 0);
|
||||
uint8_t signature[64] = {1};
|
||||
REQUIRE(topo_node_sqlite_member_block_put(inst->topo_sqlite_db, channel.c_str(), SOURCE_A, signature, 1, signature, 2,
|
||||
key, key, "{}", nullptr, 0, signature) == 0);
|
||||
REQUIRE(flag(inst, channel, SOURCE_A));
|
||||
/* Возвращаем placeholder: тест restart не требует поддельной криптоподписи. */
|
||||
std::string sql = "UPDATE \"peers_" + channel + "\" SET source=1 WHERE node_id=42";
|
||||
REQUIRE(sqlite3_exec(inst->topo_sqlite_db, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
radio_audio_stop(); radio_audio_destroy(inst); chat_service_stop(inst);
|
||||
REQUIRE(chat_service_start(inst) == 0);
|
||||
REQUIRE(flag(inst, channel, SOURCE_A));
|
||||
radio_set_active(inst, gid, 1); REQUIRE(radio_member_muted(inst, gid, SOURCE_A));
|
||||
REQUIRE(radio_audio_init(inst) == 0 && radio_audio_start(gid, 0) == 0);
|
||||
frames(inst, gid, SOURCE_A); REQUIRE(!audible(pull(gid)));
|
||||
frames(inst, gid, SOURCE_B); REQUIRE(audible(pull(gid)));
|
||||
#ifdef UTUN_TEST_WRAP_VAD
|
||||
radio_audio_stop(); REQUIRE(radio_audio_start(gid, 0) == 0 && radio_audio_capture_start(gid, 1, 1) == 0);
|
||||
REQUIRE(radio_audio_vad_mode());
|
||||
int16_t speech[1920]; for (int16_t& sample : speech) sample = 7000;
|
||||
frames(inst, gid, SOURCE_A); REQUIRE(!audible(pull(gid)));
|
||||
for (int i = 0; i < 6; ++i) REQUIRE(radio_audio_feed_pcm(gid, speech, 1920) == 0);
|
||||
REQUIRE(!radio_audio_transmitting()); /* Заглушённый голос удерживает авто-PTT. */
|
||||
radio_audio_stop(); REQUIRE(radio_audio_start(gid, 0) == 0 && radio_audio_capture_start(gid, 1, 1) == 0);
|
||||
for (int i = 0; i < 6; ++i) REQUIRE(radio_audio_feed_pcm(gid, speech, 1920) == 0);
|
||||
REQUIRE(radio_audio_transmitting()); /* При свободном эфире та же речь запускает передачу. */
|
||||
#endif
|
||||
radio_audio_stop(); radio_audio_destroy(inst);
|
||||
utun_instance_destroy(inst);
|
||||
inst = utun_instance_create_from_str(ua, config.c_str()); REQUIRE(inst); inst->config->global.db_sync_enabled = 0;
|
||||
REQUIRE(utun_core_start(inst) == 0 && chat_service_start(inst) == 0 && flag(inst, channel, SOURCE_A));
|
||||
radio_set_active(inst, gid, 1); REQUIRE(radio_member_muted(inst, gid, SOURCE_A));
|
||||
REQUIRE(radio_audio_init(inst) == 0 && radio_audio_start(gid, 0) == 0);
|
||||
frames(inst, gid, SOURCE_A); REQUIRE(!audible(pull(gid)));
|
||||
frames(inst, gid, SOURCE_B); REQUIRE(audible(pull(gid)));
|
||||
radio_audio_stop(); radio_audio_destroy(inst);
|
||||
sql = "UPDATE \"peers_" + channel + "\" SET local_nick='local alias' WHERE node_id=42";
|
||||
REQUIRE(sqlite3_exec(inst->topo_sqlite_db, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
sql = "ALTER TABLE \"peers_" + channel + "\" DROP COLUMN radio_muted";
|
||||
REQUIRE(sqlite3_exec(inst->topo_sqlite_db, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
utun_instance_destroy(inst);
|
||||
inst = utun_instance_create_from_str(ua, config.c_str()); REQUIRE(inst); inst->config->global.db_sync_enabled = 0;
|
||||
REQUIRE(utun_core_start(inst) == 0 && chat_service_start(inst) == 0 && !flag(inst, channel, SOURCE_A));
|
||||
uint8_t member[CHAT_MEMBER_DISPLAY_SIZE]; REQUIRE(chat_core_get_single_member(inst, channel.c_str(), SOURCE_A, member) == 0);
|
||||
REQUIRE(!std::strcmp(reinterpret_cast<const char*>(member + 15), "local alias"));
|
||||
utun_instance_destroy(inst); uasync_destroy(ua, 0);
|
||||
std::puts("PASS: audio mute/unmute, other source PCM, events, DB errors, channel isolation, VAD busy, member update, restart, schema upgrade");
|
||||
return 0;
|
||||
}
|
||||
@@ -2143,5 +2143,8 @@ Directories: %5</translation>
|
||||
<message><source>Message is not available</source><translation>Message is not available</translation></message>
|
||||
<message><source>Invalid poll or publication failed</source><translation>Invalid poll or publication failed</translation></message>
|
||||
<message><source>Cannot save state</source><translation>Cannot save state</translation></message>
|
||||
<message><source>Mute this member in radio</source><translation>Mute this member in radio</translation></message>
|
||||
<message><source>Listen to this member in radio</source><translation>Listen to this member in radio</translation></message>
|
||||
<message><source>Cannot change radio listening setting</source><translation>Cannot change radio listening setting</translation></message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -2149,5 +2149,8 @@ Directories: %5</source>
|
||||
<message><source>Message is not available</source><translation>Сообщение ещё не получено</translation></message>
|
||||
<message><source>Invalid poll or publication failed</source><translation>Некорректный опрос или ошибка публикации</translation></message>
|
||||
<message><source>Cannot save state</source><translation>Не удалось сохранить ответ или реакцию</translation></message>
|
||||
<message><source>Mute this member in radio</source><translation>Не слушать этого участника в рации</translation></message>
|
||||
<message><source>Listen to this member in radio</source><translation>Слушать этого участника в рации</translation></message>
|
||||
<message><source>Cannot change radio listening setting</source><translation>Не удалось изменить настройку прослушивания рации</translation></message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -246,6 +246,8 @@ void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb);
|
||||
/* Опросы/реакции: адресное изменение и коррелированный ответ команды, в GUI-потоке. */
|
||||
void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb);
|
||||
void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb);
|
||||
void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn cb);
|
||||
int gui_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id);
|
||||
/* CHAT_EVT_ATTACHMENT_PREPARATION: target + job UUID + state. */
|
||||
void gui_bridge_set_attachment_preparation_cb(gui_dm_messages_fn cb);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ extern "C" {
|
||||
#include "../../../src/routing_layer/topo_node.h"
|
||||
#include "../../../src/routing_layer/topo_group.h"
|
||||
#include "../../../src/member_files/protocol.h"
|
||||
#include "../../../src/chat/chat_member.h"
|
||||
}
|
||||
|
||||
/* ── Внутренний объект-приёмник в GUI-потоке ── */
|
||||
@@ -72,6 +73,7 @@ static gui_dm_messages_fn g_avatar_result_cb = nullptr;
|
||||
static gui_dm_messages_fn g_group_avatar_result_cb = nullptr;
|
||||
static gui_dm_messages_fn g_feedback_changed_cb = nullptr;
|
||||
static gui_dm_messages_fn g_feedback_result_cb = nullptr;
|
||||
static gui_dm_messages_fn g_radio_mute_result_cb = nullptr;
|
||||
static gui_network_changed_fn g_network_changed_cb = nullptr;
|
||||
static struct UASYNC* g_ua = nullptr;
|
||||
static struct UTUN_INSTANCE* g_inst = nullptr;
|
||||
@@ -86,6 +88,9 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
|
||||
eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33))
|
||||
g_network_changed_cb(eventType);
|
||||
switch (eventType) {
|
||||
case CHAT_EVT_RADIO_MUTE_RESULT:
|
||||
if (g_radio_mute_result_cb) g_radio_mute_result_cb(d, dlen);
|
||||
break;
|
||||
case CHAT_EVT_FEEDBACK_CHANGED:
|
||||
if (g_feedback_changed_cb) g_feedback_changed_cb(d, dlen);
|
||||
break;
|
||||
@@ -591,6 +596,22 @@ void gui_bridge_set_avatar_result_cb(gui_dm_messages_fn cb) { g_avatar_result_cb
|
||||
void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb) { g_group_avatar_result_cb = cb; }
|
||||
void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb) { g_feedback_changed_cb = cb; }
|
||||
void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb) { g_feedback_result_cb = cb; }
|
||||
void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn cb) { g_radio_mute_result_cb = cb; }
|
||||
|
||||
int gui_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id) {
|
||||
if (!g_inst || !uasync_is_running(g_ua) || !channel_id || !channel_id[0] || strlen(channel_id) >= 64 || !node_id) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: GUI mute request rejected: core unavailable or invalid member"); return -1;
|
||||
}
|
||||
auto* req = static_cast<chat_member_radio_mute_req*>(u_calloc(1, sizeof(chat_member_radio_mute_req)));
|
||||
auto* post = static_cast<posted_task*>(u_calloc(1, sizeof(posted_task)));
|
||||
if (!req || !post) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: GUI mute request allocation failed"); u_free(req); u_free(post); return -1;
|
||||
}
|
||||
req->inst = g_inst; req->node_id = node_id; strcpy(req->ch_id, channel_id);
|
||||
post->callback = chat_core_toggle_member_radio_mute_trampoline; post->arg = req;
|
||||
uasync_post_reserved(g_ua, post);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) {
|
||||
g_dm_conv_updated_cb = cb;
|
||||
|
||||
Reference in new issue
Block a user