Browse Source

radio: PTT-радио в каналах (src/radio + chatgui + chatgui-android)

Новый модуль src/radio (radio/radio_audio/radio_headless) — push-to-talk
радио поверх topo-групп, доставка аудио в канал, headless-режим и тесты.
Интеграция в chatgui (ptt_hotkey_manager, radio_audio_engine, радионастройки,
вызовы) и chatgui-android (RadioAudioEngine, RadioOverlayService, UI).
proxy
evgeny 2 weeks ago
parent
commit
b96ed8bd00
  1. 1
      lib/debug_config.c
  2. 3
      lib/debug_config.h
  3. 4
      src/Makefile.am
  4. 49
      src/call/call.c
  5. 1
      src/call/voice_sources.cmake
  6. 9
      src/chat/chat_event.c
  7. 2
      src/chat/chat_event.h
  8. 36
      src/chat/chat_headless_control.c
  9. 5
      src/config_parser.c
  10. 1
      src/config_parser.h
  11. 432
      src/radio/radio.c
  12. 75
      src/radio/radio.h
  13. 299
      src/radio/radio_audio.c
  14. 63
      src/radio/radio_audio.h
  15. 491
      src/radio/radio_headless.c
  16. 43
      src/radio/radio_headless.h
  17. 39
      src/radio/radio_proto.h
  18. 48
      src/routing_layer/topo_group.c
  19. 14
      src/routing_layer/topo_group.h
  20. 107
      src/routing_layer/topo_group_connect.c
  21. 10
      src/routing_layer/topo_group_connect.h
  22. 3
      src/routing_layer/topo_node.c
  23. 2
      src/routing_layer/topo_node.h
  24. 14
      src/routing_layer/topo_node_sqlite.c
  25. 3
      src/routing_layer/topo_node_sqlite.h
  26. 5
      src/transport_layer/etcp_api.h
  27. 21
      src/utun_instance.c
  28. 4
      src/utun_instance.h
  29. 10
      tests/Makefile.am
  30. 446
      tests/test_radio.c
  31. 470
      tests/test_radio_headless.c
  32. 10
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  33. 1
      tools/chatgui-android/app/src/main/cpp/CMakeLists.txt
  34. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  35. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  36. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  37. 205
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  38. 169
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  39. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  40. 96
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  41. 44
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  42. 77
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  43. 112
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  44. 11
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  45. 1
      tools/chatgui-android/libutun_lite/utun_sources.cmake
  46. 26
      tools/chatgui/CMakeLists.txt
  47. 3
      tools/chatgui/libutun/CMakeLists.txt
  48. 12
      tools/chatgui/src/callwindow.cpp
  49. 2
      tools/chatgui/src/callwindow.h
  50. 16
      tools/chatgui/src/mainwindow.cpp
  51. 1
      tools/chatgui/src/mainwindow.h
  52. 135
      tools/chatgui/src/messagelist.cpp
  53. 14
      tools/chatgui/src/messagelist.h
  54. 75
      tools/chatgui/src/ptt_hotkey_manager.cpp
  55. 39
      tools/chatgui/src/ptt_hotkey_manager.h
  56. 153
      tools/chatgui/src/radio_audio_engine.cpp
  57. 53
      tools/chatgui/src/radio_audio_engine.h
  58. 68
      tools/chatgui/src/radiosettingspage.cpp
  59. 29
      tools/chatgui/src/radiosettingspage.h
  60. 21
      tools/chatgui/src/settingsdialog.cpp
  61. 2
      tools/chatgui/src/settingsdialog.h
  62. 86
      tools/chatgui/tests/test_radio_audio.cpp
  63. 377
      tools/chatgui/third_party/qhotkey/qhotkey.cpp
  64. 130
      tools/chatgui/third_party/qhotkey/qhotkey.h
  65. 62
      tools/chatgui/third_party/qhotkey/qhotkey_p.h
  66. 309
      tools/chatgui/third_party/qhotkey/qhotkey_win.cpp
  67. 271
      tools/chatgui/third_party/qhotkey/qhotkey_x11.cpp
  68. 11
      tools/chatgui/transport/gui_bridge.h
  69. 27
      tools/chatgui/transport/gui_bridge_impl.cpp

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

3
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 */

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

49
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");
}

1
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

9
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);
}

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

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

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

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

432
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 <stdlib.h>
#include <string.h>
#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);
}

75
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 <stdint.h>
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 */

299
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 <pthread.h>
#include <string.h>
#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;
}

63
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 <stdint.h>
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 */

491
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 <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#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);
}

43
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 <stdint.h>
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 */

39
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 <stdint.h>
#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 */

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

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

107
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);
}

10
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-узлов с живыми соединениями.

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

2
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 прямого соединения к узлу
};

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

3
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_<ch>.node_type); -1 если нет записи/ошибка. */
int topo_node_sqlite_get_node_type(sqlite3* db, const char* channel_id, uint64_t node_id);
#endif

5
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 — маршрутизация данных

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

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

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

446
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 <sqlite3.h>
#define OPENSSL_API_COMPAT 0x10100000L
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <time.h>
#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;
}

470
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 <sqlite3.h>
#define OPENSSL_API_COMPAT 0x10100000L
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <time.h>
#include <errno.h>
#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;
}

10
tools/chatgui-android/app/src/main/AndroidManifest.xml

@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@ -47,6 +48,15 @@
android:value="p2p_chat_network_service" />
</service>
<service
android:name=".data.RadioOverlayService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="ptt_overlay_service" />
</service>
<receiver
android:name=".data.IncomingCallActionReceiver"
android:exported="false" />

1
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

16
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)
}
}

20
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<ServerEntry> = emptyList()): List<ServerEntry> {

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

205
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}")
}
}
}

169
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()
}
}

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

96
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)
}
}
}

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

77
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<Boolean> = _radioActive
private val _radioTalking = MutableStateFlow<Long?>(null)
val radioTalking: StateFlow<Long?> = _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)

112
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__ */

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

1
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 ──

26
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 <mp4>
add_executable(test_video_engine
tests/test_video_engine.cpp

3
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

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

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

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

1
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 чат) */

135
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 <QLabel>
#include <QPushButton>
#include <QHBoxLayout>
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();
}

14
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<quint64, QString> 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);
};

75
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 <QKeySequence>
#include <QDebug>
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());
}

39
tools/chatgui/src/ptt_hotkey_manager.h

@ -0,0 +1,39 @@
#pragma once
#include <QObject>
#include <QList>
#include <QStringList>
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<QHotkey*> m_hotkeys;
QStringList m_seqs;
};

153
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 <cstring>
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));
}

53
tools/chatgui/src/radio_audio_engine.h

@ -0,0 +1,53 @@
#pragma once
#include <QObject>
#include <atomic>
#include <cstdint>
#include <vector>
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<bool> m_active{false};
std::atomic<bool> m_talking{false};
std::atomic<uint64_t> m_groupId{0};
ma_device* m_device = nullptr;
bool m_deviceStarted = false;
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
int m_capturePos = 0;
};

68
tools/chatgui/src/radiosettingspage.cpp

@ -0,0 +1,68 @@
#include "radiosettingspage.h"
#include "ptt_hotkey_manager.h"
#include "../db/db_manager.h"
#include <QFormLayout>
#include <QLabel>
#include <QKeySequenceEdit>
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());
}

29
tools/chatgui/src/radiosettingspage.h

@ -0,0 +1,29 @@
#pragma once
#include <QWidget>
#include <QStringList>
#include <QVector>
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<QKeySequenceEdit*> m_edits;
};

21
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 */
{

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

86
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 <cstdio>
#include <cstring>
#include <cstdlib>
#include <cmath>
#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;
}

377
tools/chatgui/third_party/qhotkey/qhotkey.cpp vendored

@ -0,0 +1,377 @@
#include "qhotkey.h"
#include "qhotkey_p.h"
#include <QCoreApplication>
#include <QAbstractEventDispatcher>
#include <QMetaMethod>
#include <QThread>
#include <QDebug>
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<int>(_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);
}

130
tools/chatgui/third_party/qhotkey/qhotkey.h vendored

@ -0,0 +1,130 @@
#ifndef QHOTKEY_H
#define QHOTKEY_H
#include <QObject>
#include <QKeySequence>
#include <QPair>
#include <QLoggingCategory>
#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

62
tools/chatgui/third_party/qhotkey/qhotkey_p.h vendored

@ -0,0 +1,62 @@
#ifndef QHOTKEY_P_H
#define QHOTKEY_P_H
#include "qhotkey.h"
#include <QAbstractNativeEventFilter>
#include <QMultiHash>
#include <QMutex>
#include <QGlobalStatic>
#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<QPair<Qt::Key, Qt::KeyboardModifiers>, QHotkey::NativeShortcut> mapping;
QMultiHash<QHotkey::NativeShortcut, QHotkey*> 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

309
tools/chatgui/third_party/qhotkey/qhotkey_win.cpp vendored

@ -0,0 +1,309 @@
#include "qhotkey.h"
#include "qhotkey_p.h"
#include <qt_windows.h>
#include <algorithm>
#include <QDebug>
#include <QList>
#include <QTimer>
#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<QHotkey::NativeShortcut> 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<MSG*>(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<WCHAR>(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<BYTE>(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();
}

271
tools/chatgui/third_party/qhotkey/qhotkey_x11.cpp vendored

@ -0,0 +1,271 @@
#include "qhotkey.h"
#include "qhotkey_p.h"
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0)
#include <QGuiApplication>
#else
#include <QDebug>
#include <QX11Info>
#endif
#include <QThreadStorage>
#include <QTimer>
#include <X11/Xlib.h>
#include <xcb/xcb.h>
//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<quint32> 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<QNativeInterface::QX11Application>();
#else
return QX11Info::isPlatformX11();
#endif
}
const QVector<quint32> 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<xcb_generic_event_t *>(message);
if (genericEvent->response_type == XCB_KEY_PRESS) {
xcb_key_press_event_t keyEvent = *static_cast<xcb_key_press_event_t *>(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<xcb_key_release_event_t *>(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<QNativeInterface::QX11Application>();
#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<QNativeInterface::QX11Application>();
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<QNativeInterface::QX11Application>()->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;
}
}

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

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

Loading…
Cancel
Save