diff --git a/src/chat/chat_channel.c b/src/chat/chat_channel.c index a1cbe916..52177f4d 100644 --- a/src/chat/chat_channel.c +++ b/src/chat/chat_channel.c @@ -64,6 +64,7 @@ void chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_i if (!cc || !cc->initialized || !ch_id || !ch_id[0]) return; if (chat_feedback_ensure_channel(inst, ch_id) < 0) return; if (si_find(inst, ch_id)) return; + if (topo_node_sqlite_member_local_init(cc->db, ch_id) < 0) return; char tbl_msg[80]; msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg)); diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 7591090c..5f1156f0 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -50,6 +50,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [43]="RADIO_TALK", [44]="CALL_CONNECTION", [45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE", + [55]="RADIO_MUTE_RESULT", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 68927afe..0a29727f 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -76,6 +76,7 @@ extern "C" { #define CHAT_EVT_GROUP_AVATAR_RESULT 52 /* request_id/group:8LE, failed:1; результат публикации аватарки группы */ #define CHAT_EVT_FEEDBACK_CHANGED 53 /* [channel_len:1][channel][message_id:32][kind:1] — состояние после commit */ #define CHAT_EVT_FEEDBACK_RESULT 54 /* [request_id:8LE][success:1][JSON] — результат команды интерфейса */ +#define CHAT_EVT_RADIO_MUTE_RESULT 55 /* [group_id:8LE][node_id:8LE][success:1] — локальный переключатель рации */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); diff --git a/src/chat/chat_member.c b/src/chat/chat_member.c index 5009a652..6b826506 100644 --- a/src/chat/chat_member.c +++ b/src/chat/chat_member.c @@ -13,6 +13,7 @@ #include "../utun_instance.h" #include "../routing_layer/topo_group.h" +#include "../radio/radio.h" #include "../../lib/mem.h" #include "../../lib/ll_queue.h" #include "../../lib/json_flat.h" @@ -148,7 +149,7 @@ int chat_core_get_member_list(struct UTUN_INSTANCE* inst, const char* ch_id, uin snprintf(sql, sizeof(sql), "SELECT p.node_id, COALESCE(n.name,'')," " COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," - " COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig" + " COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig,p.radio_muted" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id" " ORDER BY p.node_id ASC", peers_tbl); @@ -179,6 +180,7 @@ int chat_core_get_member_list(struct UTUN_INSTANCE* inst, const char* ch_id, uin m->storage_role = member_storage_role(adm_tags, st, 7, 8, sqlite3_column_int(st, 5)); m->is_self = (uint8_t)(m->node_id == my_id ? 1 : 0); m->flags = parse_adm_tags_flags(adm_tags) | member_radio_flag(inst, ch_id, m->node_id); + if (sqlite3_column_int(st, 9)) m->flags |= CHAT_MEMBER_FLAG_RADIO_MUTED; if (sqlite3_column_int(st, 5)) m->flags |= CHAT_MEMBER_FLAG_DELETED; m->rtt = 0xFFFF; m->nat_type = 0xFF; @@ -277,7 +279,7 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u snprintf(sql, sizeof(sql), "SELECT COALESCE(n.name,'')," " COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," - " COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig" + " COALESCE(p.storage,0), COALESCE(p.deleted,0), COALESCE(p.userinfo,''),p.source,p.adm_tags_sig,p.radio_muted" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id" " WHERE p.node_id=?", peers_tbl); @@ -296,6 +298,7 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u m.storage_role = member_storage_role(adm_tags, st, 6, 7, sqlite3_column_int(st, 4)); m.is_self = (uint8_t)(node_id == my_id ? 1 : 0); m.flags = parse_adm_tags_flags(adm_tags) | member_radio_flag(inst, ch_id, node_id); + if (sqlite3_column_int(st, 8)) m.flags |= CHAT_MEMBER_FLAG_RADIO_MUTED; if (sqlite3_column_int(st, 4)) m.flags |= CHAT_MEMBER_FLAG_DELETED; m.rtt = 0xFFFF; m.nat_type = 0xFF; @@ -333,6 +336,35 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u return 0; } +int chat_core_toggle_member_radio_mute(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id) { + uint8_t member[CHAT_MEMBER_DISPLAY_SIZE]; + if (!ch_id || !ch_id[0] || strlen(ch_id) >= 64 || chat_core_get_single_member(inst, ch_id, node_id, member) < 0) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio: mute toggle member not found node=%016llx", (unsigned long long)node_id); return -1; + } + int muted = !(member[10] & CHAT_MEMBER_FLAG_RADIO_MUTED); + if (radio_set_member_muted(inst, strtoull(ch_id, NULL, 10), node_id, muted) < 0) return -1; + if (chat_core_get_single_member(inst, ch_id, node_id, member) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: cannot publish local mute ch=%s node=%016llx", + ch_id, (unsigned long long)node_id); return -1; + } + size_t cl = strlen(ch_id); + uint8_t evt[1 + 64 + 1 + CHAT_MEMBER_DISPLAY_SIZE]; + evt[0] = (uint8_t)cl; memcpy(evt + 1, ch_id, cl); evt[1 + cl] = 1; + memcpy(evt + 2 + cl, member, sizeof(member)); + chat_event_post(inst, CHAT_EVT_MEMBER_UPDATED, evt, (int)(cl + 2 + sizeof(member))); + return 0; +} + +void chat_core_toggle_member_radio_mute_trampoline(void* arg) { + struct chat_member_radio_mute_req* req = arg; + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: null member mute request"); return; } + int rc = chat_core_toggle_member_radio_mute(req->inst, req->ch_id, req->node_id); + uint8_t result[17]; + mf_put64(result, strtoull(req->ch_id, NULL, 10)); mf_put64(result + 8, req->node_id); result[16] = rc == 0; + chat_event_post(req->inst, CHAT_EVT_RADIO_MUTE_RESULT, result, sizeof(result)); + u_free(req); +} + /* ─── трамплины для gui_bridge ─── */ void chat_core_request_member_list_trampoline(void* arg) { diff --git a/src/chat/chat_member.h b/src/chat/chat_member.h index c16b9bb4..ce55230f 100644 --- a/src/chat/chat_member.h +++ b/src/chat/chat_member.h @@ -9,7 +9,7 @@ * [0-7] node_id uint64_t LE * [8] online uint8_t * [9] is_self uint8_t - * [10] flags uint8_t 0x01=supernode 0x02=admin 0x04=moder 0x08=deleted 0x10=radio + * [10] flags uint8_t 0x01=supernode 0x02=admin 0x04=moder 0x08=deleted 0x10=radio 0x20=local radio mute * [11-12] rtt uint16_t LE (ms, 0xFFFF=нет данных) * [13] nat_type uint8_t ADDR_TYPE_* * [14] storage uint8_t @@ -34,6 +34,7 @@ extern "C" { #define CHAT_MEMBER_FLAG_MODER 0x04 #define CHAT_MEMBER_FLAG_DELETED 0x08 #define CHAT_MEMBER_FLAG_RADIO 0x10 +#define CHAT_MEMBER_FLAG_RADIO_MUTED 0x20 /* локальное отключение голоса участника */ #define CHAT_MEMBER_DISPLAY_SIZE 158 @@ -74,6 +75,11 @@ void chat_core_request_member_list_trampoline(void* arg); * arg = struct chat_member_list_req*. Результат: CHAT_EVT_MEMBER_UPDATED с RTT-обновлениями. */ void chat_core_request_member_rtt_trampoline(void* arg); +/* Локальный переключатель рации; каждый запрос меняет актуальное состояние в uasync. */ +int chat_core_toggle_member_radio_mute(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id); +struct chat_member_radio_mute_req { struct UTUN_INSTANCE* inst; char ch_id[64]; uint64_t node_id; }; +void chat_core_toggle_member_radio_mute_trampoline(void* arg); + /* Инициализация: регистрирует node_props_changed callback для рассылки MEMBER_UPDATED. */ void chat_member_init(struct UTUN_INSTANCE* inst); diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index 1fe726c5..df71dbc0 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -3,6 +3,7 @@ #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" +#include "../radio/radio.h" #include "chat_core.h" #include "chat_core_priv.h" #include "../utun_instance.h" @@ -884,6 +885,7 @@ int member_sync_verify_and_purge(struct UTUN_INSTANCE* inst, const char* ch_id) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: verify_and_purge — PURGE broken record nid=0x%016llx ns=%s", MS_ID, (unsigned long long)nid, ch_id); topo_node_sqlite_member_del(db, ch_id, nid); + radio_forget_member_mute(inst, strtoull(ch_id, NULL, 10), nid); merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, ch_id, nid); /* пассивное удаление: если узел подключён напрямую — убрать из CHAT-группы */ if (inst->topo_groups) { diff --git a/src/radio/radio.c b/src/radio/radio.c index a3e98510..90f43d00 100644 --- a/src/radio/radio.c +++ b/src/radio/radio.c @@ -11,6 +11,7 @@ // Все функции — в uasync-потоке. #include +#include #include #include "../lib/platform_compat.h" #include "../lib/debug_config.h" @@ -21,6 +22,7 @@ #include "../transport_layer/etcp.h" #include "../routing_layer/topo_node.h" #include "../routing_layer/topo_group.h" +#include "../routing_layer/topo_node_sqlite.h" #include "../chat/chat_event.h" #include "radio.h" #include "radio_proto.h" @@ -57,11 +59,16 @@ struct radio_ctx { void* talker_timer; /* периодический sweep зависших говорящих (self-rearming) */ struct radio_seen seen[RADIO_DEDUP_MAX]; /* кэш дедупликации */ int seen_count; + uint64_t* muted_members; /* локальный кэш, принадлежит uasync-потоку */ + int muted_count; + int muted_loaded; }; struct radio_instance { radio_frame_fn frame_fn; void* frame_arg; + radio_mute_fn mute_fn; + void* mute_arg; }; static void radio_talker_timer_cb(void* arg); @@ -99,6 +106,7 @@ void radio_destroy(struct TOPO_GROUP* group) { RADIO_ID, (unsigned long long)group->group_id, ctx->c_tx_frames, ctx->c_rx_frames, ctx->c_fwd_frames, ctx->c_loop_dropped, ctx->c_nosub_dropped, ctx->c_bad_dropped, ctx->c_dup_dropped); + u_free(ctx->muted_members); u_free(ctx); group->radio = NULL; } @@ -439,12 +447,113 @@ void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg ri->frame_arg = arg; } +void radio_set_mute_cb(struct UTUN_INSTANCE* inst, radio_mute_fn fn, void* arg) { + struct radio_instance* ri = radio_inst(inst); + if (!ri) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: mute callback without instance"); return; } + ri->mute_fn = fn; ri->mute_arg = arg; +} + +static int radio_load_muted_members(struct radio_ctx* ctx) { + struct UTUN_INSTANCE* inst = ctx->group->instance; + sqlite3* db = inst->topo_sqlite_db; + char sql[160]; + snprintf(sql, sizeof(sql), "SELECT node_id FROM \"peers_%llu\" WHERE radio_muted<>0", + (unsigned long long)ctx->group->group_id); + sqlite3_stmt* st = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: load muted members grp=%016llx: %s", + (unsigned long long)ctx->group->group_id, db ? sqlite3_errmsg(db) : "no database"); return -1; + } + uint64_t* members = NULL; + int count = 0, rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + uint64_t* next = u_realloc(members, (size_t)(count + 1) * sizeof(*members)); + if (!next) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: muted member cache allocation failed"); + u_free(members); sqlite3_finalize(st); return -1; + } + members = next; members[count++] = (uint64_t)sqlite3_column_int64(st, 0); + } + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read muted members: %s", sqlite3_errmsg(db)); u_free(members); return -1; + } + u_free(ctx->muted_members); ctx->muted_members = members; ctx->muted_count = count; ctx->muted_loaded = 1; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio: loaded local mutes grp=%016llx count=%d", + (unsigned long long)ctx->group->group_id, count); + return 0; +} + +int radio_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id) { + if (!inst || !inst->topo_groups) return 0; + struct radio_ctx* ctx = radio_of(topo_groups_find(inst->topo_groups, group_id)); + if (!ctx) return 0; + for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) return 1; + return 0; +} + +void radio_forget_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id) { + if (!inst || !inst->topo_groups) return; + struct radio_ctx* ctx = radio_of(topo_groups_find(inst->topo_groups, group_id)); + if (!ctx) return; + for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) { + ctx->muted_members[i] = ctx->muted_members[--ctx->muted_count]; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio: forget removed member mute grp=%016llx node=%016llx", + (unsigned long long)group_id, (unsigned long long)node_id); + return; + } +} + +int radio_set_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted) { + struct TOPO_GROUP* group = inst && inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL; + struct radio_ctx* ctx = radio_of(group); + if (!ctx || group->group_type != TOPO_GROUP_TYPE_CHAT || !node_id || node_id == inst->node_id || (muted != 0 && muted != 1)) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio: invalid local mute grp=%016llx node=%016llx muted=%d", + (unsigned long long)group_id, (unsigned long long)node_id, muted); return -1; + } + if (!ctx->muted_loaded && radio_load_muted_members(ctx) < 0) return -1; + int slot = -1; + for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) { slot = i; break; } + /* Выделить память до записи БД: после commit применение не может отказать. */ + if (muted && slot < 0) { + uint64_t* next = u_realloc(ctx->muted_members, (size_t)(ctx->muted_count + 1) * sizeof(*next)); + if (!next) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: grow muted member cache failed"); return -1; } + ctx->muted_members = next; + } + sqlite3* db = inst->topo_sqlite_db; + char sql[160]; + snprintf(sql, sizeof(sql), "UPDATE \"peers_%llu\" SET radio_muted=? WHERE node_id=?", (unsigned long long)group_id); + sqlite3_stmt* st = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: prepare local mute: %s", db ? sqlite3_errmsg(db) : "no database"); return -1; + } + sqlite3_bind_int(st, 1, muted); sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id); + int rc = sqlite3_step(st); sqlite3_finalize(st); + if (rc != SQLITE_DONE || sqlite3_changes(db) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: save local mute grp=%016llx node=%016llx rc=%d changed=%d: %s", + (unsigned long long)group_id, (unsigned long long)node_id, rc, sqlite3_changes(db), sqlite3_errmsg(db)); return -1; + } + if (muted && slot < 0) ctx->muted_members[ctx->muted_count++] = node_id; + else if (!muted && slot >= 0) ctx->muted_members[slot] = ctx->muted_members[--ctx->muted_count]; + struct radio_instance* ri = radio_inst(inst); + if (ri && ri->mute_fn) ri->mute_fn(inst, group_id, node_id, muted, ri->mute_arg); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio: local member mute grp=%016llx node=%016llx muted=%d", + (unsigned long long)group_id, (unsigned long long)node_id, muted); + return 0; +} + /* ── подписка (вкл/выкл прослушивание) ── */ void radio_set_active(struct UTUN_INSTANCE* inst, uint64_t group_id, int on) { if (!inst || !inst->topo_groups) return; struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); if (!group) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: set_active group 0x%016llx not found", RADIO_ID, (unsigned long long)group_id); return; } + if (on && group->group_type == TOPO_GROUP_TYPE_CHAT) { + struct radio_ctx* ctx = radio_of(group); + if (!ctx || radio_load_muted_members(ctx) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: subscription rejected because local mutes could not be loaded"); return; + } + } if (!on) { /* выключение прослушивания: снять всех активных говорящих — индикация должна погаснуть * сразу, а не дожидаться FIN/таймаута */ diff --git a/src/radio/radio.h b/src/radio/radio.h index b2714bb0..18819216 100644 --- a/src/radio/radio.h +++ b/src/radio/radio.h @@ -2,8 +2,8 @@ // // Флудит Opus-кадры всем подписчикам канала (узлы с TOPO_FLAG_RADIO в NODEINFO). // Передача полудуплексная: радио включается на прослушивание, PTT — на передачу. -// Все функции — в uasync-потоке (кроме radio_set_frame_cb, который ставит -// обработчик приёма: его вызывает radio_audio в момент init). +// Все функции — в uasync-потоке, кроме регистрации frame/mute callbacks +// аудио-потребителем при init/destroy. #ifndef RADIO_H #define RADIO_H @@ -33,6 +33,15 @@ typedef void (*radio_frame_fn)(struct UTUN_INSTANCE* inst, uint64_t group_id, uint8_t fin, const uint8_t* opus, int len, void* arg); void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg); +/* Локальные настройки участников: только uasync, без изменения подписки и пересылки. */ +int radio_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id); +int radio_set_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted); +/* После удаления записи участника из БД удалить её из локального кэша. */ +void radio_forget_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id); +/* Аудио-потребитель очищает накопленный звук источника при изменении настройки. */ +typedef void (*radio_mute_fn)(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted, void* arg); +void radio_set_mute_cb(struct UTUN_INSTANCE* inst, radio_mute_fn fn, void* arg); + /* ── передача (PTT), uasync-поток ── */ int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id); /* новая передача, seq=0 */ diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 923eb3ac..a694bef8 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -383,6 +383,21 @@ static int radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, int begin static void radio_audio_update_transmission(void); static void radio_audio_flush_tx(void); +static void radio_audio_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted, void* arg) { + (void)arg; + if (!muted) return; + pthread_mutex_lock(&g_mtx); + if (g_inst == inst && g_active && g_group_id == group_id) { + struct radio_source* s = radio_src_find(node_id); + if (s) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: clear muted source grp=%016llx src=%016llx stream=%u", + RADIO_AUDIO_ID, (unsigned long long)group_id, (unsigned long long)node_id, s->stream_id); + radio_src_free(s); + } + } + pthread_mutex_unlock(&g_mtx); +} + /* ── lifecycle ── */ int radio_audio_init(struct UTUN_INSTANCE* inst) { @@ -392,6 +407,7 @@ int radio_audio_init(struct UTUN_INSTANCE* inst) { g_inst = inst; g_tx_pending = g_control_pending = 0; ++g_listen_generation; ++g_instance_generation; pthread_mutex_unlock(&g_mtx); radio_set_frame_cb(inst, radio_audio_on_frame, NULL); + radio_set_mute_cb(inst, radio_audio_member_mute, NULL); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: initialized", RADIO_AUDIO_ID); return 0; } @@ -399,6 +415,7 @@ int radio_audio_init(struct UTUN_INSTANCE* inst) { void radio_audio_destroy(struct UTUN_INSTANCE* inst) { if (!inst || g_inst != inst) return; radio_set_frame_cb(inst, NULL, NULL); + radio_set_mute_cb(inst, NULL, NULL); pthread_mutex_lock(&g_mtx); g_inst = NULL; ++g_instance_generation; @@ -963,13 +980,19 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t src_node_id, uint16_t stream_id, uint16_t seq, uint8_t fin, const uint8_t* opus, int len, void* arg) { - (void)inst; (void)arg; + (void)arg; if (!fin && (!opus || len <= 0)) return; uint64_t now = get_time_tb(); + int muted = radio_member_muted(inst, group_id, src_node_id); pthread_mutex_lock(&g_mtx); if (g_active && group_id == g_group_id) { if (len > 0) { g_last_rx_tb = now; g_last_rx_node = src_node_id; } /* busy-сигнал: чужой аудио-кадр */ + if (muted) { + if (seq == 0) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: muted burst grp=%016llx src=%016llx stream=%u; RX busy preserved", + RADIO_AUDIO_ID, (unsigned long long)group_id, (unsigned long long)src_node_id, stream_id); + pthread_mutex_unlock(&g_mtx); return; + } struct radio_source* s = radio_src_acquire(src_node_id, stream_id); if (s) { if (len > 0) radio_src_push_frame(s, seq, opus, len, now); diff --git a/src/radio/radio_headless.c b/src/radio/radio_headless.c index b57d7210..23e37b87 100644 --- a/src/radio/radio_headless.c +++ b/src/radio/radio_headless.c @@ -162,6 +162,18 @@ static void rh_src_free(struct rh_source* s) { s->used = 0; s->ending = 0; s->rd = s->wr = s->count = 0; } +static void rh_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted, void* arg) { + (void)inst; + struct radio_headless* rh = arg; + if (!rh || !rh->running || rh->group_id != group_id || !muted) return; + struct rh_source* s = rh_src_find(rh, node_id); + if (s) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: clear muted source grp=%016llx src=%016llx", + RH_ID, (unsigned long long)group_id, (unsigned long long)node_id); + rh_src_free(s); + } +} + static struct rh_source* rh_src_acquire(struct radio_headless* rh, uint64_t src, uint16_t stream) { struct rh_source* s = rh_src_find(rh, src); if (s) { @@ -209,13 +221,17 @@ static void rh_src_push(struct rh_source* s, const int16_t* pcm, int n) { static void rh_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t src_node_id, uint16_t stream_id, uint16_t seq, uint8_t fin, const uint8_t* opus, int len, void* arg) { - (void)seq; struct radio_headless* rh = (struct radio_headless*)arg; if (!rh || !rh->running || !opus || len <= 0) return; /* активный канал: первый кадр фиксирует group_id (узел слушает один канал) */ if (rh->group_id == 0) rh->group_id = group_id; if (group_id != rh->group_id) return; + if (radio_member_muted(inst, group_id, src_node_id)) { + if (seq == 0) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: muted burst grp=%016llx src=%016llx stream=%u", + RH_ID, (unsigned long long)group_id, (unsigned long long)src_node_id, stream_id); + return; + } struct rh_source* s = rh_src_acquire(rh, src_node_id, stream_id); if (!s) return; @@ -226,7 +242,6 @@ static void rh_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id, rh->c_dec_frames++; s->last_frame_tb = get_time_tb(); if (fin) s->ending = 1; - (void)inst; } /* ── периодический микшер: sum источников → PCM клиентам ── */ @@ -460,6 +475,7 @@ int radio_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, if (!rh->listen_sock_id) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; } radio_set_frame_cb(inst, rh_frame_cb, rh); + radio_set_mute_cb(inst, rh_member_mute, rh); uasync_set_timeout(ua, 5000, rh, rh_cleanup, "rh_cleanup"); rh->mix_timer = uasync_set_timeout(ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix"); @@ -473,6 +489,7 @@ void radio_headless_destroy(struct UTUN_INSTANCE* inst) { if (!rh || !rh->running) return; rh->running = 0; radio_set_frame_cb(inst, NULL, NULL); + radio_set_mute_cb(inst, NULL, NULL); if (rh->mix_timer && rh->ua) { uasync_cancel_timeout(rh->ua, rh->mix_timer); rh->mix_timer = NULL; } if (rh->listen_sock_id && rh->ua) uasync_remove_socket_t(rh->ua, rh->listen_fd); if (rh->listen_fd != SOCKET_INVALID) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; } diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index c2d74619..ac5f27cc 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -380,6 +380,35 @@ int topo_node_sqlite_init(sqlite3* db) { } +int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id) { + if (!db || !channel_id || !channel_id[0]) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: invalid local member schema arguments"); return -1; + } + char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); + char sql[160]; snprintf(sql, sizeof(sql), "PRAGMA table_info(\"%s\")", peers_tbl); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: inspect member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1; + } + int has_radio_muted = 0, rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + const char* column = (const char*)sqlite3_column_text(st, 1); + if (column && !strcmp(column, "radio_muted")) has_radio_muted = 1; + } + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1; + } + if (!has_radio_muted) { + snprintf(sql, sizeof(sql), "ALTER TABLE \"%s\" ADD COLUMN radio_muted INTEGER NOT NULL DEFAULT 0", peers_tbl); + if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: extend member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1; + } + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio: added local member mute setting ch=%s", channel_id); + } + return 0; +} + int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id, const char* name, uint64_t owner_node_id, const uint8_t* x25519_pub, const uint8_t* x25519_priv, @@ -416,6 +445,7 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id, " storage INTEGER NOT NULL DEFAULT 0," /* 1 = приоритетный хранитель аттачей */ " deleted INTEGER NOT NULL DEFAULT 0," /* 1 = аккаунт удалён владельцем (не отображаем, не подключаемся) */ " local_nick TEXT," /* локальный ник (GUI only, не синхронизируется) */ + " radio_muted INTEGER NOT NULL DEFAULT 0," /* локальное отключение голоса участника */ " source INTEGER NOT NULL DEFAULT 0" /* 0=полная merkle-запись 1=упрощённый плейсхолдер topo_group (не участвует в merkle-хеше, дополняется полной записью) */ ")"; @@ -430,6 +460,8 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id, u_free(ddl); } + if (topo_node_sqlite_member_local_init(db, channel_id) < 0) return -1; + int idx_sz = snprintf(NULL, 0, "CREATE INDEX IF NOT EXISTS \"idx_%s_type_rtt\" ON \"%s\" (node_type, node_RTT)", peers_tbl, peers_tbl); char* idx_sql = u_malloc(idx_sz + 1); diff --git a/src/routing_layer/topo_node_sqlite.h b/src/routing_layer/topo_node_sqlite.h index 41d33887..a86c41a7 100644 --- a/src/routing_layer/topo_node_sqlite.h +++ b/src/routing_layer/topo_node_sqlite.h @@ -41,6 +41,9 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id, const uint8_t* ed25519_pub, const uint8_t* ed25519_priv, const uint8_t* signature); +/* Подготовить локальные поля участника при создании и загрузке канала. */ +int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id); + /* Полное локальное удаление канала: DROP peers_/msg_, DELETE channels, * Удаляются feedback_ и merkle_tree_hash всех datasets этого канала. 0=ок. */ int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id); diff --git a/tests/test_radio.c b/tests/test_radio.c index 0402927d..e86309ae 100644 --- a/tests/test_radio.c +++ b/tests/test_radio.c @@ -56,6 +56,7 @@ enum rad_phase { P_WAIT_FLAGS, P_TALK1, P_WAIT_RX1, + P_WAIT_MUTED_RELAY, P_RADIO_OFF_C, P_WAIT_OFF_C, P_TALK2, @@ -266,7 +267,8 @@ static int node_radio(struct UTUN_INSTANCE* inst, uint64_t nid) { static void rx_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t src, uint16_t stream, uint16_t seq, uint8_t fin, const uint8_t* opus, int len, void* arg) { - (void)inst; (void)group_id; (void)src; (void)opus; (void)len; + (void)opus; (void)len; + if (radio_member_muted(inst, group_id, src)) return; /* локальный аудио-потребитель */ struct rx_evt* rx = (struct rx_evt*)arg; if (fin) { if (rx->active && stream == rx->stream) rx->fin_seen = 1; @@ -400,8 +402,22 @@ static void rad_tick(void* arg) { break; case P_WAIT_RX1: if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok - && g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) + && g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) { + if (chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || !radio_member_muted(B, gid, na) || + radio_member_muted(C, gid, na)) { t->result = 2; uasync_stop(t->ua); return; } + memset(g_rx, 0, sizeof(g_rx)); do_talk(A); + t->phase = P_WAIT_MUTED_RELAY; + } + break; + case P_WAIT_MUTED_RELAY: + if (g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) { + if (g_rx[IDX_B].frames || g_rx[IDX_B].fin_seen || !radio_is_active(B, gid) || + chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || radio_member_muted(B, gid, na)) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: locally muted relay blocked downstream or local mute failed"); + t->result = 2; uasync_stop(t->ua); return; + } t->phase = P_RADIO_OFF_C; + } break; case P_RADIO_OFF_C: radio_set_active(C, gid, 0); @@ -492,7 +508,7 @@ int main(int argc, char** argv) { printf("=== test_radio ===\n"); fflush(stdout); - TEST("radio: member snapshots/events + relay + off + withdraw"); { + TEST("radio: member snapshots/events + local mute relay + off + withdraw"); { struct UASYNC* ua = uasync_create(); if (!ua) { FAIL("uasync_create failed"); return 1; } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 0b7e8b87..035dba86 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -74,6 +74,7 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = val isAdmin: Boolean = false, val isSuper: Boolean = false, val isModer: Boolean = false, val isStorage: Boolean = false, val radioActive: Boolean = false, + val radioMuted: Boolean = false, val isVerified: Boolean = false, val isDeleted: Boolean = false, val nodeType: Int = 0, val rtt: Int = 0, val isSelf: Boolean = false, @@ -196,7 +197,7 @@ fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): Ch name = name, online = online != 0, isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0, isModer = (flags and 4) != 0, isStorage = storage, - isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, + isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, radioMuted = (flags and 0x20) != 0, rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf, avatarServer = avatarServer, avatarFile = avatarFile, storageRole = storageRole ) @@ -442,6 +443,7 @@ class ChatRepository { isSuper = obj.optInt("sp", 0) != 0, isModer = obj.optInt("md", 0) != 0, isStorage = obj.optInt("st", 0) != 0, + radioMuted = obj.optInt("rm", 0) != 0, isVerified = obj.optInt("vd", 0) != 0, isDeleted = obj.optInt("dl", 0) != 0, nodeType = obj.optInt("tp", 0), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 60728ec0..b7a4226f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -137,6 +137,7 @@ object NativeLib { fun cancelAvatar(id: Long): Boolean = nativeAvatarOperation(7, id, 0, 0, 0, null, null, null) fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } + fun toggleMemberRadioMute(channelId: String, nodeId: Long): Boolean = nativeToggleMemberRadioMute(channelId, nodeId) fun isChannelOwner(channelId: String): Boolean = nativeIsChannelOwner(channelId) fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, verified: Boolean, deleted: Boolean) { @@ -266,6 +267,7 @@ object NativeLib { remote: String?, local: String?, data: ByteArray?): Boolean private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String) + private external fun nativeToggleMemberRadioMute(channelId: String, nodeId: Long): Boolean private external fun nativeIsChannelOwner(channelId: String): Boolean private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, verified: Boolean, deleted: Boolean) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 9f3ad06e..bfb1f54c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -68,6 +69,7 @@ fun MemberListScreen( val memberLinks by viewModel.memberLinks.collectAsState() val localSockets by viewModel.localSockets.collectAsState() val channelOwner by viewModel.channelOwner.collectAsState() + val radioMuteFailed by viewModel.radioMuteFailed.collectAsState() LaunchedEffect(selectedMember?.nodeId) { val nodeId = selectedMember?.nodeId @@ -97,7 +99,8 @@ fun MemberListScreen( onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) }, - onInvite = { viewModel.showPmInvite(m.nodeId, m.name, channel.id) } + onInvite = { viewModel.showPmInvite(m.nodeId, m.name, channel.id) }, + onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, m.nodeId) } ) } } @@ -131,6 +134,14 @@ fun MemberListScreen( } } + if (radioMuteFailed) { + AlertDialog( + onDismissRequest = viewModel::dismissRadioMuteError, + text = { Text(stringResource(R.string.radio_member_mute_failed)) }, + confirmButton = { TextButton(onClick = viewModel::dismissRadioMuteError) { Text(stringResource(android.R.string.ok)) } } + ) + } + if (showEditDialog && editMember != null) { MemberEditDialog( member = editMember!!, @@ -159,7 +170,8 @@ private fun MemberListItem( onClick: () -> Unit, onCall: () -> Unit, onMessage: () -> Unit, - onInvite: () -> Unit + onInvite: () -> Unit, + onRadioMute: () -> Unit ) { val onlineColor = when { member.connecting -> Color(0xFFFFEB3B) // yellow: connecting @@ -239,6 +251,11 @@ private fun MemberListItem( } if (!member.isSelf) { + IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }) { + Icon(Icons.AutoMirrored.Filled.VolumeUp, + stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute), + tint = if (member.radioMuted) Color(0xFFEF5350) else Color(0xFF4CAF50)) + } IconButton(onClick = onInvite) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) } IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) } IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index d6fefbdb..1b1b4aab 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -118,6 +118,8 @@ class ChatViewModel : ViewModel() { val reactionCatalog: StateFlow> = _reactionCatalog private val _interactionFailed = MutableStateFlow(false) val interactionFailed: StateFlow = _interactionFailed + private val _radioMuteFailed = MutableStateFlow(false) + val radioMuteFailed: StateFlow = _radioMuteFailed private var nextInteractionId = 0L private val _currentChannel = MutableStateFlow(null) @@ -136,6 +138,7 @@ class ChatViewModel : ViewModel() { val members: StateFlow> = _members /* Live-подписки по каналам: JSON-refresh не должен стирать более свежие BGP-события. Только UI-поток. */ private val memberRadios = mutableMapOf>() + private val memberRadioMutes = mutableMapOf>() private val _channelOwner = MutableStateFlow(false) val channelOwner: StateFlow = _channelOwner @@ -292,6 +295,12 @@ class ChatViewModel : ViewModel() { _dmConversations.value = _dmConversations.value.map { it.copy(online = false) } } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() when (type) { + 55 -> { /* RADIO_MUTE_RESULT: group_id:8LE, node_id:8LE, success. */ + if (data == null || data.size != 17) { + LogManager.addLog("ERROR", "Radio", "Invalid member mute result"); _radioMuteFailed.value = true; return + } + if (data[16].toInt() == 0) _radioMuteFailed.value = true + } 53 -> { /* FEEDBACK_CHANGED: channel, message_id, kind. */ if (data == null || data.size < 34) return val size = data[0].toInt() and 255 @@ -394,6 +403,7 @@ class ChatViewModel : ViewModel() { 12 -> LogManager.addLog("INFO", "VM", "service started") 13 -> { memberRadios.clear() + memberRadioMutes.clear() _members.value = _members.value.map { it.copy(radioActive = false) } _attachmentPreparations.value = emptyMap() _pmInvite.value?.let { state -> @@ -513,6 +523,7 @@ class ChatViewModel : ViewModel() { } val radios = snapshot.mapValues { (_, w) -> (w.flags and 0x10) != 0 } memberRadios[chId] = radios + memberRadioMutes[chId] = snapshot.mapValues { (_, w) -> (w.flags and 0x20) != 0 } if (_currentChannel.value?.id == chId) _members.value = _members.value.map { snapshot[it.nodeId]?.toChatMember(it) ?: it } LogManager.addLog("DEBUG", "VM", "MEMBER_LIST: ch=$chId members=$count radios=${radios.values.count { it }}") @@ -530,6 +541,7 @@ class ChatViewModel : ViewModel() { val radio = (w.flags and 0x10) != 0 val previousRadio = memberRadios[chId]?.get(w.nodeId) memberRadios[chId] = memberRadios[chId].orEmpty() + (w.nodeId to radio) + memberRadioMutes[chId] = memberRadioMutes[chId].orEmpty() + (w.nodeId to ((w.flags and 0x20) != 0)) if (previousRadio != radio) LogManager.addLog("DEBUG", "VM", "Member radio: ch=$chId node=${w.nodeId} enabled=$radio self=${w.isSelf}") val cur = _currentChannel.value @@ -554,6 +566,7 @@ class ChatViewModel : ViewModel() { val nodeId = buf.getLong() val chId = String(data, 1, cl) memberRadios[chId] = memberRadios[chId].orEmpty() - nodeId + memberRadioMutes[chId] = memberRadioMutes[chId].orEmpty() - nodeId val cur = _currentChannel.value if (cur != null && cur.id == String(data, 1, cl)) _members.value = _members.value.filter { it.nodeId != nodeId } @@ -654,6 +667,7 @@ class ChatViewModel : ViewModel() { val chId = String(data, 1, cl) LogManager.addLog("INFO", "VM", "CHANNEL_DELETED ch=$chId") memberRadios.remove(chId) + memberRadioMutes.remove(chId) if (radioChannelId == chId) turnRadioOff() if (_currentChannel.value?.id == chId) { _currentChannel.value = null @@ -1170,7 +1184,8 @@ class ChatViewModel : ViewModel() { bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, bgpNodes = ns.bgpNodes ) else m - base.copy(connecting = base.nodeId in connecting, radioActive = memberRadios[chId]?.get(base.nodeId) == true) + base.copy(connecting = base.nodeId in connecting, radioActive = memberRadios[chId]?.get(base.nodeId) == true, + radioMuted = memberRadioMutes[chId]?.get(base.nodeId) ?: base.radioMuted) } NativeLib.requestMemberList(chId) } @@ -1210,6 +1225,12 @@ class ChatViewModel : ViewModel() { refreshSelectedMemberDetail(channelId, nodeId) } + fun toggleMemberRadioMute(channelId: String, nodeId: Long) { + if (repo == null || !NativeLib.toggleMemberRadioMute(channelId, nodeId)) _radioMuteFailed.value = true + } + + fun dismissRadioMuteError() { _radioMuteFailed.value = false } + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, verified: Boolean, deleted: Boolean) { val r = repo ?: return @@ -1535,6 +1556,7 @@ class ChatViewModel : ViewModel() { fun clearState() { memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null memberRadios.clear() + memberRadioMutes.clear() dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null _attachmentPreparations.value = emptyMap() repo?.close() diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 0b5892d5..fbdb5589 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -45,6 +45,9 @@ "Рация PTT" "Оверлей PTT рации" "Рация активна" + Не слушать этого участника в рации + Слушать этого участника в рации + Не удалось изменить настройку прослушивания рации "Рация: PTT / автоматическая передача по голосу" "Плавающая кнопка остановлена" "Текущий звонок" diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 6cc29d77..319394a6 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -45,6 +45,9 @@ "PTT radio" "Radio PTT overlay" "Radio is active" + Mute this member in radio + Listen to this member in radio + Cannot change radio listening setting "Radio: PTT / automatic voice transmission" "Floating button stopped" "Ongoing call" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRadioMuteTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRadioMuteTest.kt new file mode 100644 index 00000000..afb0dda0 --- /dev/null +++ b/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) + } +} diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 685ae281..26749587 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -705,7 +705,7 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { "SELECT p.node_id, COALESCE(n.name,'')," " COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," " COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)," - " COALESCE(p.userinfo,''),p.source,length(p.adm_tags_sig),COALESCE(p.deleted,0)" + " COALESCE(p.userinfo,''),p.source,length(p.adm_tags_sig),COALESCE(p.deleted,0),p.radio_muted" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id" " ORDER BY p.node_id ASC", peers_tbl); @@ -765,19 +765,19 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { size_t needed = snprintf(NULL, 0, "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," - "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d}", + "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d,\"rm\":%d}", sep, esc_name, (unsigned long long)node_id, is_admin, is_super, is_moder, storage_flag, - is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role); + is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role, sqlite3_column_int(st, 11)); while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," - "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d}", + "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d,\"ui\":\"%s\",\"sr\":%d,\"rm\":%d}", sep, esc_name, (unsigned long long)node_id, is_admin, is_super, is_moder, storage_flag, - is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role); + is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0, esc_userinfo, storage_role, sqlite3_column_int(st, 11)); u_free(esc_userinfo); u_free(esc_name); } @@ -910,6 +910,23 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i return u_strdup(buf); } +int utun_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id) { + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + struct UASYNC* ua = instance_lite_get_uasync(); + if (!inst || !uasync_is_running(ua) || !channel_id || !channel_id[0] || strlen(channel_id) >= 64 || !node_id) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: Android mute request rejected: core unavailable or invalid member"); return -1; + } + struct chat_member_radio_mute_req* req = u_calloc(1, sizeof(*req)); + struct posted_task* post = u_calloc(1, sizeof(*post)); + if (!req || !post) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: Android mute request allocation failed"); u_free(req); u_free(post); return -1; + } + req->inst = inst; req->node_id = node_id; strcpy(req->ch_id, channel_id); + post->callback = chat_core_toggle_member_radio_mute_trampoline; post->arg = req; + uasync_post_reserved(ua, post); + return 0; +} + void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick) { struct UTUN_INSTANCE* inst = instance_lite_get_instance(); sqlite3* db = chat_core_get_db(inst); @@ -2417,6 +2434,17 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberDetai return result; } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeToggleMemberRadioMute( + JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId) { + (void)thiz; + if (!channelId) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: null Android mute channel"); return JNI_FALSE; } + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + if (!ch) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: Android mute channel conversion failed"); return JNI_FALSE; } + int rc = utun_bridge_toggle_member_radio_mute(ch, (uint64_t)nodeId); + (*env)->ReleaseStringUTFChars(env, channelId, ch); + return rc == 0 ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberNick( JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, jstring nick) { (void)thiz; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 7e2faf20..26a8af94 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -166,6 +166,7 @@ int utun_bridge_chat_setting_get_int(const char* name, int def); char* utun_bridge_get_channel_members_json(const char* channel_id); char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id); void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick); +int utun_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id); int utun_bridge_is_channel_owner(const char* channel_id); void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, int admin, int supernode, int moder, int storage, diff --git a/tools/chatgui-android/tests/test_member_radio.kt b/tools/chatgui-android/tests/test_member_radio.kt index 94c8b5af..b3016509 100644 --- a/tools/chatgui-android/tests/test_member_radio.kt +++ b/tools/chatgui-android/tests/test_member_radio.kt @@ -1,14 +1,15 @@ import com.utun.chat.data.ChatMember import com.utun.chat.data.parseMemberWire import com.utun.chat.data.toChatMember +import com.utun.chat.data.MEMBER_WIRE_SIZE import java.nio.ByteBuffer import java.nio.ByteOrder /* Проверяет реальные классы из app/build/tmp/kotlin-classes/debug после assembleDebug. */ fun main() { val offset = 7 - val data = ByteArray(offset + 79) - ByteBuffer.wrap(data, offset, 79).order(ByteOrder.LITTLE_ENDIAN).apply { + val data = ByteArray(offset + MEMBER_WIRE_SIZE) + ByteBuffer.wrap(data, offset, MEMBER_WIRE_SIZE).order(ByteOrder.LITTLE_ENDIAN).apply { putLong(42L); put(1); put(1); put(0x12); putShort(250); put(1); put(1) put("Member".toByteArray()) } @@ -23,15 +24,20 @@ fun main() { check(enabled.directPresence && enabled.indirectPresence && enabled.bgpPresence) check(enabled.bgpRtt == 321 && enabled.bgpNodes == 5 && enabled.connecting) check(enabled.rtt == 250 && enabled.name == "Member") + check(!enabled.radioMuted) + data[offset + 10] = 0x32 + val muted = requireNotNull(parseMemberWire(data, offset)).toChatMember(enabled) + check(muted.radioMuted && muted.radioActive && muted.isVerified && muted.directUp) data[offset + 10] = 2 // Повторное обновление выключает рацию, сохраняя остальные сведения. - val disabled = requireNotNull(parseMemberWire(data, offset)).toChatMember(enabled) + val disabled = requireNotNull(parseMemberWire(data, offset)).toChatMember(muted) check(!disabled.radioActive && disabled.isVerified && disabled.directUp && disabled.connecting) + check(!disabled.radioMuted) data[offset + 10] = 8 check(requireNotNull(parseMemberWire(data, offset)).toChatMember(disabled).isDeleted) check(parseMemberWire(data, -1) == null) check(parseMemberWire(data, Int.MAX_VALUE) == null) check(parseMemberWire(data, offset + 1) == null) - check(parseMemberWire(ByteArray(78)) == null) - println("PASS: radio on/off; wire offsets; updates preserve verified/type/transports; deleted flag; truncated records") + check(parseMemberWire(ByteArray(MEMBER_WIRE_SIZE - 1)) == null) + println("PASS: radio subscription/mute; wire offsets; preserved properties; deleted flag; truncated records") } diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 70166485..06db1fa5 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -350,6 +350,17 @@ target_include_directories(test_radio_audio PRIVATE ${CMAKE_SOURCE_DIR}/../../sr target_link_libraries(test_radio_audio PRIVATE utun_voice utun pthread) add_test(NAME test_radio_audio COMMAND test_radio_audio) +add_executable(test_radio_member_mute tests/test_radio_member_mute.cpp) +target_include_directories(test_radio_member_mute PRIVATE ${UTUN_INCLUDE_DIRS}) +target_link_libraries(test_radio_member_mute PRIVATE ${QT_CORE} utun_voice utun pthread) +add_test(NAME test_radio_member_mute COMMAND test_radio_member_mute) +set_tests_properties(test_radio_member_mute PROPERTIES TIMEOUT 20) +if(UNIX AND NOT APPLE) + target_compile_definitions(test_radio_member_mute PRIVATE UTUN_TEST_WRAP_VAD) + target_link_options(test_radio_member_mute PRIVATE "-Wl,--wrap=silero_vad_create_default" "-Wl,--wrap=silero_vad_destroy" + "-Wl,--wrap=silero_vad_reset" "-Wl,--wrap=silero_vad_process") +endif() + add_executable(test_audio_event_queue tests/test_audio_event_queue.cpp) target_link_libraries(test_audio_event_queue PRIVATE pthread) add_test(NAME test_audio_event_queue COMMAND test_audio_event_queue) diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp index db7440d1..8c8d0986 100644 --- a/tools/chatgui/src/accountdelegate.cpp +++ b/tools/chatgui/src/accountdelegate.cpp @@ -5,11 +5,39 @@ #include #include #include +#include +#include +#include +#include +#include + +extern "C" { +#include "chat/chat_member.h" +} static QRect inviteButtonRect(const QRect& row) { return QRect(row.right() - 31, row.center().y() - 14, 28, 28); } +static QRect radioMuteButtonRect(const QRect& row) { + return QRect(row.right() - 63, row.center().y() - 14, 28, 28); +} + +static void drawRadioMuteButton(QPainter* painter, const QRect& button, bool muted) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + painter->translate(button.center()); + QColor color(muted ? "#EF5350" : "#4CAF50"); + QPainterPath speaker; + speaker.moveTo(-8, -3); speaker.lineTo(-4, -3); speaker.lineTo(1, -7); + speaker.lineTo(1, 7); speaker.lineTo(-4, 3); speaker.lineTo(-8, 3); speaker.closeSubpath(); + painter->fillPath(speaker, color); + painter->setPen(QPen(color, 1.8, Qt::SolidLine, Qt::RoundCap)); painter->setBrush(Qt::NoBrush); + painter->drawArc(QRectF(-3, -5, 10, 10), -60 * 16, 120 * 16); + painter->drawArc(QRectF(-6, -8, 16, 16), -60 * 16, 120 * 16); + painter->restore(); +} + static QRect avatarRect(const QRect& row) { const int size = MemberListModel::kAvatarSize; return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size); @@ -41,7 +69,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio const int x = option.rect.left() + iconSz + margin * 3; const bool canInvite = !index.data(Qt::UserRole + 2).toBool(); - const int w = option.rect.right() - x - margin - (canInvite ? 32 : 0); + const int w = option.rect.right() - x - margin - (canInvite ? 64 : 0); const int yMid = option.rect.top() + option.rect.height() / 2; uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF); @@ -90,6 +118,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio Qt::AlignLeft | Qt::AlignTop, sub); if (canInvite) { + drawRadioMuteButton(painter, radioMuteButtonRect(option.rect), flags & CHAT_MEMBER_FLAG_RADIO_MUTED); QFont plusFont = nameFont; plusFont.setPixelSize(22); plusFont.setStrikeOut(false); painter->setFont(plusFont); painter->setPen(option.palette.color(QPalette::Link)); @@ -105,6 +134,18 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons event->type() != QEvent::MouseButtonDblClick) return false; auto* mouse = static_cast(event); if (mouse->button() != Qt::LeftButton) return false; + const bool onMute = !index.data(Qt::UserRole + 2).toBool() && radioMuteButtonRect(option.rect).contains(mouse->pos()); + if (event->type() == QEvent::MouseButtonPress) { + m_radioMutePress = onMute ? index : QModelIndex(); m_radioMutePosition = mouse->pos(); + } + if (onMute || m_radioMutePress.isValid()) { + if (event->type() == QEvent::MouseButtonRelease) { + const bool activate = onMute && m_radioMutePress == index && (mouse->pos() - m_radioMutePosition).manhattanLength() < 5; + m_radioMutePress = QPersistentModelIndex(); + if (activate) emit radioMuteRequested(index); + } + return true; + } const bool onAvatar = avatarRect(option.rect).contains(mouse->pos()); if (event->type()==QEvent::MouseButtonPress) { m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPosition=mouse->pos(); @@ -125,6 +166,17 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons return activate; } +bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option, + const QModelIndex& index) { + if (event && !index.data(Qt::UserRole + 2).toBool() && radioMuteButtonRect(option.rect).contains(event->pos())) { + const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED; + QToolTip::showText(event->globalPos(), QCoreApplication::translate("ChatGui", + muted ? "Listen to this member in radio" : "Mute this member in radio"), view); + return true; + } + return QStyledItemDelegate::helpEvent(event, view, option, index); +} + QSize AccountDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { (void)option; (void)index; diff --git a/tools/chatgui/src/accountdelegate.h b/tools/chatgui/src/accountdelegate.h index 8ea847dc..027f08de 100644 --- a/tools/chatgui/src/accountdelegate.h +++ b/tools/chatgui/src/accountdelegate.h @@ -15,11 +15,16 @@ public: const QModelIndex &index) const override; bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) override; + bool helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option, + const QModelIndex& index) override; signals: void inviteRequested(const QModelIndex& index); void avatarRequested(const QModelIndex& index); + void radioMuteRequested(const QModelIndex& index); private: QPersistentModelIndex m_invitePress; QPersistentModelIndex m_avatarPress; QPoint m_avatarPosition; + QPersistentModelIndex m_radioMutePress; + QPoint m_radioMutePosition; }; diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 34131914..df3d2b2a 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include "../../../lib/platform_compat.h" @@ -85,6 +86,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent) m_listView->setModel(m_model); auto* delegate = new AccountDelegate(m_listView); m_listView->setItemDelegate(delegate); + connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) { + if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(index.row())) < 0) + QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"), + QCoreApplication::translate("ChatGui", "Cannot change radio listening setting")); + }); connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) { showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row())); }); @@ -143,6 +149,11 @@ AccountList::AccountList(DbManager* db, QWidget *parent) }); g_detailTarget = this; + gui_bridge_set_radio_mute_result_cb([](const uint8_t* data, int len) { + if (len != 17) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: invalid GUI mute result bytes=%d", len); return; } + if (g_detailTarget && !data[16]) QMessageBox::warning(g_detailTarget, QCoreApplication::translate("ChatGui", "Error"), + QCoreApplication::translate("ChatGui", "Cannot change radio listening setting")); + }); gui_bridge_set_member_detail_cb(memberDetailCb); static bool nodeinfoRegistered = false; @@ -160,7 +171,7 @@ AccountList::AccountList(DbManager* db, QWidget *parent) } AccountList::~AccountList() { - if (g_detailTarget == this) g_detailTarget = nullptr; + if (g_detailTarget == this) { g_detailTarget = nullptr; gui_bridge_set_radio_mute_result_cb(nullptr); } } void AccountList::setChannel(const QString& channelId) { diff --git a/tools/chatgui/tests/test_invite_links.cpp b/tools/chatgui/tests/test_invite_links.cpp index 5df1b857..72aca425 100644 --- a/tools/chatgui/tests/test_invite_links.cpp +++ b/tools/chatgui/tests/test_invite_links.cpp @@ -10,6 +10,7 @@ #include extern "C" { #include "chat/invite_link.h" +#include "chat/chat_member.h" } class InviteLinksTest : public QObject { @@ -305,6 +306,44 @@ private slots: QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, row.center().y())); QCOMPARE(invites.count(), 1); } + + void radioMuteButtonTargetsMemberAndChangesColor() { + QStandardItemModel model; + auto* other = new QStandardItem("other"); other->setData(false, Qt::UserRole + 2); model.appendRow(other); + auto* self = new QStandardItem("self"); self->setData(true, Qt::UserRole + 2); model.appendRow(self); + QListView view; AccountDelegate delegate; + view.setModel(&model); view.setItemDelegate(&delegate); view.resize(280, 140); view.show(); QTest::qWait(30); + QSignalSpy toggles(&delegate, &AccountDelegate::radioMuteRequested); + QSignalSpy invites(&delegate, &AccountDelegate::inviteRequested); + connect(&delegate, &AccountDelegate::radioMuteRequested, &model, [&](const QModelIndex& index) { + model.setData(index, index.data(Qt::UserRole + 3).toInt() ^ CHAT_MEMBER_FLAG_RADIO_MUTED, Qt::UserRole + 3); + }); + const QModelIndex index = model.index(0, 0); + const QRect row = view.visualRect(index); + const QPoint button(row.right() - 50, row.center().y()); + auto colorCount = [&](QRgb color) { + QImage image(view.viewport()->size(), QImage::Format_RGB32); image.fill(Qt::white); + QStyleOptionViewItem option; option.initFrom(&view); option.rect = row; + QPainter painter(&image); delegate.paint(&painter, option, index); painter.end(); + int count = 0; + for (int y = row.top(); y <= row.bottom(); ++y) + for (int x = row.right() - 63; x < row.right() - 35; ++x) count += image.pixel(x, y) == color; + return count; + }; + QVERIFY(colorCount(QColor("#4CAF50").rgb()) > 0); + QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, button); + QCOMPARE(toggles.count(), 1); QCOMPARE(qvariant_cast(toggles.first().first()).row(), 0); + QVERIFY(colorCount(QColor("#EF5350").rgb()) > 0); QCOMPARE(colorCount(QColor("#4CAF50").rgb()), 0); + QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, button); + QCOMPARE(toggles.count(), 2); QVERIFY(colorCount(QColor("#4CAF50").rgb()) > 0); + const QRect own = view.visualRect(model.index(1, 0)); + QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(own.right() - 50, own.center().y())); + QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(65, row.center().y())); + QCOMPARE(toggles.count(), 2); QCOMPARE(invites.count(), 0); + QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, button); + QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(65, row.center().y())); + QCOMPARE(toggles.count(), 2); + } }; QTEST_MAIN(InviteLinksTest) #include "test_invite_links.moc" diff --git a/tools/chatgui/tests/test_radio_member_mute.cpp b/tools/chatgui/tests/test_radio_member_mute.cpp new file mode 100644 index 00000000..383607dd --- /dev/null +++ b/tools/chatgui/tests/test_radio_member_mute.cpp @@ -0,0 +1,201 @@ +/* Настоящее ядро, SQLite и Opus: локальное отключение источника, события и восстановление после restart чата. */ +#include +#include +#include +#include +#include +#include + +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(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(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 pull(uint64_t group) { + std::vector 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& 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(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(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(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(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; +} diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index bcb617ce..58a3a717 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2143,5 +2143,8 @@ Directories: %5 Message is not availableMessage is not available Invalid poll or publication failedInvalid poll or publication failed Cannot save stateCannot save state + Mute this member in radioMute this member in radio + Listen to this member in radioListen to this member in radio + Cannot change radio listening settingCannot change radio listening setting diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 61b27bbc..c95331ae 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2149,5 +2149,8 @@ Directories: %5 Message is not availableСообщение ещё не получено Invalid poll or publication failedНекорректный опрос или ошибка публикации Cannot save stateНе удалось сохранить ответ или реакцию + Mute this member in radioНе слушать этого участника в рации + Listen to this member in radioСлушать этого участника в рации + Cannot change radio listening settingНе удалось изменить настройку прослушивания рации diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 06e8fc5c..8ae6a5f0 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -246,6 +246,8 @@ void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb); /* Опросы/реакции: адресное изменение и коррелированный ответ команды, в GUI-потоке. */ void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb); void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb); +void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn cb); +int gui_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id); /* CHAT_EVT_ATTACHMENT_PREPARATION: target + job UUID + state. */ void gui_bridge_set_attachment_preparation_cb(gui_dm_messages_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 3c8560c3..abd2f10b 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -13,6 +13,7 @@ extern "C" { #include "../../../src/routing_layer/topo_node.h" #include "../../../src/routing_layer/topo_group.h" #include "../../../src/member_files/protocol.h" +#include "../../../src/chat/chat_member.h" } /* ── Внутренний объект-приёмник в GUI-потоке ── */ @@ -72,6 +73,7 @@ static gui_dm_messages_fn g_avatar_result_cb = nullptr; static gui_dm_messages_fn g_group_avatar_result_cb = nullptr; static gui_dm_messages_fn g_feedback_changed_cb = nullptr; static gui_dm_messages_fn g_feedback_result_cb = nullptr; +static gui_dm_messages_fn g_radio_mute_result_cb = nullptr; static gui_network_changed_fn g_network_changed_cb = nullptr; static struct UASYNC* g_ua = nullptr; static struct UTUN_INSTANCE* g_inst = nullptr; @@ -86,6 +88,9 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_RADIO_MUTE_RESULT: + if (g_radio_mute_result_cb) g_radio_mute_result_cb(d, dlen); + break; case CHAT_EVT_FEEDBACK_CHANGED: if (g_feedback_changed_cb) g_feedback_changed_cb(d, dlen); break; @@ -591,6 +596,22 @@ void gui_bridge_set_avatar_result_cb(gui_dm_messages_fn cb) { g_avatar_result_cb void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb) { g_group_avatar_result_cb = cb; } void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb) { g_feedback_changed_cb = cb; } void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb) { g_feedback_result_cb = cb; } +void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn cb) { g_radio_mute_result_cb = cb; } + +int gui_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id) { + if (!g_inst || !uasync_is_running(g_ua) || !channel_id || !channel_id[0] || strlen(channel_id) >= 64 || !node_id) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: GUI mute request rejected: core unavailable or invalid member"); return -1; + } + auto* req = static_cast(u_calloc(1, sizeof(chat_member_radio_mute_req))); + auto* post = static_cast(u_calloc(1, sizeof(posted_task))); + if (!req || !post) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: GUI mute request allocation failed"); u_free(req); u_free(post); return -1; + } + req->inst = g_inst; req->node_id = node_id; strcpy(req->ch_id, channel_id); + post->callback = chat_core_toggle_member_radio_mute_trampoline; post->arg = req; + uasync_post_reserved(g_ua, post); + return 0; +} void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) { g_dm_conv_updated_cb = cb;