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 (!cc || !cc->initialized || !ch_id || !ch_id[0]) return;
if (chat_feedback_ensure_channel(inst, ch_id) < 0) return; if (chat_feedback_ensure_channel(inst, ch_id) < 0) return;
if (si_find(inst, ch_id)) 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)); 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", [43]="RADIO_TALK",
[44]="CALL_CONNECTION", [44]="CALL_CONNECTION",
[45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE", [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] : "?"; 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); 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_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_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_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); 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 "../utun_instance.h"
#include "../routing_layer/topo_group.h" #include "../routing_layer/topo_group.h"
#include "../radio/radio.h"
#include "../../lib/mem.h" #include "../../lib/mem.h"
#include "../../lib/ll_queue.h" #include "../../lib/ll_queue.h"
#include "../../lib/json_flat.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), snprintf(sql, sizeof(sql),
"SELECT p.node_id, COALESCE(n.name,'')," "SELECT p.node_id, COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," " 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" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" ORDER BY p.node_id ASC", peers_tbl); " 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->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->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); 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; if (sqlite3_column_int(st, 5)) m->flags |= CHAT_MEMBER_FLAG_DELETED;
m->rtt = 0xFFFF; m->rtt = 0xFFFF;
m->nat_type = 0xFF; 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), snprintf(sql, sizeof(sql),
"SELECT COALESCE(n.name,'')," "SELECT COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," " 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" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" WHERE p.node_id=?", peers_tbl); " 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.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.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); 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; if (sqlite3_column_int(st, 4)) m.flags |= CHAT_MEMBER_FLAG_DELETED;
m.rtt = 0xFFFF; m.rtt = 0xFFFF;
m.nat_type = 0xFF; 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; 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 ─── */ /* ─── трамплины для gui_bridge ─── */
void chat_core_request_member_list_trampoline(void* arg) { 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 * [0-7] node_id uint64_t LE
* [8] online uint8_t * [8] online uint8_t
* [9] is_self 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=нет данных) * [11-12] rtt uint16_t LE (ms, 0xFFFF=нет данных)
* [13] nat_type uint8_t ADDR_TYPE_* * [13] nat_type uint8_t ADDR_TYPE_*
* [14] storage uint8_t * [14] storage uint8_t
@ -34,6 +34,7 @@ extern "C" {
#define CHAT_MEMBER_FLAG_MODER 0x04 #define CHAT_MEMBER_FLAG_MODER 0x04
#define CHAT_MEMBER_FLAG_DELETED 0x08 #define CHAT_MEMBER_FLAG_DELETED 0x08
#define CHAT_MEMBER_FLAG_RADIO 0x10 #define CHAT_MEMBER_FLAG_RADIO 0x10
#define CHAT_MEMBER_FLAG_RADIO_MUTED 0x20 /* локальное отключение голоса участника */
#define CHAT_MEMBER_DISPLAY_SIZE 158 #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-обновлениями. */ * arg = struct chat_member_list_req*. Результат: CHAT_EVT_MEMBER_UPDATED с RTT-обновлениями. */
void chat_core_request_member_rtt_trampoline(void* arg); 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. */ /* Инициализация: регистрирует node_props_changed callback для рассылки MEMBER_UPDATED. */
void chat_member_init(struct UTUN_INSTANCE* inst); 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_node_sqlite.h"
#include "../routing_layer/topo_group.h" #include "../routing_layer/topo_group.h"
#include "../radio/radio.h"
#include "chat_core.h" #include "chat_core.h"
#include "chat_core_priv.h" #include "chat_core_priv.h"
#include "../utun_instance.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", 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); MS_ID, (unsigned long long)nid, ch_id);
topo_node_sqlite_member_del(db, ch_id, nid); 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); merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, ch_id, nid);
/* пассивное удаление: если узел подключён напрямую — убрать из CHAT-группы */ /* пассивное удаление: если узел подключён напрямую — убрать из CHAT-группы */
if (inst->topo_groups) { if (inst->topo_groups) {

109
src/radio/radio.c

@ -11,6 +11,7 @@
// Все функции — в uasync-потоке. // Все функции — в uasync-потоке.
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <string.h>
#include "../lib/platform_compat.h" #include "../lib/platform_compat.h"
#include "../lib/debug_config.h" #include "../lib/debug_config.h"
@ -21,6 +22,7 @@
#include "../transport_layer/etcp.h" #include "../transport_layer/etcp.h"
#include "../routing_layer/topo_node.h" #include "../routing_layer/topo_node.h"
#include "../routing_layer/topo_group.h" #include "../routing_layer/topo_group.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "../chat/chat_event.h" #include "../chat/chat_event.h"
#include "radio.h" #include "radio.h"
#include "radio_proto.h" #include "radio_proto.h"
@ -57,11 +59,16 @@ struct radio_ctx {
void* talker_timer; /* периодический sweep зависших говорящих (self-rearming) */ void* talker_timer; /* периодический sweep зависших говорящих (self-rearming) */
struct radio_seen seen[RADIO_DEDUP_MAX]; /* кэш дедупликации */ struct radio_seen seen[RADIO_DEDUP_MAX]; /* кэш дедупликации */
int seen_count; int seen_count;
uint64_t* muted_members; /* локальный кэш, принадлежит uasync-потоку */
int muted_count;
int muted_loaded;
}; };
struct radio_instance { struct radio_instance {
radio_frame_fn frame_fn; radio_frame_fn frame_fn;
void* frame_arg; void* frame_arg;
radio_mute_fn mute_fn;
void* mute_arg;
}; };
static void radio_talker_timer_cb(void* 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, RADIO_ID, (unsigned long long)group->group_id,
ctx->c_tx_frames, ctx->c_rx_frames, ctx->c_fwd_frames, 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); ctx->c_loop_dropped, ctx->c_nosub_dropped, ctx->c_bad_dropped, ctx->c_dup_dropped);
u_free(ctx->muted_members);
u_free(ctx); u_free(ctx);
group->radio = NULL; 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; 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) { void radio_set_active(struct UTUN_INSTANCE* inst, uint64_t group_id, int on) {
if (!inst || !inst->topo_groups) return; if (!inst || !inst->topo_groups) return;
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); 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 (!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) { if (!on) {
/* выключение прослушивания: снять всех активных говорящих — индикация должна погаснуть /* выключение прослушивания: снять всех активных говорящих — индикация должна погаснуть
* сразу, а не дожидаться FIN/таймаута */ * сразу, а не дожидаться FIN/таймаута */

13
src/radio/radio.h

@ -2,8 +2,8 @@
// //
// Флудит Opus-кадры всем подписчикам канала (узлы с TOPO_FLAG_RADIO в NODEINFO). // Флудит Opus-кадры всем подписчикам канала (узлы с TOPO_FLAG_RADIO в NODEINFO).
// Передача полудуплексная: радио включается на прослушивание, PTT — на передачу. // Передача полудуплексная: радио включается на прослушивание, PTT — на передачу.
// Все функции — в uasync-потоке (кроме radio_set_frame_cb, который ставит // Все функции — в uasync-потоке, кроме регистрации frame/mute callbacks
// обработчик приёма: его вызывает radio_audio в момент init). // аудио-потребителем при init/destroy.
#ifndef RADIO_H #ifndef RADIO_H
#define 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); 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); 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-поток ── */ /* ── передача (PTT), uasync-поток ── */
int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id); /* новая передача, seq=0 */ 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_update_transmission(void);
static void radio_audio_flush_tx(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 ── */ /* ── lifecycle ── */
int radio_audio_init(struct UTUN_INSTANCE* inst) { 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; g_inst = inst; g_tx_pending = g_control_pending = 0; ++g_listen_generation; ++g_instance_generation;
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
radio_set_frame_cb(inst, radio_audio_on_frame, NULL); 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); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: initialized", RADIO_AUDIO_ID);
return 0; return 0;
} }
@ -399,6 +415,7 @@ int radio_audio_init(struct UTUN_INSTANCE* inst) {
void radio_audio_destroy(struct UTUN_INSTANCE* inst) { void radio_audio_destroy(struct UTUN_INSTANCE* inst) {
if (!inst || g_inst != inst) return; if (!inst || g_inst != inst) return;
radio_set_frame_cb(inst, NULL, NULL); radio_set_frame_cb(inst, NULL, NULL);
radio_set_mute_cb(inst, NULL, NULL);
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
g_inst = NULL; g_inst = NULL;
++g_instance_generation; ++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, 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, uint64_t src_node_id, uint16_t stream_id, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) { uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)inst; (void)arg; (void)arg;
if (!fin && (!opus || len <= 0)) return; if (!fin && (!opus || len <= 0)) return;
uint64_t now = get_time_tb(); uint64_t now = get_time_tb();
int muted = radio_member_muted(inst, group_id, src_node_id);
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
if (g_active && group_id == g_group_id) { 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 (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); struct radio_source* s = radio_src_acquire(src_node_id, stream_id);
if (s) { if (s) {
if (len > 0) radio_src_push_frame(s, seq, opus, len, now); 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; 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) { 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); struct rh_source* s = rh_src_find(rh, src);
if (s) { 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, 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, uint64_t src_node_id, uint16_t stream_id, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) { uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)seq;
struct radio_headless* rh = (struct radio_headless*)arg; struct radio_headless* rh = (struct radio_headless*)arg;
if (!rh || !rh->running || !opus || len <= 0) return; if (!rh || !rh->running || !opus || len <= 0) return;
/* активный канал: первый кадр фиксирует group_id (узел слушает один канал) */ /* активный канал: первый кадр фиксирует group_id (узел слушает один канал) */
if (rh->group_id == 0) rh->group_id = group_id; if (rh->group_id == 0) rh->group_id = group_id;
if (group_id != rh->group_id) return; 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); struct rh_source* s = rh_src_acquire(rh, src_node_id, stream_id);
if (!s) return; if (!s) return;
@ -226,7 +242,6 @@ static void rh_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
rh->c_dec_frames++; rh->c_dec_frames++;
s->last_frame_tb = get_time_tb(); s->last_frame_tb = get_time_tb();
if (fin) s->ending = 1; if (fin) s->ending = 1;
(void)inst;
} }
/* ── периодический микшер: sum источников → PCM клиентам ── */ /* ── периодический микшер: 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; } 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_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"); 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"); 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; if (!rh || !rh->running) return;
rh->running = 0; rh->running = 0;
radio_set_frame_cb(inst, NULL, NULL); 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->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_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; } 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, int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
const char* name, uint64_t owner_node_id, const char* name, uint64_t owner_node_id,
const uint8_t* x25519_pub, const uint8_t* x25519_priv, 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 = приоритетный хранитель аттачей */ " storage INTEGER NOT NULL DEFAULT 0," /* 1 = приоритетный хранитель аттачей */
" deleted INTEGER NOT NULL DEFAULT 0," /* 1 = аккаунт удалён владельцем (не отображаем, не подключаемся) */ " deleted INTEGER NOT NULL DEFAULT 0," /* 1 = аккаунт удалён владельцем (не отображаем, не подключаемся) */
" local_nick TEXT," /* локальный ник (GUI only, не синхронизируется) */ " local_nick TEXT," /* локальный ник (GUI only, не синхронизируется) */
" radio_muted INTEGER NOT NULL DEFAULT 0," /* локальное отключение голоса участника */
" source INTEGER NOT NULL DEFAULT 0" /* 0=полная merkle-запись 1=упрощённый плейсхолдер topo_group " source INTEGER NOT NULL DEFAULT 0" /* 0=полная merkle-запись 1=упрощённый плейсхолдер topo_group
(не участвует в merkle-хеше, дополняется полной записью) */ (не участвует в merkle-хеше, дополняется полной записью) */
")"; ")";
@ -430,6 +460,8 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
u_free(ddl); u_free(ddl);
} }
if (topo_node_sqlite_member_local_init(db, channel_id) < 0) return -1;
int idx_sz = snprintf(NULL, 0, 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); "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); 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* ed25519_pub, const uint8_t* ed25519_priv,
const uint8_t* signature); const uint8_t* signature);
/* Подготовить локальные поля участника при создании и загрузке канала. */
int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id);
/* Полное локальное удаление канала: DROP peers_<id>/msg_<id>, DELETE channels, /* Полное локальное удаление канала: DROP peers_<id>/msg_<id>, DELETE channels,
* Удаляются feedback_<channel_id> и merkle_tree_hash всех datasets этого канала. 0=ок. */ * Удаляются feedback_<channel_id> и merkle_tree_hash всех datasets этого канала. 0=ок. */
int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id); 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_WAIT_FLAGS,
P_TALK1, P_TALK1,
P_WAIT_RX1, P_WAIT_RX1,
P_WAIT_MUTED_RELAY,
P_RADIO_OFF_C, P_RADIO_OFF_C,
P_WAIT_OFF_C, P_WAIT_OFF_C,
P_TALK2, 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, static void rx_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t src, uint16_t stream, uint16_t seq, uint64_t src, uint16_t stream, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) { 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; struct rx_evt* rx = (struct rx_evt*)arg;
if (fin) { if (fin) {
if (rx->active && stream == rx->stream) rx->fin_seen = 1; if (rx->active && stream == rx->stream) rx->fin_seen = 1;
@ -400,8 +402,22 @@ static void rad_tick(void* arg) {
break; break;
case P_WAIT_RX1: case P_WAIT_RX1:
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok 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; t->phase = P_RADIO_OFF_C;
}
break; break;
case P_RADIO_OFF_C: case P_RADIO_OFF_C:
radio_set_active(C, gid, 0); radio_set_active(C, gid, 0);
@ -492,7 +508,7 @@ int main(int argc, char** argv) {
printf("=== test_radio ===\n"); fflush(stdout); 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(); struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; } 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 isAdmin: Boolean = false, val isSuper: Boolean = false,
val isModer: Boolean = false, val isStorage: Boolean = false, val isModer: Boolean = false, val isStorage: Boolean = false,
val radioActive: Boolean = false, val radioActive: Boolean = false,
val radioMuted: Boolean = false,
val isVerified: Boolean = false, val isDeleted: Boolean = false, val isVerified: Boolean = false, val isDeleted: Boolean = false,
val nodeType: Int = 0, val nodeType: Int = 0,
val rtt: Int = 0, val isSelf: Boolean = false, 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, name = name, online = online != 0,
isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0, isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0,
isModer = (flags and 4) != 0, isStorage = storage, 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, rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf,
avatarServer = avatarServer, avatarFile = avatarFile, storageRole = storageRole avatarServer = avatarServer, avatarFile = avatarFile, storageRole = storageRole
) )
@ -442,6 +443,7 @@ class ChatRepository {
isSuper = obj.optInt("sp", 0) != 0, isSuper = obj.optInt("sp", 0) != 0,
isModer = obj.optInt("md", 0) != 0, isModer = obj.optInt("md", 0) != 0,
isStorage = obj.optInt("st", 0) != 0, isStorage = obj.optInt("st", 0) != 0,
radioMuted = obj.optInt("rm", 0) != 0,
isVerified = obj.optInt("vd", 0) != 0, isVerified = obj.optInt("vd", 0) != 0,
isDeleted = obj.optInt("dl", 0) != 0, isDeleted = obj.optInt("dl", 0) != 0,
nodeType = obj.optInt("tp", 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 cancelAvatar(id: Long): Boolean = nativeAvatarOperation(7, id, 0, 0, 0, null, null, null)
fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}"
fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } 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 isChannelOwner(channelId: String): Boolean = nativeIsChannelOwner(channelId)
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) { verified: Boolean, deleted: Boolean) {
@ -266,6 +267,7 @@ object NativeLib {
remote: String?, local: String?, data: ByteArray?): Boolean remote: String?, local: String?, data: ByteArray?): Boolean
private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String?
private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: 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 nativeIsChannelOwner(channelId: String): Boolean
private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: 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.Bolt
import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -68,6 +69,7 @@ fun MemberListScreen(
val memberLinks by viewModel.memberLinks.collectAsState() val memberLinks by viewModel.memberLinks.collectAsState()
val localSockets by viewModel.localSockets.collectAsState() val localSockets by viewModel.localSockets.collectAsState()
val channelOwner by viewModel.channelOwner.collectAsState() val channelOwner by viewModel.channelOwner.collectAsState()
val radioMuteFailed by viewModel.radioMuteFailed.collectAsState()
LaunchedEffect(selectedMember?.nodeId) { LaunchedEffect(selectedMember?.nodeId) {
val nodeId = selectedMember?.nodeId val nodeId = selectedMember?.nodeId
@ -97,7 +99,8 @@ fun MemberListScreen(
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(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) { if (showEditDialog && editMember != null) {
MemberEditDialog( MemberEditDialog(
member = editMember!!, member = editMember!!,
@ -159,7 +170,8 @@ private fun MemberListItem(
onClick: () -> Unit, onClick: () -> Unit,
onCall: () -> Unit, onCall: () -> Unit,
onMessage: () -> Unit, onMessage: () -> Unit,
onInvite: () -> Unit onInvite: () -> Unit,
onRadioMute: () -> Unit
) { ) {
val onlineColor = when { val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
@ -239,6 +251,11 @@ private fun MemberListItem(
} }
if (!member.isSelf) { 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 = 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 = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) }
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) } 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 val reactionCatalog: StateFlow<List<ReactionState>> = _reactionCatalog
private val _interactionFailed = MutableStateFlow(false) private val _interactionFailed = MutableStateFlow(false)
val interactionFailed: StateFlow<Boolean> = _interactionFailed val interactionFailed: StateFlow<Boolean> = _interactionFailed
private val _radioMuteFailed = MutableStateFlow(false)
val radioMuteFailed: StateFlow<Boolean> = _radioMuteFailed
private var nextInteractionId = 0L private var nextInteractionId = 0L
private val _currentChannel = MutableStateFlow<Channel?>(null) private val _currentChannel = MutableStateFlow<Channel?>(null)
@ -136,6 +138,7 @@ class ChatViewModel : ViewModel() {
val members: StateFlow<List<ChatMember>> = _members val members: StateFlow<List<ChatMember>> = _members
/* Live-подписки по каналам: JSON-refresh не должен стирать более свежие BGP-события. Только UI-поток. */ /* Live-подписки по каналам: JSON-refresh не должен стирать более свежие BGP-события. Только UI-поток. */
private val memberRadios = mutableMapOf<String, Map<Long, Boolean>>() private val memberRadios = mutableMapOf<String, Map<Long, Boolean>>()
private val memberRadioMutes = mutableMapOf<String, Map<Long, Boolean>>()
private val _channelOwner = MutableStateFlow(false) private val _channelOwner = MutableStateFlow(false)
val channelOwner: StateFlow<Boolean> = _channelOwner val channelOwner: StateFlow<Boolean> = _channelOwner
@ -292,6 +295,12 @@ class ChatViewModel : ViewModel() {
_dmConversations.value = _dmConversations.value.map { it.copy(online = false) } _dmConversations.value = _dmConversations.value.map { it.copy(online = false) }
} else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute()
when (type) { 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. */ 53 -> { /* FEEDBACK_CHANGED: channel, message_id, kind. */
if (data == null || data.size < 34) return if (data == null || data.size < 34) return
val size = data[0].toInt() and 255 val size = data[0].toInt() and 255
@ -394,6 +403,7 @@ class ChatViewModel : ViewModel() {
12 -> LogManager.addLog("INFO", "VM", "service started") 12 -> LogManager.addLog("INFO", "VM", "service started")
13 -> { 13 -> {
memberRadios.clear() memberRadios.clear()
memberRadioMutes.clear()
_members.value = _members.value.map { it.copy(radioActive = false) } _members.value = _members.value.map { it.copy(radioActive = false) }
_attachmentPreparations.value = emptyMap() _attachmentPreparations.value = emptyMap()
_pmInvite.value?.let { state -> _pmInvite.value?.let { state ->
@ -513,6 +523,7 @@ class ChatViewModel : ViewModel() {
} }
val radios = snapshot.mapValues { (_, w) -> (w.flags and 0x10) != 0 } val radios = snapshot.mapValues { (_, w) -> (w.flags and 0x10) != 0 }
memberRadios[chId] = radios memberRadios[chId] = radios
memberRadioMutes[chId] = snapshot.mapValues { (_, w) -> (w.flags and 0x20) != 0 }
if (_currentChannel.value?.id == chId) if (_currentChannel.value?.id == chId)
_members.value = _members.value.map { snapshot[it.nodeId]?.toChatMember(it) ?: it } _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 }}") 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 radio = (w.flags and 0x10) != 0
val previousRadio = memberRadios[chId]?.get(w.nodeId) val previousRadio = memberRadios[chId]?.get(w.nodeId)
memberRadios[chId] = memberRadios[chId].orEmpty() + (w.nodeId to radio) 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) if (previousRadio != radio)
LogManager.addLog("DEBUG", "VM", "Member radio: ch=$chId node=${w.nodeId} enabled=$radio self=${w.isSelf}") LogManager.addLog("DEBUG", "VM", "Member radio: ch=$chId node=${w.nodeId} enabled=$radio self=${w.isSelf}")
val cur = _currentChannel.value val cur = _currentChannel.value
@ -554,6 +566,7 @@ class ChatViewModel : ViewModel() {
val nodeId = buf.getLong() val nodeId = buf.getLong()
val chId = String(data, 1, cl) val chId = String(data, 1, cl)
memberRadios[chId] = memberRadios[chId].orEmpty() - nodeId memberRadios[chId] = memberRadios[chId].orEmpty() - nodeId
memberRadioMutes[chId] = memberRadioMutes[chId].orEmpty() - nodeId
val cur = _currentChannel.value val cur = _currentChannel.value
if (cur != null && cur.id == String(data, 1, cl)) if (cur != null && cur.id == String(data, 1, cl))
_members.value = _members.value.filter { it.nodeId != nodeId } _members.value = _members.value.filter { it.nodeId != nodeId }
@ -654,6 +667,7 @@ class ChatViewModel : ViewModel() {
val chId = String(data, 1, cl) val chId = String(data, 1, cl)
LogManager.addLog("INFO", "VM", "CHANNEL_DELETED ch=$chId") LogManager.addLog("INFO", "VM", "CHANNEL_DELETED ch=$chId")
memberRadios.remove(chId) memberRadios.remove(chId)
memberRadioMutes.remove(chId)
if (radioChannelId == chId) turnRadioOff() if (radioChannelId == chId) turnRadioOff()
if (_currentChannel.value?.id == chId) { if (_currentChannel.value?.id == chId) {
_currentChannel.value = null _currentChannel.value = null
@ -1170,7 +1184,8 @@ class ChatViewModel : ViewModel() {
bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt,
bgpNodes = ns.bgpNodes bgpNodes = ns.bgpNodes
) else m ) 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) NativeLib.requestMemberList(chId)
} }
@ -1210,6 +1225,12 @@ class ChatViewModel : ViewModel() {
refreshSelectedMemberDetail(channelId, nodeId) 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, fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) { verified: Boolean, deleted: Boolean) {
val r = repo ?: return val r = repo ?: return
@ -1535,6 +1556,7 @@ class ChatViewModel : ViewModel() {
fun clearState() { fun clearState() {
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
memberRadios.clear() memberRadios.clear()
memberRadioMutes.clear()
dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null
_attachmentPreparations.value = emptyMap() _attachmentPreparations.value = emptyMap()
repo?.close() 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_ptt">"Рация PTT"</string>
<string name="radio_overlay_description">"Оверлей PTT рации"</string> <string name="radio_overlay_description">"Оверлей PTT рации"</string>
<string name="radio_active">"Рация активна"</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="radio_notification">"Рация: PTT / автоматическая передача по голосу"</string>
<string name="overlay_stopped">"Плавающая кнопка остановлена"</string> <string name="overlay_stopped">"Плавающая кнопка остановлена"</string>
<string name="ongoing_call">"Текущий звонок"</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_ptt">"PTT radio"</string>
<string name="radio_overlay_description">"Radio PTT overlay"</string> <string name="radio_overlay_description">"Radio PTT overlay"</string>
<string name="radio_active">"Radio is active"</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="radio_notification">"Radio: PTT / automatic voice transmission"</string>
<string name="overlay_stopped">"Floating button stopped"</string> <string name="overlay_stopped">"Floating button stopped"</string>
<string name="ongoing_call">"Ongoing call"</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,'')," "SELECT p.node_id, COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," " COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)," " 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" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" ORDER BY p.node_id ASC", peers_tbl); " 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, size_t needed = snprintf(NULL, 0,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," "%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, sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag, 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; } while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap) if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, pos += (size_t)snprintf(json + pos, cap - pos,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," "%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, sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag, 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_userinfo);
u_free(esc_name); 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); 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) { 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(); struct UTUN_INSTANCE* inst = instance_lite_get_instance();
sqlite3* db = chat_core_get_db(inst); sqlite3* db = chat_core_get_db(inst);
@ -2417,6 +2434,17 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberDetai
return result; 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( JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberNick(
JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, jstring nick) { JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, jstring nick) {
(void)thiz; (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_channel_members_json(const char* channel_id);
char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_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); 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); int utun_bridge_is_channel_owner(const char* channel_id);
void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id,
int admin, int supernode, int moder, int storage, 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.ChatMember
import com.utun.chat.data.parseMemberWire import com.utun.chat.data.parseMemberWire
import com.utun.chat.data.toChatMember import com.utun.chat.data.toChatMember
import com.utun.chat.data.MEMBER_WIRE_SIZE
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
/* Проверяет реальные классы из app/build/tmp/kotlin-classes/debug после assembleDebug. */ /* Проверяет реальные классы из app/build/tmp/kotlin-classes/debug после assembleDebug. */
fun main() { fun main() {
val offset = 7 val offset = 7
val data = ByteArray(offset + 79) val data = ByteArray(offset + MEMBER_WIRE_SIZE)
ByteBuffer.wrap(data, offset, 79).order(ByteOrder.LITTLE_ENDIAN).apply { 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) putLong(42L); put(1); put(1); put(0x12); putShort(250); put(1); put(1)
put("Member".toByteArray()) put("Member".toByteArray())
} }
@ -23,15 +24,20 @@ fun main() {
check(enabled.directPresence && enabled.indirectPresence && enabled.bgpPresence) check(enabled.directPresence && enabled.indirectPresence && enabled.bgpPresence)
check(enabled.bgpRtt == 321 && enabled.bgpNodes == 5 && enabled.connecting) check(enabled.bgpRtt == 321 && enabled.bgpNodes == 5 && enabled.connecting)
check(enabled.rtt == 250 && enabled.name == "Member") 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 // Повторное обновление выключает рацию, сохраняя остальные сведения. 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.radioActive && disabled.isVerified && disabled.directUp && disabled.connecting)
check(!disabled.radioMuted)
data[offset + 10] = 8 data[offset + 10] = 8
check(requireNotNull(parseMemberWire(data, offset)).toChatMember(disabled).isDeleted) check(requireNotNull(parseMemberWire(data, offset)).toChatMember(disabled).isDeleted)
check(parseMemberWire(data, -1) == null) check(parseMemberWire(data, -1) == null)
check(parseMemberWire(data, Int.MAX_VALUE) == null) check(parseMemberWire(data, Int.MAX_VALUE) == null)
check(parseMemberWire(data, offset + 1) == null) check(parseMemberWire(data, offset + 1) == null)
check(parseMemberWire(ByteArray(78)) == null) check(parseMemberWire(ByteArray(MEMBER_WIRE_SIZE - 1)) == null)
println("PASS: radio on/off; wire offsets; updates preserve verified/type/transports; deleted flag; truncated records") 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) target_link_libraries(test_radio_audio PRIVATE utun_voice utun pthread)
add_test(NAME test_radio_audio COMMAND test_radio_audio) 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) add_executable(test_audio_event_queue tests/test_audio_event_queue.cpp)
target_link_libraries(test_audio_event_queue PRIVATE pthread) target_link_libraries(test_audio_event_queue PRIVATE pthread)
add_test(NAME test_audio_event_queue COMMAND test_audio_event_queue) 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 <QPainter>
#include <QFont> #include <QFont>
#include <QMouseEvent> #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) { static QRect inviteButtonRect(const QRect& row) {
return QRect(row.right() - 31, row.center().y() - 14, 28, 28); 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) { static QRect avatarRect(const QRect& row) {
const int size = MemberListModel::kAvatarSize; const int size = MemberListModel::kAvatarSize;
return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size); 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 int x = option.rect.left() + iconSz + margin * 3;
const bool canInvite = !index.data(Qt::UserRole + 2).toBool(); 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; const int yMid = option.rect.top() + option.rect.height() / 2;
uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF); 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); Qt::AlignLeft | Qt::AlignTop, sub);
if (canInvite) { if (canInvite) {
drawRadioMuteButton(painter, radioMuteButtonRect(option.rect), flags & CHAT_MEMBER_FLAG_RADIO_MUTED);
QFont plusFont = nameFont; plusFont.setPixelSize(22); plusFont.setStrikeOut(false); QFont plusFont = nameFont; plusFont.setPixelSize(22); plusFont.setStrikeOut(false);
painter->setFont(plusFont); painter->setFont(plusFont);
painter->setPen(option.palette.color(QPalette::Link)); 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; event->type() != QEvent::MouseButtonDblClick) return false;
auto* mouse = static_cast<QMouseEvent*>(event); auto* mouse = static_cast<QMouseEvent*>(event);
if (mouse->button() != Qt::LeftButton) return false; 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()); const bool onAvatar = avatarRect(option.rect).contains(mouse->pos());
if (event->type()==QEvent::MouseButtonPress) { if (event->type()==QEvent::MouseButtonPress) {
m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPosition=mouse->pos(); m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPosition=mouse->pos();
@ -125,6 +166,17 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
return activate; 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, QSize AccountDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const { const QModelIndex &index) const {
(void)option; (void)index; (void)option; (void)index;

5
tools/chatgui/src/accountdelegate.h

@ -15,11 +15,16 @@ public:
const QModelIndex &index) const override; const QModelIndex &index) const override;
bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option,
const QModelIndex& index) override; const QModelIndex& index) override;
bool helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
const QModelIndex& index) override;
signals: signals:
void inviteRequested(const QModelIndex& index); void inviteRequested(const QModelIndex& index);
void avatarRequested(const QModelIndex& index); void avatarRequested(const QModelIndex& index);
void radioMuteRequested(const QModelIndex& index);
private: private:
QPersistentModelIndex m_invitePress; QPersistentModelIndex m_invitePress;
QPersistentModelIndex m_avatarPress; QPersistentModelIndex m_avatarPress;
QPoint m_avatarPosition; QPoint m_avatarPosition;
QPersistentModelIndex m_radioMutePress;
QPoint m_radioMutePosition;
}; };

13
tools/chatgui/src/accountlist.cpp

@ -13,6 +13,7 @@
#include <QDateTime> #include <QDateTime>
#include <QFont> #include <QFont>
#include <QSet> #include <QSet>
#include <QMessageBox>
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
#include "../../../lib/platform_compat.h" #include "../../../lib/platform_compat.h"
@ -85,6 +86,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
m_listView->setModel(m_model); m_listView->setModel(m_model);
auto* delegate = new AccountDelegate(m_listView); auto* delegate = new AccountDelegate(m_listView);
m_listView->setItemDelegate(delegate); 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) { connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) {
showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row())); showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row()));
}); });
@ -143,6 +149,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
}); });
g_detailTarget = this; 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); gui_bridge_set_member_detail_cb(memberDetailCb);
static bool nodeinfoRegistered = false; static bool nodeinfoRegistered = false;
@ -160,7 +171,7 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
} }
AccountList::~AccountList() { 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) { void AccountList::setChannel(const QString& channelId) {

39
tools/chatgui/tests/test_invite_links.cpp

@ -10,6 +10,7 @@
#include <QDir> #include <QDir>
extern "C" { extern "C" {
#include "chat/invite_link.h" #include "chat/invite_link.h"
#include "chat/chat_member.h"
} }
class InviteLinksTest : public QObject { class InviteLinksTest : public QObject {
@ -305,6 +306,44 @@ private slots:
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, row.center().y())); QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, row.center().y()));
QCOMPARE(invites.count(), 1); 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) QTEST_MAIN(InviteLinksTest)
#include "test_invite_links.moc" #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>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>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>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> </context>
</TS> </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>Message is not available</source><translation>Сообщение ещё не получено</translation></message>
<message><source>Invalid poll or publication failed</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>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> </context>
</TS> </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-потоке. */ /* Опросы/реакции: адресное изменение и коррелированный ответ команды, в GUI-потоке. */
void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb); 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_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. */ /* CHAT_EVT_ATTACHMENT_PREPARATION: target + job UUID + state. */
void gui_bridge_set_attachment_preparation_cb(gui_dm_messages_fn cb); 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_node.h"
#include "../../../src/routing_layer/topo_group.h" #include "../../../src/routing_layer/topo_group.h"
#include "../../../src/member_files/protocol.h" #include "../../../src/member_files/protocol.h"
#include "../../../src/chat/chat_member.h"
} }
/* ── Внутренний объект-приёмник в GUI-потоке ── */ /* ── Внутренний объект-приёмник в 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_group_avatar_result_cb = nullptr;
static gui_dm_messages_fn g_feedback_changed_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_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 gui_network_changed_fn g_network_changed_cb = nullptr;
static struct UASYNC* g_ua = nullptr; static struct UASYNC* g_ua = nullptr;
static struct UTUN_INSTANCE* g_inst = 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)) eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33))
g_network_changed_cb(eventType); g_network_changed_cb(eventType);
switch (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: case CHAT_EVT_FEEDBACK_CHANGED:
if (g_feedback_changed_cb) g_feedback_changed_cb(d, dlen); if (g_feedback_changed_cb) g_feedback_changed_cb(d, dlen);
break; 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_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_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_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) { void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) {
g_dm_conv_updated_cb = cb; g_dm_conv_updated_cb = cb;

Loading…
Cancel
Save