Browse Source

Add local per-member radio mute controls on desktop and Android

master
evgeny 4 days ago
parent
commit
7e19cbaf3a
  1. 1
      src/chat/chat_channel.c
  2. 1
      src/chat/chat_event.c
  3. 1
      src/chat/chat_event.h
  4. 36
      src/chat/chat_member.c
  5. 8
      src/chat/chat_member.h
  6. 2
      src/chat/member_sync.c
  7. 109
      src/radio/radio.c
  8. 13
      src/radio/radio.h
  9. 25
      src/radio/radio_audio.c
  10. 21
      src/radio/radio_headless.c
  11. 32
      src/routing_layer/topo_node_sqlite.c
  12. 3
      src/routing_layer/topo_node_sqlite.h
  13. 22
      tests/test_radio.c
  14. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  15. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  16. 21
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  17. 24
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  18. 3
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  19. 3
      tools/chatgui-android/app/src/main/res/values/strings.xml
  20. 24
      tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRadioMuteTest.kt
  21. 38
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  22. 1
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  23. 16
      tools/chatgui-android/tests/test_member_radio.kt
  24. 11
      tools/chatgui/CMakeLists.txt
  25. 54
      tools/chatgui/src/accountdelegate.cpp
  26. 5
      tools/chatgui/src/accountdelegate.h
  27. 13
      tools/chatgui/src/accountlist.cpp
  28. 39
      tools/chatgui/tests/test_invite_links.cpp
  29. 201
      tools/chatgui/tests/test_radio_member_mute.cpp
  30. 3
      tools/chatgui/translations/chatgui_en.ts
  31. 3
      tools/chatgui/translations/chatgui_ru.ts
  32. 2
      tools/chatgui/transport/gui_bridge.h
  33. 21
      tools/chatgui/transport/gui_bridge_impl.cpp

1
src/chat/chat_channel.c

@ -64,6 +64,7 @@ void chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_i
if (!cc || !cc->initialized || !ch_id || !ch_id[0]) return;
if (chat_feedback_ensure_channel(inst, ch_id) < 0) return;
if (si_find(inst, ch_id)) return;
if (topo_node_sqlite_member_local_init(cc->db, ch_id) < 0) return;
char tbl_msg[80]; msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg));

1
src/chat/chat_event.c

@ -50,6 +50,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data,
[43]="RADIO_TALK",
[44]="CALL_CONNECTION",
[45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE",
[55]="RADIO_MUTE_RESULT",
};
const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?";
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len);

1
src/chat/chat_event.h

@ -76,6 +76,7 @@ extern "C" {
#define CHAT_EVT_GROUP_AVATAR_RESULT 52 /* request_id/group:8LE, failed:1; результат публикации аватарки группы */
#define CHAT_EVT_FEEDBACK_CHANGED 53 /* [channel_len:1][channel][message_id:32][kind:1] — состояние после commit */
#define CHAT_EVT_FEEDBACK_RESULT 54 /* [request_id:8LE][success:1][JSON] — результат команды интерфейса */
#define CHAT_EVT_RADIO_MUTE_RESULT 55 /* [group_id:8LE][node_id:8LE][success:1] — локальный переключатель рации */
typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len);

36
src/chat/chat_member.c

@ -13,6 +13,7 @@
#include "../utun_instance.h"
#include "../routing_layer/topo_group.h"
#include "../radio/radio.h"
#include "../../lib/mem.h"
#include "../../lib/ll_queue.h"
#include "../../lib/json_flat.h"
@ -148,7 +149,7 @@ int chat_core_get_member_list(struct UTUN_INSTANCE* inst, const char* ch_id, uin
snprintf(sql, sizeof(sql),
"SELECT p.node_id, COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig"
" COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig,p.radio_muted"
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" ORDER BY p.node_id ASC", peers_tbl);
@ -179,6 +180,7 @@ int chat_core_get_member_list(struct UTUN_INSTANCE* inst, const char* ch_id, uin
m->storage_role = member_storage_role(adm_tags, st, 7, 8, sqlite3_column_int(st, 5));
m->is_self = (uint8_t)(m->node_id == my_id ? 1 : 0);
m->flags = parse_adm_tags_flags(adm_tags) | member_radio_flag(inst, ch_id, m->node_id);
if (sqlite3_column_int(st, 9)) m->flags |= CHAT_MEMBER_FLAG_RADIO_MUTED;
if (sqlite3_column_int(st, 5)) m->flags |= CHAT_MEMBER_FLAG_DELETED;
m->rtt = 0xFFFF;
m->nat_type = 0xFF;
@ -277,7 +279,7 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u
snprintf(sql, sizeof(sql),
"SELECT COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig"
" COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig,p.radio_muted"
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" WHERE p.node_id=?", peers_tbl);
@ -296,6 +298,7 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u
m.storage_role = member_storage_role(adm_tags, st, 6, 7, sqlite3_column_int(st, 4));
m.is_self = (uint8_t)(node_id == my_id ? 1 : 0);
m.flags = parse_adm_tags_flags(adm_tags) | member_radio_flag(inst, ch_id, node_id);
if (sqlite3_column_int(st, 8)) m.flags |= CHAT_MEMBER_FLAG_RADIO_MUTED;
if (sqlite3_column_int(st, 4)) m.flags |= CHAT_MEMBER_FLAG_DELETED;
m.rtt = 0xFFFF;
m.nat_type = 0xFF;
@ -333,6 +336,35 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u
return 0;
}
int chat_core_toggle_member_radio_mute(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id) {
uint8_t member[CHAT_MEMBER_DISPLAY_SIZE];
if (!ch_id || !ch_id[0] || strlen(ch_id) >= 64 || chat_core_get_single_member(inst, ch_id, node_id, member) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio: mute toggle member not found node=%016llx", (unsigned long long)node_id); return -1;
}
int muted = !(member[10] & CHAT_MEMBER_FLAG_RADIO_MUTED);
if (radio_set_member_muted(inst, strtoull(ch_id, NULL, 10), node_id, muted) < 0) return -1;
if (chat_core_get_single_member(inst, ch_id, node_id, member) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: cannot publish local mute ch=%s node=%016llx",
ch_id, (unsigned long long)node_id); return -1;
}
size_t cl = strlen(ch_id);
uint8_t evt[1 + 64 + 1 + CHAT_MEMBER_DISPLAY_SIZE];
evt[0] = (uint8_t)cl; memcpy(evt + 1, ch_id, cl); evt[1 + cl] = 1;
memcpy(evt + 2 + cl, member, sizeof(member));
chat_event_post(inst, CHAT_EVT_MEMBER_UPDATED, evt, (int)(cl + 2 + sizeof(member)));
return 0;
}
void chat_core_toggle_member_radio_mute_trampoline(void* arg) {
struct chat_member_radio_mute_req* req = arg;
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: null member mute request"); return; }
int rc = chat_core_toggle_member_radio_mute(req->inst, req->ch_id, req->node_id);
uint8_t result[17];
mf_put64(result, strtoull(req->ch_id, NULL, 10)); mf_put64(result + 8, req->node_id); result[16] = rc == 0;
chat_event_post(req->inst, CHAT_EVT_RADIO_MUTE_RESULT, result, sizeof(result));
u_free(req);
}
/* ─── трамплины для gui_bridge ─── */
void chat_core_request_member_list_trampoline(void* arg) {

8
src/chat/chat_member.h

@ -9,7 +9,7 @@
* [0-7] node_id uint64_t LE
* [8] online uint8_t
* [9] is_self uint8_t
* [10] flags uint8_t 0x01=supernode 0x02=admin 0x04=moder 0x08=deleted 0x10=radio
* [10] flags uint8_t 0x01=supernode 0x02=admin 0x04=moder 0x08=deleted 0x10=radio 0x20=local radio mute
* [11-12] rtt uint16_t LE (ms, 0xFFFF=нет данных)
* [13] nat_type uint8_t ADDR_TYPE_*
* [14] storage uint8_t
@ -34,6 +34,7 @@ extern "C" {
#define CHAT_MEMBER_FLAG_MODER 0x04
#define CHAT_MEMBER_FLAG_DELETED 0x08
#define CHAT_MEMBER_FLAG_RADIO 0x10
#define CHAT_MEMBER_FLAG_RADIO_MUTED 0x20 /* локальное отключение голоса участника */
#define CHAT_MEMBER_DISPLAY_SIZE 158
@ -74,6 +75,11 @@ void chat_core_request_member_list_trampoline(void* arg);
* arg = struct chat_member_list_req*. Результат: CHAT_EVT_MEMBER_UPDATED с RTT-обновлениями. */
void chat_core_request_member_rtt_trampoline(void* arg);
/* Локальный переключатель рации; каждый запрос меняет актуальное состояние в uasync. */
int chat_core_toggle_member_radio_mute(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id);
struct chat_member_radio_mute_req { struct UTUN_INSTANCE* inst; char ch_id[64]; uint64_t node_id; };
void chat_core_toggle_member_radio_mute_trampoline(void* arg);
/* Инициализация: регистрирует node_props_changed callback для рассылки MEMBER_UPDATED. */
void chat_member_init(struct UTUN_INSTANCE* inst);

2
src/chat/member_sync.c

@ -3,6 +3,7 @@
#include "../routing_layer/topo_node_sqlite.h"
#include "../routing_layer/topo_group.h"
#include "../radio/radio.h"
#include "chat_core.h"
#include "chat_core_priv.h"
#include "../utun_instance.h"
@ -884,6 +885,7 @@ int member_sync_verify_and_purge(struct UTUN_INSTANCE* inst, const char* ch_id)
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: verify_and_purge — PURGE broken record nid=0x%016llx ns=%s",
MS_ID, (unsigned long long)nid, ch_id);
topo_node_sqlite_member_del(db, ch_id, nid);
radio_forget_member_mute(inst, strtoull(ch_id, NULL, 10), nid);
merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, ch_id, nid);
/* пассивное удаление: если узел подключён напрямую — убрать из CHAT-группы */
if (inst->topo_groups) {

109
src/radio/radio.c

@ -11,6 +11,7 @@
// Все функции — в uasync-потоке.
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include "../lib/platform_compat.h"
#include "../lib/debug_config.h"
@ -21,6 +22,7 @@
#include "../transport_layer/etcp.h"
#include "../routing_layer/topo_node.h"
#include "../routing_layer/topo_group.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "../chat/chat_event.h"
#include "radio.h"
#include "radio_proto.h"
@ -57,11 +59,16 @@ struct radio_ctx {
void* talker_timer; /* периодический sweep зависших говорящих (self-rearming) */
struct radio_seen seen[RADIO_DEDUP_MAX]; /* кэш дедупликации */
int seen_count;
uint64_t* muted_members; /* локальный кэш, принадлежит uasync-потоку */
int muted_count;
int muted_loaded;
};
struct radio_instance {
radio_frame_fn frame_fn;
void* frame_arg;
radio_mute_fn mute_fn;
void* mute_arg;
};
static void radio_talker_timer_cb(void* arg);
@ -99,6 +106,7 @@ void radio_destroy(struct TOPO_GROUP* group) {
RADIO_ID, (unsigned long long)group->group_id,
ctx->c_tx_frames, ctx->c_rx_frames, ctx->c_fwd_frames,
ctx->c_loop_dropped, ctx->c_nosub_dropped, ctx->c_bad_dropped, ctx->c_dup_dropped);
u_free(ctx->muted_members);
u_free(ctx);
group->radio = NULL;
}
@ -439,12 +447,113 @@ void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg
ri->frame_arg = arg;
}
void radio_set_mute_cb(struct UTUN_INSTANCE* inst, radio_mute_fn fn, void* arg) {
struct radio_instance* ri = radio_inst(inst);
if (!ri) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: mute callback without instance"); return; }
ri->mute_fn = fn; ri->mute_arg = arg;
}
static int radio_load_muted_members(struct radio_ctx* ctx) {
struct UTUN_INSTANCE* inst = ctx->group->instance;
sqlite3* db = inst->topo_sqlite_db;
char sql[160];
snprintf(sql, sizeof(sql), "SELECT node_id FROM \"peers_%llu\" WHERE radio_muted<>0",
(unsigned long long)ctx->group->group_id);
sqlite3_stmt* st = NULL;
if (!db || sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: load muted members grp=%016llx: %s",
(unsigned long long)ctx->group->group_id, db ? sqlite3_errmsg(db) : "no database"); return -1;
}
uint64_t* members = NULL;
int count = 0, rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
uint64_t* next = u_realloc(members, (size_t)(count + 1) * sizeof(*members));
if (!next) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: muted member cache allocation failed");
u_free(members); sqlite3_finalize(st); return -1;
}
members = next; members[count++] = (uint64_t)sqlite3_column_int64(st, 0);
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read muted members: %s", sqlite3_errmsg(db)); u_free(members); return -1;
}
u_free(ctx->muted_members); ctx->muted_members = members; ctx->muted_count = count; ctx->muted_loaded = 1;
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio: loaded local mutes grp=%016llx count=%d",
(unsigned long long)ctx->group->group_id, count);
return 0;
}
int radio_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id) {
if (!inst || !inst->topo_groups) return 0;
struct radio_ctx* ctx = radio_of(topo_groups_find(inst->topo_groups, group_id));
if (!ctx) return 0;
for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) return 1;
return 0;
}
void radio_forget_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id) {
if (!inst || !inst->topo_groups) return;
struct radio_ctx* ctx = radio_of(topo_groups_find(inst->topo_groups, group_id));
if (!ctx) return;
for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) {
ctx->muted_members[i] = ctx->muted_members[--ctx->muted_count];
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio: forget removed member mute grp=%016llx node=%016llx",
(unsigned long long)group_id, (unsigned long long)node_id);
return;
}
}
int radio_set_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted) {
struct TOPO_GROUP* group = inst && inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL;
struct radio_ctx* ctx = radio_of(group);
if (!ctx || group->group_type != TOPO_GROUP_TYPE_CHAT || !node_id || node_id == inst->node_id || (muted != 0 && muted != 1)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio: invalid local mute grp=%016llx node=%016llx muted=%d",
(unsigned long long)group_id, (unsigned long long)node_id, muted); return -1;
}
if (!ctx->muted_loaded && radio_load_muted_members(ctx) < 0) return -1;
int slot = -1;
for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) { slot = i; break; }
/* Выделить память до записи БД: после commit применение не может отказать. */
if (muted && slot < 0) {
uint64_t* next = u_realloc(ctx->muted_members, (size_t)(ctx->muted_count + 1) * sizeof(*next));
if (!next) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: grow muted member cache failed"); return -1; }
ctx->muted_members = next;
}
sqlite3* db = inst->topo_sqlite_db;
char sql[160];
snprintf(sql, sizeof(sql), "UPDATE \"peers_%llu\" SET radio_muted=? WHERE node_id=?", (unsigned long long)group_id);
sqlite3_stmt* st = NULL;
if (!db || sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: prepare local mute: %s", db ? sqlite3_errmsg(db) : "no database"); return -1;
}
sqlite3_bind_int(st, 1, muted); sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id);
int rc = sqlite3_step(st); sqlite3_finalize(st);
if (rc != SQLITE_DONE || sqlite3_changes(db) != 1) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: save local mute grp=%016llx node=%016llx rc=%d changed=%d: %s",
(unsigned long long)group_id, (unsigned long long)node_id, rc, sqlite3_changes(db), sqlite3_errmsg(db)); return -1;
}
if (muted && slot < 0) ctx->muted_members[ctx->muted_count++] = node_id;
else if (!muted && slot >= 0) ctx->muted_members[slot] = ctx->muted_members[--ctx->muted_count];
struct radio_instance* ri = radio_inst(inst);
if (ri && ri->mute_fn) ri->mute_fn(inst, group_id, node_id, muted, ri->mute_arg);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio: local member mute grp=%016llx node=%016llx muted=%d",
(unsigned long long)group_id, (unsigned long long)node_id, muted);
return 0;
}
/* ── подписка (вкл/выкл прослушивание) ── */
void radio_set_active(struct UTUN_INSTANCE* inst, uint64_t group_id, int on) {
if (!inst || !inst->topo_groups) return;
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id);
if (!group) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: set_active group 0x%016llx not found", RADIO_ID, (unsigned long long)group_id); return; }
if (on && group->group_type == TOPO_GROUP_TYPE_CHAT) {
struct radio_ctx* ctx = radio_of(group);
if (!ctx || radio_load_muted_members(ctx) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: subscription rejected because local mutes could not be loaded"); return;
}
}
if (!on) {
/* выключение прослушивания: снять всех активных говорящих — индикация должна погаснуть
* сразу, а не дожидаться FIN/таймаута */

13
src/radio/radio.h

@ -2,8 +2,8 @@
//
// Флудит Opus-кадры всем подписчикам канала (узлы с TOPO_FLAG_RADIO в NODEINFO).
// Передача полудуплексная: радио включается на прослушивание, PTT — на передачу.
// Все функции — в uasync-потоке (кроме radio_set_frame_cb, который ставит
// обработчик приёма: его вызывает radio_audio в момент init).
// Все функции — в uasync-потоке, кроме регистрации frame/mute callbacks
// аудио-потребителем при init/destroy.
#ifndef RADIO_H
#define RADIO_H
@ -33,6 +33,15 @@ typedef void (*radio_frame_fn)(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint8_t fin, const uint8_t* opus, int len, void* arg);
void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg);
/* Локальные настройки участников: только uasync, без изменения подписки и пересылки. */
int radio_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id);
int radio_set_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted);
/* После удаления записи участника из БД удалить её из локального кэша. */
void radio_forget_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id);
/* Аудио-потребитель очищает накопленный звук источника при изменении настройки. */
typedef void (*radio_mute_fn)(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted, void* arg);
void radio_set_mute_cb(struct UTUN_INSTANCE* inst, radio_mute_fn fn, void* arg);
/* ── передача (PTT), uasync-поток ── */
int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id); /* новая передача, seq=0 */

25
src/radio/radio_audio.c

@ -383,6 +383,21 @@ static int radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, int begin
static void radio_audio_update_transmission(void);
static void radio_audio_flush_tx(void);
static void radio_audio_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted, void* arg) {
(void)arg;
if (!muted) return;
pthread_mutex_lock(&g_mtx);
if (g_inst == inst && g_active && g_group_id == group_id) {
struct radio_source* s = radio_src_find(node_id);
if (s) {
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: clear muted source grp=%016llx src=%016llx stream=%u",
RADIO_AUDIO_ID, (unsigned long long)group_id, (unsigned long long)node_id, s->stream_id);
radio_src_free(s);
}
}
pthread_mutex_unlock(&g_mtx);
}
/* ── lifecycle ── */
int radio_audio_init(struct UTUN_INSTANCE* inst) {
@ -392,6 +407,7 @@ int radio_audio_init(struct UTUN_INSTANCE* inst) {
g_inst = inst; g_tx_pending = g_control_pending = 0; ++g_listen_generation; ++g_instance_generation;
pthread_mutex_unlock(&g_mtx);
radio_set_frame_cb(inst, radio_audio_on_frame, NULL);
radio_set_mute_cb(inst, radio_audio_member_mute, NULL);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: initialized", RADIO_AUDIO_ID);
return 0;
}
@ -399,6 +415,7 @@ int radio_audio_init(struct UTUN_INSTANCE* inst) {
void radio_audio_destroy(struct UTUN_INSTANCE* inst) {
if (!inst || g_inst != inst) return;
radio_set_frame_cb(inst, NULL, NULL);
radio_set_mute_cb(inst, NULL, NULL);
pthread_mutex_lock(&g_mtx);
g_inst = NULL;
++g_instance_generation;
@ -963,13 +980,19 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) {
void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t src_node_id, uint16_t stream_id, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)inst; (void)arg;
(void)arg;
if (!fin && (!opus || len <= 0)) return;
uint64_t now = get_time_tb();
int muted = radio_member_muted(inst, group_id, src_node_id);
pthread_mutex_lock(&g_mtx);
if (g_active && group_id == g_group_id) {
if (len > 0) { g_last_rx_tb = now; g_last_rx_node = src_node_id; } /* busy-сигнал: чужой аудио-кадр */
if (muted) {
if (seq == 0) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: muted burst grp=%016llx src=%016llx stream=%u; RX busy preserved",
RADIO_AUDIO_ID, (unsigned long long)group_id, (unsigned long long)src_node_id, stream_id);
pthread_mutex_unlock(&g_mtx); return;
}
struct radio_source* s = radio_src_acquire(src_node_id, stream_id);
if (s) {
if (len > 0) radio_src_push_frame(s, seq, opus, len, now);

21
src/radio/radio_headless.c

@ -162,6 +162,18 @@ static void rh_src_free(struct rh_source* s) {
s->used = 0; s->ending = 0; s->rd = s->wr = s->count = 0;
}
static void rh_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted, void* arg) {
(void)inst;
struct radio_headless* rh = arg;
if (!rh || !rh->running || rh->group_id != group_id || !muted) return;
struct rh_source* s = rh_src_find(rh, node_id);
if (s) {
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: clear muted source grp=%016llx src=%016llx",
RH_ID, (unsigned long long)group_id, (unsigned long long)node_id);
rh_src_free(s);
}
}
static struct rh_source* rh_src_acquire(struct radio_headless* rh, uint64_t src, uint16_t stream) {
struct rh_source* s = rh_src_find(rh, src);
if (s) {
@ -209,13 +221,17 @@ static void rh_src_push(struct rh_source* s, const int16_t* pcm, int n) {
static void rh_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t src_node_id, uint16_t stream_id, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)seq;
struct radio_headless* rh = (struct radio_headless*)arg;
if (!rh || !rh->running || !opus || len <= 0) return;
/* активный канал: первый кадр фиксирует group_id (узел слушает один канал) */
if (rh->group_id == 0) rh->group_id = group_id;
if (group_id != rh->group_id) return;
if (radio_member_muted(inst, group_id, src_node_id)) {
if (seq == 0) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: muted burst grp=%016llx src=%016llx stream=%u",
RH_ID, (unsigned long long)group_id, (unsigned long long)src_node_id, stream_id);
return;
}
struct rh_source* s = rh_src_acquire(rh, src_node_id, stream_id);
if (!s) return;
@ -226,7 +242,6 @@ static void rh_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
rh->c_dec_frames++;
s->last_frame_tb = get_time_tb();
if (fin) s->ending = 1;
(void)inst;
}
/* ── периодический микшер: sum источников → PCM клиентам ── */
@ -460,6 +475,7 @@ int radio_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst,
if (!rh->listen_sock_id) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; }
radio_set_frame_cb(inst, rh_frame_cb, rh);
radio_set_mute_cb(inst, rh_member_mute, rh);
uasync_set_timeout(ua, 5000, rh, rh_cleanup, "rh_cleanup");
rh->mix_timer = uasync_set_timeout(ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix");
@ -473,6 +489,7 @@ void radio_headless_destroy(struct UTUN_INSTANCE* inst) {
if (!rh || !rh->running) return;
rh->running = 0;
radio_set_frame_cb(inst, NULL, NULL);
radio_set_mute_cb(inst, NULL, NULL);
if (rh->mix_timer && rh->ua) { uasync_cancel_timeout(rh->ua, rh->mix_timer); rh->mix_timer = NULL; }
if (rh->listen_sock_id && rh->ua) uasync_remove_socket_t(rh->ua, rh->listen_fd);
if (rh->listen_fd != SOCKET_INVALID) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; }

32
src/routing_layer/topo_node_sqlite.c

@ -380,6 +380,35 @@ int topo_node_sqlite_init(sqlite3* db) {
}
int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id) {
if (!db || !channel_id || !channel_id[0]) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: invalid local member schema arguments"); return -1;
}
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
char sql[160]; snprintf(sql, sizeof(sql), "PRAGMA table_info(\"%s\")", peers_tbl);
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: inspect member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1;
}
int has_radio_muted = 0, rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
const char* column = (const char*)sqlite3_column_text(st, 1);
if (column && !strcmp(column, "radio_muted")) has_radio_muted = 1;
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1;
}
if (!has_radio_muted) {
snprintf(sql, sizeof(sql), "ALTER TABLE \"%s\" ADD COLUMN radio_muted INTEGER NOT NULL DEFAULT 0", peers_tbl);
if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: extend member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio: added local member mute setting ch=%s", channel_id);
}
return 0;
}
int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
const char* name, uint64_t owner_node_id,
const uint8_t* x25519_pub, const uint8_t* x25519_priv,
@ -416,6 +445,7 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
" storage INTEGER NOT NULL DEFAULT 0," /* 1 = приоритетный хранитель аттачей */
" deleted INTEGER NOT NULL DEFAULT 0," /* 1 = аккаунт удалён владельцем (не отображаем, не подключаемся) */
" local_nick TEXT," /* локальный ник (GUI only, не синхронизируется) */
" radio_muted INTEGER NOT NULL DEFAULT 0," /* локальное отключение голоса участника */
" source INTEGER NOT NULL DEFAULT 0" /* 0=полная merkle-запись 1=упрощённый плейсхолдер topo_group
(не участвует в merkle-хеше, дополняется полной записью) */
")";
@ -430,6 +460,8 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
u_free(ddl);
}
if (topo_node_sqlite_member_local_init(db, channel_id) < 0) return -1;
int idx_sz = snprintf(NULL, 0,
"CREATE INDEX IF NOT EXISTS \"idx_%s_type_rtt\" ON \"%s\" (node_type, node_RTT)", peers_tbl, peers_tbl);
char* idx_sql = u_malloc(idx_sz + 1);

3
src/routing_layer/topo_node_sqlite.h

@ -41,6 +41,9 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
const uint8_t* ed25519_pub, const uint8_t* ed25519_priv,
const uint8_t* signature);
/* Подготовить локальные поля участника при создании и загрузке канала. */
int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id);
/* Полное локальное удаление канала: DROP peers_<id>/msg_<id>, DELETE channels,
* Удаляются feedback_<channel_id> и merkle_tree_hash всех datasets этого канала. 0=ок. */
int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id);

22
tests/test_radio.c

@ -56,6 +56,7 @@ enum rad_phase {
P_WAIT_FLAGS,
P_TALK1,
P_WAIT_RX1,
P_WAIT_MUTED_RELAY,
P_RADIO_OFF_C,
P_WAIT_OFF_C,
P_TALK2,
@ -266,7 +267,8 @@ static int node_radio(struct UTUN_INSTANCE* inst, uint64_t nid) {
static void rx_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t src, uint16_t stream, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)inst; (void)group_id; (void)src; (void)opus; (void)len;
(void)opus; (void)len;
if (radio_member_muted(inst, group_id, src)) return; /* локальный аудио-потребитель */
struct rx_evt* rx = (struct rx_evt*)arg;
if (fin) {
if (rx->active && stream == rx->stream) rx->fin_seen = 1;
@ -400,8 +402,22 @@ static void rad_tick(void* arg) {
break;
case P_WAIT_RX1:
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok
&& g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok)
&& g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) {
if (chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || !radio_member_muted(B, gid, na) ||
radio_member_muted(C, gid, na)) { t->result = 2; uasync_stop(t->ua); return; }
memset(g_rx, 0, sizeof(g_rx)); do_talk(A);
t->phase = P_WAIT_MUTED_RELAY;
}
break;
case P_WAIT_MUTED_RELAY:
if (g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) {
if (g_rx[IDX_B].frames || g_rx[IDX_B].fin_seen || !radio_is_active(B, gid) ||
chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || radio_member_muted(B, gid, na)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: locally muted relay blocked downstream or local mute failed");
t->result = 2; uasync_stop(t->ua); return;
}
t->phase = P_RADIO_OFF_C;
}
break;
case P_RADIO_OFF_C:
radio_set_active(C, gid, 0);
@ -492,7 +508,7 @@ int main(int argc, char** argv) {
printf("=== test_radio ===\n"); fflush(stdout);
TEST("radio: member snapshots/events + relay + off + withdraw"); {
TEST("radio: member snapshots/events + local mute relay + off + withdraw"); {
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; }

4
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -74,6 +74,7 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean =
val isAdmin: Boolean = false, val isSuper: Boolean = false,
val isModer: Boolean = false, val isStorage: Boolean = false,
val radioActive: Boolean = false,
val radioMuted: Boolean = false,
val isVerified: Boolean = false, val isDeleted: Boolean = false,
val nodeType: Int = 0,
val rtt: Int = 0, val isSelf: Boolean = false,
@ -196,7 +197,7 @@ fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): Ch
name = name, online = online != 0,
isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0,
isModer = (flags and 4) != 0, isStorage = storage,
isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0,
isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, radioMuted = (flags and 0x20) != 0,
rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf,
avatarServer = avatarServer, avatarFile = avatarFile, storageRole = storageRole
)
@ -442,6 +443,7 @@ class ChatRepository {
isSuper = obj.optInt("sp", 0) != 0,
isModer = obj.optInt("md", 0) != 0,
isStorage = obj.optInt("st", 0) != 0,
radioMuted = obj.optInt("rm", 0) != 0,
isVerified = obj.optInt("vd", 0) != 0,
isDeleted = obj.optInt("dl", 0) != 0,
nodeType = obj.optInt("tp", 0),

2
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -137,6 +137,7 @@ object NativeLib {
fun cancelAvatar(id: Long): Boolean = nativeAvatarOperation(7, id, 0, 0, 0, null, null, null)
fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}"
fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) }
fun toggleMemberRadioMute(channelId: String, nodeId: Long): Boolean = nativeToggleMemberRadioMute(channelId, nodeId)
fun isChannelOwner(channelId: String): Boolean = nativeIsChannelOwner(channelId)
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) {
@ -266,6 +267,7 @@ object NativeLib {
remote: String?, local: String?, data: ByteArray?): Boolean
private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String?
private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String)
private external fun nativeToggleMemberRadioMute(channelId: String, nodeId: Long): Boolean
private external fun nativeIsChannelOwner(channelId: String): Boolean
private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean)

21
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -68,6 +69,7 @@ fun MemberListScreen(
val memberLinks by viewModel.memberLinks.collectAsState()
val localSockets by viewModel.localSockets.collectAsState()
val channelOwner by viewModel.channelOwner.collectAsState()
val radioMuteFailed by viewModel.radioMuteFailed.collectAsState()
LaunchedEffect(selectedMember?.nodeId) {
val nodeId = selectedMember?.nodeId
@ -97,7 +99,8 @@ fun MemberListScreen(
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) },
onInvite = { viewModel.showPmInvite(m.nodeId, m.name, channel.id) }
onInvite = { viewModel.showPmInvite(m.nodeId, m.name, channel.id) },
onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, m.nodeId) }
)
}
}
@ -131,6 +134,14 @@ fun MemberListScreen(
}
}
if (radioMuteFailed) {
AlertDialog(
onDismissRequest = viewModel::dismissRadioMuteError,
text = { Text(stringResource(R.string.radio_member_mute_failed)) },
confirmButton = { TextButton(onClick = viewModel::dismissRadioMuteError) { Text(stringResource(android.R.string.ok)) } }
)
}
if (showEditDialog && editMember != null) {
MemberEditDialog(
member = editMember!!,
@ -159,7 +170,8 @@ private fun MemberListItem(
onClick: () -> Unit,
onCall: () -> Unit,
onMessage: () -> Unit,
onInvite: () -> Unit
onInvite: () -> Unit,
onRadioMute: () -> Unit
) {
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
@ -239,6 +251,11 @@ private fun MemberListItem(
}
if (!member.isSelf) {
IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }) {
Icon(Icons.AutoMirrored.Filled.VolumeUp,
stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute),
tint = if (member.radioMuted) Color(0xFFEF5350) else Color(0xFF4CAF50))
}
IconButton(onClick = onInvite) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) }
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) }
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) }

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

@ -118,6 +118,8 @@ class ChatViewModel : ViewModel() {
val reactionCatalog: StateFlow<List<ReactionState>> = _reactionCatalog
private val _interactionFailed = MutableStateFlow(false)
val interactionFailed: StateFlow<Boolean> = _interactionFailed
private val _radioMuteFailed = MutableStateFlow(false)
val radioMuteFailed: StateFlow<Boolean> = _radioMuteFailed
private var nextInteractionId = 0L
private val _currentChannel = MutableStateFlow<Channel?>(null)
@ -136,6 +138,7 @@ class ChatViewModel : ViewModel() {
val members: StateFlow<List<ChatMember>> = _members
/* Live-подписки по каналам: JSON-refresh не должен стирать более свежие BGP-события. Только UI-поток. */
private val memberRadios = mutableMapOf<String, Map<Long, Boolean>>()
private val memberRadioMutes = mutableMapOf<String, Map<Long, Boolean>>()
private val _channelOwner = MutableStateFlow(false)
val channelOwner: StateFlow<Boolean> = _channelOwner
@ -292,6 +295,12 @@ class ChatViewModel : ViewModel() {
_dmConversations.value = _dmConversations.value.map { it.copy(online = false) }
} else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute()
when (type) {
55 -> { /* RADIO_MUTE_RESULT: group_id:8LE, node_id:8LE, success. */
if (data == null || data.size != 17) {
LogManager.addLog("ERROR", "Radio", "Invalid member mute result"); _radioMuteFailed.value = true; return
}
if (data[16].toInt() == 0) _radioMuteFailed.value = true
}
53 -> { /* FEEDBACK_CHANGED: channel, message_id, kind. */
if (data == null || data.size < 34) return
val size = data[0].toInt() and 255
@ -394,6 +403,7 @@ class ChatViewModel : ViewModel() {
12 -> LogManager.addLog("INFO", "VM", "service started")
13 -> {
memberRadios.clear()
memberRadioMutes.clear()
_members.value = _members.value.map { it.copy(radioActive = false) }
_attachmentPreparations.value = emptyMap()
_pmInvite.value?.let { state ->
@ -513,6 +523,7 @@ class ChatViewModel : ViewModel() {
}
val radios = snapshot.mapValues { (_, w) -> (w.flags and 0x10) != 0 }
memberRadios[chId] = radios
memberRadioMutes[chId] = snapshot.mapValues { (_, w) -> (w.flags and 0x20) != 0 }
if (_currentChannel.value?.id == chId)
_members.value = _members.value.map { snapshot[it.nodeId]?.toChatMember(it) ?: it }
LogManager.addLog("DEBUG", "VM", "MEMBER_LIST: ch=$chId members=$count radios=${radios.values.count { it }}")
@ -530,6 +541,7 @@ class ChatViewModel : ViewModel() {
val radio = (w.flags and 0x10) != 0
val previousRadio = memberRadios[chId]?.get(w.nodeId)
memberRadios[chId] = memberRadios[chId].orEmpty() + (w.nodeId to radio)
memberRadioMutes[chId] = memberRadioMutes[chId].orEmpty() + (w.nodeId to ((w.flags and 0x20) != 0))
if (previousRadio != radio)
LogManager.addLog("DEBUG", "VM", "Member radio: ch=$chId node=${w.nodeId} enabled=$radio self=${w.isSelf}")
val cur = _currentChannel.value
@ -554,6 +566,7 @@ class ChatViewModel : ViewModel() {
val nodeId = buf.getLong()
val chId = String(data, 1, cl)
memberRadios[chId] = memberRadios[chId].orEmpty() - nodeId
memberRadioMutes[chId] = memberRadioMutes[chId].orEmpty() - nodeId
val cur = _currentChannel.value
if (cur != null && cur.id == String(data, 1, cl))
_members.value = _members.value.filter { it.nodeId != nodeId }
@ -654,6 +667,7 @@ class ChatViewModel : ViewModel() {
val chId = String(data, 1, cl)
LogManager.addLog("INFO", "VM", "CHANNEL_DELETED ch=$chId")
memberRadios.remove(chId)
memberRadioMutes.remove(chId)
if (radioChannelId == chId) turnRadioOff()
if (_currentChannel.value?.id == chId) {
_currentChannel.value = null
@ -1170,7 +1184,8 @@ class ChatViewModel : ViewModel() {
bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt,
bgpNodes = ns.bgpNodes
) else m
base.copy(connecting = base.nodeId in connecting, radioActive = memberRadios[chId]?.get(base.nodeId) == true)
base.copy(connecting = base.nodeId in connecting, radioActive = memberRadios[chId]?.get(base.nodeId) == true,
radioMuted = memberRadioMutes[chId]?.get(base.nodeId) ?: base.radioMuted)
}
NativeLib.requestMemberList(chId)
}
@ -1210,6 +1225,12 @@ class ChatViewModel : ViewModel() {
refreshSelectedMemberDetail(channelId, nodeId)
}
fun toggleMemberRadioMute(channelId: String, nodeId: Long) {
if (repo == null || !NativeLib.toggleMemberRadioMute(channelId, nodeId)) _radioMuteFailed.value = true
}
fun dismissRadioMuteError() { _radioMuteFailed.value = false }
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) {
val r = repo ?: return
@ -1535,6 +1556,7 @@ class ChatViewModel : ViewModel() {
fun clearState() {
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
memberRadios.clear()
memberRadioMutes.clear()
dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null
_attachmentPreparations.value = emptyMap()
repo?.close()

3
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -45,6 +45,9 @@
<string name="radio_ptt">"Рация PTT"</string>
<string name="radio_overlay_description">"Оверлей PTT рации"</string>
<string name="radio_active">"Рация активна"</string>
<string name="radio_member_mute">Не слушать этого участника в рации</string>
<string name="radio_member_listen">Слушать этого участника в рации</string>
<string name="radio_member_mute_failed">Не удалось изменить настройку прослушивания рации</string>
<string name="radio_notification">"Рация: PTT / автоматическая передача по голосу"</string>
<string name="overlay_stopped">"Плавающая кнопка остановлена"</string>
<string name="ongoing_call">"Текущий звонок"</string>

3
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -45,6 +45,9 @@
<string name="radio_ptt">"PTT radio"</string>
<string name="radio_overlay_description">"Radio PTT overlay"</string>
<string name="radio_active">"Radio is active"</string>
<string name="radio_member_mute">Mute this member in radio</string>
<string name="radio_member_listen">Listen to this member in radio</string>
<string name="radio_member_mute_failed">Cannot change radio listening setting</string>
<string name="radio_notification">"Radio: PTT / automatic voice transmission"</string>
<string name="overlay_stopped">"Floating button stopped"</string>
<string name="ongoing_call">"Ongoing call"</string>

24
tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRadioMuteTest.kt

@ -0,0 +1,24 @@
package com.utun.chat.data
import org.junit.Assert.*
import org.junit.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
class MemberRadioMuteTest {
@Test fun muteUpdatesKeepRadioSubscriptionAndOtherProperties() {
val bytes = ByteArray(MEMBER_WIRE_SIZE)
ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putLong(0, 42)
bytes[8] = 1; bytes[10] = 0x32
val previous = ChatMember(42, "Alice", isVerified = true, directUp = true, nodeType = 4)
val muted = requireNotNull(parseMemberWire(bytes)).toChatMember(previous)
assertTrue(muted.radioMuted); assertTrue(muted.radioActive); assertTrue(muted.isAdmin)
assertTrue(muted.isVerified); assertTrue(muted.directUp); assertEquals(4, muted.nodeType)
bytes[10] = 0x12
val listening = requireNotNull(parseMemberWire(bytes)).toChatMember(muted)
assertFalse(listening.radioMuted); assertTrue(listening.radioActive); assertTrue(listening.isVerified)
bytes[10] = 0x20
val offlineRadio = requireNotNull(parseMemberWire(bytes)).toChatMember(listening)
assertTrue(offlineRadio.radioMuted); assertFalse(offlineRadio.radioActive)
}
}

38
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -705,7 +705,7 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
"SELECT p.node_id, COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0),"
" COALESCE(p.userinfo,''),p.source,length(p.adm_tags_sig),COALESCE(p.deleted,0)"
" COALESCE(p.userinfo,''),p.source,length(p.adm_tags_sig),COALESCE(p.deleted,0),p.radio_muted"
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" ORDER BY p.node_id ASC", peers_tbl);
@ -765,19 +765,19 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
size_t needed = snprintf(NULL, 0,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d}",
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d,\"rm\":%d}",
sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role);
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role, sqlite3_column_int(st, 11));
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d}",
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d,\"rm\":%d}",
sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role);
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role, sqlite3_column_int(st, 11));
u_free(esc_userinfo);
u_free(esc_name);
}
@ -910,6 +910,23 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i
return u_strdup(buf);
}
int utun_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
struct UASYNC* ua = instance_lite_get_uasync();
if (!inst || !uasync_is_running(ua) || !channel_id || !channel_id[0] || strlen(channel_id) >= 64 || !node_id) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: Android mute request rejected: core unavailable or invalid member"); return -1;
}
struct chat_member_radio_mute_req* req = u_calloc(1, sizeof(*req));
struct posted_task* post = u_calloc(1, sizeof(*post));
if (!req || !post) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: Android mute request allocation failed"); u_free(req); u_free(post); return -1;
}
req->inst = 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(ua, post);
return 0;
}
void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
sqlite3* db = chat_core_get_db(inst);
@ -2417,6 +2434,17 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberDetai
return result;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeToggleMemberRadioMute(
JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId) {
(void)thiz;
if (!channelId) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: null Android mute channel"); return JNI_FALSE; }
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
if (!ch) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: Android mute channel conversion failed"); return JNI_FALSE; }
int rc = utun_bridge_toggle_member_radio_mute(ch, (uint64_t)nodeId);
(*env)->ReleaseStringUTFChars(env, channelId, ch);
return rc == 0 ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberNick(
JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, jstring nick) {
(void)thiz;

1
tools/chatgui-android/jni_bridge/android_jni_bridge.h

@ -166,6 +166,7 @@ int utun_bridge_chat_setting_get_int(const char* name, int def);
char* utun_bridge_get_channel_members_json(const char* channel_id);
char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id);
void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick);
int utun_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id);
int utun_bridge_is_channel_owner(const char* channel_id);
void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id,
int admin, int supernode, int moder, int storage,

16
tools/chatgui-android/tests/test_member_radio.kt

@ -1,14 +1,15 @@
import com.utun.chat.data.ChatMember
import com.utun.chat.data.parseMemberWire
import com.utun.chat.data.toChatMember
import com.utun.chat.data.MEMBER_WIRE_SIZE
import java.nio.ByteBuffer
import java.nio.ByteOrder
/* Проверяет реальные классы из app/build/tmp/kotlin-classes/debug после assembleDebug. */
fun main() {
val offset = 7
val data = ByteArray(offset + 79)
ByteBuffer.wrap(data, offset, 79).order(ByteOrder.LITTLE_ENDIAN).apply {
val data = ByteArray(offset + MEMBER_WIRE_SIZE)
ByteBuffer.wrap(data, offset, MEMBER_WIRE_SIZE).order(ByteOrder.LITTLE_ENDIAN).apply {
putLong(42L); put(1); put(1); put(0x12); putShort(250); put(1); put(1)
put("Member".toByteArray())
}
@ -23,15 +24,20 @@ fun main() {
check(enabled.directPresence && enabled.indirectPresence && enabled.bgpPresence)
check(enabled.bgpRtt == 321 && enabled.bgpNodes == 5 && enabled.connecting)
check(enabled.rtt == 250 && enabled.name == "Member")
check(!enabled.radioMuted)
data[offset + 10] = 0x32
val muted = requireNotNull(parseMemberWire(data, offset)).toChatMember(enabled)
check(muted.radioMuted && muted.radioActive && muted.isVerified && muted.directUp)
data[offset + 10] = 2 // Повторное обновление выключает рацию, сохраняя остальные сведения.
val disabled = requireNotNull(parseMemberWire(data, offset)).toChatMember(enabled)
val disabled = requireNotNull(parseMemberWire(data, offset)).toChatMember(muted)
check(!disabled.radioActive && disabled.isVerified && disabled.directUp && disabled.connecting)
check(!disabled.radioMuted)
data[offset + 10] = 8
check(requireNotNull(parseMemberWire(data, offset)).toChatMember(disabled).isDeleted)
check(parseMemberWire(data, -1) == null)
check(parseMemberWire(data, Int.MAX_VALUE) == null)
check(parseMemberWire(data, offset + 1) == null)
check(parseMemberWire(ByteArray(78)) == null)
println("PASS: radio on/off; wire offsets; updates preserve verified/type/transports; deleted flag; truncated records")
check(parseMemberWire(ByteArray(MEMBER_WIRE_SIZE - 1)) == null)
println("PASS: radio subscription/mute; wire offsets; preserved properties; deleted flag; truncated records")
}

11
tools/chatgui/CMakeLists.txt

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

54
tools/chatgui/src/accountdelegate.cpp

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

5
tools/chatgui/src/accountdelegate.h

@ -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
tools/chatgui/src/accountlist.cpp

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

39
tools/chatgui/tests/test_invite_links.cpp

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

201
tools/chatgui/tests/test_radio_member_mute.cpp

@ -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;
}

3
tools/chatgui/translations/chatgui_en.ts

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

3
tools/chatgui/translations/chatgui_ru.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>

2
tools/chatgui/transport/gui_bridge.h

@ -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);

21
tools/chatgui/transport/gui_bridge_impl.cpp

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

Loading…
Cancel
Save