From b96ed8bd002cf4dfe0a1c5343ff981a0151343e8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 19 Sep 2026 17:09:00 +0300 Subject: [PATCH] =?UTF-8?q?radio:=20PTT-=D1=80=D0=B0=D0=B4=D0=B8=D0=BE=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=D1=85=20(src/radi?= =?UTF-8?q?o=20+=20chatgui=20+=20chatgui-android)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый модуль src/radio (radio/radio_audio/radio_headless) — push-to-talk радио поверх topo-групп, доставка аудио в канал, headless-режим и тесты. Интеграция в chatgui (ptt_hotkey_manager, radio_audio_engine, радионастройки, вызовы) и chatgui-android (RadioAudioEngine, RadioOverlayService, UI). --- lib/debug_config.c | 1 + lib/debug_config.h | 3 +- src/Makefile.am | 4 + src/call/call.c | 49 ++ src/call/voice_sources.cmake | 1 + src/chat/chat_event.c | 9 +- src/chat/chat_event.h | 2 + src/chat/chat_headless_control.c | 36 +- src/config_parser.c | 5 + src/config_parser.h | 1 + src/radio/radio.c | 432 +++++++++++++++ src/radio/radio.h | 75 +++ src/radio/radio_audio.c | 299 +++++++++++ src/radio/radio_audio.h | 63 +++ src/radio/radio_headless.c | 491 ++++++++++++++++++ src/radio/radio_headless.h | 43 ++ src/radio/radio_proto.h | 39 ++ src/routing_layer/topo_group.c | 48 ++ src/routing_layer/topo_group.h | 14 +- src/routing_layer/topo_group_connect.c | 107 +++- src/routing_layer/topo_group_connect.h | 10 +- src/routing_layer/topo_node.c | 3 + src/routing_layer/topo_node.h | 2 + src/routing_layer/topo_node_sqlite.c | 14 + src/routing_layer/topo_node_sqlite.h | 3 + src/transport_layer/etcp_api.h | 5 +- src/utun_instance.c | 21 +- src/utun_instance.h | 4 + tests/Makefile.am | 10 + tests/test_radio.c | 446 ++++++++++++++++ tests/test_radio_headless.c | 470 +++++++++++++++++ .../app/src/main/AndroidManifest.xml | 10 + .../app/src/main/cpp/CMakeLists.txt | 1 + .../main/java/com/utun/chat/MainActivity.kt | 16 + .../java/com/utun/chat/data/ConfigProvider.kt | 20 + .../main/java/com/utun/chat/data/NativeLib.kt | 20 + .../com/utun/chat/data/RadioAudioEngine.kt | 205 ++++++++ .../com/utun/chat/data/RadioOverlayService.kt | 169 ++++++ .../com/utun/chat/ui/screens/CallScreen.kt | 9 + .../com/utun/chat/ui/screens/ChatScreen.kt | 96 +++- .../utun/chat/ui/screens/SettingsScreen.kt | 44 ++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 77 ++- .../jni_bridge/android_jni_bridge.c | 112 ++++ .../jni_bridge/android_jni_bridge.h | 11 + .../libutun_lite/utun_sources.cmake | 1 + tools/chatgui/CMakeLists.txt | 26 +- tools/chatgui/libutun/CMakeLists.txt | 3 +- tools/chatgui/src/callwindow.cpp | 12 + tools/chatgui/src/callwindow.h | 2 + tools/chatgui/src/mainwindow.cpp | 16 + tools/chatgui/src/mainwindow.h | 1 + tools/chatgui/src/messagelist.cpp | 135 +++++ tools/chatgui/src/messagelist.h | 14 + tools/chatgui/src/ptt_hotkey_manager.cpp | 75 +++ tools/chatgui/src/ptt_hotkey_manager.h | 39 ++ tools/chatgui/src/radio_audio_engine.cpp | 153 ++++++ tools/chatgui/src/radio_audio_engine.h | 53 ++ tools/chatgui/src/radiosettingspage.cpp | 68 +++ tools/chatgui/src/radiosettingspage.h | 29 ++ tools/chatgui/src/settingsdialog.cpp | 21 + tools/chatgui/src/settingsdialog.h | 2 + tools/chatgui/tests/test_radio_audio.cpp | 86 +++ tools/chatgui/third_party/qhotkey/qhotkey.cpp | 377 ++++++++++++++ tools/chatgui/third_party/qhotkey/qhotkey.h | 130 +++++ tools/chatgui/third_party/qhotkey/qhotkey_p.h | 62 +++ .../third_party/qhotkey/qhotkey_win.cpp | 309 +++++++++++ .../third_party/qhotkey/qhotkey_x11.cpp | 271 ++++++++++ tools/chatgui/transport/gui_bridge.h | 11 + tools/chatgui/transport/gui_bridge_impl.cpp | 27 + 69 files changed, 5372 insertions(+), 51 deletions(-) create mode 100644 src/radio/radio.c create mode 100644 src/radio/radio.h create mode 100644 src/radio/radio_audio.c create mode 100644 src/radio/radio_audio.h create mode 100644 src/radio/radio_headless.c create mode 100644 src/radio/radio_headless.h create mode 100644 src/radio/radio_proto.h create mode 100644 tests/test_radio.c create mode 100644 tests/test_radio_headless.c create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt create mode 100644 tools/chatgui/src/ptt_hotkey_manager.cpp create mode 100644 tools/chatgui/src/ptt_hotkey_manager.h create mode 100644 tools/chatgui/src/radio_audio_engine.cpp create mode 100644 tools/chatgui/src/radio_audio_engine.h create mode 100644 tools/chatgui/src/radiosettingspage.cpp create mode 100644 tools/chatgui/src/radiosettingspage.h create mode 100644 tools/chatgui/tests/test_radio_audio.cpp create mode 100644 tools/chatgui/third_party/qhotkey/qhotkey.cpp create mode 100644 tools/chatgui/third_party/qhotkey/qhotkey.h create mode 100644 tools/chatgui/third_party/qhotkey/qhotkey_p.h create mode 100644 tools/chatgui/third_party/qhotkey/qhotkey_win.cpp create mode 100644 tools/chatgui/third_party/qhotkey/qhotkey_x11.cpp diff --git a/lib/debug_config.c b/lib/debug_config.c index dc9c16a4..cf48031a 100644 --- a/lib/debug_config.c +++ b/lib/debug_config.c @@ -133,6 +133,7 @@ static const struct { {"reality", DEBUG_CATEGORY_REALITY}, {"dm", DEBUG_CATEGORY_DM}, {"call", DEBUG_CATEGORY_CALL}, + {"radio", DEBUG_CATEGORY_RADIO}, {"all", DEBUG_CATEGORY_ALL}, {NULL, DEBUG_CATEGORY_NONE} }; diff --git a/lib/debug_config.h b/lib/debug_config.h index a88e08ea..9447eaea 100644 --- a/lib/debug_config.h +++ b/lib/debug_config.h @@ -71,7 +71,8 @@ typedef int debug_category_t; #define DEBUG_CATEGORY_REALITY 30 // REALITY TLS camouflage #define DEBUG_CATEGORY_DM 31 // DM — direct p2p chat between two users #define DEBUG_CATEGORY_CALL 32 // Call — P2P audio call (signaling + media) -#define DEBUG_CATEGORY_COUNT 33 // Total number of categories +#define DEBUG_CATEGORY_RADIO 33 // Radio — PTT walkie-talkie multicast (voice) +#define DEBUG_CATEGORY_COUNT 34 // Total number of categories #define DEBUG_CATEGORY_ALL (-1) // special value for all categories /* Debug configuration structure */ diff --git a/src/Makefile.am b/src/Makefile.am index a1436265..3343660f 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -97,6 +97,8 @@ utun_CORE_SOURCES = \ dm/dm_mailbox.c \ call/call.c \ call/call_headless.c \ + radio/radio.c \ + radio/radio_headless.c \ broadcast.c # libutun: all core sources except main() @@ -193,6 +195,8 @@ libutun_a_SOURCES = \ dm/dm_mailbox.c \ call/call.c \ call/call_headless.c \ + radio/radio.c \ + radio/radio_headless.c \ broadcast.c libutun_a_CFLAGS = $(utun_CFLAGS) diff --git a/src/call/call.c b/src/call/call.c index 16dcc83d..36a3c740 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -219,6 +219,52 @@ static void call_event_incoming(struct call_ctx* ctx, uint64_t group_id, uint64_ chat_event_post(ctx->inst, CHAT_EVT_CALL_INCOMING, buf, (int)off); } +/* ─────────────────── BGP-маршрут до пира ─────────────────── */ + +/* Формирует строку BGP-пути до peer_node_id и постит CHAT_EVT_CALL_PATH ([call_id:8][text:var]). + * hop_list = [peer, m1, ..., mN] (mN — наш прямой сосед), поэтому идём от нас к пиру (в обратном порядке). + * Формат hop'а: "[%04X] имя" (короткий адрес + имя, как в chat_status). */ +static void call_post_bgp_path(struct call_ctx* ctx, struct call_session* s) { + if (!ctx || !s || !ctx->inst || !ctx->inst->topo_groups) return; + struct TOPO_GROUP* g = topo_groups_find(ctx->inst->topo_groups, s->group_id); + if (!g) return; + struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, s->peer_node_id); + if (!nq) return; + + uint8_t hc = 0; + uint64_t* hops = topo_node_best_hop_list(nq, &hc, NULL); + uint16_t cr = topo_get_chain_rtt(nq); + + char text[512]; + int off = 0; + if (hc <= 1) { + off = snprintf(text, sizeof(text), "маршрут: прямой"); + } else { + off = snprintf(text, sizeof(text), "маршрут: через "); + for (int i = hc - 1; i >= 1 && off < (int)sizeof(text) - 48; i--) { + char hop[48]; + struct TOPO_NODE* ni = topo_node_registry_find(ctx->inst->topo_groups, hops[i]); + if (ni && ni->node_name && ni->node_name[0]) + snprintf(hop, sizeof(hop), "[%04X] %s", (unsigned)(hops[i] & 0xFFFF), ni->node_name); + else + snprintf(hop, sizeof(hop), "[%04X]", (unsigned)(hops[i] & 0xFFFF)); + if (i != hc - 1) off += snprintf(text + off, sizeof(text) - (size_t)off, " → "); + off += snprintf(text + off, sizeof(text) - (size_t)off, "%s", hop); + } + } + if (cr != 0xFFFF) + off += snprintf(text + off, sizeof(text) - (size_t)off, " (rtt %ums)", cr / 10); + + uint8_t buf[8 + 512]; + size_t boff = 0; + memcpy(buf + boff, &s->call_id, 8); boff += 8; + memcpy(buf + boff, text, (size_t)off); boff += (size_t)off; + chat_event_post(ctx->inst, CHAT_EVT_CALL_PATH, buf, (int)boff); + + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: path id=%016llx hc=%u rtt=%u: %s", + CALL_ID, (unsigned long long)s->call_id, hc, cr, text); +} + /* ─────────────────── завершение ─────────────────── */ /* Отложенный разрыв: единственная точка, где закрывается conn_mgr (после того, @@ -279,6 +325,7 @@ static void call_conn_cb(struct CONN_MGR_HANDLE* h, uint64_t node_id, uint64_t g DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: conn UP id=%016llx peer=0x%016llx -> ACTIVE", CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); call_event_id(ctx, CHAT_EVT_CALL_ACCEPTED, s->call_id, 0); + call_post_bgp_path(ctx, s); if (!s->stats_timer) s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); } else if (event == CONN_EVENT_DOWN) { @@ -447,6 +494,7 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro (unsigned long long)group, (unsigned long long)inv_group); call_event_incoming(ctx, group, from, call_id); + call_post_bgp_path(ctx, s); uint8_t pkt[sizeof(struct call_pkt_ringing)]; struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt; @@ -789,6 +837,7 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod call_end(ctx, s, CALL_REASON_CONNECT_FAIL); return; } + call_post_bgp_path(ctx, s); s->ring_timer = uasync_set_timeout(inst->ua, CALL_RING_TIMEOUT_TB, s, call_ring_timeout_cb, "call_ring"); } diff --git a/src/call/voice_sources.cmake b/src/call/voice_sources.cmake index d844d2f7..b7606483 100644 --- a/src/call/voice_sources.cmake +++ b/src/call/voice_sources.cmake @@ -20,6 +20,7 @@ function(utun_setup_voice LIB_DIR CALL_DIR) ${CALL_DIR}/call_audio.c ${CALL_DIR}/call_tones.c ${CALL_DIR}/voice_jitter.cpp + ${_src_dir}/radio/radio_audio.c ${_st_dir}/source/SoundTouch/SoundTouch.cpp ${_st_dir}/source/SoundTouch/TDStretch.cpp ${_st_dir}/source/SoundTouch/RateTransposer.cpp diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 28d5fe3f..22b3e3a6 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -39,8 +39,15 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [22]="MEMBER_DETAIL", [23]="MEMBER_LIST", [24]="MEMBER_UPDATED", [25]="MEMBER_REMOVED", [26]="BGP_INFO", [27]="CONNECTING_NODES", + [28]="ADMIN_KEY_RECEIVED", [29]="INVITE_LINK_READY", + [30]="INVITE_CANDIDATES", [31]="CHANNEL_DELETED", + [32]="DM_MSG_RECEIVED", [33]="DM_CONV_UPDATED", + [34]="CALL_INCOMING", [35]="CALL_RINGING", [36]="CALL_ACCEPTED", + [37]="CALL_ENDED", [38]="CALL_DECLINED", [39]="CALL_STATS", + [40]="CALL_ERROR", [41]="DM_MESSAGES", [42]="CALL_PATH", + [43]="RADIO_TALK", }; - const char* n = (type >= 1 && type <= 27) ? names[type] : "?"; + const char* n = (type >= 1 && type <= 43) ? names[type] : "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); if (data && len > 0) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len); } diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 1bbbcfcd..4194ae4a 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -62,6 +62,8 @@ extern "C" { #define CHAT_EVT_CALL_STATS 39 /* [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] */ #define CHAT_EVT_CALL_ERROR 40 /* [call_id:8][err:1][text:var] */ #define CHAT_EVT_DM_MESSAGES 41 /* [conv_id_len:1][conv_id:var][count:2][msg:var]* — расшифрованные сообщения беседы */ +#define CHAT_EVT_CALL_PATH 42 /* [call_id:8][text:var] — BGP-маршрут до пира звонка */ +#define CHAT_EVT_RADIO_TALK 43 /* [group_id:8][src_node_id:8][stream_id:2][on: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_headless_control.c b/src/chat/chat_headless_control.c index c147773c..381a637e 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -21,6 +21,7 @@ #include "../dm/dm_crypto.h" #include "../call/call.h" #include "../call/call_proto.h" +#include "../radio/radio.h" #include "../transport_layer/secure_channel.h" #include "../transport_layer/etcp.h" #include "../transport_layer/etcp_connections.h" @@ -728,12 +729,43 @@ static void hc_handle_call_hangup(struct headless_client* cli, int id, const cha send_response(cli, id, "{\"hungup\":true}", NULL); } +/* ── Рация (PTT walkie-talkie) ── */ + +static void hc_handle_radio_on(struct headless_client* cli, int id, const char* json) { + char ch[64]; + if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; } + uint64_t group_id = strtoull(ch, NULL, 10); + if (!group_id || !cli->hc->inst) { send_response(cli, id, NULL, "invalid ch"); return; } + radio_set_active(cli->hc->inst, group_id, 1); + char resp[128]; snprintf(resp, sizeof(resp), "{\"radio\":true,\"ch\":\"%llu\"}", (unsigned long long)group_id); + send_response(cli, id, resp, NULL); +} + +static void hc_handle_radio_off(struct headless_client* cli, int id, const char* json) { + char ch[64]; + if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; } + uint64_t group_id = strtoull(ch, NULL, 10); + if (!group_id || !cli->hc->inst) { send_response(cli, id, NULL, "invalid ch"); return; } + radio_set_active(cli->hc->inst, group_id, 0); + char resp[128]; snprintf(resp, sizeof(resp), "{\"radio\":false,\"ch\":\"%llu\"}", (unsigned long long)group_id); + send_response(cli, id, resp, NULL); +} + +static void hc_handle_radio_subscribers(struct headless_client* cli, int id, const char* json) { + char ch[64]; + if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; } + uint64_t group_id = strtoull(ch, NULL, 10); + int n = radio_subscribers(cli->hc->inst, group_id); + char resp[128]; snprintf(resp, sizeof(resp), "{\"subscribers\":%d,\"ch\":\"%llu\"}", n, (unsigned long long)group_id); + send_response(cli, id, resp, NULL); +} + /* ── Command dispatch ── */ typedef void (*hc_cmd_fn)(struct headless_client* cli, int id, const char* json); -static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "dm_start", "dm_list", "dm_messages", "dm_send", "call_start", "call_accept", "call_decline", "call_hangup", "quit", NULL }; -static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_dm_start, hc_handle_dm_list, hc_handle_dm_messages, hc_handle_dm_send, hc_handle_call_start, hc_handle_call_accept, hc_handle_call_decline, hc_handle_call_hangup, hc_handle_quit }; +static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "dm_start", "dm_list", "dm_messages", "dm_send", "call_start", "call_accept", "call_decline", "call_hangup", "radio_on", "radio_off", "radio_subscribers", "quit", NULL }; +static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_dm_start, hc_handle_dm_list, hc_handle_dm_messages, hc_handle_dm_send, hc_handle_call_start, hc_handle_call_accept, hc_handle_call_decline, hc_handle_call_hangup, hc_handle_radio_on, hc_handle_radio_off, hc_handle_radio_subscribers, hc_handle_quit }; static void hc_handle_command(struct headless_client* cli, const char* json) { if (!cli || !json) return; diff --git a/src/config_parser.c b/src/config_parser.c index 84b265d4..2bc51f45 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -838,6 +838,11 @@ static int parse_chatserver(const char *key, const char *value, struct global_co global->call_audio_bind[sizeof(global->call_audio_bind) - 1] = '\0'; return 0; } + if (strcmp(key, "radio_audio_bind") == 0) { + strncpy(global->radio_audio_bind, value, sizeof(global->radio_audio_bind) - 1); + global->radio_audio_bind[sizeof(global->radio_audio_bind) - 1] = '\0'; + return 0; + } return -1; } diff --git a/src/config_parser.h b/src/config_parser.h index 8d8e77da..fded04b8 100644 --- a/src/config_parser.h +++ b/src/config_parser.h @@ -223,6 +223,7 @@ struct global_config { uint64_t chatserver_storage_unit_size; // bytes, лимит одного файла (0 = отключено) char headless_control_bind[64]; // ip:port для headless chat CLI (пусто = отключено) char call_audio_bind[64]; // ip:port для headless аудио-сокета звонков (пусто = отключено) + char radio_audio_bind[64]; // ip:port для headless аудио-сокета рации (пусто = отключено) struct chat_setting_state chat_settings; // chat-настройки из [chatserver] (per-instance) // Reality-камуфляж ([reality] section) diff --git a/src/radio/radio.c b/src/radio/radio.c new file mode 100644 index 00000000..6d34347c --- /dev/null +++ b/src/radio/radio.c @@ -0,0 +1,432 @@ +// radio.c — рация (PTT walkie-talkie): мультикаст голоса по CHAT-группе. +// +// Флудит Opus-кадры (ETCP_ID_RADIO) всем подписчикам канала — узлам с +// TOPO_FLAG_RADIO в NODEINFO. Пакет несёт hop_list (узлы, через которые он уже +// прошёл) для защиты от циклов и форвардится только в те ветви, за которыми есть +// хотя бы один подписчик (RPF-обрезание по BGP-путям). Кадры нумеруются от 0, +// последний кадр передачи помечается FIN. +// +// Приём: если мы подписаны (group->radio_active) — доставляем кадр в frame_fn +// (аудио-движок radio_audio). Независимо от подписки форвардим дальше. +// Все функции — в uasync-потоке. + +#include +#include +#include "../lib/platform_compat.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "../lib/u_async.h" +#include "../lib/ll_queue.h" +#include "../transport_layer/etcp_api.h" +#include "../transport_layer/etcp.h" +#include "../routing_layer/topo_node.h" +#include "../routing_layer/topo_group.h" +#include "../chat/chat_event.h" +#include "radio.h" +#include "radio_proto.h" + +#define RADIO_ID "radio" + +#define RADIO_MAX_TALKERS 16 /* максимум одновременно говорящих источников */ + +struct radio_ctx { + struct TOPO_GROUP* group; + uint16_t stream_id; /* id текущей передачи (talk-burst) */ + uint16_t seq; /* seq следующего исходящего кадра */ + int talking; /* 1 = идёт передача (PTT зажата) */ + uint32_t c_tx_frames; /* отправлено кадров */ + uint32_t c_rx_frames; /* доставлено локально (мы подписаны) */ + uint32_t c_fwd_frames; /* форварднуто кадров */ + uint32_t c_loop_dropped; /* дропнуто из-за цикла (self в hop_list) */ + uint32_t c_nosub_dropped; /* не отправлено: за соседом нет подписчиков */ + uint32_t c_bad_dropped; /* дропнуто некорректных пакетов */ + struct radio_talker { uint64_t src; uint16_t stream; } talkers[RADIO_MAX_TALKERS]; /* активные говорящие */ +}; + +struct radio_instance { + radio_frame_fn frame_fn; + void* frame_arg; +}; + +static struct radio_ctx* radio_of(struct TOPO_GROUP* group) { + return group ? group->radio : NULL; +} + +static struct radio_instance* radio_inst(struct UTUN_INSTANCE* inst) { + return inst ? inst->radio : NULL; +} + +int radio_init(struct TOPO_GROUP* group) { + if (!group) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: init null group", RADIO_ID); return -1; } + if (group->radio) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: already initialized grp=%016llx", RADIO_ID, (unsigned long long)group->group_id); return -1; } + + struct radio_ctx* ctx = u_calloc(1, sizeof(struct radio_ctx)); + if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: alloc failed", RADIO_ID); return -1; } + ctx->group = group; + ctx->stream_id = (uint16_t)(get_time_tb() & 0xFFFF); + group->radio = ctx; + + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: init ok grp=%016llx", RADIO_ID, (unsigned long long)group->group_id); + return 0; +} + +void radio_destroy(struct TOPO_GROUP* group) { + if (!group || !group->radio) return; + struct radio_ctx* ctx = group->radio; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: destroy grp=%016llx (tx=%u rx=%u fwd=%u loop=%u nosub=%u bad=%u)", + 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); + u_free(ctx); + group->radio = NULL; +} + +/* ── сборка payload ── */ + +/* Пишет payload рации (начиная с group_id) в buf, возвращает длину. */ +static size_t radio_build(uint8_t* buf, uint64_t group_id, uint64_t src, uint16_t stream, + uint16_t seq, uint8_t flags, const uint64_t* hops, uint8_t hop_count, + const uint8_t* opus, int opus_len) { + size_t off = 0; + memcpy(buf + off, &group_id, 8); off += 8; + memcpy(buf + off, &src, 8); off += 8; + memcpy(buf + off, &stream, 2); off += 2; + memcpy(buf + off, &seq, 2); off += 2; + buf[off++] = flags; + buf[off++] = hop_count; + if (hop_count) { memcpy(buf + off, hops, (size_t)hop_count * 8); off += (size_t)hop_count * 8; } + if (opus_len > 0) { memcpy(buf + off, opus, (size_t)opus_len); off += (size_t)opus_len; } + return off; +} + +/* ── отправка ── */ + +static void radio_send_to(struct TOPO_GROUP* group, struct ETCP_CONN* conn, + const uint8_t* payload, size_t payload_len) { + struct ll_entry* ee = queue_entry_new(0); + if (!ee) return; + uint8_t* pkt = u_malloc(1 + payload_len); + if (!pkt) { queue_entry_free(ee); return; } + pkt[0] = ETCP_ID_RADIO; + memcpy(pkt + 1, payload, payload_len); + ee->dgram = pkt; + ee->len = (size_t)(1 + payload_len); + if (etcp_send(conn, ee) != 0) { u_free(pkt); queue_entry_free(ee); } +} + +/* Есть ли за conn (не считая уже пройденных hop_list) хотя бы один подписчик рации. */ +static int radio_neighbor_has_subscriber(struct TOPO_GROUP* group, struct ETCP_CONN* conn, + const uint64_t* hop_list, uint8_t hop_count) { + if (!group || !conn || !group->nodes) return 0; + uint64_t self = group->instance ? group->instance->node_id : 0; + struct ll_entry* ne = group->nodes->head; + while (ne) { + struct TOPO_GROUP_NODE* nq = (struct TOPO_GROUP_NODE*)ne; + if (nq->radio && nq->node_id != self) { + int visited = 0; + for (int i = 0; i < hop_count; i++) if (hop_list[i] == nq->node_id) { visited = 1; break; } + if (!visited) { + struct ll_entry* pe = nq->paths ? nq->paths->head : NULL; + while (pe) { + struct TOPO_NODEPATH* p = (struct TOPO_NODEPATH*)pe; + if (p->conn == conn) return 1; + pe = pe->next; + } + } + } + ne = ne->next; + } + return 0; +} + +/* Флудит payload (уже содержит group_id..hop_list..opus) всем соседям, кроме from_conn. */ +static void radio_flood(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, + const uint8_t* payload, size_t payload_len, + const uint64_t* hop_list, uint8_t hop_count) { + struct radio_ctx* ctx = radio_of(group); + if (!ctx || !group->senders_list) return; + struct ll_entry* se = group->senders_list->head; + while (se) { + struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)se->data; + if (item && item->conn && item->conn != from_conn) { + if (radio_neighbor_has_subscriber(group, item->conn, hop_list, hop_count)) { + radio_send_to(group, item->conn, payload, payload_len); + ctx->c_fwd_frames++; + } else { + ctx->c_nosub_dropped++; + } + } + se = se->next; + } +} + +/* ── индикация говорящих ── */ + +static void radio_talk_event(struct TOPO_GROUP* group, uint64_t src, uint16_t stream, uint8_t on) { + uint8_t buf[19]; + size_t off = 0; + memcpy(buf + off, &group->group_id, 8); off += 8; + memcpy(buf + off, &src, 8); off += 8; + memcpy(buf + off, &stream, 2); off += 2; + buf[off++] = on; + chat_event_post(group->instance, CHAT_EVT_RADIO_TALK, buf, (int)off); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: talk %s src=%016llx stream=%u", + RADIO_ID, on ? "START" : "STOP", (unsigned long long)src, stream); +} + +/* зафиксировать начало передачи: пост on=1, если это новый поток источника */ +static void radio_talker_on(struct radio_ctx* ctx, uint64_t src, uint16_t stream) { + for (int i = 0; i < RADIO_MAX_TALKERS; i++) { + if (ctx->talkers[i].src == src) { + if (ctx->talkers[i].stream != stream) { + ctx->talkers[i].stream = stream; + radio_talk_event(ctx->group, src, stream, 1); + } + return; + } + } + for (int i = 0; i < RADIO_MAX_TALKERS; i++) { + if (ctx->talkers[i].src == 0) { + ctx->talkers[i].src = src; + ctx->talkers[i].stream = stream; + radio_talk_event(ctx->group, src, stream, 1); + return; + } + } +} + +/* зафиксировать конец передачи: пост on=0, если источник был в таблице */ +static void radio_talker_off(struct radio_ctx* ctx, uint64_t src) { + for (int i = 0; i < RADIO_MAX_TALKERS; i++) { + if (ctx->talkers[i].src == src) { + radio_talk_event(ctx->group, src, ctx->talkers[i].stream, 0); + ctx->talkers[i].src = 0; + ctx->talkers[i].stream = 0; + return; + } + } +} + +/* ── приём ── */ + +static void radio_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, + const uint8_t* payload, size_t payload_len) { + struct radio_ctx* ctx = radio_of(group); + if (!ctx || !from_conn || payload_len < RADIO_HDR_SIZE) return; + + const uint8_t* p = payload; + uint64_t group_id, src; + uint16_t stream, seq; + uint8_t flags, hop_count; + memcpy(&group_id, p + RADIO_OFF_GROUP, 8); + memcpy(&src, p + RADIO_OFF_SRC, 8); + memcpy(&stream, p + RADIO_OFF_STREAM, 2); + memcpy(&seq, p + RADIO_OFF_SEQ, 2); + flags = p[RADIO_OFF_FLAGS]; + hop_count = p[RADIO_OFF_HOPS]; + + if (group_id != group->group_id || hop_count > RADIO_MAX_HOPS + || payload_len < RADIO_HDR_SIZE + (size_t)hop_count * 8) { + ctx->c_bad_dropped++; + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: bad pkt grp=%016llx hops=%u len=%zu from=%s", + RADIO_ID, (unsigned long long)group_id, hop_count, payload_len, from_conn->log_name); + return; + } + const uint64_t* hops = (const uint64_t*)(p + RADIO_OFF_HOPLIST); + const uint8_t* opus = p + RADIO_OFF_HOPLIST + (size_t)hop_count * 8; + int opus_len = (int)(payload_len - RADIO_OFF_HOPLIST - (size_t)hop_count * 8); + if (opus_len > RADIO_MAX_OPUS) { ctx->c_bad_dropped++; return; } + + uint64_t self = group->instance->node_id; + + /* цикл: мы уже проходили этот пакет */ + for (int i = 0; i < hop_count; i++) { + if (hops[i] == self) { + ctx->c_loop_dropped++; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: loop drop src=%016llx stream=%u seq=%u", RADIO_ID, (unsigned long long)src, stream, seq); + return; + } + } + + /* доставка локально, если мы подписаны */ + if (group->radio_active && ctx->group) { + struct radio_instance* ri = radio_inst(group->instance); + if (ri && ri->frame_fn) { + ri->frame_fn(group->instance, group_id, src, stream, seq, + (flags & RADIO_FLAG_FIN) ? 1 : 0, opus, opus_len, ri->frame_arg); + ctx->c_rx_frames++; + } + /* индикация говорящих: старт/стоп передачи */ + if (flags & RADIO_FLAG_FIN) radio_talker_off(ctx, src); + else radio_talker_on(ctx, src, stream); + } + + /* форвардинг: новый hop_list = старый + self */ + uint64_t new_hops[RADIO_MAX_HOPS]; + memcpy(new_hops, hops, (size_t)hop_count * 8); + new_hops[hop_count] = self; + uint8_t new_count = hop_count + 1; + + if (new_count >= RADIO_MAX_HOPS) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: max hops reached, no fwd src=%016llx", RADIO_ID, (unsigned long long)src); + return; + } + + uint8_t out[RADIO_HDR_SIZE + RADIO_MAX_HOPS * 8 + RADIO_MAX_OPUS]; + size_t out_len = radio_build(out, group_id, src, stream, seq, flags, new_hops, new_count, opus, opus_len); + radio_flood(group, from_conn, out, out_len, new_hops, new_count); +} + +static void radio_recv_dispatcher(struct ETCP_CONN* from_conn, struct ll_entry* entry) { + if (!from_conn || !entry || entry->len < 1 + 8 + RADIO_HDR_SIZE) { + if (entry) { queue_dgram_free(entry); queue_entry_free(entry); } + return; + } + struct UTUN_INSTANCE* instance = from_conn->instance; + if (!instance) { queue_dgram_free(entry); queue_entry_free(entry); return; } + + uint8_t* dgram = entry->dgram; + if (dgram[0] != ETCP_ID_RADIO) { queue_dgram_free(entry); queue_entry_free(entry); return; } + + uint64_t group_id; + memcpy(&group_id, dgram + 1, 8); + if (instance->topo_groups) { + struct TOPO_GROUP* group = topo_groups_find(instance->topo_groups, group_id); + if (group) radio_recv(group, from_conn, dgram + 1, entry->len - 1); + } + queue_dgram_free(entry); queue_entry_free(entry); +} + +int radio_init_instance(struct UTUN_INSTANCE* inst) { + if (!inst) return -1; + if (inst->radio) return 0; + struct radio_instance* ri = u_calloc(1, sizeof(*ri)); + if (!ri) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: alloc instance failed", RADIO_ID); return -1; } + inst->radio = ri; + if (etcp_bind(inst, ETCP_ID_RADIO, radio_recv_dispatcher) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: etcp_bind(ETCP_ID_RADIO) failed", RADIO_ID); + u_free(ri); inst->radio = NULL; + return -1; + } + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: instance initialized (bound ETCP_ID_RADIO)", RADIO_ID); + return 0; +} + +void radio_destroy_instance(struct UTUN_INSTANCE* inst) { + if (!inst || !inst->radio) return; + etcp_unbind(inst, ETCP_ID_RADIO); + u_free(inst->radio); + inst->radio = NULL; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: instance destroyed", RADIO_ID); +} + +void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg) { + struct radio_instance* ri = radio_inst(inst); + if (!ri) return; + ri->frame_fn = fn; + ri->frame_arg = arg; +} + +/* ── подписка (вкл/выкл прослушивание) ── */ + +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; } + topo_group_set_radio(group, on); +} + +int radio_is_active(struct UTUN_INSTANCE* inst, uint64_t group_id) { + if (!inst || !inst->topo_groups) return 0; + struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); + return group ? group->radio_active : 0; +} + +int radio_subscribers(struct UTUN_INSTANCE* inst, uint64_t group_id) { + if (!inst || !inst->topo_groups) return 0; + struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); + return group ? topo_group_radio_subscribers(group) : 0; +} + +/* ── передача (PTT) ── */ + +int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id) { + if (!inst || !inst->topo_groups) return -1; + struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); + if (!group) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_begin group 0x%016llx not found", RADIO_ID, (unsigned long long)group_id); return -1; } + struct radio_ctx* ctx = radio_of(group); + if (!ctx) return -1; + ctx->stream_id = (uint16_t)(ctx->stream_id + 1); + ctx->seq = 0; + ctx->talking = 1; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: talk_begin grp=%016llx stream=%u", RADIO_ID, (unsigned long long)group_id, ctx->stream_id); + return 0; +} + +int radio_talk_send(struct UTUN_INSTANCE* inst, uint64_t group_id, const uint8_t* opus, int len) { + if (!inst || !inst->topo_groups || !opus || len < 0 || len > RADIO_MAX_OPUS) return -1; + struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); + if (!group) return -1; + struct radio_ctx* ctx = radio_of(group); + if (!ctx) return -1; + if (!ctx->talking) radio_talk_begin(inst, group_id); + + uint64_t self = inst->node_id; + uint64_t hops[1] = { self }; + uint8_t out[RADIO_HDR_SIZE + 8 + RADIO_MAX_OPUS]; + size_t out_len = radio_build(out, group_id, self, ctx->stream_id, ctx->seq, 0, hops, 1, opus, len); + ctx->seq++; + ctx->c_tx_frames++; + + radio_flood(group, NULL, out, out_len, hops, 1); + return 0; +} + +int radio_talk_end(struct UTUN_INSTANCE* inst, uint64_t group_id) { + if (!inst || !inst->topo_groups) return -1; + struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); + if (!group) return -1; + struct radio_ctx* ctx = radio_of(group); + if (!ctx) return -1; + if (!ctx->talking) return 0; + + uint64_t self = inst->node_id; + uint64_t hops[1] = { self }; + uint8_t out[RADIO_HDR_SIZE + 8]; + size_t out_len = radio_build(out, group_id, self, ctx->stream_id, ctx->seq, + RADIO_FLAG_FIN, hops, 1, NULL, 0); + ctx->seq++; + ctx->c_tx_frames++; + ctx->talking = 0; + + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: talk_end grp=%016llx stream=%u frames=%u", + RADIO_ID, (unsigned long long)group_id, ctx->stream_id, ctx->c_tx_frames); + radio_flood(group, NULL, out, out_len, hops, 1); + return 0; +} + +/* ── trampoline-функции (GUI/аудио-поток → uasync) ── */ + +void radio_talk_begin_trampoline(void* arg) { + struct radio_talk_arg* a = (struct radio_talk_arg*)arg; + radio_talk_begin(a->inst, a->group_id); + u_free(a); +} + +void radio_talk_end_trampoline(void* arg) { + struct radio_talk_arg* a = (struct radio_talk_arg*)arg; + radio_talk_end(a->inst, a->group_id); + u_free(a); +} + +void radio_talk_send_trampoline(void* arg) { + struct radio_media_arg* a = (struct radio_media_arg*)arg; + radio_talk_send(a->inst, a->group_id, a->opus, a->len); + u_free(a); +} + +void radio_set_active_trampoline(void* arg) { + struct radio_set_active_arg* a = (struct radio_set_active_arg*)arg; + radio_set_active(a->inst, a->group_id, a->on); + u_free(a); +} diff --git a/src/radio/radio.h b/src/radio/radio.h new file mode 100644 index 00000000..5734a077 --- /dev/null +++ b/src/radio/radio.h @@ -0,0 +1,75 @@ +// radio.h — рация (PTT walkie-talkie): мультикаст голоса по CHAT-группе. +// +// Флудит Opus-кадры всем подписчикам канала (узлы с TOPO_FLAG_RADIO в NODEINFO). +// Передача полудуплексная: радио включается на прослушивание, PTT — на передачу. +// Все функции — в uasync-потоке (кроме radio_set_frame_cb, который ставит +// обработчик приёма: его вызывает radio_audio в момент init). +#ifndef RADIO_H +#define RADIO_H + +#include + +struct TOPO_GROUP; +struct UTUN_INSTANCE; + +#ifdef __cplusplus +extern "C" { +#endif + +/* ── жизненный цикл ── */ + +int radio_init(struct TOPO_GROUP* group); /* из topo_group_create */ +void radio_destroy(struct TOPO_GROUP* group); /* из topo_group_destroy */ + +int radio_init_instance(struct UTUN_INSTANCE* inst); /* etcp_bind(ETCP_ID_RADIO) */ +void radio_destroy_instance(struct UTUN_INSTANCE* inst); /* etcp_unbind */ + +/* ── приём Opus-кадра пира (для аудио-движка radio_audio) ── */ + +typedef void (*radio_frame_fn)(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 radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg); + +/* ── передача (PTT), uasync-поток ── */ + +int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id); /* новая передача, seq=0 */ +int radio_talk_send(struct UTUN_INSTANCE* inst, uint64_t group_id, const uint8_t* opus, int len); +int radio_talk_end(struct UTUN_INSTANCE* inst, uint64_t group_id); /* FIN-кадр */ + +/* Включить/выключить прослушивание рации канала (флаг + BGP-рассылка). */ +void radio_set_active(struct UTUN_INSTANCE* inst, uint64_t group_id, int on); +int radio_is_active(struct UTUN_INSTANCE* inst, uint64_t group_id); + +/* Число подписчиков рации канала (без себя) — для индикатора в GUI. */ +int radio_subscribers(struct UTUN_INSTANCE* inst, uint64_t group_id); + +/* ── trampoline-структуры (GUI/аудио-поток → uasync) ── */ + +struct radio_talk_arg { + struct UTUN_INSTANCE* inst; + uint64_t group_id; +}; + +struct radio_media_arg { + struct UTUN_INSTANCE* inst; + uint64_t group_id; + uint16_t len; + uint8_t opus[256]; +}; + +struct radio_set_active_arg { + struct UTUN_INSTANCE* inst; + uint64_t group_id; + int on; +}; + +void radio_talk_begin_trampoline(void* arg); +void radio_talk_end_trampoline(void* arg); +void radio_talk_send_trampoline(void* arg); +void radio_set_active_trampoline(void* arg); + +#ifdef __cplusplus +} +#endif +#endif /* RADIO_H */ diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c new file mode 100644 index 00000000..c4aee647 --- /dev/null +++ b/src/radio/radio_audio.c @@ -0,0 +1,299 @@ +// radio_audio.c — сервисный аудио-движок рации (Opus TX + per-source jitter + микшер). +// +// Таблица говорящих: per-source инстанс (Opus-декодер + voice_jitter с time-stretch), +// ключ = src_node_id. Один пир — один поток: новый stream_id сбрасывает инстанс +// (не создаём дубль). Инстанс удаляется по FIN (после дренажа буфера) или по таймауту +// (нет кадров 2с — потерян FIN / пир отвалился). +// +// RX: radio.c → radio_set_frame_cb → radio_audio_on_frame (uasync) → vj_push (кодированный кадр); +// аудио-поток radio_audio_pull_pcm → vj_pull (декод + SoundTouch stretch) → микшер. +// TX: аудио-поток radio_audio_feed_pcm → encode → uasync_post(radio_talk_send_trampoline). +// Микшер избыточных источников — насыщающее суммирование (soft-clip). Полудуплекс. +// Синглтон на один активный group_id (GUI слушает один канал), защищён мьютексом. + +#include "radio_audio.h" +#include "radio.h" +#include "../call/voice_jitter.h" +#include "../utun_instance.h" +#include "../../lib/opus_codec.h" +#include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include "../../lib/u_async.h" +#include "../../lib/platform_compat.h" + +#include +#include + +#define RADIO_AUDIO_ID "radio_audio" +#define RADIO_AUDIO_BITRATE 32000 +#define RADIO_AUDIO_MAX_SOURCES 16 +#define RADIO_AUDIO_MAX_FRAMES 50 /* ~1000мс @20мс */ +#define RADIO_AUDIO_MIX_SAMPLES 960 /* максимум сэмплов за pull */ +#define RADIO_AUDIO_SOURCE_TIMEOUT_TB 20000 /* 2с без кадров → источник завершён */ + +struct radio_source { + uint64_t src_node_id; + uint16_t stream_id; + uint8_t used; + uint8_t ending; /* получен FIN — дренируем и удаляем */ + uint64_t last_frame_tb; + opus_codec_decoder_t* dec; /* decode-аргумент для vj (stateless per call) */ + struct vj* vj; /* jitter-буфер с time-stretch */ +}; + +static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; +static struct UTUN_INSTANCE* g_inst = NULL; +static int g_active = 0; +static uint64_t g_group_id = 0; +static opus_codec_encoder_t* g_encoder = NULL; +static struct radio_source g_sources[RADIO_AUDIO_MAX_SOURCES]; +static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES]; + +/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ +static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { + opus_codec_decoder_t* dec = (opus_codec_decoder_t*)arg; + return opus_codec_decode(dec, enc, len, pcm, max_samples); +} + +/* ── таблица говорящих ── */ + +static struct radio_source* radio_src_find(uint64_t src) { + for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) + if (g_sources[i].used && g_sources[i].src_node_id == src) return &g_sources[i]; + return NULL; +} + +static void radio_src_free(struct radio_source* s) { + if (s->vj) { vj_destroy(s->vj); s->vj = NULL; } + if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } + s->used = 0; s->ending = 0; s->last_frame_tb = 0; +} + +/* Получить/создать инстанс источника. Один пир — один инстанс: если stream_id + * сменился (новая передача PTT того же пира) — сбрасываем jitter, не дублируем. */ +static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { + struct radio_source* s = radio_src_find(src); + if (s) { + if (s->stream_id != stream) { + s->stream_id = stream; + s->ending = 0; + vj_destroy(s->vj); + s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, + RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec); + if (!s->vj) { radio_src_free(s); return NULL; } + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source reset src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); + } + return s; + } + for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { + if (g_sources[i].used) continue; + s = &g_sources[i]; + s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); + if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RADIO_AUDIO_ID); return NULL; } + s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, + RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec); + if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } + s->src_node_id = src; + s->stream_id = stream; + s->used = 1; s->ending = 0; s->last_frame_tb = 0; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: new source src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); + return s; + } + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: too many sources (>=%d), drop src=%016llx", + RADIO_AUDIO_ID, RADIO_AUDIO_MAX_SOURCES, (unsigned long long)src); + return NULL; +} + +/* ── lifecycle ── */ + +int radio_audio_init(struct UTUN_INSTANCE* inst) { + if (!inst) return -1; + if (g_inst == inst) return 0; + g_inst = inst; + radio_set_frame_cb(inst, radio_audio_on_frame, NULL); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: initialized", RADIO_AUDIO_ID); + return 0; +} + +void radio_audio_destroy(struct UTUN_INSTANCE* inst) { + if (!inst || g_inst != inst) return; + radio_set_frame_cb(inst, NULL, NULL); + g_inst = NULL; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: destroyed", RADIO_AUDIO_ID); +} + +int radio_audio_start(uint64_t group_id) { + if (group_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; } + + pthread_mutex_lock(&g_mtx); + if (g_active && g_group_id == group_id) { pthread_mutex_unlock(&g_mtx); return 0; } + if (g_active) { pthread_mutex_unlock(&g_mtx); radio_audio_stop(); pthread_mutex_lock(&g_mtx); } + + opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); + if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } + opus_codec_encoder_bitrate_set(enc, RADIO_AUDIO_BITRATE); + + g_encoder = enc; + g_group_id = group_id; + g_active = 1; + memset(g_sources, 0, sizeof(g_sources)); + pthread_mutex_unlock(&g_mtx); + + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); + return 0; +} + +void radio_audio_stop(void) { + opus_codec_encoder_t* enc = NULL; + int was_active = 0; + + pthread_mutex_lock(&g_mtx); + if (g_active) { + was_active = 1; + g_active = 0; + g_group_id = 0; + enc = g_encoder; g_encoder = NULL; + for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) + if (g_sources[i].used) radio_src_free(&g_sources[i]); + } + pthread_mutex_unlock(&g_mtx); + + if (was_active) { + if (enc) opus_codec_encoder_destroy(enc); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID); + } +} + +int radio_audio_active(void) { + int a = 0; + pthread_mutex_lock(&g_mtx); + a = g_active; + pthread_mutex_unlock(&g_mtx); + return a; +} + +/* ── PTT (GUI → uasync) ── */ + +static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*)) { + struct radio_talk_arg* a = (struct radio_talk_arg*)u_malloc(sizeof(*a)); + if (!a) return; + a->inst = g_inst; + a->group_id = group_id; + uasync_post(ua, fn, a); +} + +void radio_audio_talk_begin(uint64_t group_id) { + if (!g_inst || !g_inst->ua) return; + radio_audio_post_talk(g_inst->ua, group_id, radio_talk_begin_trampoline); +} + +void radio_audio_talk_end(uint64_t group_id) { + if (!g_inst || !g_inst->ua) return; + radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline); +} + +/* ── TX (аудио-поток) ── */ + +int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { + if (!pcm || count != RADIO_AUDIO_FRAME_SAMPLES) return -1; + + uint8_t opus[256]; + struct UASYNC* ua = NULL; + struct UTUN_INSTANCE* inst = NULL; + int len = -1; + + pthread_mutex_lock(&g_mtx); + if (!g_active || group_id != g_group_id || !g_encoder) { + pthread_mutex_unlock(&g_mtx); + return -1; + } + len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus)); + ua = g_inst ? g_inst->ua : NULL; + inst = g_inst; + pthread_mutex_unlock(&g_mtx); + + if (len <= 0) return -1; + if (!ua || !inst) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: feed_pcm: instance not set", RADIO_AUDIO_ID); + return -1; + } + + struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); + if (!a) return -1; + a->inst = inst; + a->group_id = group_id; + a->len = (uint16_t)len; + memcpy(a->opus, opus, (size_t)len); + uasync_post(ua, radio_talk_send_trampoline, a); + return 0; +} + +/* ── RX (uasync-поток): кодированный кадр → vj_push ── */ + +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)seq; (void)arg; + if (!opus || len <= 0) return; + + pthread_mutex_lock(&g_mtx); + if (g_active && group_id == g_group_id) { + struct radio_source* s = radio_src_acquire(src_node_id, stream_id); + if (s) { + vj_push(s->vj, opus, len); + s->last_frame_tb = get_time_tb(); + if (fin) s->ending = 1; + } + } + pthread_mutex_unlock(&g_mtx); +} + +/* ── RX микшер (аудио-поток) ── */ + +int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { + if (!out || max_samples <= 0) return 0; + if (max_samples > RADIO_AUDIO_MIX_SAMPLES) max_samples = RADIO_AUDIO_MIX_SAMPLES; + + int active = 0; + pthread_mutex_lock(&g_mtx); + if (g_active && group_id == g_group_id) active = 1; + pthread_mutex_unlock(&g_mtx); + if (!active) { memset(out, 0, (size_t)max_samples * sizeof(int16_t)); return max_samples; } + + memset(g_mix, 0, (size_t)max_samples * sizeof(int32_t)); + + uint64_t now = get_time_tb(); + + pthread_mutex_lock(&g_mtx); + for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { + struct radio_source* s = &g_sources[i]; + if (!s->used) continue; + /* таймаут источника (нет кадров 2с — потерян FIN/обрыв передающего). + * только для НЕ-ending: ending-источник дренируется до пустого буфера. */ + if (!s->ending && s->last_frame_tb && (now - s->last_frame_tb > RADIO_AUDIO_SOURCE_TIMEOUT_TB)) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: source timeout src=%016llx", RADIO_AUDIO_ID, (unsigned long long)s->src_node_id); + radio_src_free(s); + continue; + } + int16_t tmp[RADIO_AUDIO_MIX_SAMPLES]; + int n = vj_pull(s->vj, tmp, max_samples); + for (int k = 0; k < n; k++) g_mix[k] += tmp[k]; + if (s->ending && n == 0) { + uint32_t dropped = 0, underruns = 0; + vj_get_stats(s->vj, NULL, NULL, &dropped, &underruns); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source ended src=%016llx dropped=%u underruns=%u", + RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, dropped, underruns); + radio_src_free(s); + } + } + pthread_mutex_unlock(&g_mtx); + + /* насыщающее суммирование (soft-clip) в int16 */ + for (int k = 0; k < max_samples; k++) { + int32_t v = g_mix[k]; + if (v > 32767) v = 32767; + else if (v < -32768) v = -32768; + out[k] = (int16_t)v; + } + return max_samples; +} diff --git a/src/radio/radio_audio.h b/src/radio/radio_audio.h new file mode 100644 index 00000000..03ee7a9d --- /dev/null +++ b/src/radio/radio_audio.h @@ -0,0 +1,63 @@ +// radio_audio.h — сервисный аудио-движок рации (единый для desktop/Android/headless). +// +// Владеет Opus encoder (TX), per-source декодерами и микшером (RX). Приём: +// radio.c → radio_set_frame_cb → radio_audio_on_frame (uasync) декодирует Opus-кадр +// и кладёт PCM в ring-буфер источника; аудио-поток radio_audio_pull_pcm микширует +// PCM всех источников. Передача: аудио-поток radio_audio_feed_pcm кодирует PCM и +// постит radio_talk_send в uasync. Микшер избыточных источников — насыщающее +// суммирование (soft-clip). Полудуплекс: радио включено — слушаем, PTT — говорим. +// +// Потоки (как call_audio): +// - radio_audio_start / talk_begin / talk_end / stop — GUI-поток; +// - radio_audio_feed_pcm / pull_pcm — аудио-поток; +// - radio_audio_on_frame — uasync-поток (из radio.c). +// Общее состояние — под мьютексом. Синглтон на один активный group_id. + +#ifndef RADIO_AUDIO_H +#define RADIO_AUDIO_H + +#include + +struct UTUN_INSTANCE; + +#ifdef __cplusplus +extern "C" { +#endif + +#define RADIO_AUDIO_SAMPLE_RATE 48000 +#define RADIO_AUDIO_FRAME_MS 20 +#define RADIO_AUDIO_FRAME_SAMPLES (RADIO_AUDIO_SAMPLE_RATE * RADIO_AUDIO_FRAME_MS / 1000) /* 960 */ + +/* Зарегистрировать аудио-движок в radio.c (идемпотентно). */ +int radio_audio_init(struct UTUN_INSTANCE* inst); +void radio_audio_destroy(struct UTUN_INSTANCE* inst); + +/* GUI-поток: начать прослушивание канала (encoder + реестр источников). */ +int radio_audio_start(uint64_t group_id); + +/* GUI-поток: прекратить прослушивание (освободить всё). */ +void radio_audio_stop(void); + +/* GUI-поток: PTT зажата/отпущена — постит radio_talk_begin/end в uasync. */ +void radio_audio_talk_begin(uint64_t group_id); +void radio_audio_talk_end(uint64_t group_id); + +/* аудио-поток: закодировать PCM-кадр (ровно RADIO_AUDIO_FRAME_SAMPLES) и отправить. */ +int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count); + +/* аудио-поток: выдать до max_samples микшированного PCM (источники микшируются, + * источники без данных дают тишину). Возвращает число сэмплов (обычно max). */ +int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples); + +/* 1 = аудио-движок активен (радио канала включено). */ +int radio_audio_active(void); + +/* uasync-поток: принятый Opus-кадр источника (декодируем + кладём в ring). */ +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); + +#ifdef __cplusplus +} +#endif +#endif /* RADIO_AUDIO_H */ diff --git a/src/radio/radio_headless.c b/src/radio/radio_headless.c new file mode 100644 index 00000000..ae5fa724 --- /dev/null +++ b/src/radio/radio_headless.c @@ -0,0 +1,491 @@ +// radio_headless.c — headless-аудио-сокет рации (PCM, без тактирования). +// +// TCP-сокет с бинарным фреймингом [type:1][group_id:8][len:2][data:len]. +// Клиент привязывается к каналу (HELLO) и обменивается готовым PCM (int16 mono 48k): +// клиент → ядро: FRAME (960 сэмплов) — кодируется в Opus и флудится подписчикам; +// клиент → ядро: PTT_START / PTT_STOP — radio_talk_begin / radio_talk_end; +// ядро → клиент: микшированный PCM всех говорящих (FRAME), таймер 20мс. +// Таблица говорящих: per-source (src_node_id) декодер + PCM-ring; один пир — один +// поток; инстанс удаляется по FIN (дренаж) или по таймауту 2с. Без time-stretch — +// тактирование на стороне клиента (как в call_headless). +// +// Управление подпиской (вкл/выкл рации) — на control-сокете (chat_headless_control): +// radio_on / radio_off. +#include "radio_headless.h" +#include "radio.h" +#include "../utun_instance.h" +#include "../../lib/opus_codec.h" +#include "../../lib/u_async.h" +#include "../../lib/mem.h" +#include "../../lib/socket_compat.h" +#include "../../lib/debug_config.h" +#include "../../lib/platform_compat.h" + +#include +#include +#include +#include + +#define RH_ID "radio_headless" + +#define RH_RECV_BUF_SIZE 65536 +#define RH_SEND_BUF_SIZE 65536 +#define RH_MAX_CLIENTS 16 +#define RH_MAX_FRAME 2048 + +#define RH_SAMPLE_RATE 48000 +#define RH_FRAME_SAMPLES 960 /* 20мс @48k */ +#define RH_PCM_FRAME_BYTES (RH_FRAME_SAMPLES * (int)sizeof(int16_t)) /* 1920 */ +#define RH_BITRATE 32000 + +#define RH_MAX_SOURCES 16 +#define RH_RING_FRAMES 25 /* ~500мс @20мс */ +#define RH_RING_SAMPLES (RH_RING_FRAMES * RH_FRAME_SAMPLES) +#define RH_MIX_TIMER_TB 200 /* 20мс — период микширования */ +#define RH_SOURCE_TIMEOUT_TB 20000 /* 2с без кадров → источник завершён */ + +struct rh_source { + uint64_t src_node_id; + uint16_t stream_id; + uint8_t used; + uint8_t ending; + uint64_t last_frame_tb; + opus_codec_decoder_t* dec; + int16_t* ring; /* u_malloc(RH_RING_SAMPLES) */ + int rd, wr, count; +}; + +struct radio_headless_client { + socket_t fd; + void* socket_id; + uint64_t group_id; /* 0 = не привязан */ + uint8_t recv_buf[RH_RECV_BUF_SIZE]; + int recv_len; + uint8_t send_buf[RH_SEND_BUF_SIZE]; + int send_len; + int send_offset; + int closing; + struct radio_headless* rh; + struct radio_headless_client* next; +}; + +struct radio_headless { + struct UASYNC* ua; + struct UTUN_INSTANCE* inst; + struct radio_headless_client* clients; + int client_count; + socket_t listen_fd; + void* listen_sock_id; + int running; + uint64_t group_id; /* активный канал (0 = не задан) */ + uint64_t enc_group_id; + opus_codec_encoder_t* enc; /* TX-кодер активного канала */ + struct rh_source sources[RH_MAX_SOURCES]; + void* mix_timer; + int32_t mix[RH_FRAME_SAMPLES]; + /* диагностика */ + uint32_t c_enc_frames, c_dec_frames, c_enc_dropped, c_dec_dropped; +}; + +static struct radio_headless* rh_of(struct UTUN_INSTANCE* inst) { + return inst ? (struct radio_headless*)inst->radio_headless : NULL; +} + +static void rh_close_client(struct radio_headless_client* cli); + +/* ── отправка фрейма клиенту (буферизация как в call_headless) ── */ + +static void rh_send_raw(struct radio_headless_client* cli, const uint8_t* data, size_t len) { + struct radio_headless* rh = cli->rh; + if (!cli || cli->closing || cli->fd == SOCKET_INVALID || !data || len == 0) return; + if (cli->send_len > 0) { + size_t space = sizeof(cli->send_buf) - (size_t)cli->send_len; + if (len > space) return; + memcpy(cli->send_buf + cli->send_len, data, len); + cli->send_len += (int)len; + return; + } + ssize_t r = send(cli->fd, data, (int)len, 0); + if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) { + if (len <= sizeof(cli->send_buf)) { memcpy(cli->send_buf, data, len); cli->send_len = (int)len; cli->send_offset = 0; } + if (rh && rh->ua) uasync_set_socket_write(rh->ua, cli->socket_id, 1); + return; + } + if (r >= 0 && (size_t)r < len) { + size_t remain = len - (size_t)r; + if (remain <= sizeof(cli->send_buf)) { memcpy(cli->send_buf, data + r, remain); cli->send_len = (int)remain; cli->send_offset = 0; } + if (rh && rh->ua) uasync_set_socket_write(rh->ua, cli->socket_id, 1); + } + if (r < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: send failed err=%d, closing client", RH_ID, socket_get_error()); + rh_close_client(cli); + } +} + +static void rh_write_frame(struct radio_headless_client* cli, uint8_t type, uint64_t group_id, + const uint8_t* data, uint16_t len) { + uint8_t hdr[RADIO_AUDIO_HDR_SIZE]; + hdr[0] = type; + memcpy(hdr + 1, &group_id, 8); + hdr[9] = (uint8_t)(len >> 8); + hdr[10] = (uint8_t)(len & 0xFF); + rh_send_raw(cli, hdr, sizeof(hdr)); + if (len) rh_send_raw(cli, data, len); +} + +/* ── Opus-кодер активного канала ── */ + +static opus_codec_encoder_t* rh_encoder(struct radio_headless* rh, uint64_t group_id) { + if (rh->enc && rh->enc_group_id == group_id) return rh->enc; + if (rh->enc) { opus_codec_encoder_destroy(rh->enc); rh->enc = NULL; } + rh->enc = opus_codec_encoder_create(RH_SAMPLE_RATE, 1); + if (rh->enc) { + opus_codec_encoder_bitrate_set(rh->enc, RH_BITRATE); + rh->enc_group_id = group_id; + } else { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RH_ID); + } + return rh->enc; +} + +/* ── таблица говорящих ── */ + +static struct rh_source* rh_src_find(struct radio_headless* rh, uint64_t src) { + for (int i = 0; i < RH_MAX_SOURCES; i++) + if (rh->sources[i].used && rh->sources[i].src_node_id == src) return &rh->sources[i]; + return NULL; +} + +static void rh_src_free(struct rh_source* s) { + if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } + if (s->ring) { u_free(s->ring); s->ring = NULL; } + s->used = 0; s->ending = 0; s->rd = s->wr = s->count = 0; +} + +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) { + if (s->stream_id != stream) { + s->stream_id = stream; + s->ending = 0; s->rd = s->wr = s->count = 0; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source reset src=%016llx stream=%u", RH_ID, (unsigned long long)src, stream); + } + return s; + } + for (int i = 0; i < RH_MAX_SOURCES; i++) { + if (rh->sources[i].used) continue; + s = &rh->sources[i]; + s->dec = opus_codec_decoder_create(RH_SAMPLE_RATE, 1); + if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RH_ID); return NULL; } + s->ring = u_malloc(RH_RING_SAMPLES * sizeof(int16_t)); + if (!s->ring) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } + s->src_node_id = src; + s->stream_id = stream; + s->used = 1; s->ending = 0; s->rd = s->wr = s->count = 0; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: new source src=%016llx stream=%u", RH_ID, (unsigned long long)src, stream); + return s; + } + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: too many sources (>=%d), drop src=%016llx", RH_ID, RH_MAX_SOURCES, (unsigned long long)src); + return NULL; +} + +static void rh_src_push(struct rh_source* s, const int16_t* pcm, int n) { + int cap = RH_RING_SAMPLES; + if (n > cap) { pcm += n - cap; n = cap; } + if (s->count + n > cap) { + int overflow = s->count + n - cap; + s->rd = (s->rd + overflow) % cap; + s->count -= overflow; + } + for (int i = 0; i < n; i++) { + s->ring[s->wr] = pcm[i]; + s->wr = (s->wr + 1) % cap; + } + s->count += n; +} + +/* ── приём Opus-кадра пира (frame cb) → декод → ring источника ── */ + +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; + + struct rh_source* s = rh_src_acquire(rh, src_node_id, stream_id); + if (!s) return; + int16_t pcm[RH_FRAME_SAMPLES]; + int n = opus_codec_decode(s->dec, opus, len, pcm, RH_FRAME_SAMPLES); + if (n <= 0) { rh->c_dec_dropped++; return; } + rh_src_push(s, pcm, n); + rh->c_dec_frames++; + s->last_frame_tb = get_time_tb(); + if (fin) s->ending = 1; + (void)inst; +} + +/* ── периодический микшер: sum источников → PCM клиентам ── */ + +static void rh_mix_tick(void* arg) { + struct radio_headless* rh = (struct radio_headless*)arg; + rh->mix_timer = NULL; + if (!rh || !rh->running) return; + + if (rh->group_id != 0) { + memset(rh->mix, 0, sizeof(rh->mix)); + uint64_t now = get_time_tb(); + int any = 0; + + for (int i = 0; i < RH_MAX_SOURCES; i++) { + struct rh_source* s = &rh->sources[i]; + if (!s->used) continue; + if (!s->ending && s->last_frame_tb && (now - s->last_frame_tb > RH_SOURCE_TIMEOUT_TB)) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: source timeout src=%016llx", RH_ID, (unsigned long long)s->src_node_id); + rh_src_free(s); + continue; + } + int take = s->count < RH_FRAME_SAMPLES ? s->count : RH_FRAME_SAMPLES; + for (int k = 0; k < take; k++) { + rh->mix[k] += s->ring[s->rd]; + s->rd = (s->rd + 1) % RH_RING_SAMPLES; + } + s->count -= take; + if (take > 0) any = 1; + if (s->ending && s->count == 0) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source ended src=%016llx", RH_ID, (unsigned long long)s->src_node_id); + rh_src_free(s); + } + } + + /* шлём PCM клиентам только когда есть что микшировать (тишина — неявно) */ + if (any) { + int16_t pcm[RH_FRAME_SAMPLES]; + for (int k = 0; k < RH_FRAME_SAMPLES; k++) { + int32_t v = rh->mix[k]; + if (v > 32767) v = 32767; + else if (v < -32768) v = -32768; + pcm[k] = (int16_t)v; + } + for (struct radio_headless_client* c = rh->clients; c; c = c->next) { + if (c->group_id == rh->group_id && !c->closing) + rh_write_frame(c, RADIO_AUDIO_FRAME, rh->group_id, (const uint8_t*)pcm, RH_PCM_FRAME_BYTES); + } + } + } + + rh->mix_timer = uasync_set_timeout(rh->ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix"); +} + +/* ── разбор входящих фреймов ── */ + +static void rh_parse_frames(struct radio_headless_client* cli) { + struct radio_headless* rh = cli->rh; + uint8_t* p = cli->recv_buf; + int avail = cli->recv_len; + int consumed = 0; + while (avail - consumed >= RADIO_AUDIO_HDR_SIZE) { + uint8_t type = p[consumed]; + uint64_t group_id; memcpy(&group_id, p + consumed + 1, 8); + uint16_t flen = (uint16_t)((p[consumed + 9] << 8) | p[consumed + 10]); + if (flen > RH_MAX_FRAME) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: frame too big %u — closing", RH_ID, flen); + rh_close_client(cli); + return; + } + if (avail - consumed < (int)(RADIO_AUDIO_HDR_SIZE + flen)) break; + const uint8_t* data = p + consumed + RADIO_AUDIO_HDR_SIZE; + + switch (type) { + case RADIO_AUDIO_HELLO: + cli->group_id = group_id; + if (rh->group_id == 0) rh->group_id = group_id; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: client bound to group=0x%016llx", RH_ID, (unsigned long long)group_id); + break; + case RADIO_AUDIO_PTT_START: + radio_talk_begin(rh->inst, group_id); + break; + case RADIO_AUDIO_PTT_STOP: + radio_talk_end(rh->inst, group_id); + break; + case RADIO_AUDIO_FRAME: + if (flen != RH_PCM_FRAME_BYTES) { + rh->c_enc_dropped++; + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: bad PCM frame size=%u (expect %d) — dropped", + RH_ID, flen, RH_PCM_FRAME_BYTES); + } else { + opus_codec_encoder_t* enc = rh_encoder(rh, group_id); + uint8_t opus[256]; + int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, RH_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; + if (olen <= 0) { + rh->c_enc_dropped++; + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: encode failed (olen=%d) group=0x%016llx", + RH_ID, olen, (unsigned long long)group_id); + } else { + rh->c_enc_frames++; + if (radio_talk_send(rh->inst, group_id, opus, olen) != 0) + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_send failed group=0x%016llx len=%d", + RH_ID, (unsigned long long)group_id, olen); + } + } + break; + default: + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: unknown frame type=0x%02x — closing", RH_ID, type); + rh_close_client(cli); + return; + } + consumed += RADIO_AUDIO_HDR_SIZE + flen; + } + if (consumed > 0) { + memmove(cli->recv_buf, cli->recv_buf + consumed, (size_t)(cli->recv_len - consumed)); + cli->recv_len -= consumed; + } +} + +/* ── socket callbacks ── */ + +static void rh_write_callback(socket_t fd, void* arg) { + struct radio_headless_client* cli = (struct radio_headless_client*)arg; + struct radio_headless* rh = cli ? cli->rh : NULL; + if (!cli || cli->closing || fd != cli->fd) return; + if (cli->send_len <= 0) { if (rh && rh->ua) uasync_set_socket_write(rh->ua, cli->socket_id, 0); return; } + ssize_t r = send(cli->fd, cli->send_buf + cli->send_offset, cli->send_len - cli->send_offset, 0); + if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) return; + if (r <= 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: send failed (r=%zd err=%d) — closing client", RH_ID, r, socket_get_error()); + rh_close_client(cli); + return; + } + cli->send_offset += (int)r; + if (cli->send_offset >= cli->send_len) { cli->send_len = 0; cli->send_offset = 0; if (rh && rh->ua) uasync_set_socket_write(rh->ua, cli->socket_id, 0); } +} + +static void rh_close_client(struct radio_headless_client* cli) { + struct radio_headless* rh = cli ? cli->rh : NULL; + if (!cli || cli->closing) return; + cli->closing = 1; + if (rh && rh->ua && cli->socket_id) uasync_remove_socket_t(rh->ua, cli->fd); + if (cli->fd != SOCKET_INVALID) { socket_close_wrapper(cli->fd); cli->fd = SOCKET_INVALID; } + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: client closed group=0x%016llx", RH_ID, (unsigned long long)cli->group_id); +} + +static void rh_read_callback(socket_t fd, void* arg) { + struct radio_headless_client* cli = (struct radio_headless_client*)arg; + if (!cli || cli->closing) return; + uint8_t tmp[4096]; + ssize_t r = recv(fd, tmp, (int)sizeof(tmp), 0); + if (r <= 0) { rh_close_client(cli); return; } + if (cli->recv_len + (int)r > RH_RECV_BUF_SIZE) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: recv buffer overflow — closing", RH_ID); + rh_close_client(cli); + return; + } + memcpy(cli->recv_buf + cli->recv_len, tmp, (size_t)r); + cli->recv_len += (int)r; + rh_parse_frames(cli); +} + +static void rh_accept_callback(socket_t fd, void* arg) { + struct radio_headless* rh = (struct radio_headless*)arg; + struct sockaddr_storage addr; socklen_t alen = sizeof(addr); + socket_t cfd = accept(fd, (struct sockaddr*)&addr, &alen); + if (cfd == SOCKET_INVALID) { + int e = socket_get_error(); + if (e != ERR_AGAIN && e != ERR_WOULDBLOCK) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: accept failed: %s", RH_ID, socket_strerror(e)); + return; + } + socket_set_nonblocking(cfd); + if (rh->client_count >= RH_MAX_CLIENTS) { socket_close_wrapper(cfd); DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: max clients reached", RH_ID); return; } + + struct radio_headless_client* cli = u_calloc(1, sizeof(*cli)); + if (!cli) { socket_close_wrapper(cfd); return; } + cli->fd = cfd; + cli->rh = rh; + cli->socket_id = uasync_add_socket_t(rh->ua, cfd, rh_read_callback, rh_write_callback, NULL, cli); + if (!cli->socket_id) { u_free(cli); socket_close_wrapper(cfd); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: failed to register client fd", RH_ID); return; } + + cli->next = rh->clients; rh->clients = cli; rh->client_count++; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: client connected fd=%d total=%d", RH_ID, (int)cfd, rh->client_count); +} + +/* ── периодическая очистка закрытых клиентов ── */ + +static void rh_cleanup(void* arg) { + struct radio_headless* rh = (struct radio_headless*)arg; + struct radio_headless_client** prev = &rh->clients; + while (*prev) { + if ((*prev)->closing) { + struct radio_headless_client* dead = *prev; *prev = dead->next; + if (dead->fd != SOCKET_INVALID) socket_close_wrapper(dead->fd); + u_free(dead); rh->client_count--; + } else prev = &(*prev)->next; + } + if (rh->ua) uasync_set_timeout(rh->ua, 5000, rh, rh_cleanup, "rh_cleanup"); +} + +/* ── init / destroy ── */ + +int radio_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, + const char* bind_ip, int port) { + if (!ua || !inst || !bind_ip || port <= 0 || port > 65535) return -1; + struct radio_headless* rh = rh_of(inst); + if (rh && rh->running) return 0; + + if (!rh) { + rh = u_calloc(1, sizeof(*rh)); + if (!rh) return -1; + inst->radio_headless = rh; + } + rh->ua = ua; rh->inst = inst; + + rh->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + if (rh->listen_fd == SOCKET_INVALID) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: socket() failed: %s", RH_ID, socket_strerror(socket_get_error())); return -1; } + socket_set_reuseaddr(rh->listen_fd, 1); + socket_set_nonblocking(rh->listen_fd); + + struct sockaddr_in sin; memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; sin.sin_port = htons((uint16_t)port); + if (inet_pton(AF_INET, bind_ip, &sin.sin_addr) != 1) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; } + if (bind(rh->listen_fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: bind(%s:%d) failed: %s", RH_ID, bind_ip, port, socket_strerror(socket_get_error())); + socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; + } + if (listen(rh->listen_fd, 5) < 0) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; } + + rh->listen_sock_id = uasync_add_socket_t(ua, rh->listen_fd, rh_accept_callback, NULL, NULL, rh); + 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); + 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->running = 1; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: audio socket listening on %s:%d", RH_ID, bind_ip, port); + return 0; +} + +void radio_headless_destroy(struct UTUN_INSTANCE* inst) { + struct radio_headless* rh = rh_of(inst); + if (!rh || !rh->running) return; + rh->running = 0; + radio_set_frame_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; } + if (rh->enc) { opus_codec_encoder_destroy(rh->enc); rh->enc = NULL; } + for (int i = 0; i < RH_MAX_SOURCES; i++) if (rh->sources[i].used) rh_src_free(&rh->sources[i]); + struct radio_headless_client* c = rh->clients; + while (c) { + struct radio_headless_client* n = c->next; + if (c->fd != SOCKET_INVALID) socket_close_wrapper(c->fd); + u_free(c); + c = n; + } + rh->clients = NULL; rh->client_count = 0; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)", + RH_ID, rh->c_enc_frames, rh->c_dec_frames, rh->c_enc_dropped, rh->c_dec_dropped); +} diff --git a/src/radio/radio_headless.h b/src/radio/radio_headless.h new file mode 100644 index 00000000..0bdf9550 --- /dev/null +++ b/src/radio/radio_headless.h @@ -0,0 +1,43 @@ +// radio_headless.h — headless-аудио-сокет рации (PCM, без тактирования). +// +// Отдельный TCP-сокет, через который headless-клиент (CLI/скрипт) обменивается +// готовым PCM с ядром рации. Ядро кодирует PCM→Opus (TX), принимает Opus от пиров, +// микширует их в общий PCM (RX, простой микшер без time-stretch — тактирование на +// стороне клиента, как в call_headless). Управление рацией/PTT — через фреймы +// PTT_START/PTT_STOP и команды radio_on/radio_off на control-сокете. +// +// Бинарный фрейм (обе стороны): +// [type:1][group_id:8][len:2][data:len] +// type = 0x01 HELLO (клиент → ядро: привязаться к group_id, data пусто) +// type = 0x02 PTT_START (клиент → ядро: radio_talk_begin, data пусто) +// type = 0x03 PTT_STOP (клиент → ядро: radio_talk_end, data пусто) +// type = 0x04 FRAME (PCM int16 mono 48k, 960 сэмплов = 1920 байт) +// клиент → ядро = PCM захвата (кодируется и флудится подписчикам); +// ядро → клиент = микшированный PCM всех говорящих +#ifndef RADIO_HEADLESS_H +#define RADIO_HEADLESS_H + +#include + +struct UASYNC; +struct UTUN_INSTANCE; + +#define RADIO_AUDIO_HELLO 0x01 +#define RADIO_AUDIO_PTT_START 0x02 +#define RADIO_AUDIO_PTT_STOP 0x03 +#define RADIO_AUDIO_FRAME 0x04 +#define RADIO_AUDIO_HDR_SIZE 11 /* type(1) + group_id(8) + len(2) */ +#define RADIO_AUDIO_PCM_FRAME_BYTES (960 * (int)sizeof(int16_t)) /* 1920 — PCM int16 mono 48k 20мс */ + +#ifdef __cplusplus +extern "C" { +#endif + +int radio_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, + const char* bind_ip, int port); +void radio_headless_destroy(struct UTUN_INSTANCE* inst); + +#ifdef __cplusplus +} +#endif +#endif /* RADIO_HEADLESS_H */ diff --git a/src/radio/radio_proto.h b/src/radio/radio_proto.h new file mode 100644 index 00000000..d80c94c8 --- /dev/null +++ b/src/radio/radio_proto.h @@ -0,0 +1,39 @@ +// radio_proto.h — wire-формат и константы рации (PTT walkie-talkie мультикаст). +// +// Транспорт: raw ETCP (etcp_bind/etcp_send), первый байт кодограммы = ETCP_ID_RADIO. +// Пакет флудится по senders_list CHAT-группы с hop-list против циклов и +// RPF-обрезанием ветвей без подписчиков (узел с radio==1 в NODEINFO). +// +// Формат payload (после dgram[0] = ETCP_ID_RADIO): +// [group_id:8][src_node_id:8][stream_id:2][seq:2][flags:1][hop_count:1] +// [hop_list:8*hop_count][opus...] +// flags: bit0 = FIN (последний кадр передачи) +// stream_id — случайный идентификатор передачи (talk-burst), seq нумерует кадры от 0. +#ifndef RADIO_PROTO_H +#define RADIO_PROTO_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define RADIO_FLAG_FIN 0x01 + +#define RADIO_MAX_HOPS 16 +#define RADIO_MAX_OPUS 256 + +#define RADIO_HDR_SIZE 22 /* group(8)+src(8)+stream(2)+seq(2)+flags(1)+hop_count(1), без hop_list */ + +#define RADIO_OFF_GROUP 0 +#define RADIO_OFF_SRC 8 +#define RADIO_OFF_STREAM 16 +#define RADIO_OFF_SEQ 18 +#define RADIO_OFF_FLAGS 20 +#define RADIO_OFF_HOPS 21 +#define RADIO_OFF_HOPLIST 22 + +#ifdef __cplusplus +} +#endif +#endif /* RADIO_PROTO_H */ diff --git a/src/routing_layer/topo_group.c b/src/routing_layer/topo_group.c index da0329e8..a1c94b4b 100644 --- a/src/routing_layer/topo_group.c +++ b/src/routing_layer/topo_group.c @@ -27,6 +27,7 @@ #include "topo_group_connect.h" #include "topo_recovery.h" #include "broadcast.h" +#include "../radio/radio.h" #include "../chat/chat_setting.h" #define TOPO_GROUP_UTUN 0x8000000000000000ULL @@ -355,6 +356,7 @@ static struct TOPO_GROUP* topo_group_create(struct UTUN_INSTANCE* instance, uint group->conn_mgr = conn_mgr_init(group); broadcast_init(group); + radio_init(group); DEBUG_INFO(DEBUG_CATEGORY_BGP, "TOPO group created: group_id=%016llx", (unsigned long long)group_id); return group; @@ -374,6 +376,9 @@ static void topo_group_destroy(struct TOPO_GROUP* group) { broadcast_destroy(group); DEBUG_INFO(DEBUG_CATEGORY_SYS, "[GRP_DESTROY] 2b broadcast_destroy done"); + radio_destroy(group); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "[GRP_DESTROY] 2c radio_destroy done"); + struct ll_entry* e; while ((e = queue_data_get(group->senders_list)) != NULL) { struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)e; @@ -628,6 +633,48 @@ void topo_group_set_nat_check_local(struct TOPO_GROUP* group, int allow) { nat_detection_set_allow_local(group->instance->nat_det, allow); } +/* ── Рация (PTT walkie-talkie, per-CHAT-группа) ── */ + +void topo_group_set_radio(struct TOPO_GROUP* group, int on) { + if (!group || !group->instance) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "set_radio: invalid args"); return; } + if (group->group_type != TOPO_GROUP_TYPE_CHAT) { + DEBUG_WARN(DEBUG_CATEGORY_BGP, "set_radio: only CHAT groups support radio grp=%016llx type=%u", + (unsigned long long)group->group_id, group->group_type); + return; + } + uint8_t v = on ? 1 : 0; + if (group->radio_active == v) { + DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "set_radio: unchanged (%u) grp=%016llx ch=%s", v, + (unsigned long long)group->group_id, group->channel_id); + return; + } + group->radio_active = v; + DEBUG_INFO(DEBUG_CATEGORY_BGP, "set_radio: %s grp=%016llx ch=%s", + v ? "ON" : "OFF", (unsigned long long)group->group_id, group->channel_id); + topo_group_update_my_nodeinfo(group->instance, group); + if (group->senders_list) { + struct ll_entry* se = group->senders_list->head; + while (se) { + struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)se->data; + if (item && item->conn && group->local_node) + topo_group_send_nodeinfo(group, group->local_node, item->conn, 0); + se = se->next; + } + } +} + +int topo_group_radio_subscribers(struct TOPO_GROUP* group) { + if (!group || !group->nodes) return 0; + int n = 0; + struct ll_entry* e = group->nodes->head; + while (e) { + struct TOPO_GROUP_NODE* nq = (struct TOPO_GROUP_NODE*)e; + if (nq->radio && nq->node_id != group->instance->node_id) n++; + e = e->next; + } + return n; +} + /* Устанавливает колбэк сохранения узла в БД (используется chatgui/member_sync). */ void topo_groups_set_node_updated_cb(struct TOPO_GROUPS* groups, topo_node_updated_fn fn) { if (groups) groups->node_updated_cb = fn; @@ -981,6 +1028,7 @@ int topo_group_process_nodeinfo(struct TOPO_GROUP* group, struct ETCP_CONN* from (unsigned long long)node_id, (unsigned long long)group->group_id, group->group_type, group->channel_id); nodeinfo1->paths = paths; nodeinfo1->last_ver = new_ver; + nodeinfo1->radio = (ni->flags & TOPO_FLAG_RADIO) ? 1 : 0; uint64_t hop_list[MAX_HOPS]; if (new_hop_count > 0) memcpy(hop_list, new_hop_list, new_hop_count * 8); diff --git a/src/routing_layer/topo_group.h b/src/routing_layer/topo_group.h index 7f37bd8a..324d2fe2 100644 --- a/src/routing_layer/topo_group.h +++ b/src/routing_layer/topo_group.h @@ -51,6 +51,7 @@ struct CONN_MGR; struct TOPO_RECOVERY_CTX; struct TOPO_GROUP_CONNECT; struct broadcast_ctx; +struct radio_ctx; /** Callback when a node's info (pubkeys, addresses) is persisted in the DB */ typedef void (*topo_node_updated_fn)(struct UTUN_INSTANCE* inst, uint64_t node_id, @@ -191,6 +192,8 @@ struct TOPO_GROUP { struct TOPO_RECOVERY_CTX* recovery_list; // список активных процедур восстановления struct TOPO_GROUP_CONNECT* connect; // авто-подключение к узлам группы (CHAT) struct broadcast_ctx* broadcast; // broadcast protocol per-group context + struct radio_ctx* radio; // radio (PTT walkie-talkie) per-group context + uint8_t radio_active; // 1 = я слушаю рацию этого канала (TOPO_FLAG_RADIO в NODEINFO) struct topo_node_cbk_entry* node_cbks; // цепочка подписчиков на события узлов }; @@ -346,7 +349,16 @@ void topo_groups_set_node_updated_cb(struct TOPO_GROUPS* groups, topo_node_updat /** Разрешить/запретить NAT check для локальных подсетей (тестовый хелпер, делегат в nat_detection) */ void topo_group_set_nat_check_local(struct TOPO_GROUP* group, int allow); -/* ── BGP node event callbacks ── */ +/* ── Рация (PTT walkie-talkie, per-CHAT-группа) ── */ + +/* Включить/выключить прослушивание рации канала: выставляет local_node->radio и + * рассылает NODEINFO (TOPO_FLAG_RADIO) через BGP. Работает только для CHAT-групп. */ +void topo_group_set_radio(struct TOPO_GROUP* group, int on); + +/* Число узлов группы, слушающих рацию (radio==1), без учёта себя. */ +int topo_group_radio_subscribers(struct TOPO_GROUP* group); + +/* BGP node event callbacks ── */ void topo_group_add_node_cbk(struct TOPO_GROUP* group, topo_node_event_fn fn, void* arg); void topo_group_remove_node_cbk(struct TOPO_GROUP* group, topo_node_event_fn fn, void* arg); diff --git a/src/routing_layer/topo_group_connect.c b/src/routing_layer/topo_group_connect.c index 29863d1a..7230a1c0 100644 --- a/src/routing_layer/topo_group_connect.c +++ b/src/routing_layer/topo_group_connect.c @@ -1,8 +1,8 @@ /* - * topo_group_connect.c — авто-подключение к узлам группы (бесконечный цикл до успеха) + * topo_group_connect.c — авто-подключение к узлам группы (бесконечный цикл до цели) * * Phase 1: одновременный запуск node_conn_direct_open для пиров с connected=1. - * Таймаут = TGC_DIRECT_TIMEOUT_MS. Если хоть один подключился → done. + * Таймаут = TGC_DIRECT_TIMEOUT_MS. Если цель достигнута → done. * * Phase 2: последовательный перебор (supernode, затем public/EIM адреса). * После каждого TIMEOUT — пауза перед следующей попыткой. @@ -12,9 +12,15 @@ * * После исчерпания Phase 3 → пауза → cycle_restart → Phase 1 (бесконечно). * + * Цель (tgc_goal_reached): подключены хотя бы к одному суперузлу (node_type==4) + * ЛИБО к 3+ не-мобильным клиентам (device_type != MOBILE, не суперузлам). + * Пока цель не достигнута, успешное подключение НЕ останавливает цикл — + * продолжается перебор следующих кандидатов. + * * Пауза: активный режим — 1с; фоновый (Android standby) — standby_wait * (burst → сразу, sleep → до следующего burst). - * При DOWN (active_conn_count → 0) — немедленный cycle_restart. + * При DOWN, если цель перестала выполняться — немедленный cycle_restart + * (уже установленные соединения не трогаются). * При повторном topo_group_connect_init — destroy + fresh init. * * Счётчик active_conn_count — число уникальных peer-узлов с реальными соединениями. @@ -33,6 +39,7 @@ #include "../lib/u_async.h" #include "../lib/ll_queue.h" #include "etcp.h" +#include "etcp_connections.h" #include "../chat/chat_event.h" #ifdef UTUN_HAVE_STANDBY #include "standby.h" @@ -48,6 +55,8 @@ #define TGC_PHASE_PAUSE 4 #define TGC_MAX_HANDLES 128 #define TGC_PAUSE_ACTIVE_TB 10000 /* 1s пауза в активном режиме */ +#define TGC_GOAL_SUPERNODES 1 /* цель: подключены хотя бы к одному суперузлу */ +#define TGC_GOAL_NONMOBILE 3 /* цель: подключены к 3+ не-мобильным клиентам (не суперузлам) */ struct TOPO_GROUP_CONNECT { struct TOPO_GROUP* group; @@ -72,6 +81,7 @@ static void tgc_phase3_try_next(struct TOPO_GROUP_CONNECT* gc); static void tgc_phase1_timeout(void* arg); static void tgc_cycle_restart(struct TOPO_GROUP_CONNECT* gc); static void tgc_start_pause(struct TOPO_GROUP_CONNECT* gc, void (*cb)(void*), const char* label); +static int tgc_goal_reached(struct TOPO_GROUP_CONNECT* gc); static void tgc_callback(struct NODE_CONN_DIRECT* h, enum ncd_event event, void* arg); @@ -97,6 +107,58 @@ static void tgc_notify_connecting(struct TOPO_GROUP_CONNECT* gc, const uint64_t* TGC_ID, gc->group->channel_id, count); } +/* ═══════════════════════════════════════════════════════════════════════ + * Условие достижения цели авто-подключения + * ══════════════════════════════════════════════════════════════════════ */ + +/* Тип устройства пира из handshake (peer_device_type на первом UP-линке). */ +static uint8_t tgc_peer_device_type(struct ETCP_CONN* conn) { + if (!conn || !conn->links) return CLIENT_TYPE_SERVER; + return conn->links->peer_device_type; +} + +/** + * Достигнута ли цель подключения к группе: + * - подключены хотя бы к одному суперузлу (peers node_type==4), ЛИБО + * - подключены к 3+ не-мобильным клиентам (device_type != MOBILE и не суперузел). + * + * Сканирует senders_list (активные BGP-пиры) с дедупом по node_id. + * Состояние не хранится — пересчитывается на лету в редких точках принятия + * решения (таймаут фазы, каждый успех, каждый down). + */ +static int tgc_goal_reached(struct TOPO_GROUP_CONNECT* gc) { + if (!gc || !gc->group || !gc->group->senders_list) return 0; + struct TOPO_GROUP* group = gc->group; + sqlite3* db = group->instance->topo_sqlite_db; + + uint64_t seen[TGC_MAX_HANDLES]; int seen_count = 0; + int supernode_count = 0, nonmobile_count = 0; + + struct ll_entry* e = group->senders_list->head; + while (e) { + struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)e->data; + struct ETCP_CONN* conn = item ? item->conn : NULL; + if (conn) { + int dup = 0; + for (int i = 0; i < seen_count; i++) if (seen[i] == conn->peer_node_id) { dup = 1; break; } + if (!dup && seen_count < TGC_MAX_HANDLES) { + seen[seen_count++] = conn->peer_node_id; + int is_super = db && topo_node_sqlite_get_node_type(db, group->channel_id, conn->peer_node_id) == 4; + int is_mobile = tgc_peer_device_type(conn) == CLIENT_TYPE_MOBILE; + if (is_super) supernode_count++; + else if (!is_mobile) nonmobile_count++; + } + } + e = e->next; + } + + int reached = (supernode_count >= TGC_GOAL_SUPERNODES) || (nonmobile_count >= TGC_GOAL_NONMOBILE); + DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: goal ch=%s super=%d/%d nonmobile=%d/%d → %s", + TGC_ID, group->channel_id, supernode_count, TGC_GOAL_SUPERNODES, + nonmobile_count, TGC_GOAL_NONMOBILE, reached ? "reached" : "not reached"); + return reached; +} + /* ═══════════════════════════════════════════════════════════════════════ * Жизненный цикл * ══════════════════════════════════════════════════════════════════════ */ @@ -259,8 +321,8 @@ void topo_group_connect_on_down(struct TOPO_GROUP* group, struct ETCP_CONN* conn if (db) topo_node_sqlite_set_connected(db, group->channel_id, peer, 0); DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: DOWN peer=0x%016llx ch=%s active=%d", TGC_ID, (unsigned long long)peer, group->channel_id, gc->active_conn_count); - if (gc->active_conn_count == 0) { - DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: DOWN no connections — cycle restart ch=%s", TGC_ID, group->channel_id); + if (!tgc_goal_reached(gc)) { + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: DOWN goal not reached — cycle restart ch=%s", TGC_ID, group->channel_id); tgc_cycle_restart(gc); } } @@ -272,8 +334,8 @@ void topo_group_connect_on_down(struct TOPO_GROUP* group, struct ETCP_CONN* conn /** * Единый коллбэк на результат каждой попытки подключения (успех/таймаут/обрыв). * В Phase 1 — просто считает результаты (итог подводит tgc_phase1_timeout). - * В Phase 2/3 — успех завершает цикл; провал закрывает handle (чтобы не оставить - * залипшее состояние) и ставит паузу перед следующей попыткой. + * В Phase 2/3 — успех проверяет цель: достигнута → done, иначе следующий кандидат; + * провал закрывает handle (чтобы не оставить залипшее состояние) и ставит паузу. */ /* Передача владения: после topo_group_new_conn (который взял свой NCD-handle) * закрываем connect-фазу handle, чтобы владельцем conn остался один BGP-хэндл @@ -312,9 +374,14 @@ static void tgc_callback(struct NODE_CONN_DIRECT* h, enum ncd_event event, void* break; case TGC_PHASE_TWO: if (ok) { - DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 connected to 0x%016llx — done", TGC_ID, (unsigned long long)node_id); - gc->phase = TGC_PHASE_DONE; - tgc_notify_connecting(gc, NULL, 0); + if (tgc_goal_reached(gc)) { + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 connected to 0x%016llx — goal reached, done", TGC_ID, (unsigned long long)node_id); + gc->phase = TGC_PHASE_DONE; + tgc_notify_connecting(gc, NULL, 0); + } else { + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 connected to 0x%016llx — goal not reached, try next", TGC_ID, (unsigned long long)node_id); + tgc_phase2_try_next(gc); + } } else { DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 0x%016llx FAIL — pause then try next", TGC_ID, (unsigned long long)node_id); if (gc->cur_handle) { node_conn_direct_close(gc->cur_handle); gc->cur_handle = NULL; } @@ -323,9 +390,14 @@ static void tgc_callback(struct NODE_CONN_DIRECT* h, enum ncd_event event, void* break; case TGC_PHASE_THREE: if (ok) { - DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 connected to 0x%016llx — done", TGC_ID, (unsigned long long)node_id); - gc->phase = TGC_PHASE_DONE; - tgc_notify_connecting(gc, NULL, 0); + if (tgc_goal_reached(gc)) { + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 connected to 0x%016llx — goal reached, done", TGC_ID, (unsigned long long)node_id); + gc->phase = TGC_PHASE_DONE; + tgc_notify_connecting(gc, NULL, 0); + } else { + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 connected to 0x%016llx — goal not reached, try next", TGC_ID, (unsigned long long)node_id); + tgc_phase3_try_next(gc); + } } else { DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 0x%016llx FAIL — pause then try next", TGC_ID, (unsigned long long)node_id); if (gc->cur_handle) { node_conn_direct_close(gc->cur_handle); gc->cur_handle = NULL; } @@ -368,8 +440,8 @@ static void tgc_start_pause(struct TOPO_GROUP_CONNECT* gc, void (*cb)(void*), co /** * Таймаут параллельных попыток Phase 1 (2 секунды). Закрывает неудачные - * handles и сбрасывает connected в БД. Если хоть один узел подключился — - * цикл завершён; иначе переходим к Phase 2. + * handles и сбрасывает connected в БД. Если цель достигнута — цикл завершён; + * иначе переходим к Phase 2 (дополнительные попытки). */ static void tgc_phase1_timeout(void* arg) { struct TOPO_GROUP_CONNECT* gc = (struct TOPO_GROUP_CONNECT*)arg; @@ -400,12 +472,13 @@ static void tgc_phase1_timeout(void* arg) { } gc->handle_count = remaining; - if (gc->connected_count > 0) { - DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase1 done — %d connected, skipping Phase 2", TGC_ID, gc->connected_count); + if (tgc_goal_reached(gc)) { + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase1 done — goal reached (%d connected), skipping Phase 2", TGC_ID, gc->connected_count); gc->phase = TGC_PHASE_DONE; tgc_notify_connecting(gc, NULL, 0); return; } + DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase1 done — goal not reached (%d connected) → Phase 2", TGC_ID, gc->connected_count); gc->phase = TGC_PHASE_TWO; tgc_phase2_try_next(gc); } diff --git a/src/routing_layer/topo_group_connect.h b/src/routing_layer/topo_group_connect.h index f72db192..e638e9bb 100644 --- a/src/routing_layer/topo_group_connect.h +++ b/src/routing_layer/topo_group_connect.h @@ -1,19 +1,23 @@ /** * @file topo_group_connect.h - * @brief Авто-подключение к узлам группы — бесконечный цикл до успеха. + * @brief Авто-подключение к узлам группы — бесконечный цикл до цели. * * При старте CHAT-группы (topo_group_connect_init): * Phase 1 — параллельный запуск ко всем пирам с connected=1 (таймаут 2с). - * Если хоть один подключился → done. + * Если цель достигнута → done. * Phase 2 — последовательный перебор (supernode, затем public/EIM). * После каждого TIMEOUT — пауза перед следующей попыткой. * Phase 3 — последовательный перебор локальных/strict NAT. * После каждого TIMEOUT — пауза перед следующей попыткой. * После исчерпания Phase 3 → пауза → cycle_restart → Phase 1 (бесконечно). * + * Цель (tgc_goal_reached): подключены хотя бы к одному суперузлу (node_type==4) + * ЛИБО к 3+ не-мобильным клиентам (device_type != MOBILE, не суперузлам). + * Пока цель не достигнута, успех НЕ останавливает цикл. + * * Пауза: активный режим — 1с; фоновый (Android standby) — standby_wait * (burst → сразу, sleep → до следующего burst). - * При DOWN (active_conn_count → 0) — немедленный cycle_restart из любой фазы. + * При DOWN, если цель перестала выполняться — немедленный cycle_restart. * При повторном вызове topo_group_connect_init — destroy + fresh init. * * active_conn_count — количество уникальных peer-узлов с живыми соединениями. diff --git a/src/routing_layer/topo_node.c b/src/routing_layer/topo_node.c index a63e5acd..432f4136 100644 --- a/src/routing_layer/topo_node.c +++ b/src/routing_layer/topo_node.c @@ -376,6 +376,7 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq, msg.client_type = ni->client_type; msg.client_activity = ni->client_activity; msg.cumulative_rtt = cumulative_rtt; + if (nq->radio) msg.flags |= TOPO_FLAG_RADIO; size_t total = TOPOMSG_NODE_HDR_SIZE + (size_t)topo_node_dyn_size(&msg); if (total > out_max) return -1; @@ -853,6 +854,7 @@ int topo_group_update_my_nodeinfo(struct UTUN_INSTANCE* instance, struct TOPO_GR || (vc6 != (group->local_node->subnets ? topo_list_count((struct _topo_head*)group->local_node->subnets->v6_subnets) : 0)) || (oni->client_type != instance->client_type) || (oni->client_activity != instance->client_activity) + || (group->local_node->radio != group->radio_active) || (memcmp(oni->ed25519_public_key, group->ed25519_public_key, SC_PUBKEY_SIZE) != 0); } } @@ -879,6 +881,7 @@ int topo_group_update_my_nodeinfo(struct UTUN_INSTANCE* instance, struct TOPO_GR ni->ver = (old_ver % 255) + 1; ni->client_type = instance->client_type; ni->client_activity = instance->client_activity; + lq->radio = group->radio_active; memcpy(ni->public_key, instance->my_keys.public_key, SC_PUBKEY_SIZE); memcpy(ni->ed25519_public_key, group->ed25519_public_key, SC_PUBKEY_SIZE); if (name_len) { ni->node_name = u_malloc(name_len + 1); if (ni->node_name) { memcpy(ni->node_name, instance->name, name_len); ni->node_name[name_len] = 0; } } diff --git a/src/routing_layer/topo_node.h b/src/routing_layer/topo_node.h index 3ddf9364..e6bb246f 100644 --- a/src/routing_layer/topo_node.h +++ b/src/routing_layer/topo_node.h @@ -82,6 +82,7 @@ struct UTUN_INSTANCE; #define NCONN_BGP (1 << 2) // узел виден в топологии (BGP) #define TOPO_FLAG_SEND_SUBNETS 0x01 +#define TOPO_FLAG_RADIO 0x02 #define TOPO_GROUP_UTUN 0x8000000000000000ULL #define TOPO_GROUP_TYPE_UTUN 1 #define TOPO_GROUP_TYPE_CHAT 2 @@ -211,6 +212,7 @@ struct TOPO_GROUP_NODE { struct TOPO_CONNECTIVITY connectivity; // результаты ping-проб (interface/nat/real) uint8_t conn_presence; // NODE_CONN_* — какие подключения есть в принципе uint8_t conn_up; // NODE_CONN_* — какие из них подняты + uint8_t radio; // 1 = узел слушает рацию канала (TOPO_FLAG_RADIO) struct NODE_CONN_DIRECT* handle; // активный ncd-handle прямого соединения к узлу }; diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index b3d419e4..fe426bdd 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -1237,3 +1237,17 @@ int topo_node_sqlite_get_local_peers(sqlite3* db, const char* channel_id, *out_ids = ids; *out_count = cnt; return 0; } + +int topo_node_sqlite_get_node_type(sqlite3* db, const char* channel_id, uint64_t node_id) { + if (!db || !channel_id || !node_id) return -1; + char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT node_type FROM \"%s\" WHERE node_id=?", peers_tbl); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return -1; + sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); + int node_type = -1; + if (sqlite3_step(st) == SQLITE_ROW) node_type = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + return node_type; +} diff --git a/src/routing_layer/topo_node_sqlite.h b/src/routing_layer/topo_node_sqlite.h index 19b31baf..52a8db11 100644 --- a/src/routing_layer/topo_node_sqlite.h +++ b/src/routing_layer/topo_node_sqlite.h @@ -112,4 +112,7 @@ int topo_node_sqlite_get_public_peers(sqlite3* db, const char* channel_id, uint6 int topo_node_sqlite_get_local_peers(sqlite3* db, const char* channel_id, uint64_t** out_ids, int* out_count, uint64_t self_node_id); int topo_node_sqlite_get_supernode_peers(sqlite3* db, const char* channel_id, uint64_t** out_ids, int* out_count); +/* node_type узла в канале (peers_.node_type); -1 если нет записи/ошибка. */ +int topo_node_sqlite_get_node_type(sqlite3* db, const char* channel_id, uint64_t node_id); + #endif diff --git a/src/transport_layer/etcp_api.h b/src/transport_layer/etcp_api.h index 98cd8e34..08dfa520 100644 --- a/src/transport_layer/etcp_api.h +++ b/src/transport_layer/etcp_api.h @@ -44,8 +44,8 @@ extern "C" { // (raw-пространство) — помечены «(raw)»; их значения занимают raw-реестр. // // raw: 0x00 DATA, 0x01 TOPO_ENTRY, 0x02 NAT_DETECTION, 0x03 SVC_ROUTE, -// 0x04 NTP_TIME, 0x05 BROADCAST, 0x12 NCD_CONTROL, 0x13 GROUP_INVITE, -// 0x20 DB_SYNC, 0x30 CHAT_SYNC, 0x31 MEMBER_SYNC +// 0x04 NTP_TIME, 0x05 BROADCAST, 0x06 RADIO, 0x12 NCD_CONTROL, +// 0x13 GROUP_INVITE, 0x20 DB_SYNC, 0x30 CHAT_SYNC, 0x31 MEMBER_SYNC // router: 0x00 DATA, 0x02 NAT, 0x04 TCP_PROXY_SERVER, 0x05 UDP_PROXY, // 0x06 ICMP_PROXY, 0x07 MEDIA_DELIVERY, 0x08 TCP_PROXY_CLIENT, // 0x11 CONN_MGR, 0x32 ADMIN_KEY, 0x33 JOIN, 0x34 DM, @@ -62,6 +62,7 @@ extern "C" { #define ETCP_ID_SVC_ROUTE 0x03 // транспорт роутера (etcp_router) #define ETCP_ID_NTP_TIME 0x04 // NTP time sync P2P #define ETCP_ID_BROADCAST 0x05 // broadcast-сообщения между пирами +#define ETCP_ID_RADIO 0x06 // рация (PTT мультикаст голоса) между пирами // === Router-сервисы (etcp_router_bind/etcp_route_send) === #define ETCP_RT_ID_DATA 0x00 // routing.c — маршрутизация данных diff --git a/src/utun_instance.c b/src/utun_instance.c index 48985a1e..4709d468 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -23,6 +23,8 @@ #include "call/call.h" #include "call/call_headless.h" #include "broadcast.h" +#include "radio/radio.h" +#include "radio/radio_headless.h" #include "stcp_server.h" #include "control_server.h" #include "transport_layer/node_conn_direct.h" @@ -465,7 +467,8 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) { topo_groups_destroy(instance); } broadcast_destroy_instance(instance); - DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] F done — BGP + broadcast"); + radio_destroy_instance(instance); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] F done — BGP + broadcast + radio"); /* Phase F2: NAT transport — отвязываем и закрываем nat_tun ДО разборки сокетов/соединений, чтобы nat_transport_etcp_recv_cb не был вызван с @@ -497,6 +500,7 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) { dm_core_destroy(instance); call_headless_destroy(instance); call_destroy(instance); + radio_headless_destroy(instance); chat_sync_destroy(instance); chat_core_destroy(instance); DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] H done — chat"); @@ -780,6 +784,20 @@ int utun_instance_init(struct UTUN_INSTANCE *instance) { if (call_headless_init(instance->ua, instance, ip_str, port) != 0) DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to init headless call audio socket (non-fatal)"); } + + // Initialize headless radio audio socket if configured in [chatserver] + if (instance->config->global.radio_audio_bind[0] != '\0') { + char ip_str[64] = "127.0.0.1"; int port = 10002; + const char* colon = strrchr(instance->config->global.radio_audio_bind, ':'); + if (colon) { + size_t ip_len = (size_t)(colon - instance->config->global.radio_audio_bind); + if (ip_len < sizeof(ip_str)) { memcpy(ip_str, instance->config->global.radio_audio_bind, ip_len); ip_str[ip_len] = '\0'; } + port = atoi(colon + 1); + if (port <= 0 || port > 65535) port = 10002; + } + if (radio_headless_init(instance->ua, instance, ip_str, port) != 0) + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to init headless radio audio socket (non-fatal)"); + } // Start the main loop instance->running = 1; @@ -795,6 +813,7 @@ int utun_instance_init(struct UTUN_INSTANCE *instance) { } broadcast_init_instance(instance); + radio_init_instance(instance); DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "Connections initialized successfully"); return 0; diff --git a/src/utun_instance.h b/src/utun_instance.h index fdfcdf31..b2cbe9b1 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -40,6 +40,8 @@ struct join_key_entry; /* chat_join.c */ struct dm_state; struct dm_mb_state; struct call_ctx; /* call/call.c */ +struct radio_instance; /* radio/radio.c */ +struct radio_headless; /* radio/radio_headless.c */ struct uasync_s; struct ROUTE_TABLE; struct ETCP_CONN; @@ -202,6 +204,8 @@ struct UTUN_INSTANCE { struct dm_mb_state* dm_mailbox; // dm/dm_mailbox.c (was g_mb) struct call_ctx* call; // call/call.c — P2P audio call void* call_audio; // call/call_headless.c — audio stream socket + struct radio_instance* radio; // radio/radio.c — PTT walkie-talkie (frame cb + counters) + struct radio_headless* radio_headless; // radio/radio_headless.c — audio stream socket struct chat_setting_state chat_settings; // chat_setting.c (was globals) chat_event_handler_fn chat_event_handler; // chat_event.c (was g_handler) void* headless; // chat_headless_control.c (was g_hc) diff --git a/tests/Makefile.am b/tests/Makefile.am index 2ff70aee..a2dae42e 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -67,6 +67,8 @@ check_PROGRAMS = \ test_dm_e2e \ test_call \ test_call_headless \ + test_radio \ + test_radio_headless \ test_stcp_traffic \ test_bbr_integration \ test_audio_compressor \ @@ -407,6 +409,14 @@ test_call_headless_SOURCES = test_call_headless.c test_call_headless_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/src/dm -I$(top_srcdir)/src/call -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib test_call_headless_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_radio_SOURCES = test_radio.c +test_radio_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/src/dm -I$(top_srcdir)/src/call -I$(top_srcdir)/src/radio -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib +test_radio_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + +test_radio_headless_SOURCES = test_radio_headless.c +test_radio_headless_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/src/dm -I$(top_srcdir)/src/call -I$(top_srcdir)/src/radio -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib +test_radio_headless_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + test_stcp_traffic_SOURCES = test_stcp_traffic.c test_stcp_traffic_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib test_stcp_traffic_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) diff --git a/tests/test_radio.c b/tests/test_radio.c new file mode 100644 index 00000000..35f7b0d7 --- /dev/null +++ b/tests/test_radio.c @@ -0,0 +1,446 @@ +// test_radio.c — интеграционный тест рации (PTT мультикаст голоса). +// +// Однопоточный: 3 узла (A, B, C) в одном процессе, один UASYNC, без fork. +// Топология — цепочка A←B←C (A сервер, B клиент к A + сервер, C клиент к B). +// Все три в одной CHAT-группе. Master state-machine на таймере. +// +// Сценарий: +// 1. A,B,C radio_on → флаг TOPO_FLAG_RADIO распространяется по BGP (node.radio==1). +// 2. A: radio_talk_begin + 5 кадров + radio_talk_end → B и C (через relay B) получают +// кадры в порядке seq 0..4 и FIN. +// 3. C radio_off (обрезание) → A снова говорит → B получает, C не получает. + +#include "chat_core.h" +#include "chat_sync.h" +#include "chat_event.h" +#include "member_sync.h" +#include "../radio/radio.h" +#include "../routing_layer/topo_node_sqlite.h" +#include "../routing_layer/topo_group.h" +#include "../routing_layer/topo_node.h" +#include "../utun_instance.h" +#include "../transport_layer/etcp.h" +#include "../transport_layer/secure_channel.h" +#include "../ntp_time.h" +#include "../lib/u_async.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "../lib/platform_compat.h" + +#include +#define OPENSSL_API_COMPAT 0x10100000L +#include +#include +#include +#include +#include +#include + +#define CH_ID "4242424242424242" + +#define IDX_A 0 +#define IDX_B 1 +#define IDX_C 2 + +#define TICK_TB 100 /* 10 ms на такт */ +#define MAX_TICKS 4000 /* глобальный таймаут ≈ 40 c */ +#define N_FRAMES 5 + +enum rad_phase { + P_WAIT_CONN, + P_SETUP, + P_WAIT_BGP, + P_RADIO_ON, + P_WAIT_FLAGS, + P_TALK1, + P_WAIT_RX1, + P_RADIO_OFF_C, + P_WAIT_OFF_C, + P_TALK2, + P_WAIT_RX2, + P_DONE, +}; + +struct rad_ctx { + struct UASYNC* ua; + struct UTUN_INSTANCE* inst[3]; + enum rad_phase phase; + int result; /* 0=running, 1=ok, 2=fail */ + int ticks; + void* timer; +}; + +/* приёмник кадров (per-узел): проверка порядка seq и FIN */ +struct rx_evt { + int active; + uint16_t stream; + uint16_t expect_seq; + int frames; /* число data-кадров (fin=0) текущего потока */ + int fin_seen; + int seq_ok; +}; + +struct rad_shared { + uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64]; + uint64_t nid[3]; + uint8_t x_pub[3][32], x_priv[3][32], ed_pub[3][32]; + int port[3]; +}; +static struct rad_shared g_sh; +static struct rx_evt g_rx[3]; + +static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0; +#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0) +#define OK() do { G_PASSED++; printf("OK\n"); } while(0) +#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0) + +/* ── крипто-хелперы ── */ + +static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL); + EVP_PKEY* pkey = NULL; + EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); + size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); + l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); + EVP_PKEY_free(pkey); +} + +static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL); + EVP_PKEY* pkey = NULL; + EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); + size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); + l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); + EVP_PKEY_free(pkey); +} + +static void to_hex(const uint8_t* bin, size_t n, char* out) { + for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]); + out[n * 2] = '\0'; +} + +static int wf(const char* p, const char* f, ...) { + va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1; + va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0; +} + +/* ── данные сценария + конфиги (цепочка A←B←C) ── */ + +static void fill_shared(int base_port) { + memset(&g_sh, 0, sizeof(g_sh)); + for (int i = 0; i < 3; i++) { + gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]); + sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]); + g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]); + g_sh.port[i] = base_port + i * 1000; + } + { uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); } + gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv); + { + uint8_t msg[256]; size_t off = 0; + off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1; + off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "radio-test") + 1; + memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8; + memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32; + memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32; + sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig); + } +} + +static void write_configs(const char* dir) { + for (int i = 0; i < 3; i++) { + char priv[65], pub[65], path[512], dbd[512]; + to_hex(g_sh.x_priv[i], 32, priv); + to_hex(g_sh.x_pub[i], 32, pub); + snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c"); + snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c"); + utun_mkdir(dbd, 0755); + + char client[512] = ""; + if (i == IDX_B) { /* B → A */ + char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub); + snprintf(client, sizeof(client), "[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", apub, g_sh.port[IDX_A]); + } else if (i == IDX_C) { /* C → B */ + char bpub[65]; to_hex(g_sh.x_pub[IDX_B], 32, bpub); + snprintf(client, sizeof(client), "[client: to_b]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", bpub, g_sh.port[IDX_B]); + } + + wf(path, + "[global]\ntun_ip=10.98.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n" + "my_private_key=%s\nmy_public_key=%s\n" + "[server: s1]\naddr=127.0.0.1:%d\ntype=public\n" + "%s" + "[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n", + i, i, dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c", + priv, pub, g_sh.port[i], client); + } +} + +/* ── проверки ── */ + +static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) { + struct ETCP_CONN* c = instance_find_conn(inst, nid); + return c && c->initialized && c->links_up; +} + +static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) { + uint64_t gid = strtoull(CH_ID, NULL, 10); + struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); + if (!g) return 0; + struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid); + return nq && nq->paths && nq->paths->head; +} + +static int node_radio(struct UTUN_INSTANCE* inst, uint64_t nid) { + uint64_t gid = strtoull(CH_ID, NULL, 10); + struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); + if (!g) return 0; + struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid); + return nq ? nq->radio : 0; +} + +/* ── frame cb (per-узел) ── */ + +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; + struct rx_evt* rx = (struct rx_evt*)arg; + if (fin) { + if (rx->active && stream == rx->stream) rx->fin_seen = 1; + return; + } + if (!rx->active || stream != rx->stream) { + rx->active = 1; + rx->stream = stream; + rx->expect_seq = 0; + rx->frames = 0; + rx->fin_seen = 0; + rx->seq_ok = 1; + } + if (seq != rx->expect_seq) rx->seq_ok = 0; + rx->expect_seq = (uint16_t)(seq + 1); + rx->frames++; +} + +/* ── настройка канала и мемберов ── */ + +static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) { + topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "radio-test", g_sh.nid[IDX_A], + g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig); + chat_core_ensure_channel_ready(inst, CH_ID); +} + +static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) { + uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst); + uint8_t jm[128]; + int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id, + inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm)); + uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig); + char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); + member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey, + join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); +} + +static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) { + char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); + member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx], + NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); +} + +static void do_setup(struct UTUN_INSTANCE* inst, int role) { + switch (role) { + case IDX_A: + channel_put_shared(inst, 1); + member_put_self(inst, "A"); + member_put_placeholder(inst, IDX_B, "B"); + member_put_placeholder(inst, IDX_C, "C"); + member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL); + break; + case IDX_B: + channel_put_shared(inst, 0); + member_put_self(inst, "B"); + member_put_placeholder(inst, IDX_A, "A"); + member_put_placeholder(inst, IDX_C, "C"); + member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL); + member_sync_start(inst, g_sh.nid[IDX_C], CH_ID, NULL, NULL); + break; + case IDX_C: + channel_put_shared(inst, 0); + member_put_self(inst, "C"); + member_put_placeholder(inst, IDX_A, "A"); + member_put_placeholder(inst, IDX_B, "B"); + member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL); + break; + } +} + +/* ── talk: 5 кадров + FIN от A ── */ + +static void do_talk(struct UTUN_INSTANCE* A) { + uint64_t gid = strtoull(CH_ID, NULL, 10); + static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d }; + radio_talk_begin(A, gid); + for (int i = 0; i < N_FRAMES; i++) radio_talk_send(A, gid, opus, sizeof(opus)); + radio_talk_end(A, gid); +} + +/* ── master state-machine ── */ + +static void rad_tick(void* arg) { + struct rad_ctx* t = arg; + t->timer = NULL; + if (t->result) return; + + struct UTUN_INSTANCE* A = t->inst[IDX_A]; + struct UTUN_INSTANCE* B = t->inst[IDX_B]; + struct UTUN_INSTANCE* C = t->inst[IDX_C]; + uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B], nc = g_sh.nid[IDX_C]; + uint64_t gid = strtoull(CH_ID, NULL, 10); + + switch (t->phase) { + case P_WAIT_CONN: + if (conn_up(A, nb) && conn_up(B, na) && conn_up(B, nc) && conn_up(C, nb)) + t->phase = P_SETUP; + break; + case P_SETUP: + do_setup(A, IDX_A); + do_setup(B, IDX_B); + do_setup(C, IDX_C); + t->phase = P_WAIT_BGP; + break; + case P_WAIT_BGP: + if (bgp_has(A, nb) && bgp_has(A, nc) && bgp_has(B, na) && bgp_has(B, nc) && bgp_has(C, na) && bgp_has(C, nb)) + t->phase = P_RADIO_ON; + break; + case P_RADIO_ON: + radio_set_active(A, gid, 1); + radio_set_active(B, gid, 1); + radio_set_active(C, gid, 1); + t->phase = P_WAIT_FLAGS; + break; + case P_WAIT_FLAGS: + if (node_radio(A, nb) && node_radio(A, nc) && node_radio(B, na) && node_radio(B, nc) && node_radio(C, na) && node_radio(C, nb)) + t->phase = P_TALK1; + break; + case P_TALK1: + memset(g_rx, 0, sizeof(g_rx)); + do_talk(A); + t->phase = P_WAIT_RX1; + 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) + t->phase = P_RADIO_OFF_C; + break; + case P_RADIO_OFF_C: + radio_set_active(C, gid, 0); + t->phase = P_WAIT_OFF_C; + break; + case P_WAIT_OFF_C: + /* ждём, пока A и B увидят radio=0 у C (обрезание ветви C) */ + if (!node_radio(A, nc) && !node_radio(B, nc)) { + memset(g_rx, 0, sizeof(g_rx)); + t->phase = P_TALK2; + } + break; + case P_TALK2: + do_talk(A); + t->phase = P_WAIT_RX2; + break; + case P_WAIT_RX2: + 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 == 0 && !g_rx[IDX_C].fin_seen) + t->phase = P_DONE; + break; + case P_DONE: + t->result = 1; + uasync_stop(t->ua); + return; + } + + if (++t->ticks > MAX_TICKS) { + fprintf(stderr, "test: timeout in phase %d (B_rx=%d/%d fin=%d seqok=%d C_rx=%d/%d fin=%d seqok=%d radio_flags: A(B=%d C=%d) B(A=%d C=%d) C(A=%d B=%d))\n", + (int)t->phase, + 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, + node_radio(A, nb), node_radio(A, nc), node_radio(B, na), node_radio(B, nc), + node_radio(C, na), node_radio(C, nb)); + t->result = 2; + uasync_stop(t->ua); + return; + } + t->timer = uasync_set_timeout(t->ua, TICK_TB, t, rad_tick, "rad_tick"); +} + +/* ── main ── */ + +int main(int argc, char** argv) { + (void)argc; (void)argv; + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + if (getenv("UTUN_TEST_DEBUG")) { + debug_set_category_level_by_name("radio", "debug"); + debug_set_category_level_by_name("bgp", "info"); + debug_set_category_level_by_name("member_sync", "info"); + debug_set_category_level_by_name("chat_sync", "info"); + } + utun_instance_set_tun_init_enabled(0); + srand((unsigned)time(NULL)); + + char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_radio_XXXXXX"); + if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } + setenv("UTUN_TEST_DIR", dir, 1); + + fill_shared(58000 + (getpid() % 2000)); + write_configs(dir); + + printf("=== test_radio ===\n"); fflush(stdout); + + TEST("single-thread: radio flag + talk (seq/fin) + relay + prune (3 nodes)"); { + struct UASYNC* ua = uasync_create(); + if (!ua) { FAIL("uasync_create failed"); return 1; } + + struct rad_ctx t; + memset(&t, 0, sizeof(t)); + t.ua = ua; + t.phase = P_WAIT_CONN; + + char cfgA[512], cfgB[512], cfgC[512]; + snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir); + snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir); + snprintf(cfgC, sizeof(cfgC), "%s/c.conf", dir); + + t.inst[IDX_A] = utun_instance_create(ua, cfgA); + t.inst[IDX_B] = utun_instance_create(ua, cfgB); + t.inst[IDX_C] = utun_instance_create(ua, cfgC); + if (!t.inst[IDX_A] || !t.inst[IDX_B] || !t.inst[IDX_C]) { + FAIL("instance create failed A=%p B=%p C=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B], (void*)t.inst[IDX_C]); + uasync_destroy(ua, 0); + return 1; + } + utun_instance_init(t.inst[IDX_A]); + utun_instance_init(t.inst[IDX_B]); + utun_instance_init(t.inst[IDX_C]); + + radio_set_frame_cb(t.inst[IDX_A], rx_frame_cb, &g_rx[IDX_A]); + radio_set_frame_cb(t.inst[IDX_B], rx_frame_cb, &g_rx[IDX_B]); + radio_set_frame_cb(t.inst[IDX_C], rx_frame_cb, &g_rx[IDX_C]); + + t.timer = uasync_set_timeout(ua, TICK_TB, &t, rad_tick, "rad_tick"); + uasync_mainloop(ua); + + int ok = (t.result == 1); + + if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]); + if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]); + if (t.inst[IDX_C]) utun_instance_destroy(t.inst[IDX_C]); + uasync_destroy(ua, 0); + + if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase); + } + + char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd); + + printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); + return G_FAILED > 0 ? 1 : 0; +} diff --git a/tests/test_radio_headless.c b/tests/test_radio_headless.c new file mode 100644 index 00000000..59659e19 --- /dev/null +++ b/tests/test_radio_headless.c @@ -0,0 +1,470 @@ +// test_radio_headless.c — e2e-тест рации через headless API (control + audio сокеты). +// +// Два узла (A, B) в одном процессе, один UASYNC. Тест выступает «headless-клиентом» +// обоих узлов: control-сокеты (JSON: radio_on/off/subscribers) и audio-сокеты +// (бинарные PCM-фреймы рации) каждого узла. +// +// Сценарий (через реальные TCP-сокеты): +// A radio_on, B radio_on → A: PTT_START + FRAME(ненулевой PCM) + PTT_STOP +// → B получает микшированный ненулевой PCM → radio_subscribers(A) == 1. + +#include "chat_core.h" +#include "chat_sync.h" +#include "chat_event.h" +#include "member_sync.h" +#include "chat_headless_control.h" +#include "../radio/radio.h" +#include "../radio/radio_headless.h" +#include "../routing_layer/topo_node_sqlite.h" +#include "../routing_layer/topo_group.h" +#include "../routing_layer/topo_node.h" +#include "../utun_instance.h" +#include "../transport_layer/etcp.h" +#include "../transport_layer/secure_channel.h" +#include "../ntp_time.h" +#include "../lib/u_async.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "../lib/socket_compat.h" +#include "../lib/platform_compat.h" + +#include +#define OPENSSL_API_COMPAT 0x10100000L +#include +#include +#include +#include +#include +#include +#include + +#define CH_ID "4242424242424242" + +#define IDX_A 0 +#define IDX_B 1 + +#define TICK_TB 100 /* 10 ms на такт */ +#define MAX_TICKS 4000 /* глобальный таймаут ≈ 40 c */ + +enum hr_phase { + P_WAIT_CONN, + P_SETUP, + P_WAIT_BGP, + P_CONNECT_CLIENTS, + P_RADIO_ON, + P_WAIT_RADIO_ON, + P_BIND_AUDIO, + P_TALK, + P_WAIT_MEDIA, + P_CHECK_SUBSCRIBERS, + P_WAIT_SUBSCRIBERS, + P_DONE, +}; + +struct hr_conn { + socket_t fd; + void* socket_id; + char buf[65536]; + int len; +}; + +struct hr_ctx { + struct UASYNC* ua; + struct UTUN_INSTANCE* inst[2]; + enum hr_phase phase; + int result; + int ticks; + void* timer; + struct hr_conn ctl[2]; + struct hr_conn aud[2]; +}; + +struct hr_shared { + uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64]; + uint64_t nid[2]; + uint8_t x_pub[2][32], x_priv[2][32], ed_pub[2][32]; + int port[2]; +}; +static struct hr_shared g_sh; + +static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0; +#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0) +#define OK() do { G_PASSED++; printf("OK\n"); } while(0) +#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0) + +static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL); + EVP_PKEY* pkey = NULL; + EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); + size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); + l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); + EVP_PKEY_free(pkey); +} + +static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL); + EVP_PKEY* pkey = NULL; + EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); + size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); + l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); + EVP_PKEY_free(pkey); +} + +static void to_hex(const uint8_t* bin, size_t n, char* out) { + for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]); + out[n * 2] = '\0'; +} + +static int wf(const char* p, const char* f, ...) { + va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1; + va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0; +} + +static void fill_shared(int base_port) { + memset(&g_sh, 0, sizeof(g_sh)); + for (int i = 0; i < 2; i++) { + gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]); + sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]); + g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]); + g_sh.port[i] = base_port + i * 1000; + } + { uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); } + gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv); + { + uint8_t msg[256]; size_t off = 0; + off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1; + off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "hl-radio") + 1; + memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8; + memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32; + memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32; + sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig); + } +} + +static void write_configs(const char* dir) { + for (int i = 0; i < 2; i++) { + char priv[65], pub[65], path[512], dbd[512]; + to_hex(g_sh.x_priv[i], 32, priv); + to_hex(g_sh.x_pub[i], 32, pub); + snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : "b"); + snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : "b"); + utun_mkdir(dbd, 0755); + + char client[512] = ""; + if (i == IDX_B) { + char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub); + snprintf(client, sizeof(client), "[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", apub, g_sh.port[IDX_A]); + } + wf(path, + "[global]\ntun_ip=10.99.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n" + "my_private_key=%s\nmy_public_key=%s\n" + "[server: s1]\naddr=127.0.0.1:%d\ntype=public\n" + "%s" + "[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n", + i, i, dir, i == IDX_A ? "a" : "b", + priv, pub, g_sh.port[i], client); + } +} + +static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) { + struct ETCP_CONN* c = instance_find_conn(inst, nid); + return c && c->initialized && c->links_up; +} + +static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) { + uint64_t gid = strtoull(CH_ID, NULL, 10); + struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); + if (!g) return 0; + struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid); + return nq && nq->paths && nq->paths->head; +} + +static int node_radio(struct UTUN_INSTANCE* inst, uint64_t nid) { + uint64_t gid = strtoull(CH_ID, NULL, 10); + struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); + if (!g) return 0; + struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid); + return nq ? nq->radio : 0; +} + +static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) { + topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "hl-radio", g_sh.nid[IDX_A], + g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig); + chat_core_ensure_channel_ready(inst, CH_ID); +} + +static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) { + uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst); + uint8_t jm[128]; + int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id, + inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm)); + uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig); + char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); + member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey, + join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); +} + +static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) { + char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); + member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx], + NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); +} + +static void do_setup(struct UTUN_INSTANCE* inst, int role) { + switch (role) { + case IDX_A: + channel_put_shared(inst, 1); + member_put_self(inst, "A"); + member_put_placeholder(inst, IDX_B, "B"); + member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL); + break; + case IDX_B: + channel_put_shared(inst, 0); + member_put_self(inst, "B"); + member_put_placeholder(inst, IDX_A, "A"); + member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL); + break; + } +} + +/* ── клиентские сокеты ── */ + +static void hr_recv_cb(socket_t fd, void* arg) { + struct hr_conn* c = (struct hr_conn*)arg; + (void)fd; + char tmp[4096]; + ssize_t r = recv(fd, tmp, sizeof(tmp) - 1, 0); + if (r <= 0) return; + if (c->len + (int)r < (int)sizeof(c->buf)) { + memcpy(c->buf + c->len, tmp, (size_t)r); + c->len += (int)r; + } +} + +static int hr_connect(struct hr_ctx* t, struct hr_conn* c, int port) { + c->fd = socket(AF_INET, SOCK_STREAM, 0); + if (c->fd == SOCKET_INVALID) return -1; + struct sockaddr_in sin; memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; sin.sin_port = htons((uint16_t)port); + inet_pton(AF_INET, "127.0.0.1", &sin.sin_addr); + if (connect(c->fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) { + socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; + } + socket_set_nonblocking(c->fd); + c->socket_id = uasync_add_socket_t(t->ua, c->fd, hr_recv_cb, NULL, NULL, c); + if (!c->socket_id) { socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; } + return 0; +} + +static void hr_send(struct hr_conn* c, const void* data, size_t len) { + if (!c || c->fd == SOCKET_INVALID) return; + ssize_t off = 0; + while (off < (ssize_t)len) { + ssize_t r = send(c->fd, (const char*)data + off, len - (size_t)off, 0); + if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) { continue; } + if (r <= 0) break; + off += r; + } +} + +static int hr_has(struct hr_conn* c, const char* substr) { + return c && memmem(c->buf, (size_t)c->len, substr, strlen(substr)) != NULL; +} + +/* есть ли в audio-буфере FRAME с ненулевым PCM (данные рации) */ +static int hr_has_nonzero_pcm(struct hr_conn* c) { + if (!c) return 0; + const char* buf = c->buf; + int len = c->len; + for (int i = 0; i + RADIO_AUDIO_HDR_SIZE <= len; i++) { + if ((uint8_t)buf[i] != RADIO_AUDIO_FRAME) continue; + uint16_t flen = (uint16_t)(((uint8_t)buf[i + 9] << 8) | (uint8_t)buf[i + 10]); + if (flen != RADIO_AUDIO_PCM_FRAME_BYTES) continue; + if (i + RADIO_AUDIO_HDR_SIZE + flen > len) break; + const int16_t* pcm = (const int16_t*)(buf + i + RADIO_AUDIO_HDR_SIZE); + for (int k = 0; k < 960; k++) if (pcm[k] != 0) return 1; + } + return 0; +} + +/* ── master state-machine ── */ + +static void hr_tick(void* arg) { + struct hr_ctx* t = arg; + t->timer = NULL; + if (t->result) return; + + struct UTUN_INSTANCE* A = t->inst[IDX_A]; + struct UTUN_INSTANCE* B = t->inst[IDX_B]; + uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B]; + uint64_t gid = strtoull(CH_ID, NULL, 10); + + switch (t->phase) { + case P_WAIT_CONN: + if (conn_up(A, nb) && conn_up(B, na)) t->phase = P_SETUP; + break; + case P_SETUP: + do_setup(A, IDX_A); + do_setup(B, IDX_B); + if (chat_headless_control_init(A->ua, A, "127.0.0.1", 9700 + (int)(getpid() % 100)) != 0 + || chat_headless_control_init(B->ua, B, "127.0.0.1", 9800 + (int)(getpid() % 100)) != 0) { + fprintf(stderr, "headless control init failed\n"); t->result = 2; break; + } + if (radio_headless_init(A->ua, A, "127.0.0.1", 9710 + (int)(getpid() % 100)) != 0 + || radio_headless_init(B->ua, B, "127.0.0.1", 9810 + (int)(getpid() % 100)) != 0) { + fprintf(stderr, "radio headless audio init failed\n"); t->result = 2; break; + } + t->phase = P_WAIT_BGP; + break; + case P_WAIT_BGP: + if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CONNECT_CLIENTS; + break; + case P_CONNECT_CLIENTS: + if (hr_connect(t, &t->ctl[IDX_A], 9700 + (int)(getpid() % 100)) != 0 + || hr_connect(t, &t->ctl[IDX_B], 9800 + (int)(getpid() % 100)) != 0 + || hr_connect(t, &t->aud[IDX_A], 9710 + (int)(getpid() % 100)) != 0 + || hr_connect(t, &t->aud[IDX_B], 9810 + (int)(getpid() % 100)) != 0) { + fprintf(stderr, "client connect failed\n"); t->result = 2; break; + } + t->phase = P_RADIO_ON; + break; + case P_RADIO_ON: { + char json[256]; + snprintf(json, sizeof(json), "{\"id\":1,\"cmd\":\"radio_on\",\"ch\":\"%s\"}\n", CH_ID); + hr_send(&t->ctl[IDX_A], json, strlen(json)); + hr_send(&t->ctl[IDX_B], json, strlen(json)); + t->phase = P_WAIT_RADIO_ON; + break; + } + case P_WAIT_RADIO_ON: + /* оба подтвердили radio_on и A видит флаг B через BGP */ + if (hr_has(&t->ctl[IDX_A], "\"radio\":true") && hr_has(&t->ctl[IDX_B], "\"radio\":true") + && node_radio(A, nb) && node_radio(B, na)) + t->phase = P_BIND_AUDIO; + break; + case P_BIND_AUDIO: { + uint8_t hdr[RADIO_AUDIO_HDR_SIZE]; + hdr[0] = RADIO_AUDIO_HELLO; + memcpy(hdr + 1, &gid, 8); + hdr[9] = 0; hdr[10] = 0; + hr_send(&t->aud[IDX_A], hdr, sizeof(hdr)); + hr_send(&t->aud[IDX_B], hdr, sizeof(hdr)); + t->phase = P_TALK; + break; + } + case P_TALK: { + uint8_t ptt[RADIO_AUDIO_HDR_SIZE]; + ptt[0] = RADIO_AUDIO_PTT_START; memcpy(ptt + 1, &gid, 8); ptt[9] = 0; ptt[10] = 0; + hr_send(&t->aud[IDX_A], ptt, sizeof(ptt)); + + uint8_t frm[RADIO_AUDIO_HDR_SIZE + RADIO_AUDIO_PCM_FRAME_BYTES]; + frm[0] = RADIO_AUDIO_FRAME; memcpy(frm + 1, &gid, 8); + frm[9] = (uint8_t)(RADIO_AUDIO_PCM_FRAME_BYTES >> 8); + frm[10] = (uint8_t)(RADIO_AUDIO_PCM_FRAME_BYTES & 0xFF); + for (int k = 0; k < 960; k++) ((int16_t*)(frm + RADIO_AUDIO_HDR_SIZE))[k] = 1000; + hr_send(&t->aud[IDX_A], frm, sizeof(frm)); + + ptt[0] = RADIO_AUDIO_PTT_STOP; + hr_send(&t->aud[IDX_A], ptt, sizeof(ptt)); + t->phase = P_WAIT_MEDIA; + break; + } + case P_WAIT_MEDIA: + if (hr_has_nonzero_pcm(&t->aud[IDX_B])) t->phase = P_CHECK_SUBSCRIBERS; + break; + case P_CHECK_SUBSCRIBERS: { + char json[256]; + snprintf(json, sizeof(json), "{\"id\":2,\"cmd\":\"radio_subscribers\",\"ch\":\"%s\"}\n", CH_ID); + hr_send(&t->ctl[IDX_A], json, strlen(json)); + t->phase = P_WAIT_SUBSCRIBERS; + break; + } + case P_WAIT_SUBSCRIBERS: + if (hr_has(&t->ctl[IDX_A], "\"subscribers\":1")) t->phase = P_DONE; + break; + case P_DONE: + t->result = 1; + uasync_stop(t->ua); + return; + } + + if (t->phase == P_DONE) { t->result = 1; uasync_stop(t->ua); return; } + + if (++t->ticks > MAX_TICKS) { + fprintf(stderr, "test: timeout in phase %d (A_conn=%d B_conn=%d radio_flags A(B)=%d B(A)=%d)\n", + (int)t->phase, conn_up(A, nb), conn_up(B, na), node_radio(A, nb), node_radio(B, na)); + t->result = 2; + uasync_stop(t->ua); + return; + } + t->timer = uasync_set_timeout(t->ua, TICK_TB, t, hr_tick, "hr_tick"); +} + +/* ── main ── */ + +int main(int argc, char** argv) { + (void)argc; (void)argv; + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + if (getenv("UTUN_TEST_DEBUG")) { + debug_set_category_level_by_name("radio", "debug"); + debug_set_category_level_by_name("bgp", "info"); + debug_set_category_level_by_name("member_sync", "info"); + debug_set_category_level_by_name("chat_sync", "info"); + } + utun_instance_set_tun_init_enabled(0); + srand((unsigned)time(NULL)); + + char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_radio_hl_XXXXXX"); + if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } + setenv("UTUN_TEST_DIR", dir, 1); + + fill_shared(59000 + (getpid() % 2000)); + write_configs(dir); + + printf("=== test_radio_headless ===\n"); fflush(stdout); + + TEST("headless: radio_on + PTT PCM-поток + radio_subscribers через сокеты"); { + struct UASYNC* ua = uasync_create(); + if (!ua) { FAIL("uasync_create failed"); return 1; } + + struct hr_ctx t; + memset(&t, 0, sizeof(t)); + t.ua = ua; + t.phase = P_WAIT_CONN; + for (int i = 0; i < 2; i++) { t.ctl[i].fd = SOCKET_INVALID; t.aud[i].fd = SOCKET_INVALID; } + + char cfgA[512], cfgB[512]; + snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir); + snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir); + + t.inst[IDX_A] = utun_instance_create(ua, cfgA); + t.inst[IDX_B] = utun_instance_create(ua, cfgB); + if (!t.inst[IDX_A] || !t.inst[IDX_B]) { + FAIL("instance create failed A=%p B=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B]); + uasync_destroy(ua, 0); + return 1; + } + utun_instance_init(t.inst[IDX_A]); + utun_instance_init(t.inst[IDX_B]); + + t.timer = uasync_set_timeout(ua, TICK_TB, &t, hr_tick, "hr_tick"); + uasync_mainloop(ua); + + int ok = (t.result == 1); + + for (int i = 0; i < 2; i++) { + if (t.ctl[i].fd != SOCKET_INVALID) { uasync_remove_socket_t(ua, t.ctl[i].fd); socket_close_wrapper(t.ctl[i].fd); } + if (t.aud[i].fd != SOCKET_INVALID) { uasync_remove_socket_t(ua, t.aud[i].fd); socket_close_wrapper(t.aud[i].fd); } + } + if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]); + if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]); + uasync_destroy(ua, 0); + + if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase); + } + + char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd); + + printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); + return G_FAILED > 0 ? 1 : 0; +} diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index f88dfe60..42160bbc 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + @@ -47,6 +48,15 @@ android:value="p2p_chat_network_service" /> + + + + diff --git a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt index ae2385d9..d7d3bfdc 100644 --- a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt +++ b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt @@ -36,6 +36,7 @@ utun_setup_voice("${LIB_DIR}" "${CALL_DIR}") add_library(utun_voice STATIC ${UTUN_VOICE_SOURCES}) target_include_directories(utun_voice PUBLIC ${UTUN_VOICE_INCLUDE_DIRS}) set_source_files_properties(${CALL_DIR}/call_audio.c ${CALL_DIR}/call_tones.c PROPERTIES LANGUAGE C) +set_source_files_properties(${SRC_DIR}/radio/radio_audio.c PROPERTIES LANGUAGE C) set_target_properties(utun_voice PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON CXX_STANDARD 11 CXX_STANDARD_REQUIRED ON diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 7ce71c00..00dae398 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle @@ -32,6 +33,7 @@ import com.utun.chat.data.InviteData import com.utun.chat.data.LogManager import com.utun.chat.data.Message import com.utun.chat.data.NativeLib +import com.utun.chat.data.RadioAudioEngine import com.utun.chat.headless.HeadlessService import com.utun.chat.ui.components.ActiveCallBanner import com.utun.chat.ui.screens.* @@ -140,6 +142,7 @@ class MainActivity : ComponentActivity() { Screen.MemberList -> screen = Screen.Chat Screen.VideoRecord -> screen = Screen.Chat Screen.PhotoRecord -> screen = Screen.Chat + Screen.Chat -> { vm.radioOff(); screen = Screen.ChannelList } Screen.Dm -> { vm.closeDm(); screen = Screen.ChannelList } Screen.Call -> screen = screenBeforeCall ?: Screen.ChannelList else -> screen = Screen.ChannelList @@ -399,4 +402,17 @@ class MainActivity : ComponentActivity() { super.onStop() if (NativeLib.isReady()) NativeLib.setActive(false) } + + /** Volume-up (удержание) → PTT рации, при активной рации и включённой опции. */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP && + ChatApplication.instance.configProvider.getRadioVolumeUpPtt() && + RadioAudioEngine.instance().isActive()) { + when (event.action) { + KeyEvent.ACTION_DOWN -> { RadioAudioEngine.instance().talkBegin(); return true } + KeyEvent.ACTION_UP -> { RadioAudioEngine.instance().talkEnd(); return true } + } + } + return super.dispatchKeyEvent(event) + } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 30adc125..b51b9f22 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -53,6 +53,8 @@ class ConfigProvider(private val context: Context) { private val soundEnabledKey = booleanPreferencesKey("sound_enabled") private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private val callAudioRouteKey = stringPreferencesKey("call_audio_route") + private val radioVolumeUpPttKey = booleanPreferencesKey("radio_volume_up_ptt") + private val radioOverlayKey = booleanPreferencesKey("radio_overlay") private fun keysFilePath(): String { val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath @@ -259,6 +261,24 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[callAudioRouteKey] = r.name } } + /* ── Рация: volume-up PTT и оверлей ── */ + + fun getRadioVolumeUpPtt(): Boolean = runBlocking { + context.dataStore.data.first()[radioVolumeUpPttKey] ?: false + } + + suspend fun setRadioVolumeUpPtt(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[radioVolumeUpPttKey] = enabled } + } + + fun getRadioOverlay(): Boolean = runBlocking { + context.dataStore.data.first()[radioOverlayKey] ?: false + } + + suspend fun setRadioOverlay(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled } + } + /* ── Server entries ── */ suspend fun detectInterfaces(saved: List = emptyList()): List { 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 1c2f6b79..d2b647be 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 @@ -152,6 +152,16 @@ object NativeLib { fun callAudioPull(callId: Long, out: ShortArray): Int = nativeCallAudioPull(callId, out) fun callAudioStop() { nativeCallAudioStop() } + /* ── Рация (PTT walkie-talkie) ── */ + fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) } + fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId) + fun radioAudioStart(groupId: Long): Boolean = nativeRadioAudioStart(groupId) + fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples) + fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out) + fun radioAudioStop() { nativeRadioAudioStop() } + fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) } + fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(groupId) } + /* ── DM (личные сообщения) ── */ fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) } fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) } @@ -247,6 +257,16 @@ object NativeLib { private external fun nativeCallAudioPull(callId: Long, out: ShortArray): Int private external fun nativeCallAudioStop() + /* ── Рация JNI ── */ + private external fun nativeRadioSetActive(groupId: Long, on: Boolean) + private external fun nativeRadioSubscribers(groupId: Long): Int + private external fun nativeRadioAudioStart(groupId: Long): Boolean + private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean + private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int + private external fun nativeRadioAudioStop() + private external fun nativeRadioTalkBegin(groupId: Long) + private external fun nativeRadioTalkEnd(groupId: Long) + /* ── DM JNI ── */ private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String) private external fun nativeDmSend(convId: String, text: String) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt new file mode 100644 index 00000000..1099154b --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -0,0 +1,205 @@ +package com.utun.chat.data + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import com.utun.chat.ChatApplication + +/** + * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). + * + * Потоки: + * - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT) + * - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write + * + * Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека + * в C (radio_audio): per-source jitter + time-stretch + микшер. + */ +class RadioAudioEngine { + + companion object { + const val SAMPLE_RATE = 48000 + const val FRAME_MS = 20 + const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 + + /** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ + @Volatile private var inst: RadioAudioEngine? = null + fun instance(): RadioAudioEngine = inst ?: synchronized(this) { + inst ?: RadioAudioEngine().also { inst = it } + } + } + + private var audioRecord: AudioRecord? = null + private var captureThread: Thread? = null + private var audioTrack: AudioTrack? = null + private var playThread: Thread? = null + private var prevMode: Int = AudioManager.MODE_NORMAL + private var prevSpeaker: Boolean = false + + @Volatile private var active = false + @Volatile private var groupId = 0L + @Volatile private var talking = false + + fun isActive(): Boolean = active + + fun isTalking(): Boolean = talking + + /** Начать прослушивание канала (переключатель рации вкл). */ + fun start(group: Long): Boolean { + stop() + + if (!NativeLib.radioAudioStart(group)) { + LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") + return false + } + + groupId = group + talking = false + + val ctx = ChatApplication.instance + val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + prevMode = am.mode + prevSpeaker = am.isSpeakerphoneOn + try { + am.mode = AudioManager.MODE_IN_COMMUNICATION + am.isSpeakerphoneOn = true + } catch (e: Exception) { + LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}") + } + + // ── AudioRecord (захват) ── + val recMin = AudioRecord.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + audioRecord = try { + AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, + (recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}") + NativeLib.radioAudioStop(); restoreAudioMode(); return false + } + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized") + audioRecord?.release(); audioRecord = null + NativeLib.radioAudioStop(); restoreAudioMode(); return false + } + + // ── AudioTrack (воспроизведение) ── + val trackMin = AudioTrack.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) + val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) + audioTrack = try { + AudioTrack( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), + AudioFormat.Builder().setSampleRate(SAMPLE_RATE) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(), + trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") + audioRecord?.release(); audioRecord = null + audioTrack = null + NativeLib.radioAudioStop(); restoreAudioMode(); return false + } + + active = true + audioRecord?.startRecording() + audioTrack?.play() + + captureThread = Thread { captureLoop() }.apply { + priority = Thread.MAX_PRIORITY; start() + } + playThread = Thread { playLoop() }.apply { + priority = Thread.MAX_PRIORITY; start() + } + + LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") + return true + } + + fun stop() { + if (!active) return + active = false + talking = false + + captureThread?.join(1000); captureThread = null + playThread?.join(1000); playThread = null + + audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() } + audioRecord = null + audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } + audioTrack = null + + NativeLib.radioAudioStop() + restoreAudioMode() + + groupId = 0L + LogManager.addLog("INFO", "RadioAudio", "stopped") + } + + /** PTT зажата — начинаем передачу. */ + fun talkBegin() { + if (!active || talking) return + talking = true + NativeLib.radioTalkBegin(groupId) + } + + /** PTT отпущена — завершаем передачу (FIN). */ + fun talkEnd() { + if (!active || !talking) return + talking = false + NativeLib.radioTalkEnd(groupId) + } + + private fun captureLoop() { + val buf = ShortArray(FRAME_SAMPLES) + while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + var read = 0 + while (read < FRAME_SAMPLES) { + if (!active) return + val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return + if (n <= 0) return + read += n + } + if (talking && !NativeLib.radioAudioFeed(groupId, buf)) { + LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") + } + } + LogManager.addLog("DEBUG", "RadioAudio", "capture loop done") + } + + private fun playLoop() { + val buf = ShortArray(FRAME_SAMPLES) + val silence = ShortArray(FRAME_SAMPLES) + while (active) { + val t0 = System.nanoTime() + val track = audioTrack ?: return + val n = NativeLib.radioAudioPull(groupId, buf) + if (n > 0) track.write(buf, 0, n) + if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) + val elapsedMs = (System.nanoTime() - t0) / 1_000_000 + val sleepMs = FRAME_MS - elapsedMs + if (sleepMs > 0) { + try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return } + } + } + LogManager.addLog("DEBUG", "RadioAudio", "play loop done") + } + + private fun restoreAudioMode() { + try { + val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager + am.isSpeakerphoneOn = prevSpeaker + am.mode = prevMode + } catch (e: Exception) { + LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt new file mode 100644 index 00000000..1d8e4145 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt @@ -0,0 +1,169 @@ +package com.utun.chat.data + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.graphics.PixelFormat +import android.os.Build +import android.os.IBinder +import android.provider.Settings +import android.view.Gravity +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.TextView +import androidx.core.app.NotificationCompat +import kotlin.math.abs + +/** + * Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. + * Удержание активирует передачу (RadioAudioEngine.talkBegin/talkEnd), отпускание — + * завершает. Работает пока включена опция «PTT поверх приложений» и разрешено + * «поверх других приложений» (SYSTEM_ALERT_WINDOW). + */ +class RadioOverlayService : Service() { + + companion object { + const val CHANNEL_ID = "radio_overlay" + const val NOTIF_ID = 1002 + + fun canDrawOverlays(ctx: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) + + fun start(ctx: Context) { + val i = Intent(ctx, RadioOverlayService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + ctx.startForegroundService(i) + else + ctx.startService(i) + } + + fun stop(ctx: Context) { + ctx.stopService(Intent(ctx, RadioOverlayService::class.java)) + } + } + + private var windowManager: WindowManager? = null + private var overlayView: View? = null + private var params: WindowManager.LayoutParams? = null + private var talking = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + startForeground(NOTIF_ID, buildNotification()) + + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + if (!canDrawOverlays(this)) { + LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping") + stopSelf() + return + } + addOverlay() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply { + description = "Оверлей PTT рации" + } + getSystemService(NotificationManager::class.java).createNotificationChannel(ch) + } + } + + private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setContentTitle("Рация активна") + .setContentText("PTT-кнопка поверх приложений") + .setOngoing(true) + .build() + + private fun addOverlay() { + val wm = windowManager ?: return + val v = TextView(this).apply { + text = "\uD83C\uDF99" + textSize = 30f + gravity = android.view.Gravity.CENTER + setBackgroundColor(0xFFE74C3C.toInt()) + } + val lp = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + else + @Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT + ).apply { + width = dp(96); height = dp(96) + gravity = Gravity.TOP or Gravity.START + x = 16; y = 200 + } + v.setOnTouchListener(object : View.OnTouchListener { + private var startX = 0; private var startY = 0 + private var touchX = 0f; private var touchY = 0f + private var dragging = false + + override fun onTouch(view: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startX = params?.x ?: lp.x + startY = params?.y ?: lp.y + touchX = event.rawX + touchY = event.rawY + dragging = false + beginTalk() + return true + } + MotionEvent.ACTION_MOVE -> { + if (abs(event.rawX - touchX) > 8 || abs(event.rawY - touchY) > 8) { + dragging = true + endTalk() + lp.x = startX + (event.rawX - touchX).toInt() + lp.y = startY + (event.rawY - touchY).toInt() + wm.updateViewLayout(v, lp) + } + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + endTalk() + return true + } + } + return false + } + }) + wm.addView(v, lp) + overlayView = v + params = lp + LogManager.addLog("INFO", "RadioOverlay", "overlay shown") + } + + private fun beginTalk() { + if (talking) return + talking = true + RadioAudioEngine.instance().talkBegin() + } + + private fun endTalk() { + if (!talking) return + talking = false + RadioAudioEngine.instance().talkEnd() + } + + private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() + + override fun onDestroy() { + endTalk() + overlayView?.let { windowManager?.removeView(it) } + overlayView = null + LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") + super.onDestroy() + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index ee639aff..b90b6c68 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -131,6 +131,15 @@ fun CallScreen( color = Color(0xFF9BA6AD), fontSize = 16.sp ) + val bgpPath = st?.bgpPath ?: "" + if (bgpPath.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + bgpPath, + color = Color(0xFF9BA6AD), + fontSize = 13.sp + ) + } Spacer(Modifier.height(40.dp)) if (phase == CallPhase.ENDED) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 872f0892..bbb677cc 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -6,17 +6,21 @@ import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.People import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -57,6 +61,10 @@ fun ChatScreen( val context = LocalContext.current val scope = rememberCoroutineScope() + /* рация */ + val radioActive by viewModel.radioActive.collectAsState() + val radioTalking by viewModel.radioTalking.collectAsState() + /* attach menu */ var showAttachMenu by remember { mutableStateOf(false) } @@ -127,29 +135,50 @@ fun ChatScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, actions = { + IconButton(onClick = { viewModel.toggleRadio() }) { + Text(if (radioActive) "\uD83D\uDCFB" else "\uD83D\uDD07", fontSize = 18.sp) + } IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") } IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } } ) - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), - state = listState, - reverseLayout = true - ) { - item { Spacer(Modifier.height(8.dp)) } - items(messages.reversed()) { msg -> - MessageBubble(msg, playbackState = playbackState, - onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, - onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) }, - onDownloadFile = { msg -> viewModel.downloadAttachment(msg) }, - onOpenFile = { msg -> - if (msg.filePath.isNotEmpty()) { - pendingSaveFilePath = msg.filePath - saveFileLauncher.launch(msg.text) /* suggest original filename */ - } - }, - onOpenImage = { msg -> onViewPhoto(msg) }, - onPlayVideo = { msg -> onPlayVideo(msg) }) + if (radioTalking != null) { + Surface(color = MaterialTheme.colorScheme.primaryContainer) { + Text( + text = "\uD83C\uDF99 ${viewModel.radioName(radioTalking!!)} говорит", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true + ) { + item { Spacer(Modifier.height(8.dp)) } + items(messages.reversed()) { msg -> + MessageBubble(msg, playbackState = playbackState, + onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, + onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) }, + onDownloadFile = { msg -> viewModel.downloadAttachment(msg) }, + onOpenFile = { msg -> + if (msg.filePath.isNotEmpty()) { + pendingSaveFilePath = msg.filePath + saveFileLauncher.launch(msg.text) /* suggest original filename */ + } + }, + onOpenImage = { msg -> onViewPhoto(msg) }, + onPlayVideo = { msg -> onPlayVideo(msg) }) + } + } + if (radioActive) { + RadioPttButton( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), + onTalkBegin = { viewModel.radioTalkBegin() }, + onTalkEnd = { viewModel.radioTalkEnd() } + ) } } InputBar( @@ -226,3 +255,32 @@ fun ChatScreen( } } } + +/* Круглая PTT-кнопка рации: держим — говорим. */ +@Composable +private fun RadioPttButton( + modifier: Modifier = Modifier, + onTalkBegin: () -> Unit, + onTalkEnd: () -> Unit +) { + Surface( + modifier = modifier + .size(96.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + onTalkBegin() + tryAwaitRelease() + onTalkEnd() + } + ) + }, + shape = CircleShape, + color = MaterialTheme.colorScheme.error, + shadowElevation = 6.dp + ) { + Box(contentAlignment = Alignment.Center) { + Text("\uD83C\uDF99", fontSize = 36.sp) + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 10499fbd..1dcac386 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -19,6 +19,7 @@ import com.utun.chat.ChatApplication import com.utun.chat.data.NativeLib import com.utun.chat.data.NotificationSound import com.utun.chat.data.IncomingCallManager +import com.utun.chat.data.RadioOverlayService import com.utun.chat.data.LogManager import com.utun.chat.data.ServerEntry import com.utun.chat.data.LocalSocketState @@ -358,6 +359,49 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool HorizontalDivider(Modifier.padding(vertical = 8.dp)) + /* ── Рация (PTT) ── */ + Text("Рация (PTT)", style = MaterialTheme.typography.titleMedium) + + var volumeUpPtt by remember { mutableStateOf(provider.getRadioVolumeUpPtt()) } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Volume-up (удержание) — передача", modifier = Modifier.weight(1f)) + Switch(checked = volumeUpPtt, onCheckedChange = { + volumeUpPtt = it + scope.launch { provider.setRadioVolumeUpPtt(it) } + }) + } + + var overlayEnabled by remember { mutableStateOf(provider.getRadioOverlay()) } + val canOverlay = RadioOverlayService.canDrawOverlays(ChatApplication.instance) + Row( + modifier = Modifier.fillMaxWidth().clickable(enabled = !canOverlay) { + if (!canOverlay) { + try { + ChatApplication.instance.startActivity( + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${ChatApplication.instance.packageName}"))) + } catch (e: Exception) { + LogManager.addLog("ERROR", "SETTINGS", "open overlay permission failed: ${e.message}") + } + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text("PTT-кнопка поверх приложений") + if (!canOverlay) Text("Требуется разрешение «поверх других приложений» — нажмите", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = { + overlayEnabled = it + scope.launch { provider.setRadioOverlay(it) } + if (it) RadioOverlayService.start(ChatApplication.instance) + else RadioOverlayService.stop(ChatApplication.instance) + }) + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + /* Debug */ Text("Debug", style = MaterialTheme.typography.titleMedium) DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug) 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 ca2aba36..0691933b 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 @@ -44,7 +44,8 @@ class ChatViewModel : ViewModel() { val peerName: String = "", val phase: CallPhase = CallPhase.NONE, val reason: Int = -1, - val durationMs: Int = 0 + val durationMs: Int = 0, + val bgpPath: String = "" ) private var repo: ChatRepository? = null @@ -55,6 +56,15 @@ class ChatViewModel : ViewModel() { val callAudio = CallAudioEngine() + val radioAudio = RadioAudioEngine.instance() + + /* рация: активна ли для текущего канала, кто говорит (src node_id, null = никто) */ + private val _radioActive = MutableStateFlow(false) + val radioActive: StateFlow = _radioActive + + private val _radioTalking = MutableStateFlow(null) + val radioTalking: StateFlow = _radioTalking + private var callStartedMs = 0L private var callIncomingAtMs = 0L @@ -608,6 +618,29 @@ class ChatViewModel : ViewModel() { LogManager.addLog("ERROR", "VM", "CALL_ERROR id=0x%016x err=$err $text".format(callId)) finishCall(callId, 7, "ERROR") } + 42 -> { /* CALL_PATH: [call_id:8][text:var] — BGP-маршрут до пира */ + if (data == null || data.size < 9) return + val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong() + val text = String(data, 8, data.size - 8) + val st = _callState.value + if (st != null && st.callId == callId) { + _callState.value = st.copy(bgpPath = text) + LogManager.addLog("INFO", "VM", "CALL_PATH id=0x%016x %s".format(callId, text)) + } + } + 43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */ + if (data == null || data.size < 19) return + val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val groupId = bb.getLong() + val src = bb.getLong() + bb.getShort() /* stream */ + val on = bb.get() + val cur = _currentChannel.value + if (cur != null && cur.id.toULongOrNull()?.toLong() == groupId) { + _radioTalking.value = if (on.toInt() != 0) src else null + LogManager.addLog("INFO", "VM", "RADIO_TALK ${if (on.toInt() != 0) "start" else "stop"} src=0x%016x".format(src)) + } + } } } @@ -693,6 +726,47 @@ class ChatViewModel : ViewModel() { _callState.value = null } + /* ── рация ── */ + + fun toggleRadio() { + val ch = _currentChannel.value ?: return + val groupId = ch.id.toULongOrNull()?.toLong() ?: return + val next = !_radioActive.value + NativeLib.radioSetActive(groupId, next) + _radioActive.value = next + if (next) { + radioAudio.start(groupId) + } else { + _radioTalking.value = null + radioAudio.stop() + } + LogManager.addLog("INFO", "VM", "toggleRadio -> ${if (next) "on" else "off"} group=$groupId") + } + + fun radioTalkBegin() { + if (!_radioActive.value) return + radioAudio.talkBegin() + } + + fun radioTalkEnd() { + if (!_radioActive.value) return + radioAudio.talkEnd() + } + + fun radioOff() { + if (!_radioActive.value && !radioAudio.isActive()) return + val ch = _currentChannel.value ?: return + val groupId = ch.id.toULongOrNull()?.toLong() ?: return + NativeLib.radioSetActive(groupId, false) + _radioActive.value = false + _radioTalking.value = null + radioAudio.stop() + } + + fun radioName(nodeId: Long): String = + members.value.firstOrNull { it.nodeId == nodeId }?.name + ?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId) + fun toggleMute() { val m = !callAudio.isMuted() callAudio.setMuted(m) @@ -760,6 +834,7 @@ class ChatViewModel : ViewModel() { fun selectChannel(channel: Channel) { val r = repo ?: return + radioOff() /* рацию выключаем при переключении канала */ _currentChannel.value = channel r.connectChannel(channel.id) refreshMessages(channel.id) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index fae1d9ba..ab331912 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -18,6 +18,8 @@ #include "../libutun_lite/standby.h" #include "../../../src/call/call_audio.h" #include "../../../src/call/call.h" +#include "../../../src/radio/radio.h" +#include "../../../src/radio/radio_audio.h" #include "../../../src/dm/dm_core.h" #include "../../../lib/debug_config.h" #include "../../../lib/u_async.h" @@ -1456,6 +1458,55 @@ int utun_bridge_call_audio_pull(uint64_t call_id, int16_t* out, int max_samples) return call_audio_pull_pcm(call_id, out, max_samples); } +/* ────────────────────────────────────────────────────────────────── + * Рация (PTT walkie-talkie): подписка + аудио-движок + * ────────────────────────────────────────────────────────────────── */ + +void utun_bridge_radio_set_active(uint64_t group_id, int on) { + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + struct UASYNC* ua = instance_lite_get_uasync(); + if (!inst || !ua) { bridge_log(BLEV_ERROR, "radio_set_active: no instance/ua"); return; } + struct radio_set_active_arg* a = (struct radio_set_active_arg*)u_calloc(1, sizeof(*a)); + if (!a) return; + a->inst = inst; + a->group_id = group_id; + a->on = on; + uasync_post(ua, radio_set_active_trampoline, a); +} + +int utun_bridge_radio_subscribers(uint64_t group_id) { + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + if (!inst) return 0; + return radio_subscribers(inst, group_id); +} + +int utun_bridge_radio_audio_start(uint64_t group_id) { + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio_audio_start: no instance"); return -1; } + if (radio_audio_init(inst) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio_audio_start: radio_audio_init failed"); return -1; } + return radio_audio_start(group_id); +} + +int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count) { + return radio_audio_feed_pcm(group_id, pcm, count); +} + +void utun_bridge_radio_audio_stop(void) { + radio_audio_stop(); +} + +int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_samples) { + return radio_audio_pull_pcm(group_id, out, max_samples); +} + +void utun_bridge_radio_talk_begin(uint64_t group_id) { + radio_audio_talk_begin(group_id); +} + +void utun_bridge_radio_talk_end(uint64_t group_id) { + radio_audio_talk_end(group_id); +} + /* ────────────────────────────────────────────────────────────────── * JNI functions (compiled only for Android via NDK) * ────────────────────────────────────────────────────────────────── */ @@ -2299,4 +2350,65 @@ JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioPull( return (jint)n; } +/* ── Рация (PTT walkie-talkie) ── */ + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioSetActive( + JNIEnv* env, jobject thiz, jlong groupId, jboolean on) { + (void)env; (void)thiz; + utun_bridge_radio_set_active((uint64_t)groupId, on ? 1 : 0); +} + +JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioSubscribers( + JNIEnv* env, jobject thiz, jlong groupId) { + (void)env; (void)thiz; + return (jint)utun_bridge_radio_subscribers((uint64_t)groupId); +} + +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStart( + JNIEnv* env, jobject thiz, jlong groupId) { + (void)env; (void)thiz; + return (utun_bridge_radio_audio_start((uint64_t)groupId) == 0) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioFeed( + JNIEnv* env, jobject thiz, jlong groupId, jshortArray samples) { + (void)thiz; + if (!samples) return JNI_FALSE; + jsize len = (*env)->GetArrayLength(env, samples); + jshort* data = (*env)->GetShortArrayElements(env, samples, NULL); + int rc = utun_bridge_radio_audio_feed((uint64_t)groupId, (const int16_t*)data, (int)len); + (*env)->ReleaseShortArrayElements(env, samples, data, JNI_ABORT); + return (rc == 0) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStop( + JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + utun_bridge_radio_audio_stop(); +} + +JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioPull( + JNIEnv* env, jobject thiz, jlong groupId, jshortArray out) { + (void)thiz; + if (!out) return 0; + jsize len = (*env)->GetArrayLength(env, out); + jshort* data = (*env)->GetShortArrayElements(env, out, NULL); + if (!data) return 0; + int n = utun_bridge_radio_audio_pull((uint64_t)groupId, (int16_t*)data, (int)len); + (*env)->ReleaseShortArrayElements(env, out, data, 0); + return (jint)n; +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTalkBegin( + JNIEnv* env, jobject thiz, jlong groupId) { + (void)env; (void)thiz; + utun_bridge_radio_talk_begin((uint64_t)groupId); +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTalkEnd( + JNIEnv* env, jobject thiz, jlong groupId) { + (void)env; (void)thiz; + utun_bridge_radio_talk_end((uint64_t)groupId); +} + #endif /* __ANDROID__ */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index c20aa45f..36a71fbc 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -133,6 +133,17 @@ int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count int utun_bridge_call_audio_pull(uint64_t call_id, int16_t* out, int max_samples); void utun_bridge_call_audio_stop(void); +/* ── Рация (PTT walkie-talkie): подписка + аудио-движок ── */ + +void utun_bridge_radio_set_active(uint64_t group_id, int on); +int utun_bridge_radio_subscribers(uint64_t group_id); +int utun_bridge_radio_audio_start(uint64_t group_id); +int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count); +int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_samples); +void utun_bridge_radio_audio_stop(void); +void utun_bridge_radio_talk_begin(uint64_t group_id); +void utun_bridge_radio_talk_end(uint64_t group_id); + /* ── Invite link (for sharing) — асинхронно через uasync-поток. Результат приходит событиями CHAT_EVT_INVITE_CANDIDATES / CHAT_EVT_INVITE_LINK_READY. ── */ void utun_bridge_request_invite_candidates(const char* channel_id); diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake index ccc535a5..157f2173 100644 --- a/tools/chatgui-android/libutun_lite/utun_sources.cmake +++ b/tools/chatgui-android/libutun_lite/utun_sources.cmake @@ -33,6 +33,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR) # C++ библиотекой utun_voice (см. src/call/voice_sources.cmake) list(FILTER _src_src EXCLUDE REGEX "/call/call_audio\\.c$") list(FILTER _src_src EXCLUDE REGEX "/call/call_tones\\.c$") + list(FILTER _src_src EXCLUDE REGEX "/radio/radio_audio\\.c$") list(REMOVE_ITEM _src_src "${SRC_DIR}/utun.c") # ── Our added files ── diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 607f4252..6006b604 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -92,6 +92,7 @@ utun_setup_voice("${LIB_DIR}" "${CALL_DIR}") add_library(utun_voice STATIC ${UTUN_VOICE_SOURCES}) target_include_directories(utun_voice PUBLIC ${UTUN_VOICE_INCLUDE_DIRS}) set_source_files_properties(${CALL_DIR}/call_audio.c ${CALL_DIR}/call_tones.c PROPERTIES LANGUAGE C) +set_source_files_properties(${CMAKE_SOURCE_DIR}/../../src/radio/radio_audio.c PROPERTIES LANGUAGE C) set_target_properties(utun_voice PROPERTIES POSITION_INDEPENDENT_CODE ON) @@ -141,6 +142,10 @@ add_executable(vibechat src/connmonitorwindow.cpp src/callwindow.cpp src/call_audio_engine.cpp + src/radio_audio_engine.cpp + src/ptt_hotkey_manager.cpp + src/radiosettingspage.cpp + third_party/qhotkey/qhotkey.cpp transport/utun_node.cpp transport/node_config.cpp transport/config_updater.cpp @@ -151,16 +156,25 @@ add_executable(vibechat resources/chatgui.qrc ) -target_include_directories(vibechat PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include ${FFMPEG_INCLUDE_DIRS}) +target_include_directories(vibechat PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include ${CMAKE_SOURCE_DIR}/third_party/qhotkey ${FFMPEG_INCLUDE_DIRS}) target_compile_definitions(vibechat PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE) if(FFMPEG_FOUND) target_compile_definitions(vibechat PRIVATE HAVE_FFMPEG) endif() set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C) + +# QHotkey — глобальные хоткеи (PTT): X11 на Linux, RegisterHotKey на Windows +if(WIN32) + target_sources(vibechat PRIVATE third_party/qhotkey/qhotkey_win.cpp) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_package(X11 REQUIRED) + target_sources(vibechat PRIVATE third_party/qhotkey/qhotkey_x11.cpp) +endif() + if(WIN32) target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) else() - target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) + target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread dl ${X11_LIBRARIES} ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) endif() # ============================================================================ @@ -205,6 +219,14 @@ target_include_directories(test_voice_jitter PRIVATE ${CMAKE_SOURCE_DIR}/../../s target_link_libraries(test_voice_jitter PRIVATE utun_voice pthread) add_test(NAME test_voice_jitter COMMAND test_voice_jitter) +# тест аудио-движка рации (per-source jitter + микшер + soft-clip) +add_executable(test_radio_audio + tests/test_radio_audio.cpp +) +target_include_directories(test_radio_audio PRIVATE ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../lib/libopus/include) +target_link_libraries(test_radio_audio PRIVATE utun_voice utun pthread) +add_test(NAME test_radio_audio COMMAND test_radio_audio) + # smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine add_executable(test_video_engine tests/test_video_engine.cpp diff --git a/tools/chatgui/libutun/CMakeLists.txt b/tools/chatgui/libutun/CMakeLists.txt index 8ef437d6..f3511a9f 100644 --- a/tools/chatgui/libutun/CMakeLists.txt +++ b/tools/chatgui/libutun/CMakeLists.txt @@ -77,9 +77,10 @@ file(GLOB_RECURSE UTUN_COMMON_SOURCES CONFIGURE_DEPENDS "${SRC_DIR}/*.c") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/lwip_orig/") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/uip/") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/transport_layer/BBR/") -# аудио-контур звонка зависит от C++ (SoundTouch/voice_jitter) — собирается в libutun_voice +# аудио-контур звонка/рации зависит от C++ (SoundTouch/voice_jitter) — собирается в libutun_voice list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_audio\\.c$") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_tones\\.c$") +list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/radio/radio_audio\\.c$") list(REMOVE_ITEM UTUN_COMMON_SOURCES "${SRC_DIR}/utun.c") # Platform-specific TUN implementation diff --git a/tools/chatgui/src/callwindow.cpp b/tools/chatgui/src/callwindow.cpp index 327cb0f1..2e2daaa1 100644 --- a/tools/chatgui/src/callwindow.cpp +++ b/tools/chatgui/src/callwindow.cpp @@ -156,6 +156,12 @@ CallWindow::CallWindow(QWidget* parent) m_durationLabel->setAlignment(Qt::AlignCenter); m_durationLabel->setStyleSheet("font-size: 15px; color: palette(text); font-family: monospace;"); + m_bgpPathLabel = new QLabel(this); + m_bgpPathLabel->setAlignment(Qt::AlignCenter); + m_bgpPathLabel->setWordWrap(true); + m_bgpPathLabel->setStyleSheet("color: gray; font-size: 11px;"); + m_bgpPathLabel->hide(); + m_acceptBtn = makeRoundBtn(makeRoundIcon(QColor("#27ae60"), Glyph::Phone, 96), QStringLiteral("Принять"), this); m_declineBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QStringLiteral("Отклонить"), this); m_hangupBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QStringLiteral("Завершить"), this); @@ -174,6 +180,7 @@ CallWindow::CallWindow(QWidget* parent) lay->addWidget(m_peerLabel); lay->addWidget(m_statusLabel); lay->addWidget(m_durationLabel); + lay->addWidget(m_bgpPathLabel); lay->addStretch(1); m_acceptCell = wrapWithCaption(m_acceptBtn, QStringLiteral("Принять")); @@ -213,6 +220,11 @@ void CallWindow::setPeerName(const QString& name) { m_avatarLabel->setPixmap(makeAvatar(name, 80)); } +void CallWindow::setBgpPath(const QString& path) { + m_bgpPathLabel->setText(path); + m_bgpPathLabel->setVisible(!path.isEmpty()); +} + void CallWindow::setState(State s) { m_state = s; switch (s) { diff --git a/tools/chatgui/src/callwindow.h b/tools/chatgui/src/callwindow.h index b4dc86ef..efbb3d66 100644 --- a/tools/chatgui/src/callwindow.h +++ b/tools/chatgui/src/callwindow.h @@ -22,6 +22,7 @@ public: quint64 callId() const { return m_callId; } void setPeerName(const QString& name); + void setBgpPath(const QString& path); void setState(State s); State state() const { return m_state; } @@ -52,6 +53,7 @@ private: QLabel* m_peerLabel; QLabel* m_statusLabel; QLabel* m_durationLabel; + QLabel* m_bgpPathLabel; QPushButton* m_acceptBtn; QPushButton* m_declineBtn; QPushButton* m_hangupBtn; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index eb9e0839..028a0543 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -19,6 +19,7 @@ #include "voicemessageencoder.h" #include "callwindow.h" #include "call_audio_engine.h" +#include "ptt_hotkey_manager.h" extern "C" { #include "../../lib/audio_compressor.h" @@ -385,6 +386,7 @@ void MainWindow::setupBridgeCallbacks() { s_mainWindow->m_db->setDb(chat_core_get_db(gui_bridge_get_inst())); s_mainWindow->m_channelList->loadChannels(); s_mainWindow->restoreWindowGeometry(); + PttHotkeyManager::instance()->init(s_mainWindow->m_db); /* глобальные PTT-хоткеи */ QString lastCid = s_mainWindow->m_db->getUiState("last_channel_id"); if (!lastCid.isEmpty()) { /* восстанавливаем канал только если он реально существует в БД — @@ -446,6 +448,15 @@ void MainWindow::setupBridgeCallbacks() { gui_bridge_set_call_declined_cb([](uint64_t call_id, uint8_t reason) { if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason); }); + gui_bridge_set_call_path_cb([](uint64_t call_id, const char* text, int len) { + if (s_mainWindow) s_mainWindow->onCallPath(call_id, QString::fromUtf8(text, len)); + }); + + /* ── рация: индикация говорящего ── */ + gui_bridge_set_radio_talk_cb([](uint64_t group_id, uint64_t src_node_id, uint8_t on) { + if (s_mainWindow && s_mainWindow->m_messageList) + s_mainWindow->m_messageList->onRadioTalk((quint64)group_id, (quint64)src_node_id, on != 0); + }); /* ── DM (p2p чат) ── */ gui_bridge_set_dm_conv_updated_cb([](const char* conv_id, int conv_id_len) { @@ -573,6 +584,11 @@ void MainWindow::onCallEnded(quint64 callId, uint8_t reason) { closeCallWindow(callId); } +void MainWindow::onCallPath(quint64 callId, const QString& path) { + CallWindow* w = m_activeCalls.value(callId, nullptr); + if (w) w->setBgpPath(path); +} + void MainWindow::closeCallWindow(quint64 callId) { SoundManager::instance()->stopLoop(); CallWindow* w = m_activeCalls.take(callId); diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 7e495a9c..4a20235d 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -72,6 +72,7 @@ public: void onCallRinging(quint64 callId); void onCallAccepted(quint64 callId); void onCallEnded(quint64 callId, uint8_t reason); + void onCallPath(quint64 callId, const QString& path); void closeCallWindow(quint64 callId); /* DM (p2p чат) */ diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index b6cc39e8..4f5571a2 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -37,12 +37,18 @@ extern "C" { #include "chat/chat_core.h" #include "dm/dm_core.h" #include "video/video.h" +#include "radio/radio.h" #include "../../lib/u_async.h" #include "../../lib/mem.h" #include "../../lib/debug_config.h" void chat_core_save_ui_state_trampoline(void* arg); } +#include "radio_audio_engine.h" +#include +#include +#include + static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) { QPixmap pix(size, size); pix.fill(Qt::transparent); @@ -308,6 +314,38 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); + /* ── шапка чата: название канала + переключатель рации ── */ + { + auto* header = new QWidget(this); + auto* hl = new QHBoxLayout(header); + hl->setContentsMargins(10, 6, 10, 6); + hl->setSpacing(8); + + m_channelTitle = new QLabel(header); + m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;"); + m_channelTitle->setText(QStringLiteral("Канал")); + hl->addWidget(m_channelTitle, 1); + + m_talkingLabel = new QLabel(header); + m_talkingLabel->setStyleSheet("color: #27ae60; font-size: 12px;"); + m_talkingLabel->hide(); + hl->addWidget(m_talkingLabel); + + m_radioBtn = new QPushButton(QStringLiteral("\xF0\x9F\x93\xBB"), header); + m_radioBtn->setCheckable(true); + m_radioBtn->setFixedSize(34, 30); + m_radioBtn->setCursor(Qt::PointingHandCursor); + m_radioBtn->setToolTip(QStringLiteral("Рация: включить/выключить прослушивание канала")); + m_radioBtn->setStyleSheet( + "QPushButton { border: 1px solid palette(mid); border-radius: 6px; background: palette(base); font-size: 15px; }" + "QPushButton:checked { background: #27ae60; border-color: #27ae60; }"); + connect(m_radioBtn, &QPushButton::toggled, this, &MessageList::onRadioToggled); + hl->addWidget(m_radioBtn); + + header->setStyleSheet("background: palette(window);"); + layout->addWidget(header); + } + m_view->setModel(m_model); auto* msgDelegate = new MessageDelegate(this); msgDelegate->setVideoOpenCallback([this](const QString& path) { @@ -328,6 +366,22 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_view->setBackgroundImage("../resources/bg.jpg"); layout->addWidget(m_view, 1); + /* ── плавающая PTT-кнопка поверх чата (видна при включённой рации) ── */ + m_pttBtn = new QPushButton(QStringLiteral("\xF0\x9F\x8E\xA4"), this); + m_pttBtn->setFixedSize(96, 96); + m_pttBtn->setCursor(Qt::PointingHandCursor); + m_pttBtn->setToolTip(QStringLiteral("Держите и говорите")); + m_pttBtn->setStyleSheet( + "QPushButton { border: 3px solid white; border-radius: 48px; background: #e74c3c; color: white; font-size: 36px; }" + "QPushButton:pressed { background: #c0392b; }"); + m_pttBtn->hide(); + connect(m_pttBtn, &QPushButton::pressed, this, []() { + RadioAudioEngine::instance()->talkBegin(); + }); + connect(m_pttBtn, &QPushButton::released, this, []() { + RadioAudioEngine::instance()->talkEnd(); + }); + if (s_icons.empty()) { for (const auto &cat : builtinEmojiSet()) { for (const auto &e : cat.items) { @@ -548,6 +602,15 @@ void MessageList::loadChannel(const QString& channelId) { if (!m_currentChannelId.isEmpty()) { if (!sameChannel) { saveChannelState(); + /* выключаем рацию предыдущего канала при переключении */ + if (m_radioOn) { + m_radioBtn->blockSignals(true); + m_radioBtn->setChecked(false); + m_radioBtn->blockSignals(false); + m_radioOn = false; + RadioAudioEngine::instance()->stop(); + updateRadioUi(); + } } else { QModelIndex topIdx = m_view->indexAt(QPoint(0, 0)); m_savedTopMsgId = topIdx.isValid() ? topIdx.data(MsgIdRole).toLongLong() : 0; @@ -562,8 +625,18 @@ void MessageList::loadChannel(const QString& channelId) { m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId); m_readDebounce->stop(); + /* шапка: название канала */ + QString title = channelId; + if (m_db && !isDmChannel(channelId)) { + for (const auto& ch : m_db->getChannels()) { + if (ch.channelId == channelId) { title = ch.name; break; } + } + } + if (m_channelTitle) m_channelTitle->setText(title); + /* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */ if (isDmChannel(channelId)) { + m_radioBtn->setEnabled(false); m_inputBar->setMediaEnabled(false); requestDmMessages(channelId); if (prevFocus) @@ -572,6 +645,7 @@ void MessageList::loadChannel(const QString& channelId) { m_inputBar->setFocus(); return; } + m_radioBtn->setEnabled(true); m_inputBar->setMediaEnabled(true); if (!m_db || !m_db->isOpen()) return; @@ -1015,3 +1089,64 @@ void MessageList::onDmMessages(const uint8_t* data, int len) { m_view->scrollToBottom(); } + +/* ── рация ── */ + +void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) { + if (m_currentChannelId.isEmpty()) return; + if (groupId != m_currentChannelId.toULongLong()) return; + if (!m_talkingLabel) return; + if (on) { + QString name = authorName(srcNodeId); + if (name.isEmpty()) name = QStringLiteral("0x%1").arg(srcNodeId, 0, 16); + m_talkingLabel->setText(QStringLiteral("\xF0\x9F\x8E\x99 %1 говорит").arg(name)); + m_talkingLabel->show(); + } else { + m_talkingLabel->hide(); + } +} + +void MessageList::onRadioToggled(bool on) { + if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return; + + quint64 groupId = m_currentChannelId.toULongLong(); + if (groupId == 0) return; + + struct radio_set_active_arg* a = (struct radio_set_active_arg*)u_malloc(sizeof(*a)); + if (!a) return; + a->inst = gui_bridge_get_inst(); + a->group_id = groupId; + a->on = on ? 1 : 0; + gui_bridge_post_uasync_fn(radio_set_active_trampoline, a); + + m_radioOn = on; + if (on) { + RadioAudioEngine::instance()->start(groupId); + } else { + RadioAudioEngine::instance()->stop(); + } + updateRadioUi(); +} + +void MessageList::updateRadioUi() { + if (!m_pttBtn) return; + bool show = m_radioOn && !isDmChannel(m_currentChannelId); + m_pttBtn->setVisible(show); + if (show) { + m_pttBtn->raise(); + layoutPttButton(); + } +} + +void MessageList::layoutPttButton() { + if (!m_pttBtn || !m_pttBtn->isVisible()) return; + int w = width(); + int x = (w - m_pttBtn->width()) / 2; + int y = 48; /* под шапкой, поверх списка сообщений */ + m_pttBtn->move(x, y); +} + +void MessageList::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + layoutPttButton(); +} diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index c7cde907..28855f67 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -12,6 +12,8 @@ class EmojiPanel; class DbManager; class ChannelList; class AudioRecorder; +class QLabel; +class QPushButton; class MessageList : public QWidget { Q_OBJECT @@ -27,12 +29,16 @@ public: void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks); void onAttachmentDownloaded(const QString& chId, int64_t msgId); void onDmMessages(const uint8_t* data, int len); + void onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on); /* индикация говорящего */ signals: void readPositionChanged(const QString& channelId, qint64 lastReadMsgId); void openVideoRequested(const QString& filePath); void openImageRequested(const QString& filePath); +protected: + void resizeEvent(QResizeEvent* event) override; + private: ChatView *m_view; QStandardItemModel *m_model; @@ -48,12 +54,20 @@ private: bool m_wasAtBottom = false; QMap m_localNicks; AudioRecorder* m_recorder = nullptr; + QLabel* m_channelTitle = nullptr; /* шапка: название канала */ + QLabel* m_talkingLabel = nullptr; /* шапка: кто говорит в рации */ + QPushButton* m_radioBtn = nullptr; /* шапка: переключатель рации */ + QPushButton* m_pttBtn = nullptr; /* плавающая кнопка PTT поверх чата */ + bool m_radioOn = false; QString authorName(quint64 nodeId) const; void restoreScrollPosition(const QString& channelId); void updateReadPosition(); void onMessageContextMenu(const QPoint& pos); void requestDmMessages(const QString& channelId); + void onRadioToggled(bool on); + void updateRadioUi(); + void layoutPttButton(); static bool isDmChannel(const QString& channelId); static QString dmConvId(const QString& channelId); }; diff --git a/tools/chatgui/src/ptt_hotkey_manager.cpp b/tools/chatgui/src/ptt_hotkey_manager.cpp new file mode 100644 index 00000000..17890bc2 --- /dev/null +++ b/tools/chatgui/src/ptt_hotkey_manager.cpp @@ -0,0 +1,75 @@ +#include "ptt_hotkey_manager.h" +#include "radio_audio_engine.h" +#include "../db/db_manager.h" +#include "qhotkey.h" + +#include +#include + +static PttHotkeyManager* g_pttHotkeys = nullptr; + +PttHotkeyManager* PttHotkeyManager::instance() { + if (!g_pttHotkeys) g_pttHotkeys = new PttHotkeyManager(); + return g_pttHotkeys; +} + +bool PttHotkeyManager::isSupported() { + return QHotkey::isPlatformSupported(); +} + +void PttHotkeyManager::init(DbManager* db) { + m_db = db; + reload(); +} + +QStringList PttHotkeyManager::hotkeys() const { + return m_seqs; +} + +void PttHotkeyManager::setHotkeys(const QStringList& seqs) { + applyHotkeys(seqs); +} + +void PttHotkeyManager::reload() { + QStringList seqs; + if (m_db && m_db->isOpen()) { + const QString raw = m_db->getUiState("radio_ptt_hotkeys"); + if (!raw.isEmpty()) + seqs = raw.split('|', Qt::SkipEmptyParts); + } + applyHotkeys(seqs); +} + +void PttHotkeyManager::clearHotkeys() { + for (QHotkey* h : m_hotkeys) { + h->resetShortcut(); + h->deleteLater(); + } + m_hotkeys.clear(); +} + +void PttHotkeyManager::applyHotkeys(const QStringList& seqs) { + clearHotkeys(); + m_seqs.clear(); + + for (const QString& s : seqs) { + QKeySequence seq(s); + if (seq.isEmpty()) continue; + auto* hk = new QHotkey(seq, true, this); + if (!hk->isRegistered()) { + qWarning("PttHotkey: failed to register '%s'", qPrintable(s)); + hk->deleteLater(); + continue; + } + connect(hk, &QHotkey::activated, this, []() { + RadioAudioEngine::instance()->talkBegin(); + }); + connect(hk, &QHotkey::released, this, []() { + RadioAudioEngine::instance()->talkEnd(); + }); + m_hotkeys.append(hk); + m_seqs.append(seq.toString()); + } + + qDebug("PttHotkey: applied %d hotkey(s)", (int)m_hotkeys.size()); +} diff --git a/tools/chatgui/src/ptt_hotkey_manager.h b/tools/chatgui/src/ptt_hotkey_manager.h new file mode 100644 index 00000000..e4586aba --- /dev/null +++ b/tools/chatgui/src/ptt_hotkey_manager.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +class QHotkey; +class DbManager; + +/* Глобальные PTT-хоткеи рации (QHotkey): удержание любой комбинации активирует + * передачу (talkBegin), отпускание — завершает (talkEnd). Работает из любого + * приложения. Хранится в ui_state ("radio_ptt_hotkeys", разделитель "|"). */ +class PttHotkeyManager : public QObject { + Q_OBJECT +public: + static PttHotkeyManager* instance(); + + void init(DbManager* db); + + /* Текущий список хоткеев (QKeySequence-строки). */ + QStringList hotkeys() const; + + /* Применить список хоткеев (без записи в БД — сохранение делает SettingsDialog). */ + void setHotkeys(const QStringList& seqs); + + /* Перечитать из ui_state и применить. */ + void reload(); + + static bool isSupported(); + +private: + PttHotkeyManager() = default; + void clearHotkeys(); + void applyHotkeys(const QStringList& seqs); + + DbManager* m_db = nullptr; + QList m_hotkeys; + QStringList m_seqs; +}; diff --git a/tools/chatgui/src/radio_audio_engine.cpp b/tools/chatgui/src/radio_audio_engine.cpp new file mode 100644 index 00000000..8be3b5cd --- /dev/null +++ b/tools/chatgui/src/radio_audio_engine.cpp @@ -0,0 +1,153 @@ +#include "radio_audio_engine.h" +#include "sound_manager.h" +#include "../transport/gui_bridge.h" + +#include "miniaudio.h" + +extern "C" { +#include "radio/radio_audio.h" +#include "../../lib/debug_config.h" +} + +#include + +static RadioAudioEngine* g_radioAudioEngine = nullptr; + +static void radioAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) { + RadioAudioEngine* e = (RadioAudioEngine*)dev->pUserData; + if (e) e->dataCallback(in, out, frames); +} + +RadioAudioEngine* RadioAudioEngine::instance() { + if (!g_radioAudioEngine) g_radioAudioEngine = new RadioAudioEngine(); + return g_radioAudioEngine; +} + +int RadioAudioEngine::start(quint64 groupId) { + if (m_active) { + if (m_groupId == groupId) return 0; + stop(); + } + m_groupId = groupId; + + struct UTUN_INSTANCE* inst = gui_bridge_get_inst(); + if (!inst) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: no instance, cannot start"); + return -1; + } + if (radio_audio_init(inst) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_init failed"); + return -1; + } + if (radio_audio_start(groupId) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_start failed grp=%016llx", (unsigned long long)groupId); + return -1; + } + + m_captureBuf.assign(RADIO_AUDIO_FRAME_SAMPLES, 0); + m_capturePos = 0; + + if (initDevice() != 0) { + radio_audio_stop(); + return -1; + } + + m_active = true; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: started grp=%016llx", (unsigned long long)groupId); + return 0; +} + +int RadioAudioEngine::initDevice() { + ma_context* ctx = SoundManager::instance()->context(); + if (!ctx) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: no audio context"); + return -1; + } + + m_device = new ma_device; + std::memset(m_device, 0, sizeof(*m_device)); + ma_device_config config = ma_device_config_init(ma_device_type_duplex); + config.capture.format = ma_format_s16; + config.capture.channels = 1; + config.playback.format = ma_format_s16; + config.playback.channels = 1; + config.sampleRate = RADIO_AUDIO_SAMPLE_RATE; + config.periodSizeInFrames = 480; + config.dataCallback = radioAudioDataCallback; + config.pUserData = this; + + if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: device init failed, trying default"); + config.playback.pDeviceID = nullptr; + config.capture.pDeviceID = nullptr; + if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: default device init also failed"); + delete m_device; m_device = nullptr; + return -1; + } + } + if (ma_device_start(m_device) != MA_SUCCESS) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: device start failed"); + ma_device_uninit(m_device); + delete m_device; m_device = nullptr; + return -1; + } + m_deviceStarted = true; + m_capturePos = 0; + return 0; +} + +void RadioAudioEngine::stop() { + if (!m_active && !m_device && !m_deviceStarted) return; + m_active = false; + m_talking = false; + quint64 gid = m_groupId.load(); + + teardownDevice(); + radio_audio_stop(); + + m_groupId = 0; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: stopped"); +} + +void RadioAudioEngine::talkBegin() { + if (!m_active) return; + m_talking = true; + radio_audio_talk_begin(m_groupId.load()); +} + +void RadioAudioEngine::talkEnd() { + if (!m_active) return; + m_talking = false; + radio_audio_talk_end(m_groupId.load()); +} + +void RadioAudioEngine::teardownDevice() { + if (m_deviceStarted) { + ma_device_stop(m_device); + ma_device_uninit(m_device); + m_deviceStarted = false; + } + if (m_device) { delete m_device; m_device = nullptr; } +} + +void RadioAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) { + const int16_t* in16 = (const int16_t*)in; + int16_t* out16 = (int16_t*)out; + + /* захват: копим 960 samples → encode+send (сервисный слой). Только когда PTT зажата. */ + if (in16 && m_talking.load()) { + for (unsigned int i = 0; i < frames; i++) { + m_captureBuf[m_capturePos++] = in16[i]; + if (m_capturePos >= (int)m_captureBuf.size()) { + radio_audio_feed_pcm(m_groupId.load(), m_captureBuf.data(), (int)m_captureBuf.size()); + m_capturePos = 0; + } + } + } + + /* воспроизведение: микшированный PCM из сервисного слоя */ + int n = radio_audio_pull_pcm(m_groupId.load(), out16, (int)frames); + if (n < (int)frames) + std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t)); +} diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h new file mode 100644 index 00000000..76a12578 --- /dev/null +++ b/tools/chatgui/src/radio_audio_engine.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +struct ma_device; + +/* Аудио-контур рации (desktop): только miniaudio I/O + захват/воспроизведение. + * Весь аудиопроцессинг (Opus, per-source декодеры, микшер) — в сервисном слое + * src/radio/radio_audio.c; сюда приходит/отсюда уходит финальный PCM. + * + * Потоки: + * - аудио-поток (miniaudio callback): capture → radio_audio_feed_pcm, + * radio_audio_pull_pcm → playback; + * - GUI-поток: start()/stop() по включению/выключению рации канала. */ +class RadioAudioEngine : public QObject { + Q_OBJECT +public: + static RadioAudioEngine* instance(); + + /* Включить прослушивание канала (после toggle рации). 0 = успех. */ + int start(quint64 groupId); + + /* Выключить прослушивание канала (toggle рации выключен). */ + void stop(); + + /* PTT: зажата / отпущена (только когда радио канала включено). */ + void talkBegin(); + void talkEnd(); + + bool isActive() const { return m_active.load(); } + quint64 groupId() const { return m_groupId.load(); } + + /* аудио-поток: miniaudio data callback (захват + воспроизведение) */ + void dataCallback(const void* in, void* out, unsigned int frames); + +private: + RadioAudioEngine() = default; + void teardownDevice(); + int initDevice(); + + std::atomic m_active{false}; + std::atomic m_talking{false}; + std::atomic m_groupId{0}; + + ma_device* m_device = nullptr; + bool m_deviceStarted = false; + + std::vector m_captureBuf; /* 960 samples, аудио-поток */ + int m_capturePos = 0; +}; diff --git a/tools/chatgui/src/radiosettingspage.cpp b/tools/chatgui/src/radiosettingspage.cpp new file mode 100644 index 00000000..84190dd7 --- /dev/null +++ b/tools/chatgui/src/radiosettingspage.cpp @@ -0,0 +1,68 @@ +#include "radiosettingspage.h" +#include "ptt_hotkey_manager.h" +#include "../db/db_manager.h" + +#include +#include +#include + +RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) + : QWidget(parent), m_db(db) { + auto* layout = new QVBoxLayout(this); + + auto* title = new QLabel("Рация (PTT)", this); + title->setStyleSheet("font-weight: bold; font-size: 13px;"); + layout->addWidget(title); + + auto* hint = new QLabel( + "Глобальные хоткеи передачи: удержание любой комбинации активирует PTT " + "из любого приложения (рация канала должна быть включена).", this); + hint->setWordWrap(true); + hint->setStyleSheet("color: gray; font-size: 11px;"); + layout->addWidget(hint); + + if (!PttHotkeyManager::isSupported()) { + auto* warn = new QLabel("Глобальные хоткеи не поддерживаются на этой платформе.", this); + warn->setStyleSheet("color: #e74c3c; font-size: 12px;"); + layout->addWidget(warn); + } + + auto* form = new QFormLayout(); + for (int i = 0; i < 3; i++) { + auto* edit = new QKeySequenceEdit(this); + edit->setStyleSheet( + "QKeySequenceEdit { border: 1px solid palette(mid); border-radius: 4px;" + " padding: 6px 10px; font-size: 13px; background: palette(base); }"); + m_edits.append(edit); + form->addRow(QString("PTT хоткей %1:").arg(i + 1), edit); + } + layout->addLayout(form); + layout->addStretch(1); + + loadFromDb(); +} + +void RadioSettingsPage::loadFromDb() { + QStringList seqs; + if (m_db && m_db->isOpen()) { + const QString raw = m_db->getUiState("radio_ptt_hotkeys"); + if (!raw.isEmpty()) + seqs = raw.split('|', Qt::SkipEmptyParts); + } + for (int i = 0; i < m_edits.size(); i++) + m_edits[i]->setKeySequence(i < seqs.size() ? QKeySequence(seqs[i]) : QKeySequence()); +} + +QStringList RadioSettingsPage::hotkeys() const { + QStringList out; + for (QKeySequenceEdit* e : m_edits) { + const QKeySequence seq = e->keySequence(); + if (!seq.isEmpty()) + out << seq.toString(); + } + return out; +} + +void RadioSettingsPage::apply() { + PttHotkeyManager::instance()->setHotkeys(hotkeys()); +} diff --git a/tools/chatgui/src/radiosettingspage.h b/tools/chatgui/src/radiosettingspage.h new file mode 100644 index 00000000..2d7310eb --- /dev/null +++ b/tools/chatgui/src/radiosettingspage.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +class QKeySequenceEdit; +class DbManager; + +/* Страница настроек рации: глобальные PTT-хоткеи (до 3 комбинаций). + * Удержание любой из них активирует передачу из любого приложения. */ +class RadioSettingsPage : public QWidget { + Q_OBJECT +public: + explicit RadioSettingsPage(DbManager* db, QWidget* parent = nullptr); + + /* Загрузить хоткеи из ui_state в поля ввода. */ + void loadFromDb(); + + /* Список непустых QKeySequence-строк из полей. */ + QStringList hotkeys() const; + + /* Применить текущие хоткеи (без записи в БД). */ + void apply(); + +private: + DbManager* m_db; + QVector m_edits; +}; diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 95d577f1..5d3197ea 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -7,6 +7,7 @@ #include "soundsettingspage.h" #include "audiodevicesettingspage.h" #include "storagesettingspage.h" +#include "radiosettingspage.h" #include "connmonitorwindow.h" #include "../transport/gui_bridge.h" #include "../../lib/mem.h" @@ -145,6 +146,9 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_audioDevicePage = new AudioDeviceSettingsPage(db, recorder, m_pages); m_audioDevicePage->setConfigPath(m_configPath); m_pages->addWidget(m_audioDevicePage); + + m_radioPage = new RadioSettingsPage(db, m_pages); + m_pages->addWidget(m_radioPage); rightLayout->addWidget(m_pages, 1); // --- Bottom buttons --- @@ -289,6 +293,23 @@ void SettingsDialog::onSave() { m_soundPage->applyAndSave(); m_audioDevicePage->applyAndSave(); m_storagePage->applyAndSave(); + m_radioPage->apply(); + + /* save radio PTT hotkeys to ui_state */ + { + const char* key = "radio_ptt_hotkeys"; + QByteArray val = m_radioPage->hotkeys().join('|').toUtf8(); + size_t klen = strlen(key), vlen = (size_t)val.size(); + size_t total = klen + 1 + vlen + 1; + if (total <= 256) { + struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg)); + if (!arg) return; + arg->inst = gui_bridge_get_inst(); + memcpy(arg->data, key, klen + 1); + memcpy(arg->data + klen + 1, val.constData(), vlen + 1); + gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); + } + } /* save join_policy */ { diff --git a/tools/chatgui/src/settingsdialog.h b/tools/chatgui/src/settingsdialog.h index 565ef870..5917fc85 100644 --- a/tools/chatgui/src/settingsdialog.h +++ b/tools/chatgui/src/settingsdialog.h @@ -18,6 +18,7 @@ class ConnMonitorWindow; class SoundSettingsPage; class AudioDeviceSettingsPage; class StorageSettingsPage; +class RadioSettingsPage; class DbManager; class UtunNode; class AudioRecorder; @@ -53,4 +54,5 @@ private: SoundSettingsPage* m_soundPage; AudioDeviceSettingsPage* m_audioDevicePage; StorageSettingsPage* m_storagePage; + RadioSettingsPage* m_radioPage; }; diff --git a/tools/chatgui/tests/test_radio_audio.cpp b/tools/chatgui/tests/test_radio_audio.cpp new file mode 100644 index 00000000..d7f67e8b --- /dev/null +++ b/tools/chatgui/tests/test_radio_audio.cpp @@ -0,0 +1,86 @@ +// test_radio_audio.cpp — тест аудио-движка рации (jitter + микшер). +// +// Проверяет сервисный слой src/radio/radio_audio.c без полного UTUN_INSTANCE: +// 1. single-source: подаём кодированные Opus-кадры через radio_audio_on_frame, +// radio_audio_pull_pcm выдаёт декодированный ненулевой PCM (jitter+decode). +// 2. mixing: два источника с большими амплитудами — сумма насыщается (soft-clip), +// что доказывает микширование нескольких источников. +// +// Сборка/запуск: через CMake (tools/chatgui), линкуется utun_voice + utun. + +#include "radio/radio_audio.h" + +extern "C" { +#include "../../lib/opus_codec.h" +} + +#include +#include +#include +#include + +#define GID 0x1234567890abcdefULL +#define SRC_A 0xAAAAULL +#define SRC_B 0xBBBBULL + +static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0; +#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0) +#define OK() do { G_PASSED++; printf("OK\n"); } while(0) +#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0) + +/* закодировать N кадров постоянного PCM и подать через on_frame */ +static void push_const(uint64_t src, uint16_t stream, int value, int nframes) { + opus_codec_encoder_t* enc = opus_codec_encoder_create(48000, 1); + if (!enc) { FAIL("encoder create failed"); return; } + opus_codec_encoder_bitrate_set(enc, 32000); + + int16_t pcm[960]; + for (int i = 0; i < 960; i++) pcm[i] = (int16_t)value; + + for (int f = 0; f < nframes; f++) { + uint8_t opus[256]; + int len = opus_codec_encode(enc, pcm, 960, opus, (int)sizeof(opus)); + if (len <= 0) { FAIL("encode failed"); break; } + radio_audio_on_frame(NULL, GID, src, stream, (uint16_t)f, 0, opus, len, NULL); + } + opus_codec_encoder_destroy(enc); +} + +/* вытянуть pull-цикл, вернуть max |sample| */ +static int pull_max_abs(int npulls) { + int mx = 0; + int16_t out[960]; + for (int p = 0; p < npulls; p++) { + int n = radio_audio_pull_pcm(GID, out, 960); + (void)n; + for (int i = 0; i < 960; i++) { + int a = out[i] < 0 ? -out[i] : out[i]; + if (a > mx) mx = a; + } + } + return mx; +} + +int main() { + printf("=== test_radio_audio ===\n"); fflush(stdout); + + TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); { + radio_audio_start(GID); + push_const(SRC_A, 1, 10000, 5); + int mx = pull_max_abs(10); + radio_audio_stop(); + if (mx > 1000) OK(); else FAIL("max|pcm|=%d (ожидали > 1000)", mx); + } + + TEST("mixing: два источника складываются (soft-clip)"); { + radio_audio_start(GID); + push_const(SRC_A, 1, 20000, 5); + push_const(SRC_B, 1, 20000, 5); + int mx = pull_max_abs(10); + radio_audio_stop(); + if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx); + } + + printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); + return G_FAILED > 0 ? 1 : 0; +} diff --git a/tools/chatgui/third_party/qhotkey/qhotkey.cpp b/tools/chatgui/third_party/qhotkey/qhotkey.cpp new file mode 100644 index 00000000..2509c72f --- /dev/null +++ b/tools/chatgui/third_party/qhotkey/qhotkey.cpp @@ -0,0 +1,377 @@ +#include "qhotkey.h" +#include "qhotkey_p.h" +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(logQHotkey, "QHotkey") + +void QHotkey::addGlobalMapping(const QKeySequence &shortcut, QHotkey::NativeShortcut nativeShortcut) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const int key = shortcut[0].toCombined(); +#else + const int key = shortcut[0]; +#endif + + QMetaObject::invokeMethod(QHotkeyPrivate::instance(), "addMappingInvoked", Qt::QueuedConnection, + Q_ARG(Qt::Key, Qt::Key(key & ~Qt::KeyboardModifierMask)), + Q_ARG(Qt::KeyboardModifiers, Qt::KeyboardModifiers(key & Qt::KeyboardModifierMask)), + Q_ARG(QHotkey::NativeShortcut, nativeShortcut)); +} + +bool QHotkey::isPlatformSupported() +{ + return QHotkeyPrivate::isPlatformSupported(); +} + +QHotkey::QHotkey(QObject *parent) : + QObject(parent), + _keyCode(Qt::Key_unknown), + _modifiers(Qt::NoModifier), + _registered(false) +{} + +QHotkey::QHotkey(const QKeySequence &shortcut, bool autoRegister, QObject *parent) : + QHotkey(parent) +{ + setShortcut(shortcut, autoRegister); +} + +QHotkey::QHotkey(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister, QObject *parent) : + QHotkey(parent) +{ + setShortcut(keyCode, modifiers, autoRegister); +} + +QHotkey::QHotkey(QHotkey::NativeShortcut shortcut, bool autoRegister, QObject *parent) : + QHotkey(parent) +{ + setNativeShortcut(shortcut, autoRegister); +} + +QHotkey::~QHotkey() +{ + if(_registered) + QHotkeyPrivate::instance()->removeShortcut(this); +} + +QKeySequence QHotkey::shortcut() const +{ + if(_keyCode == Qt::Key_unknown) + return QKeySequence(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + return QKeySequence((_keyCode | _modifiers).toCombined()); +#else + return QKeySequence(static_cast(_keyCode | _modifiers)); +#endif +} + +Qt::Key QHotkey::keyCode() const +{ + return _keyCode; +} + +Qt::KeyboardModifiers QHotkey::modifiers() const +{ + return _modifiers; +} + +QHotkey::NativeShortcut QHotkey::currentNativeShortcut() const +{ + return _nativeShortcut; +} + +bool QHotkey::isRegistered() const +{ + return _registered; +} + +bool QHotkey::setShortcut(const QKeySequence &shortcut, bool autoRegister) +{ + if(shortcut.isEmpty()) + return resetShortcut(); + if(shortcut.count() > 1) { + qCWarning(logQHotkey, "Keysequences with multiple shortcuts are not allowed! " + "Only the first shortcut will be used!"); + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const int key = shortcut[0].toCombined(); +#else + const int key = shortcut[0]; +#endif + + return setShortcut(Qt::Key(key & ~Qt::KeyboardModifierMask), + Qt::KeyboardModifiers(key & Qt::KeyboardModifierMask), + autoRegister); +} + +bool QHotkey::setShortcut(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister) +{ + if(_registered) { + if(autoRegister) { + if(!QHotkeyPrivate::instance()->removeShortcut(this)) + return false; + } else + return false; + } + + if(keyCode == Qt::Key_unknown) { + _keyCode = Qt::Key_unknown; + _modifiers = Qt::NoModifier; + _nativeShortcut = NativeShortcut(); + return true; + } + + _keyCode = keyCode; + _modifiers = modifiers; + _nativeShortcut = QHotkeyPrivate::instance()->nativeShortcut(keyCode, modifiers); + if(_nativeShortcut.isValid()) { + if(autoRegister) + return QHotkeyPrivate::instance()->addShortcut(this); + return true; + } + + qCWarning(logQHotkey) << "Unable to map shortcut to native keys. Key:" << keyCode << "Modifiers:" << modifiers; + _keyCode = Qt::Key_unknown; + _modifiers = Qt::NoModifier; + _nativeShortcut = NativeShortcut(); + return false; +} + +bool QHotkey::resetShortcut() +{ + if(_registered && + !QHotkeyPrivate::instance()->removeShortcut(this)) { + return false; + } + + _keyCode = Qt::Key_unknown; + _modifiers = Qt::NoModifier; + _nativeShortcut = NativeShortcut(); + return true; +} + +bool QHotkey::setNativeShortcut(QHotkey::NativeShortcut nativeShortcut, bool autoRegister) +{ + if(_registered) { + if(autoRegister) { + if(!QHotkeyPrivate::instance()->removeShortcut(this)) + return false; + } else + return false; + } + + if(nativeShortcut.isValid()) { + _keyCode = Qt::Key_unknown; + _modifiers = Qt::NoModifier; + _nativeShortcut = nativeShortcut; + if(autoRegister) + return QHotkeyPrivate::instance()->addShortcut(this); + return true; + } + + _keyCode = Qt::Key_unknown; + _modifiers = Qt::NoModifier; + _nativeShortcut = NativeShortcut(); + return true; +} + +bool QHotkey::setRegistered(bool registered) +{ + if(_registered && !registered) + return QHotkeyPrivate::instance()->removeShortcut(this); + if(!_registered && registered) { + if(!_nativeShortcut.isValid()) + return false; + return QHotkeyPrivate::instance()->addShortcut(this); + } + return true; +} + + + +// ---------- QHotkeyPrivate implementation ---------- + +QHotkeyPrivate::QHotkeyPrivate() +{ + Q_ASSERT_X(qApp, Q_FUNC_INFO, "QHotkey requires QCoreApplication to be instantiated"); + qApp->eventDispatcher()->installNativeEventFilter(this); +} + +QHotkeyPrivate::~QHotkeyPrivate() +{ + if(!shortcuts.isEmpty()) + qCWarning(logQHotkey) << "QHotkeyPrivate destroyed with registered shortcuts!"; + if(qApp && qApp->eventDispatcher()) + qApp->eventDispatcher()->removeNativeEventFilter(this); +} + +QHotkey::NativeShortcut QHotkeyPrivate::nativeShortcut(Qt::Key keycode, Qt::KeyboardModifiers modifiers) +{ + Qt::ConnectionType conType = (QThread::currentThread() == thread() ? + Qt::DirectConnection : + Qt::BlockingQueuedConnection); + QHotkey::NativeShortcut res; + if(!QMetaObject::invokeMethod(this, "nativeShortcutInvoked", conType, + Q_RETURN_ARG(QHotkey::NativeShortcut, res), + Q_ARG(Qt::Key, keycode), + Q_ARG(Qt::KeyboardModifiers, modifiers))) { + return QHotkey::NativeShortcut(); + } + return res; +} + +bool QHotkeyPrivate::addShortcut(QHotkey *hotkey) +{ + if(hotkey->_registered) + return false; + + Qt::ConnectionType conType = (QThread::currentThread() == thread() ? + Qt::DirectConnection : + Qt::BlockingQueuedConnection); + bool res = false; + if(!QMetaObject::invokeMethod(this, "addShortcutInvoked", conType, + Q_RETURN_ARG(bool, res), + Q_ARG(QHotkey*, hotkey))) { + return false; + } + + if(res) + emit hotkey->registeredChanged(true); + return res; +} + +bool QHotkeyPrivate::removeShortcut(QHotkey *hotkey) +{ + if(!hotkey->_registered) + return false; + + Qt::ConnectionType conType = (QThread::currentThread() == thread() ? + Qt::DirectConnection : + Qt::BlockingQueuedConnection); + bool res = false; + if(!QMetaObject::invokeMethod(this, "removeShortcutInvoked", conType, + Q_RETURN_ARG(bool, res), + Q_ARG(QHotkey*, hotkey))) { + return false; + } + + if(res) + emit hotkey->registeredChanged(false); + return res; +} + +void QHotkeyPrivate::activateShortcut(QHotkey::NativeShortcut shortcut) +{ + QMetaMethod signal = QMetaMethod::fromSignal(&QHotkey::activated); + for(QHotkey *hkey : shortcuts.values(shortcut)) + signal.invoke(hkey, Qt::QueuedConnection); +} + +void QHotkeyPrivate::releaseShortcut(QHotkey::NativeShortcut shortcut) +{ + QMetaMethod signal = QMetaMethod::fromSignal(&QHotkey::released); + for(QHotkey *hkey : shortcuts.values(shortcut)) + signal.invoke(hkey, Qt::QueuedConnection); +} + +void QHotkeyPrivate::addMappingInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers, QHotkey::NativeShortcut nativeShortcut) +{ + mapping.insert({keycode, modifiers}, nativeShortcut); +} + +bool QHotkeyPrivate::addShortcutInvoked(QHotkey *hotkey) +{ + QHotkey::NativeShortcut shortcut = hotkey->_nativeShortcut; + + if(!shortcuts.contains(shortcut)) { + if(!registerShortcut(shortcut)) { + qCWarning(logQHotkey) << QHotkey::tr("Failed to register %1. Error: %2").arg(hotkey->shortcut().toString(), error); + return false; + } + } + + shortcuts.insert(shortcut, hotkey); + hotkey->_registered = true; + return true; +} + +bool QHotkeyPrivate::removeShortcutInvoked(QHotkey *hotkey) +{ + QHotkey::NativeShortcut shortcut = hotkey->_nativeShortcut; + + if(shortcuts.remove(shortcut, hotkey) == 0) + return false; + hotkey->_registered = false; + emit hotkey->registeredChanged(true); + if(shortcuts.count(shortcut) == 0) { + if (!unregisterShortcut(shortcut)) { + qCWarning(logQHotkey) << QHotkey::tr("Failed to unregister %1. Error: %2").arg(hotkey->shortcut().toString(), error); + return false; + } + return true; + } + return true; +} + +QHotkey::NativeShortcut QHotkeyPrivate::nativeShortcutInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers) +{ + if(mapping.contains({keycode, modifiers})) + return mapping.value({keycode, modifiers}); + + bool ok1 = false; + auto k = nativeKeycode(keycode, ok1); + bool ok2 = false; + auto m = nativeModifiers(modifiers, ok2); + if(ok1 && ok2) + return {k, m}; + return {}; +} + + + +QHotkey::NativeShortcut::NativeShortcut() : + key(), + modifier(), + valid(false) +{} + +QHotkey::NativeShortcut::NativeShortcut(quint32 key, quint32 modifier) : + key(key), + modifier(modifier), + valid(true) +{} + +bool QHotkey::NativeShortcut::isValid() const +{ + return valid; +} + +bool QHotkey::NativeShortcut::operator ==(QHotkey::NativeShortcut other) const +{ + return (key == other.key) && + (modifier == other.modifier) && + valid == other.valid; +} + +bool QHotkey::NativeShortcut::operator !=(QHotkey::NativeShortcut other) const +{ + return (key != other.key) || + (modifier != other.modifier) || + valid != other.valid; +} + +QHOTKEY_HASH_SEED qHash(QHotkey::NativeShortcut key) +{ + return qHash(key.key) ^ qHash(key.modifier); +} + +QHOTKEY_HASH_SEED qHash(QHotkey::NativeShortcut key, QHOTKEY_HASH_SEED seed) +{ + return qHash(key.key, seed) ^ qHash(key.modifier, seed); +} diff --git a/tools/chatgui/third_party/qhotkey/qhotkey.h b/tools/chatgui/third_party/qhotkey/qhotkey.h new file mode 100644 index 00000000..12f5fec0 --- /dev/null +++ b/tools/chatgui/third_party/qhotkey/qhotkey.h @@ -0,0 +1,130 @@ +#ifndef QHOTKEY_H +#define QHOTKEY_H + +#include +#include +#include +#include + +#ifdef QHOTKEY_SHARED +# ifdef QHOTKEY_LIBRARY +# define QHOTKEY_EXPORT Q_DECL_EXPORT +# else +# define QHOTKEY_EXPORT Q_DECL_IMPORT +# endif +#else +# define QHOTKEY_EXPORT +#endif + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + #define QHOTKEY_HASH_SEED size_t +#else + #define QHOTKEY_HASH_SEED uint +#endif + +//! A class to define global, systemwide Hotkeys +class QHOTKEY_EXPORT QHotkey : public QObject +{ + Q_OBJECT + //! @private + friend class QHotkeyPrivate; + + //! Specifies whether this hotkey is currently registered or not + Q_PROPERTY(bool registered READ isRegistered WRITE setRegistered NOTIFY registeredChanged) + //! Holds the shortcut this hotkey will be triggered on + Q_PROPERTY(QKeySequence shortcut READ shortcut WRITE setShortcut RESET resetShortcut) + +public: + //! Defines shortcut with native keycodes + class QHOTKEY_EXPORT NativeShortcut { + public: + //! The native keycode + quint32 key; + //! The native modifiers + quint32 modifier; + + //! Creates an invalid native shortcut + NativeShortcut(); + //! Creates a valid native shortcut, with the given key and modifiers + NativeShortcut(quint32 key, quint32 modifier = 0); + + //! Checks, whether this shortcut is valid or not + bool isValid() const; + + //! Equality operator + bool operator ==(NativeShortcut other) const; + //! Inequality operator + bool operator !=(NativeShortcut other) const; + + private: + bool valid; + }; + + //! Adds a global mapping of a key sequence to a replacement native shortcut + static void addGlobalMapping(const QKeySequence &shortcut, NativeShortcut nativeShortcut); + + //! Checks if global shortcuts are supported by the current platform + static bool isPlatformSupported(); + + //! Default Constructor + explicit QHotkey(QObject *parent = nullptr); + //! Constructs a hotkey with a shortcut and optionally registers it + explicit QHotkey(const QKeySequence &shortcut, bool autoRegister = false, QObject *parent = nullptr); + //! Constructs a hotkey with a key and modifiers and optionally registers it + explicit QHotkey(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister = false, QObject *parent = nullptr); + //! Constructs a hotkey from a native shortcut and optionally registers it + explicit QHotkey(NativeShortcut shortcut, bool autoRegister = false, QObject *parent = nullptr); + ~QHotkey() override; + + //! @readAcFn{QHotkey::registered} + bool isRegistered() const; + //! @readAcFn{QHotkey::shortcut} + QKeySequence shortcut() const; + //! @readAcFn{QHotkey::shortcut} - the key only + Qt::Key keyCode() const; + //! @readAcFn{QHotkey::shortcut} - the modifiers only + Qt::KeyboardModifiers modifiers() const; + + //! Get the current native shortcut + NativeShortcut currentNativeShortcut() const; + +public Q_SLOTS: + //! @writeAcFn{QHotkey::registered} + bool setRegistered(bool registered); + + //! @writeAcFn{QHotkey::shortcut} + bool setShortcut(const QKeySequence &shortcut, bool autoRegister = false); + //! @writeAcFn{QHotkey::shortcut} + bool setShortcut(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister = false); + //! @resetAcFn{QHotkey::shortcut} + bool resetShortcut(); + + //! Set this hotkey to a native shortcut + bool setNativeShortcut(QHotkey::NativeShortcut nativeShortcut, bool autoRegister = false); + +Q_SIGNALS: + //! Will be emitted if the shortcut is pressed + void activated(QPrivateSignal); + + //! Will be emitted if the shortcut press is released + void released(QPrivateSignal); + + //! @notifyAcFn{QHotkey::registered} + void registeredChanged(bool registered); + +private: + Qt::Key _keyCode; + Qt::KeyboardModifiers _modifiers; + + NativeShortcut _nativeShortcut; + bool _registered; +}; + +QHOTKEY_HASH_SEED QHOTKEY_EXPORT qHash(QHotkey::NativeShortcut key); +QHOTKEY_HASH_SEED QHOTKEY_EXPORT qHash(QHotkey::NativeShortcut key, QHOTKEY_HASH_SEED seed); + +QHOTKEY_EXPORT Q_DECLARE_LOGGING_CATEGORY(logQHotkey) + +Q_DECLARE_METATYPE(QHotkey::NativeShortcut) + +#endif // QHOTKEY_H diff --git a/tools/chatgui/third_party/qhotkey/qhotkey_p.h b/tools/chatgui/third_party/qhotkey/qhotkey_p.h new file mode 100644 index 00000000..8bc5ab64 --- /dev/null +++ b/tools/chatgui/third_party/qhotkey/qhotkey_p.h @@ -0,0 +1,62 @@ +#ifndef QHOTKEY_P_H +#define QHOTKEY_P_H + +#include "qhotkey.h" +#include +#include +#include +#include + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + #define _NATIVE_EVENT_RESULT qintptr +#else + #define _NATIVE_EVENT_RESULT long +#endif + +class QHOTKEY_EXPORT QHotkeyPrivate : public QObject, public QAbstractNativeEventFilter +{ + Q_OBJECT + +public: + QHotkeyPrivate();//singleton!!! + ~QHotkeyPrivate(); + + static QHotkeyPrivate *instance(); + static bool isPlatformSupported(); + + QHotkey::NativeShortcut nativeShortcut(Qt::Key keycode, Qt::KeyboardModifiers modifiers); + + bool addShortcut(QHotkey *hotkey); + bool removeShortcut(QHotkey *hotkey); + +protected: + void activateShortcut(QHotkey::NativeShortcut shortcut); + void releaseShortcut(QHotkey::NativeShortcut shortcut); + + virtual quint32 nativeKeycode(Qt::Key keycode, bool &ok) = 0;//platform implement + virtual quint32 nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) = 0;//platform implement + + virtual bool registerShortcut(QHotkey::NativeShortcut shortcut) = 0;//platform implement + virtual bool unregisterShortcut(QHotkey::NativeShortcut shortcut) = 0;//platform implement + + QString error; + +private: + QHash, QHotkey::NativeShortcut> mapping; + QMultiHash shortcuts; + + Q_INVOKABLE void addMappingInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers, QHotkey::NativeShortcut nativeShortcut); + Q_INVOKABLE bool addShortcutInvoked(QHotkey *hotkey); + Q_INVOKABLE bool removeShortcutInvoked(QHotkey *hotkey); + Q_INVOKABLE QHotkey::NativeShortcut nativeShortcutInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers); +}; + +#define NATIVE_INSTANCE(ClassName) \ + Q_GLOBAL_STATIC(ClassName, hotkeyPrivate) \ + \ + QHotkeyPrivate *QHotkeyPrivate::instance()\ + {\ + return hotkeyPrivate;\ + } + +#endif // QHOTKEY_P_H diff --git a/tools/chatgui/third_party/qhotkey/qhotkey_win.cpp b/tools/chatgui/third_party/qhotkey/qhotkey_win.cpp new file mode 100644 index 00000000..949d87a6 --- /dev/null +++ b/tools/chatgui/third_party/qhotkey/qhotkey_win.cpp @@ -0,0 +1,309 @@ +#include "qhotkey.h" +#include "qhotkey_p.h" +#include +#include +#include +#include +#include + +#define HKEY_ID(nativeShortcut) (((nativeShortcut.key ^ (nativeShortcut.modifier << 8)) & 0x0FFF) | 0x7000) + +#if !defined(MOD_NOREPEAT) +#define MOD_NOREPEAT 0x4000 +#endif + +class QHotkeyPrivateWin : public QHotkeyPrivate +{ +public: + QHotkeyPrivateWin(); + // QAbstractNativeEventFilter interface + bool nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) override; + +protected: + void pollForHotkeyRelease(); + // QHotkeyPrivate interface + quint32 nativeKeycode(Qt::Key keycode, bool &ok) Q_DECL_OVERRIDE; + quint32 nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) Q_DECL_OVERRIDE; + bool registerShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; + bool unregisterShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; + +private: + static QString formatWinError(DWORD winError); + QTimer pollTimer; + QList polledShortcuts; +}; +NATIVE_INSTANCE(QHotkeyPrivateWin) + +QHotkeyPrivateWin::QHotkeyPrivateWin(){ + pollTimer.setInterval(50); + connect(&pollTimer, &QTimer::timeout, this, &QHotkeyPrivateWin::pollForHotkeyRelease); +} + +bool QHotkeyPrivate::isPlatformSupported() +{ + return true; +} + +bool QHotkeyPrivateWin::nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) +{ + Q_UNUSED(eventType) + Q_UNUSED(result) + + MSG* msg = static_cast(message); + if(msg->message == WM_HOTKEY) { + QHotkey::NativeShortcut shortcut = {HIWORD(msg->lParam), LOWORD(msg->lParam)}; + this->activateShortcut(shortcut); + if (this->polledShortcuts.empty()) + this->pollTimer.start(); + this->polledShortcuts.append(shortcut); + } + + return false; +} + +void QHotkeyPrivateWin::pollForHotkeyRelease() +{ + auto it = std::remove_if(this->polledShortcuts.begin(), this->polledShortcuts.end(), [this](const QHotkey::NativeShortcut &shortcut) { + bool pressed = (GetAsyncKeyState(shortcut.key) & (1 << 15)) != 0; + if (!pressed) + this->releaseShortcut(shortcut); + return !pressed; + }); + this->polledShortcuts.erase(it, this->polledShortcuts.end()); + if (this->polledShortcuts.empty()) + this->pollTimer.stop(); +} + +quint32 QHotkeyPrivateWin::nativeKeycode(Qt::Key keycode, bool &ok) +{ + ok = true; + if(keycode <= 0xFFFF) {//Try to obtain the key from it's "character" + const SHORT vKey = VkKeyScanW(static_cast(keycode)); + if(vKey > -1) + return LOBYTE(vKey); + } + + //find key from switch/case --> Only finds a very small subset of keys + switch (keycode) + { + case Qt::Key_Escape: + return VK_ESCAPE; + case Qt::Key_Tab: + case Qt::Key_Backtab: + return VK_TAB; + case Qt::Key_Backspace: + return VK_BACK; + case Qt::Key_Return: + case Qt::Key_Enter: + return VK_RETURN; + case Qt::Key_Insert: + return VK_INSERT; + case Qt::Key_Delete: + return VK_DELETE; + case Qt::Key_Pause: + return VK_PAUSE; + case Qt::Key_Print: + return VK_PRINT; + case Qt::Key_Clear: + return VK_CLEAR; + case Qt::Key_Home: + return VK_HOME; + case Qt::Key_End: + return VK_END; + case Qt::Key_Left: + return VK_LEFT; + case Qt::Key_Up: + return VK_UP; + case Qt::Key_Right: + return VK_RIGHT; + case Qt::Key_Down: + return VK_DOWN; + case Qt::Key_PageUp: + return VK_PRIOR; + case Qt::Key_PageDown: + return VK_NEXT; + case Qt::Key_CapsLock: + return VK_CAPITAL; + case Qt::Key_NumLock: + return VK_NUMLOCK; + case Qt::Key_ScrollLock: + return VK_SCROLL; + + case Qt::Key_F1: + return VK_F1; + case Qt::Key_F2: + return VK_F2; + case Qt::Key_F3: + return VK_F3; + case Qt::Key_F4: + return VK_F4; + case Qt::Key_F5: + return VK_F5; + case Qt::Key_F6: + return VK_F6; + case Qt::Key_F7: + return VK_F7; + case Qt::Key_F8: + return VK_F8; + case Qt::Key_F9: + return VK_F9; + case Qt::Key_F10: + return VK_F10; + case Qt::Key_F11: + return VK_F11; + case Qt::Key_F12: + return VK_F12; + case Qt::Key_F13: + return VK_F13; + case Qt::Key_F14: + return VK_F14; + case Qt::Key_F15: + return VK_F15; + case Qt::Key_F16: + return VK_F16; + case Qt::Key_F17: + return VK_F17; + case Qt::Key_F18: + return VK_F18; + case Qt::Key_F19: + return VK_F19; + case Qt::Key_F20: + return VK_F20; + case Qt::Key_F21: + return VK_F21; + case Qt::Key_F22: + return VK_F22; + case Qt::Key_F23: + return VK_F23; + case Qt::Key_F24: + return VK_F24; + + case Qt::Key_Menu: + return VK_APPS; + case Qt::Key_Help: + return VK_HELP; + case Qt::Key_MediaNext: + return VK_MEDIA_NEXT_TRACK; + case Qt::Key_MediaPrevious: + return VK_MEDIA_PREV_TRACK; + case Qt::Key_MediaPlay: + return VK_MEDIA_PLAY_PAUSE; + case Qt::Key_MediaStop: + return VK_MEDIA_STOP; + case Qt::Key_VolumeDown: + return VK_VOLUME_DOWN; + case Qt::Key_VolumeUp: + return VK_VOLUME_UP; + case Qt::Key_VolumeMute: + return VK_VOLUME_MUTE; + case Qt::Key_Mode_switch: + return VK_MODECHANGE; + case Qt::Key_Select: + return VK_SELECT; + case Qt::Key_Printer: + return VK_PRINT; + case Qt::Key_Execute: + return VK_EXECUTE; + case Qt::Key_Sleep: + return VK_SLEEP; + case Qt::Key_Period: + return VK_DECIMAL; + case Qt::Key_Play: + return VK_PLAY; + case Qt::Key_Cancel: + return VK_CANCEL; + + case Qt::Key_Forward: + return VK_BROWSER_FORWARD; + case Qt::Key_Refresh: + return VK_BROWSER_REFRESH; + case Qt::Key_Stop: + return VK_BROWSER_STOP; + case Qt::Key_Search: + return VK_BROWSER_SEARCH; + case Qt::Key_Favorites: + return VK_BROWSER_FAVORITES; + case Qt::Key_HomePage: + return VK_BROWSER_HOME; + + case Qt::Key_LaunchMail: + return VK_LAUNCH_MAIL; + case Qt::Key_LaunchMedia: + return VK_LAUNCH_MEDIA_SELECT; + case Qt::Key_Launch0: + return VK_LAUNCH_APP1; + case Qt::Key_Launch1: + return VK_LAUNCH_APP2; + + case Qt::Key_Massyo: + return VK_OEM_FJ_MASSHOU; + case Qt::Key_Touroku: + return VK_OEM_FJ_TOUROKU; + + default: + if(keycode <= 0xFFFF) + return static_cast(keycode); + else { + ok = false; + return 0; + } + } +} + +quint32 QHotkeyPrivateWin::nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) +{ + quint32 nMods = 0; + if (modifiers & Qt::ShiftModifier) + nMods |= MOD_SHIFT; + if (modifiers & Qt::ControlModifier) + nMods |= MOD_CONTROL; + if (modifiers & Qt::AltModifier) + nMods |= MOD_ALT; + if (modifiers & Qt::MetaModifier) + nMods |= MOD_WIN; + ok = true; + return nMods; +} + +bool QHotkeyPrivateWin::registerShortcut(QHotkey::NativeShortcut shortcut) +{ + BOOL ok = RegisterHotKey(NULL, + HKEY_ID(shortcut), + shortcut.modifier + MOD_NOREPEAT, + shortcut.key); + if(ok) + return true; + else { + error = QHotkeyPrivateWin::formatWinError(::GetLastError()); + return false; + } +} + +bool QHotkeyPrivateWin::unregisterShortcut(QHotkey::NativeShortcut shortcut) +{ + BOOL ok = UnregisterHotKey(NULL, HKEY_ID(shortcut)); + if(ok) + return true; + else { + error = QHotkeyPrivateWin::formatWinError(::GetLastError()); + return false; + } +} + +QString QHotkeyPrivateWin::formatWinError(DWORD winError) +{ + wchar_t *buffer = NULL; + DWORD num = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, + NULL, + winError, + 0, + (LPWSTR)&buffer, + 0, + NULL); + if(buffer) { + QString res = QString::fromWCharArray(buffer, num); + LocalFree(buffer); + return res; + } else + return QString(); +} diff --git a/tools/chatgui/third_party/qhotkey/qhotkey_x11.cpp b/tools/chatgui/third_party/qhotkey/qhotkey_x11.cpp new file mode 100644 index 00000000..d9d73f75 --- /dev/null +++ b/tools/chatgui/third_party/qhotkey/qhotkey_x11.cpp @@ -0,0 +1,271 @@ +#include "qhotkey.h" +#include "qhotkey_p.h" + +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) + #include +#else + #include + #include +#endif + +#include +#include +#include +#include + +//compatibility to pre Qt 5.8 +#ifndef Q_FALLTHROUGH +#define Q_FALLTHROUGH() (void)0 +#endif + +class QHotkeyPrivateX11 : public QHotkeyPrivate +{ +public: + // QAbstractNativeEventFilter interface + bool nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) override; + +protected: + // QHotkeyPrivate interface + quint32 nativeKeycode(Qt::Key keycode, bool &ok) Q_DECL_OVERRIDE; + quint32 nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) Q_DECL_OVERRIDE; + static QString getX11String(Qt::Key keycode); + bool registerShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; + bool unregisterShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; + +private: + static const QVector specialModifiers; + static const quint32 validModsMask; + xcb_key_press_event_t prevHandledEvent; + xcb_key_press_event_t prevEvent; + + static QString formatX11Error(Display *display, int errorCode); + + class HotkeyErrorHandler { + public: + HotkeyErrorHandler(); + ~HotkeyErrorHandler(); + + static bool hasError; + static QString errorString; + + private: + XErrorHandler prevHandler; + + static int handleError(Display *display, XErrorEvent *error); + }; +}; +NATIVE_INSTANCE(QHotkeyPrivateX11) + +bool QHotkeyPrivate::isPlatformSupported() +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) + return qGuiApp->nativeInterface(); +#else + return QX11Info::isPlatformX11(); +#endif +} + +const QVector QHotkeyPrivateX11::specialModifiers = {0, Mod2Mask, LockMask, (Mod2Mask | LockMask)}; +const quint32 QHotkeyPrivateX11::validModsMask = ShiftMask | ControlMask | Mod1Mask | Mod4Mask; + +bool QHotkeyPrivateX11::nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) +{ + Q_UNUSED(eventType) + Q_UNUSED(result) + + auto *genericEvent = static_cast(message); + if (genericEvent->response_type == XCB_KEY_PRESS) { + xcb_key_press_event_t keyEvent = *static_cast(message); + this->prevEvent = keyEvent; + if (this->prevHandledEvent.response_type == XCB_KEY_RELEASE) { + if(this->prevHandledEvent.time == keyEvent.time) return false; + } + this->activateShortcut({keyEvent.detail, keyEvent.state & QHotkeyPrivateX11::validModsMask}); + } else if (genericEvent->response_type == XCB_KEY_RELEASE) { + xcb_key_release_event_t keyEvent = *static_cast(message); + this->prevEvent = keyEvent; + QTimer::singleShot(50, [this, keyEvent] { + if(this->prevEvent.time == keyEvent.time && this->prevEvent.response_type == keyEvent.response_type && this->prevEvent.detail == keyEvent.detail){ + this->releaseShortcut({keyEvent.detail, keyEvent.state & QHotkeyPrivateX11::validModsMask}); + } + }); + this->prevHandledEvent = keyEvent; + } + + return false; +} + +QString QHotkeyPrivateX11::getX11String(Qt::Key keycode) +{ + switch(keycode){ + + case Qt::Key_MediaLast : + case Qt::Key_MediaPrevious : + return QStringLiteral("XF86AudioPrev"); + case Qt::Key_MediaNext : + return QStringLiteral("XF86AudioNext"); + case Qt::Key_MediaPause : + case Qt::Key_MediaPlay : + case Qt::Key_MediaTogglePlayPause : + return QStringLiteral("XF86AudioPlay"); + case Qt::Key_MediaRecord : + return QStringLiteral("XF86AudioRecord"); + case Qt::Key_MediaStop : + return QStringLiteral("XF86AudioStop"); + default : + return QKeySequence(keycode).toString(QKeySequence::NativeText); + } +} + +quint32 QHotkeyPrivateX11::nativeKeycode(Qt::Key keycode, bool &ok) +{ + QString keyString = getX11String(keycode); + + KeySym keysym = XStringToKeysym(keyString.toLatin1().constData()); + if (keysym == NoSymbol) { + //not found -> just use the key + if(keycode <= 0xFFFF) + keysym = keycode; + else + return 0; + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) + const QNativeInterface::QX11Application *x11Interface = qGuiApp->nativeInterface(); +#else + const bool x11Interface = QX11Info::isPlatformX11(); +#endif + + if(x11Interface) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) + Display *display = x11Interface->display(); +#else + Display *display = QX11Info::display(); +#endif + auto res = XKeysymToKeycode(display, keysym); + if(res != 0) + ok = true; + return res; + } + return 0; +} + +quint32 QHotkeyPrivateX11::nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) +{ + quint32 nMods = 0; + if (modifiers & Qt::ShiftModifier) + nMods |= ShiftMask; + if (modifiers & Qt::ControlModifier) + nMods |= ControlMask; + if (modifiers & Qt::AltModifier) + nMods |= Mod1Mask; + if (modifiers & Qt::MetaModifier) + nMods |= Mod4Mask; + ok = true; + return nMods; +} + +bool QHotkeyPrivateX11::registerShortcut(QHotkey::NativeShortcut shortcut) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) + const QNativeInterface::QX11Application *x11Interface = qGuiApp->nativeInterface(); + Display *display = x11Interface->display(); +#else + const bool x11Interface = QX11Info::isPlatformX11(); + Display *display = QX11Info::display(); +#endif + + if(!display || !x11Interface) + return false; + + HotkeyErrorHandler errorHandler; + for(quint32 specialMod : QHotkeyPrivateX11::specialModifiers) { + XGrabKey(display, + shortcut.key, + shortcut.modifier | specialMod, + DefaultRootWindow(display), + True, + GrabModeAsync, + GrabModeAsync); + } + XSync(display, False); + + if(errorHandler.hasError) { + error = errorHandler.errorString; + this->unregisterShortcut(shortcut); + return false; + } + return true; +} + +bool QHotkeyPrivateX11::unregisterShortcut(QHotkey::NativeShortcut shortcut) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) + Display *display = qGuiApp->nativeInterface()->display(); +#else + Display *display = QX11Info::display(); +#endif + + if(!display) + return false; + + HotkeyErrorHandler errorHandler; + for(quint32 specialMod : QHotkeyPrivateX11::specialModifiers) { + XUngrabKey(display, + shortcut.key, + shortcut.modifier | specialMod, + XDefaultRootWindow(display)); + } + XSync(display, False); + + if(HotkeyErrorHandler::hasError) { + error = HotkeyErrorHandler::errorString; + return false; + } + return true; +} + +QString QHotkeyPrivateX11::formatX11Error(Display *display, int errorCode) +{ + char errStr[256]; + XGetErrorText(display, errorCode, errStr, 256); + return QString::fromLatin1(errStr); +} + + + +// ---------- QHotkeyPrivateX11::HotkeyErrorHandler implementation ---------- + +bool QHotkeyPrivateX11::HotkeyErrorHandler::hasError = false; +QString QHotkeyPrivateX11::HotkeyErrorHandler::errorString; + +QHotkeyPrivateX11::HotkeyErrorHandler::HotkeyErrorHandler() +{ + prevHandler = XSetErrorHandler(&HotkeyErrorHandler::handleError); +} + +QHotkeyPrivateX11::HotkeyErrorHandler::~HotkeyErrorHandler() +{ + XSetErrorHandler(prevHandler); + hasError = false; + errorString.clear(); +} + +int QHotkeyPrivateX11::HotkeyErrorHandler::handleError(Display *display, XErrorEvent *error) +{ + switch (error->error_code) { + case BadAccess: + case BadValue: + case BadWindow: + if (error->request_code == 33 || //grab key + error->request_code == 34) {// ungrab key + hasError = true; + errorString = QHotkeyPrivateX11::formatX11Error(display, error->error_code); + return 1; + } + Q_FALLTHROUGH(); + // fall through + default: + return 0; + } +} diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 68dd3a94..60b68164 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -48,6 +48,8 @@ struct UTUN_INSTANCE; #define GUI_EVT_CALL_DECLINED 38 /* data: [call_id:8][reason:1] */ #define GUI_EVT_CALL_STATS 39 /* data: [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] */ #define GUI_EVT_DM_MESSAGES 41 /* data: [conv_id_len:1][conv_id:var][count:2]([dir:1][seq:8][ts:8][author:8][ct_len:1][ct:var][data_len:2][data:var])* */ +#define GUI_EVT_CALL_PATH 42 /* data: [call_id:8][text:var] — BGP-маршрут до пира звонка */ +#define GUI_EVT_RADIO_TALK 43 /* data: [group_id:8][src_node_id:8][stream_id:2][on:1] — кто-то начал/закончил говорить */ /* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */ #define MEMBER_DETAIL_LINK_SIZE 64 @@ -188,6 +190,15 @@ typedef void (*gui_call_ended_fn)(uint64_t call_id, uint8_t reason); void gui_bridge_set_call_ended_cb(gui_call_ended_fn cb); void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb); +/* Callback для BGP-маршрута звонка (вызывается из GUI-потока) */ +typedef void (*gui_call_path_fn)(uint64_t call_id, const char* text, int len); +void gui_bridge_set_call_path_cb(gui_call_path_fn cb); + +/* Callback индикации говорящего в рации (вызывается из GUI-потока) + * group_id — канал, src_node_id — кто говорит, on — 1=начал / 0=закончил. */ +typedef void (*gui_radio_talk_fn)(uint64_t group_id, uint64_t src_node_id, uint8_t on); +void gui_bridge_set_radio_talk_cb(gui_radio_talk_fn cb); + /* Callback для входящего DM-сообщения (вызывается из GUI-потока) * conv_id_len/conv_id — идентификатор беседы (десятичная строка). */ typedef void (*gui_dm_msg_received_fn)(const char* conv_id, int conv_id_len, uint64_t author); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index d579b452..8d1cbf4b 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -53,6 +53,8 @@ static gui_call_id_fn g_call_ringing_cb = nullptr; static gui_call_id_fn g_call_accepted_cb = nullptr; static gui_call_ended_fn g_call_ended_cb = nullptr; static gui_call_ended_fn g_call_declined_cb = nullptr; +static gui_call_path_fn g_call_path_cb = nullptr; +static gui_radio_talk_fn g_radio_talk_cb = nullptr; static gui_dm_msg_received_fn g_dm_msg_received_cb = nullptr; static gui_dm_conv_updated_fn g_dm_conv_updated_cb = nullptr; static gui_dm_messages_fn g_dm_messages_cb = nullptr; @@ -305,6 +307,14 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { if (g_call_declined_cb) g_call_declined_cb(callId, d[8]); } break; + case GUI_EVT_CALL_PATH: + if (dlen >= 9) { + uint64_t callId; memcpy(&callId, d, 8); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: CALL_PATH id=0x%016llx text=%.*s", + (unsigned long long)callId, dlen - 8, (const char*)d + 8); + if (g_call_path_cb) g_call_path_cb(callId, (const char*)d + 8, dlen - 8); + } + break; case GUI_EVT_CALL_STATS: if (dlen >= 14) { uint64_t callId; memcpy(&callId, d, 8); @@ -323,6 +333,15 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { (unsigned)dropped, (unsigned)underruns); } break; + case GUI_EVT_RADIO_TALK: + if (dlen >= 19) { + uint64_t groupId, src; + memcpy(&groupId, d, 8); + memcpy(&src, d + 8, 8); + uint8_t on = d[18]; + if (g_radio_talk_cb) g_radio_talk_cb(groupId, src, on); + } + break; default: DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType); break; @@ -484,6 +503,14 @@ void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb) { g_call_declined_cb = cb; } +void gui_bridge_set_call_path_cb(gui_call_path_fn cb) { + g_call_path_cb = cb; +} + +void gui_bridge_set_radio_talk_cb(gui_radio_talk_fn cb) { + g_radio_talk_cb = cb; +} + void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb) { g_dm_msg_received_cb = cb; }