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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user