Author SHA1 Message Date
evgeny 51a5c5bcbf router: fix UAF on rconn->recv_conn after ETCP_CONN destroy
recv_conn хранил заимствованный указатель на физический conn без очистки
при его освобождении. Добавлен etcp_router_conn_destroyed(), вызываемый из
etcp_connection_close фазы 1; RST теперь отправляется через живой conn,
доставивший пакет, а не через устаревший recv_conn.
2026-09-16 20:27:03 +03:00
evgeny 51d6d22a27 sock_match: классификация сокета по адресу, а не по type из конфига (fix Q8/Android→LAN)
ip_is_public() вынесен в lib/platform_compat (единый классификатор IPv4 с ntohl).
auto_socket_reconcile_config: тип сокета выставляется по фактическому адресу.
sock_collect_views: has_priv/has_inet/is_eim — по классификации адреса и NAT-детекции.
is_local_subnet упрощён до !ip_is_public(). Тест test_sock_match обновлён.
2026-09-16 20:26:55 +03:00
evgeny 21d9a4e5b7 dm: заполнять peer_name при автосоздании беседы (иначе в списке 0x...) 2026-09-16 20:26:55 +03:00
evgeny 5293fdd3a9 call(android): звать call_audio_init перед start; логировать feed_pcm без instance
Без call_audio_init(inst) g_inst оставался NULL и call_set_audio_ops не
регистрировался: TX (feed_pcm) падал на !ua, RX (audio_ops->on_media) не
вызывался — тишина и спам 'callAudioFeed returned false'. Зеркалим desktop:
utun_bridge_call_audio_start теперь делает call_audio_init + call_audio_start.
2026-09-16 20:26:55 +03:00
evgeny 2de554f8b9 call: единый голосовой стек (libutun_voice) + SoundTouch на Android
- voice_jitter (кодированные Opus-кадры + decode-on-pull + SoundTouch time-stretch)
  вместо PCM-буфера audio_jitter; излишек держится до декодирования
- call_audio.c/.h — единая реализация для desktop/Android/headless (C-интерфейс)
- libutun_voice (voice_sources.cmake): SoundTouch + voice_jitter + call_audio + call_tones
- Android: pull-модель (nativeCallAudioPull), убран Kotlin FIFO, ANDROID_STL=c++_static
- headless audio-сокет: PCM-протокол (decode-only, без тактирования), daemon остаётся C-only
- CALL_STATS расширен dropped/underruns; удалены audio_jitter.cpp и libutun_lite/call_audio.c
- тест test_voice_jitter (ёмкость/догон/стационар), test_call_headless переведён на PCM
2026-09-16 20:26:55 +03:00
evgeny de930b21a2 ncd: fix UAF on deferred UP delivery after handle close
ncd_deliver_up_cb (uasync_call_soon) держал сырой указатель handle h,
который мог быть освобождён node_conn_direct_close/force_close раньше
выполнения отложенного callback'а. Сохраняем токен call_soon в handle
и отменяем его при close через uasync_call_soon_cancel.
2026-09-16 20:26:55 +03:00
evgeny e4557b0473 dm: маршрутизация через общую группу (group_id), JSON-эскейпинг; звонки: иконки/UI в desktop и android 2026-09-16 15:05:48 +03:00
evgeny b483335c06 bgp/ncd: владение conn через NCD-handle в senders_list; call audio route persist + incoming call sound 2026-09-16 13:29:28 +03:00
evgeny b11e77e4c0 call: P2P аудиозвонки — сигналинг, релей медиа, аудио-контур, headless/desktop/android
- src/call/: сигналинг (call.c, call_proto.h, ETCP_RT_ID_CALL 0x36) + релей медиа
  + headless аудио-сокет (call_headless.c, call_audio_bind) + Opus/jitter/тоны
- chat: события CHAT_EVT_CALL_* (34–40), CHAT_EVT_DM_MESSAGES (41), DM-трамплины
- chatgui: call window, miniaudio I/O, audio_frame_ring, test_audio_jitter
- chatgui-android: CallScreen/IncomingCallDialog, аудио-роутинг, JNI, рингтон
- tests: test_call, test_call_headless
2026-09-16 10:17:57 +03:00
evgeny 0157f7f96b build: fix --asan to build lib/libopus dependency 2026-09-15 22:44:27 +03:00
evgeny 21ec740d82 socks_proxy: defer conn free to fix UAF from synchronous write_queue flush 2026-09-15 22:39:27 +03:00
evgeny b085715654 tun/nat: fix UAF при teardown и use-after-free в tun_read_packet 2026-09-15 22:07:55 +03:00
110 changed files with 8238 additions and 328 deletions

No files matched your search

+2
View File
@@ -170,6 +170,8 @@ if [ "$USE_ASAN" -eq 1 ]; then
AF="-fsanitize=address -fno-omit-frame-pointer -g -Og"
AL="-fsanitize=address"
(cd lib && make clean $JOBS $MAKE_ARGS 2>&1 && make "CFLAGS+=$AF" "LDFLAGS+=$AL" $JOBS $MAKE_ARGS 2>&1) || { echo "lib build failed"; exit 1; }
# libopus собирается без ASAN (собственный CFLAGS в Makefile, .a несовместим с PIE)
(cd lib/libopus && make clean $JOBS $MAKE_ARGS 2>&1 && make $JOBS $MAKE_ARGS 2>&1) || { echo "libopus build failed"; exit 1; }
(cd src && make clean $JOBS $MAKE_ARGS 2>&1 && make "CFLAGS+=$AF" "LDFLAGS+=$AL" $JOBS $MAKE_ARGS 2>&1) || { echo "src build failed"; exit 1; }
BUILD_SUCCESS=1
elif [ "$USE_HARDENED" -eq 1 ]; then
+59 -1
View File
@@ -3,6 +3,17 @@
Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ.
## Текущая задача
[+] **Q8/Android не подключается к локальному чатгуи (LAN)** — сделано. Причина: тип сокета
в Android-конфиге был захардкожен `type=public`, из-за чего `sock_match` ставил
`has_priv=0` и не создавал линки к приватным (LAN) адресам пиров (`create_links … → 0 links`).
Исправлено: (1) `ip_is_public()` вынесен в `lib/platform_compat` (единый классификатор
IPv4 с корректным ntohl); (2) в `auto_socket_reconcile_config` (Android-режим) тип сокета
выставляется по адресу (public→PUBLIC, приватный/link-local→NAT), конфиг `type` игнорируется;
(3) `sock_collect_views` больше не смотрит на `type` из конфига: `has_priv` — по классификации
локального адреса, `has_inet=1` (исходящие считаем доступными), `is_eim` — по NAT-детекции
(hairpin). `is_local_subnet` упрощён до `!ip_is_public()`. Тест `test_sock_match` обновлён
(+ `ip_is_public` кейсы). `check.sh`: 86 passed / 1 failed / 1 skipped.
[ ] **DM: прямой p2p чат с любым пользователем группы** — отдельная DM-подсистема.
ТЗ и архитектура: `/doc/dm_arch.md`. Статус: реализовано (dm_core/dm_mailbox/dm_crypto),
тесты `test_dm` (крипто) и `test_dm_e2e` (интеграция). Остались задачи ниже.
@@ -12,7 +23,50 @@
(локальный буфер отправки при переполнении роутера) + адаптивный джиттер-буфер
(SoundTouch, ускорение до 1.6x) + тоны (глитч/завершение) + watchdog 20с.
Android: переключение источника (трубка/громкая/гарнитура).
Архитектура: `/doc/chat_call_arch.md`. Статус: спроектировано, ждёт реализации.
Архитектура: `/doc/chat_call_arch.md`.
[+] **Сигналинг + релей медиа** — сделано. `call_proto.h` (svc `ETCP_RT_ID_CALL
0x36`, subcmd INVITE/RINGING/ACCEPT/DECLINE/BUSY/CANCEL/HANGUP/MEDIA),
`call.c` (машина состояний, реестр сессий, conn_mgr, ring-timeout 45с,
watchdog no-traffic 20с, потеря соединения → end, события
CHAT_EVT_CALL_* 34–40), `DEBUG_CATEGORY_CALL 32`. `test_call` (2 инстанса,
happy path + decline + media-релей). Приём медиа — через `call_set_media_recv_cb`.
[+] **Headless API** — сделано. Команды control-сокета `call_start/accept/decline/
hangup` + события `call_incoming/ringing/accepted/ended/declined` (JSON);
аудио-сокет `call/call_headless.c` (TCP, бинарный фрейм
`[type:1][call_id:8][len:2][data]`, HELLO/FRAME), конфиг `call_audio_bind`.
Разрыв conn_mgr — отложенно 500мс (`CALL_TEARDOWN_DELAY_TB`) после ENDED,
чтобы HANGUP успел долететь; защита от stale-handle через conn-status.
Тесты: `test_call` (сигналинг+медиа), `test_call_headless` (полный e2e через
control+audio сокеты).
[+] **Аудио-контур (time-stretch в сервисном слое)** — сделано. `src/call/audio_jitter.cpp`
(SoundTouch, C API `ajb_*`: адаптивный джиттер-буфер ≤1000мс, target 60мс,
tempo 1.0→1.6x — догон live без потери данных при сетевой паузе),
`src/call/call_tones.c` (глитч при сталле >500мс + тон завершения),
`src/call/call_audio.c` (Opus 48kHz/20мс + jitter + тоны + pull API финального PCM).
`call.c`: `call_audio_ops` (on_media c seq/ts, get_stats) + событие
`CHAT_EVT_CALL_STATS` раз в 1с [rtt_ms, buffer_ms, tempo_x100].
Desktop `call_audio_engine.cpp` — только miniaudio I/O (получает финальный PCM).
Тест `test_audio_jitter` (ёмкость/догон/стационар). Демон остаётся C-only.
[+] **Android** — сделано. JNI (`utun_bridge_call_*`/`nativeCall*`), AudioRecord/Track
(CallAudioEngine), роутинг источника (CallAudioRouter: EARPIECE/SPEAKER/HEADSET,
BT SCO), CallScreen + IncomingCallDialog + RingtonePlayer + IncomingCallManager,
`libutun_lite/call_audio.c` (собственный аудио-контур Opus 48kHz/20мс).
[+] **Единый голосовой стек (voice stack) + SoundTouch на Android** — сделано.
Унификация аудио-контура звонка: `libutun_voice` (статическая C++-либа с
C-интерфейсом `call_audio.h` = SoundTouch + `voice_jitter.cpp` + `call_audio.c`
+ `call_tones.c`), собирается через `src/call/voice_sources.cmake` и линкуется
desktop/Android/headless-CLI. Джиттер-буфер держит КОДИРОВАННЫЕ (Opus) кадры,
декодирует только на pull (непосредственно перед воспроизведением) — память ~48x
меньше и uasync-поток разгружен. Android переведён с push-FIFO (Kotlin ArrayDeque)
на pull-модель (`nativeCallAudioPull`), своего джиттера больше нет → убрана
асимметрия задержки (desktop↔Android). Headless-аудио-сокет отдаёт готовый PCM
(декод Opus→PCM, без тактирования; клиент сам задаёт темп). Диагностика:
`CALL_STATS` расширен [dropped, underruns] (раз в 1с), счётчики headless.
Удалены `audio_jitter.cpp` и `libutun_lite/call_audio.c`. Тест `test_voice_jitter`
(ёмкость/догон/стационар). Android — `ANDROID_STL=c++_static`.
[ ] **DM-звонки** (fallback `group_id==0`) — не сделано: `call_is_online`/`call_start`
требуют реальную группу (`topo_groups_find`), для DM (group_id==0) маршрута нет.
## Рефакторинг: per-instance chat/dm (однопоточный test_dm_e2e)
@@ -132,6 +186,10 @@
`router_no_route`), не влияет на результат — стоит разобрать отдельно.
## Открытые флаки
[ ] **test_chat_join_e2e** — стабильно падает (2/3 сценария): «J not signed by A»
`get_sign_rc=-1` (signed_by-верификация invite). Не связан с socket-классификацией
(обнаружен при прогоне после фикса Q8/LAN). Скорее всего регресс join-протокола.
[ ] **test_etcp_reconnect** — флаки под параллельной нагрузкой `make check -j4`:
phase 4 (reconnect после server restart) таймаутит (`sent=480 recv=0`), при одиночном
запуске стабильно `TEST PASSED`. Наш код (chat_join/chat_sync) его не трогает.
+1
View File
@@ -132,6 +132,7 @@ static const struct {
{"video", DEBUG_CATEGORY_VIDEO},
{"reality", DEBUG_CATEGORY_REALITY},
{"dm", DEBUG_CATEGORY_DM},
{"call", DEBUG_CATEGORY_CALL},
{"all", DEBUG_CATEGORY_ALL},
{NULL, DEBUG_CATEGORY_NONE}
};
+2 -1
View File
@@ -70,7 +70,8 @@ typedef int debug_category_t;
#define DEBUG_CATEGORY_VIDEO 29 // Video module (probe/transcode)
#define DEBUG_CATEGORY_REALITY 30 // REALITY TLS camouflage
#define DEBUG_CATEGORY_DM 31 // DM — direct p2p chat between two users
#define DEBUG_CATEGORY_COUNT 32 // Total number of categories
#define DEBUG_CATEGORY_CALL 32 // Call — P2P audio call (signaling + media)
#define DEBUG_CATEGORY_COUNT 33 // Total number of categories
#define DEBUG_CATEGORY_ALL (-1) // special value for all categories
/* Debug configuration structure */
+19
View File
@@ -39,6 +39,25 @@
#endif
#endif
/*
* Классифицирует IPv4-адрес (network byte order):
* 1 = глобальный/публичный (маршрутизируемый в интернете),
* 0 = приватный, CGNAT, loopback, link-local, multicast или зарезервированный.
*/
int ip_is_public(uint32_t addr_be) {
uint32_t a = ntohl(addr_be);
if ((a & 0xFF000000u) == 0x0A000000u) return 0; /* 10.0.0.0/8 */
if ((a & 0xFFF00000u) == 0xAC100000u) return 0; /* 172.16.0.0/12 */
if ((a & 0xFFFF0000u) == 0xC0A80000u) return 0; /* 192.168.0.0/16 */
if ((a & 0xFF000000u) == 0x7F000000u) return 0; /* 127.0.0.0/8 */
if ((a & 0xFFFF0000u) == 0xA9FE0000u) return 0; /* 169.254.0.0/16 */
if ((a & 0xFF000000u) == 0x00000000u) return 0; /* 0.0.0.0/8 */
if ((a & 0xF0000000u) == 0xE0000000u) return 0; /* 224.0.0.0/4 */
if ((a & 0xF0000000u) == 0xF0000000u) return 0; /* 240.0.0.0/4 */
if ((a & 0xFFC00000u) == 0x64400000u) return 0; /* 100.64.0.0/10 (CGNAT) */
return 1;
}
/*
* Generate cryptographically secure random bytes
* Returns 0 on success, -1 on error
+5
View File
@@ -201,6 +201,11 @@ int get_interface_ipv6_addr_nl(uint32_t netif_index, int prefer_stable, uint8_t*
// family: AF_INET or AF_INET6, returns ifindex (0 on failure)
uint32_t get_default_route_netif_index(int family);
// Классифицирует IPv4-адрес (network byte order):
// 1 = глобальный/публичный (маршрутизируемый в интернете),
// 0 = приватный, CGNAT, loopback, link-local, multicast или зарезервированный.
int ip_is_public(uint32_t addr_be);
// Cross-platform mkdir
#ifdef _WIN32
+5
View File
@@ -95,6 +95,8 @@ utun_CORE_SOURCES = \
dm/dm_core.c \
dm/dm_crypto.c \
dm/dm_mailbox.c \
call/call.c \
call/call_headless.c \
broadcast.c
# libutun: all core sources except main()
@@ -189,6 +191,8 @@ libutun_a_SOURCES = \
dm/dm_core.c \
dm/dm_crypto.c \
dm/dm_mailbox.c \
call/call.c \
call/call_headless.c \
broadcast.c
libutun_a_CFLAGS = $(utun_CFLAGS)
@@ -206,6 +210,7 @@ utun_CFLAGS = \
-I$(top_srcdir)/src/media_delivery \
-I$(top_srcdir)/src/media_async \
-I$(top_srcdir)/src/video \
-I$(top_srcdir)/src/call \
-I$(top_srcdir)/src/uip \
-g \
-DUSE_SQLITE \
+882
View File
@@ -0,0 +1,882 @@
// call.c — P2P аудио-звонок: сигналинг + медиа (per-instance)
//
// Полный цикл: INVITE → RINGING → ACCEPT → conn_mgr (прямой линк) → ACTIVE →
// медиа (CALL_MEDIA) → HANGUP/ENDED. Звонок из CHAT-группы или DM — модуль
// оперирует (group_id, peer_node_id), маршрутизация через etcp_router.
//
// Сигналинг идёт с ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT, медиа — без подписи.
// Реестр сессий: ll_queue с hash-индексом по call_id (8 байт).
//
// Thread-модель: всё в uasync-потоке (single-threaded). Освобождение сессии
// отложено через uasync_call_soon, чтобы не было UAF при завершении из
// коллбэков conn_mgr/таймеров.
#include "call.h"
#include "call_proto.h"
#include "../utun_instance.h"
#include "../chat/chat_event.h"
#include "../routing_layer/etcp_router.h"
#include "../routing_layer/route_crypto.h"
#include "../routing_layer/conn_mgr.h"
#include "../routing_layer/topo_group.h"
#include "../routing_layer/topo_node.h"
#include "../transport_layer/etcp_api.h"
#include "../transport_layer/etcp.h"
#include "../../lib/mem.h"
#include "../../lib/ll_queue.h"
#include "../../lib/debug_config.h"
#include "../../lib/u_async.h"
#include "../../lib/platform_compat.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define CALL_ID "call"
enum call_state {
CALL_ST_IDLE = 0,
CALL_ST_OUTGOING, /* caller: INVITE отправлен */
CALL_ST_RINGING, /* caller: получил RINGING; callee: отправил RINGING */
CALL_ST_CONNECTING, /* обе стороны: conn_mgr_open в процессе */
CALL_ST_ACTIVE, /* обе стороны: соединение поднято */
CALL_ST_ENDED,
};
enum call_role { CALL_ROLE_CALLER = 0, CALL_ROLE_CALLEE };
struct call_session {
struct ll_entry ll; /* dummy-заголовок; структура хранится в entry->data */
uint64_t call_id; /* hash-индекс (8 байт) */
uint64_t group_id;
uint64_t peer_node_id;
uint8_t role; /* CALL_ROLE_* */
uint8_t state; /* CALL_ST_* */
uint8_t reason; /* CALL_REASON_* (при ENDED) */
struct call_ctx* ctx; /* обратная ссылка (для коллбэков conn_mgr/таймеров) */
struct CONN_MGR_HANDLE* cm_handle;
void* ring_timer; /* caller: 45с ожидания ответа */
void* traffic_timer; /* ACTIVE: 20с без медиа (запускается по первому медиа) */
void* teardown_timer; /* отложенный разрыв conn_mgr (500мс после завершения) */
void* stats_timer; /* ACTIVE: раз в 1с — CALL_STATS (rtt/buffer/tempo) */
uint64_t last_media_tb; /* время последнего CALL_MEDIA (0.1ms) */
uint16_t tx_media_seq; /* seq следующего исходящего CALL_MEDIA */
struct ll_queue* tx_q; /* FIFO закодированных CALL_MEDIA (backpressure) */
struct queue_waiter_handle send_waiter; /* waiter на send_q роутера */
uint32_t c_tx_frames; /* отправлено медиа-кадров */
uint32_t c_rx_frames; /* принято медиа-кадров */
uint32_t c_tx_dropped; /* дропнуто при переполнении tx_q */
uint32_t c_rx_dropped; /* дропнуто при переполнении jitter-буфера */
};
struct call_ctx {
struct UTUN_INSTANCE* inst;
int initialized;
struct ll_queue* sessions; /* struct call_session, hash по call_id */
uint32_t c_calls_started; /* суммарно начато звонков */
uint32_t c_calls_ended; /* суммарно завершено звонков */
call_media_recv_fn media_recv_fn; /* приём Opus-кадров пира */
void* media_recv_arg;
const struct call_audio_ops* audio_ops; /* аудио-движок (GUI-сборки), NULL в демоне */
};
/* ───────────────────────────────────────────────────────────────────────── */
static struct call_ctx* call_of(struct UTUN_INSTANCE* inst) {
return inst ? inst->call : NULL;
}
static const char* call_state_name(uint8_t st) {
switch (st) {
case CALL_ST_IDLE: return "IDLE";
case CALL_ST_OUTGOING: return "OUTGOING";
case CALL_ST_RINGING: return "RINGING";
case CALL_ST_CONNECTING: return "CONNECTING";
case CALL_ST_ACTIVE: return "ACTIVE";
case CALL_ST_ENDED: return "ENDED";
default: return "?";
}
}
static const char* call_subcmd_name(uint8_t sc) {
switch (sc) {
case CALL_SUBCMD_INVITE: return "INVITE";
case CALL_SUBCMD_RINGING: return "RINGING";
case CALL_SUBCMD_ACCEPT: return "ACCEPT";
case CALL_SUBCMD_DECLINE: return "DECLINE";
case CALL_SUBCMD_BUSY: return "BUSY";
case CALL_SUBCMD_CANCEL: return "CANCEL";
case CALL_SUBCMD_HANGUP: return "HANGUP";
case CALL_SUBCMD_MEDIA: return "MEDIA";
default: return "?";
}
}
/* ─────────────────── отправка ─────────────────── */
static void call_stats_timer_cb(void* arg);
/* data[0] = subcmd. etcp_route_send забирает владение entry. */
static int call_send(struct call_ctx* ctx, uint64_t group_id, uint64_t dst,
const uint8_t* data, size_t len, int mode) {
struct ll_entry* e = queue_entry_new(0);
if (!e) return -1;
e->dgram = u_malloc(1 + len);
if (!e->dgram) { queue_entry_free(e); return -1; }
e->dgram[0] = ETCP_RT_ID_CALL;
if (len) memcpy(e->dgram + 1, data, len);
e->len = (uint16_t)(1 + len);
int rc = etcp_route_send(ctx->inst, group_id, dst, e, 1, mode);
if (rc != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: send FAIL rc=%d subcmd=%s(0x%02x) dst=0x%016llx group=0x%016llx",
CALL_ID, rc, call_subcmd_name(len ? data[0] : 0), len ? data[0] : 0,
(unsigned long long)dst, (unsigned long long)group_id);
}
return rc;
}
/* ─────────────────── реестр сессий ─────────────────── */
static struct call_session* call_session_find(struct call_ctx* ctx, uint64_t call_id) {
if (!ctx || !ctx->sessions) return NULL;
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &call_id);
return e ? (struct call_session*)e->data : NULL;
}
static struct call_session* call_session_new(struct call_ctx* ctx, uint64_t call_id,
uint64_t group_id, uint64_t peer, uint8_t role) {
if (!ctx || !ctx->sessions) return NULL;
struct ll_entry* qe = queue_entry_new(sizeof(struct call_session));
if (!qe) return NULL;
struct call_session* s = (struct call_session*)qe->data;
memset(s, 0, sizeof(*s));
s->call_id = call_id;
s->group_id = group_id;
s->peer_node_id = peer;
s->role = role;
s->state = CALL_ST_IDLE;
s->ctx = ctx;
s->tx_q = queue_new(ctx->inst->ua, 0, 0, 0, "call_txq");
if (!s->tx_q) { queue_entry_free(qe); return NULL; }
if (queue_data_put_with_index(ctx->sessions, qe) != 0) {
queue_free(s->tx_q);
queue_entry_free(qe);
return NULL;
}
return s;
}
static void call_session_free_cb(void* arg) {
struct ll_entry* e = (struct ll_entry*)arg;
queue_entry_free(e);
}
/* Освободить медиа-объекты сессии: отменить waiter на send_q роутера и
* дренировать tx_q (без отправки). Безопасно вызывать из uasync-потока. */
static void call_session_cleanup(struct call_ctx* ctx, struct call_session* s) {
etcp_router_cancel_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL, &s->send_waiter);
if (s->tx_q) {
struct ll_entry* e;
while ((e = queue_data_get(s->tx_q)) != NULL) {
queue_dgram_free(e);
queue_entry_free(e);
}
queue_free(s->tx_q);
s->tx_q = NULL;
}
}
/* Убрать сессию из реестра и освободить отложенно (не из коллбэков conn_mgr/таймеров). */
static void call_session_remove_and_free(struct call_ctx* ctx, struct call_session* s) {
call_session_cleanup(ctx, s);
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &s->call_id);
if (e) {
queue_remove_data(ctx->sessions, e);
uasync_call_soon(ctx->inst->ua, e, call_session_free_cb);
}
}
/* ─────────────────── события в GUI ─────────────────── */
static void call_event_id(struct call_ctx* ctx, int type, uint64_t call_id, uint8_t reason) {
uint8_t buf[16];
size_t off = 0;
memcpy(buf + off, &call_id, 8); off += 8;
buf[off++] = reason;
chat_event_post(ctx->inst, type, buf, (int)off);
}
static void call_event_incoming(struct call_ctx* ctx, uint64_t group_id, uint64_t caller, uint64_t call_id) {
char ch[32];
snprintf(ch, sizeof(ch), "%llu", (unsigned long long)group_id);
size_t ch_len = strlen(ch);
uint8_t buf[1 + 32 + 8 + 8];
size_t off = 0;
buf[off++] = (uint8_t)ch_len;
memcpy(buf + off, ch, ch_len); off += ch_len;
memcpy(buf + off, &caller, 8); off += 8;
memcpy(buf + off, &call_id, 8); off += 8;
chat_event_post(ctx->inst, CHAT_EVT_CALL_INCOMING, buf, (int)off);
}
/* ─────────────────── завершение ─────────────────── */
/* Отложенный разрыв: единственная точка, где закрывается conn_mgr (после того,
* как финальный сигналинг — HANGUP/DECLINE — гарантированно долетел). */
static void call_teardown_timer_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->teardown_timer = NULL;
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; }
call_session_remove_and_free(ctx, s);
}
static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reason) {
if (!ctx || !s || s->state == CALL_ST_ENDED) return;
s->state = CALL_ST_ENDED;
s->reason = reason;
ctx->c_calls_ended++;
if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; }
if (s->traffic_timer) { uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = NULL; }
if (s->stats_timer) { uasync_cancel_timeout(ctx->inst->ua, s->stats_timer); s->stats_timer = NULL; }
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call ENDED id=%016llx reason=%d role=%s rx=%u tx=%u tx_dropped=%u",
CALL_ID, (unsigned long long)s->call_id, reason,
s->role == CALL_ROLE_CALLER ? "caller" : "callee",
s->c_rx_frames, s->c_tx_frames, s->c_tx_dropped);
call_event_id(ctx, CHAT_EVT_CALL_ENDED, s->call_id, reason);
/* Разрыв conn_mgr — отложенно (500мс), чтобы HANGUP/DECLINE успел долететь.
* Без cm_handle рвать нечего — освобождаем сразу (отложенно). */
if (s->cm_handle) {
s->teardown_timer = uasync_set_timeout(ctx->inst->ua, CALL_TEARDOWN_DELAY_TB, s, call_teardown_timer_cb, "call_teardown");
} else {
call_session_remove_and_free(ctx, s);
}
}
/* ─────────────────── conn_mgr ─────────────────── */
static void call_conn_cb(struct CONN_MGR_HANDLE* h, uint64_t node_id, uint64_t group_id,
enum conn_mgr_event event, void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
if (s->state == CALL_ST_ENDED) {
/* сессия завершена, ждёт teardown: conn_mgr сообщает об обрыве — handle больше
* не трогаем (entry чистится самим conn_mgr / удалённой стороной) */
if (event == CONN_EVENT_DOWN || event == CONN_EVENT_TIMEOUT) s->cm_handle = NULL;
return;
}
if (event == CONN_EVENT_UP) {
s->cm_handle = h;
s->state = CALL_ST_ACTIVE;
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);
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) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn DOWN id=%016llx peer=0x%016llx (temporary, keep alive)",
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id);
} else { /* CONN_EVENT_TIMEOUT */
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn TIMEOUT id=%016llx peer=0x%016llx -> fail",
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id);
/* entry уже чистится вызывающим (monitor) — handle не закрываем */
s->cm_handle = NULL;
call_end(ctx, s, CALL_REASON_CONNECT_FAIL);
}
}
/* ─────────────────── таймеры ─────────────────── */
static void call_ring_timeout_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->ring_timer = NULL;
if (s->state == CALL_ST_ENDED) return;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: ring timeout id=%016llx -> CANCEL",
CALL_ID, (unsigned long long)s->call_id);
uint8_t pkt[sizeof(struct call_pkt_cancel)];
struct call_pkt_cancel* c = (struct call_pkt_cancel*)pkt;
c->subcmd = CALL_SUBCMD_CANCEL;
c->call_id = s->call_id;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_end(ctx, s, CALL_REASON_RING_TIMEOUT);
}
static void call_traffic_timeout_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->traffic_timer = NULL;
if (s->state == CALL_ST_ENDED) return;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: no traffic 20s id=%016llx -> HANGUP",
CALL_ID, (unsigned long long)s->call_id);
uint8_t pkt[sizeof(struct call_pkt_hangup)];
struct call_pkt_hangup* hu = (struct call_pkt_hangup*)pkt;
hu->subcmd = CALL_SUBCMD_HANGUP;
hu->call_id = s->call_id;
hu->reason = CALL_REASON_NO_TRAFFIC;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_end(ctx, s, CALL_REASON_NO_TRAFFIC);
}
/* Периодическое событие CALL_STATS (раз в 1с): rtt_ms, buffer_ms, tempo_x100. */
static void call_stats_timer_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->stats_timer = NULL;
if (s->state != CALL_ST_ACTIVE) return;
int buffer_ms = 0, tempo_x100 = 100;
uint32_t dropped = 0, underruns = 0;
if (ctx->audio_ops && ctx->audio_ops->get_stats)
ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100, &dropped, &underruns);
uint16_t rtt = 0;
struct ETCP_ROUTER_CONN* rconn =
etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL);
if (rconn && rconn->recv_conn) rtt = rconn->recv_conn->rtt_last;
uint8_t buf[18];
size_t off = 0;
memcpy(buf + off, &s->call_id, 8); off += 8;
buf[off++] = (uint8_t)(rtt >> 8); buf[off++] = (uint8_t)(rtt & 0xFF);
uint16_t bms = (uint16_t)(buffer_ms < 0 ? 0 : (buffer_ms > 0xFFFF ? 0xFFFF : buffer_ms));
buf[off++] = (uint8_t)(bms >> 8); buf[off++] = (uint8_t)(bms & 0xFF);
uint16_t t = (uint16_t)(tempo_x100 < 0 ? 0 : (tempo_x100 > 0xFFFF ? 0xFFFF : tempo_x100));
buf[off++] = (uint8_t)(t >> 8); buf[off++] = (uint8_t)(t & 0xFF);
uint16_t dr = (uint16_t)(dropped > 0xFFFF ? 0xFFFF : dropped);
buf[off++] = (uint8_t)(dr >> 8); buf[off++] = (uint8_t)(dr & 0xFF);
uint16_t un = (uint16_t)(underruns > 0xFFFF ? 0xFFFF : underruns);
buf[off++] = (uint8_t)(un >> 8); buf[off++] = (uint8_t)(un & 0xFF);
chat_event_post(ctx->inst, CHAT_EVT_CALL_STATS, buf, (int)off);
s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats");
}
/* ─────────────────── потеря соединения ─────────────────── */
/* На обрыв ETCP-соединения с пиром завершаем ACTIVE-звонки (robust fallback:
* HANGUP может не успеть долететь, если закрывающая сторона рвёт conn_mgr). */
static void call_on_conn_status(struct ETCP_CONN* conn, int status, void* arg) {
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg;
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized || !conn) return;
if (status != ETCP_CONN_STATUS_DOWN && status != ETCP_CONN_STATUS_DELETE) return;
uint64_t peer = conn->peer_node_id;
if (!peer) return;
struct ll_entry* e = ctx->sessions ? ctx->sessions->head : NULL;
while (e) {
struct ll_entry* next = e->next;
struct call_session* s = (struct call_session*)e->data;
if (s->peer_node_id == peer) {
if (s->state == CALL_ST_ACTIVE) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: peer conn lost id=%016llx peer=0x%016llx -> end",
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)peer);
call_end(ctx, s, CALL_REASON_REMOTE_HANGUP);
} else if (s->state == CALL_ST_ENDED) {
/* удалённая сторона уже порвала (DISCONNECT чистит entry без коллбэка) */
s->cm_handle = NULL;
}
}
e = next;
}
}
/* ─────────────────── обработчики приёма ─────────────────── */
static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t group,
const uint8_t* b, size_t n) {
if (n < sizeof(struct call_pkt_invite) - sizeof(struct call_pkt_track)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: INVITE too short n=%zu", CALL_ID, n);
return;
}
uint64_t call_id, inv_group;
memcpy(&call_id, b + 1, 8);
memcpy(&inv_group, b + 9, 8);
/* повторная доставка INVITE (тот же call_id) — идемпотентно RINGING */
struct call_session* s = call_session_find(ctx, call_id);
if (s && s->state != CALL_ST_ENDED) {
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE retransmit id=%016llx -> re-RINGING", CALL_ID, (unsigned long long)call_id);
uint8_t pkt[sizeof(struct call_pkt_ringing)];
struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt;
r->subcmd = CALL_SUBCMD_RINGING;
r->call_id = call_id;
call_send(ctx, s->group_id, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
return;
}
/* занятость: есть другая активная сессия */
struct ll_entry* e = ctx->sessions ? ctx->sessions->head : NULL;
while (e) {
struct call_session* other = (struct call_session*)e->data;
if (other->state != CALL_ST_ENDED) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: busy — active call id=%016llx, reply BUSY to id=%016llx",
CALL_ID, (unsigned long long)other->call_id, (unsigned long long)call_id);
uint8_t pkt[sizeof(struct call_pkt_busy)];
struct call_pkt_busy* bz = (struct call_pkt_busy*)pkt;
bz->subcmd = CALL_SUBCMD_BUSY;
bz->call_id = call_id;
call_send(ctx, group, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
return;
}
e = e->next;
}
s = call_session_new(ctx, call_id, group, from, CALL_ROLE_CALLEE);
if (!s) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: session alloc failed id=%016llx", CALL_ID, (unsigned long long)call_id);
return;
}
s->state = CALL_ST_RINGING;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: INVITE id=%016llx from=0x%016llx group=0x%016llx (declared group=0x%016llx)",
CALL_ID, (unsigned long long)call_id, (unsigned long long)from,
(unsigned long long)group, (unsigned long long)inv_group);
call_event_incoming(ctx, group, from, call_id);
uint8_t pkt[sizeof(struct call_pkt_ringing)];
struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt;
r->subcmd = CALL_SUBCMD_RINGING;
r->call_id = call_id;
call_send(ctx, group, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
}
static void call_handle_ringing(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < 9) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED) return;
if (s->state == CALL_ST_OUTGOING) {
s->state = CALL_ST_RINGING;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: RINGING id=%016llx", CALL_ID, (unsigned long long)call_id);
call_event_id(ctx, CHAT_EVT_CALL_RINGING, call_id, 0);
}
(void)from;
}
static void call_handle_accept(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < 9) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: ACCEPT id=%016llx -> conn_mgr_open", CALL_ID, (unsigned long long)call_id);
s->state = CALL_ST_CONNECTING;
if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; }
int rc = conn_mgr_open(ctx->inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle);
if (rc != 0) {
/* либо уже получили TIMEOUT (сессия ENDED), либо ошибка без коллбэка */
if (s->state != CALL_ST_ENDED) call_end(ctx, s, CALL_REASON_CONNECT_FAIL);
}
}
static void call_handle_decline(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < 10) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
uint8_t reason = b[9];
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: DECLINE id=%016llx reason=%d", CALL_ID, (unsigned long long)call_id, reason);
call_event_id(ctx, CHAT_EVT_CALL_DECLINED, call_id, reason);
call_end(ctx, s, CALL_REASON_DECLINE);
(void)from;
}
static void call_handle_busy(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < 9) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: BUSY id=%016llx", CALL_ID, (unsigned long long)call_id);
call_end(ctx, s, CALL_REASON_BUSY);
(void)from;
}
static void call_handle_cancel(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < 9) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: CANCEL id=%016llx", CALL_ID, (unsigned long long)call_id);
call_end(ctx, s, CALL_REASON_CANCEL);
(void)from;
}
static void call_handle_hangup(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < 10) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
uint8_t reason = b[9];
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: HANGUP id=%016llx reason=%d", CALL_ID, (unsigned long long)call_id, reason);
call_end(ctx, s, CALL_REASON_REMOTE_HANGUP);
(void)from;
}
static void call_handle_media(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
if (n < CALL_MEDIA_HDR_SIZE) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state != CALL_ST_ACTIVE) {
/* медиа вне ACTIVE — игнорируем (гонка с завершением) */
return;
}
s->c_rx_frames++;
s->last_media_tb = get_time_tb();
uint16_t seq = 0;
uint32_t ts_ms = 0;
memcpy(&seq, b + 10, 2);
memcpy(&ts_ms, b + 12, 4);
/* аудио-движок (декод + джиттер-буфер) — если зарегистрирован */
if (ctx->audio_ops && ctx->audio_ops->on_media)
ctx->audio_ops->on_media(ctx->inst, call_id, seq, ts_ms,
b + CALL_MEDIA_HDR_SIZE, (int)(n - CALL_MEDIA_HDR_SIZE));
/* релей Opus-кадра потребителю (headless-аудио-сокет) */
if (ctx->media_recv_fn)
ctx->media_recv_fn(ctx->inst, call_id, b + CALL_MEDIA_HDR_SIZE,
(int)(n - CALL_MEDIA_HDR_SIZE), ctx->media_recv_arg);
/* watchdog: пере-взводим таймер no-traffic на каждый принятый кадр */
if (s->traffic_timer) { uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = NULL; }
s->traffic_timer = uasync_set_timeout(ctx->inst->ua, CALL_NO_TRAFFIC_TB, s, call_traffic_timeout_cb, "call_traffic");
(void)from;
}
/* ─────────────────── приём (etcp_router) ─────────────────── */
static void call_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
if (!entry || !conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF + 1) {
if (entry) { queue_dgram_free(entry); queue_entry_free(entry); }
return;
}
struct call_ctx* ctx = call_of(conn->instance);
if (!ctx || !ctx->initialized) { queue_dgram_free(entry); queue_entry_free(entry); return; }
const uint8_t* d = entry->dgram;
uint64_t from; memcpy(&from, d + ROUTER_SVC_SRC_OFF, 8);
uint64_t group; memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8);
uint8_t subcmd = d[ROUTER_SVC_PAYLOAD_OFF];
const uint8_t* body = d + ROUTER_SVC_PAYLOAD_OFF;
size_t blen = entry->len - ROUTER_SVC_PAYLOAD_OFF;
switch (subcmd) {
case CALL_SUBCMD_INVITE: call_handle_invite(ctx, from, group, body, blen); break;
case CALL_SUBCMD_RINGING: call_handle_ringing(ctx, from, body, blen); break;
case CALL_SUBCMD_ACCEPT: call_handle_accept(ctx, from, body, blen); break;
case CALL_SUBCMD_DECLINE: call_handle_decline(ctx, from, body, blen); break;
case CALL_SUBCMD_BUSY: call_handle_busy(ctx, from, body, blen); break;
case CALL_SUBCMD_CANCEL: call_handle_cancel(ctx, from, body, blen); break;
case CALL_SUBCMD_HANGUP: call_handle_hangup(ctx, from, body, blen); break;
case CALL_SUBCMD_MEDIA: call_handle_media(ctx, from, body, blen); break;
default:
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: unknown subcmd=0x%02x from=0x%016llx len=%zu",
CALL_ID, subcmd, (unsigned long long)from, blen);
break;
}
queue_dgram_free(entry);
queue_entry_free(entry);
}
/* ─────────────────── публичный API ─────────────────── */
int call_init(struct UTUN_INSTANCE* inst) {
if (!inst) return -1;
if (inst->call && inst->call->initialized) return 0;
struct call_ctx* ctx = u_calloc(1, sizeof(*ctx));
if (!ctx) return -1;
ctx->inst = inst;
inst->call = ctx;
ctx->sessions = queue_new(inst->ua, 256, offsetof(struct call_session, call_id), 8, "call_sessions");
if (!ctx->sessions) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: queue_new(sessions) failed", CALL_ID);
u_free(ctx); inst->call = NULL;
return -1;
}
if (etcp_router_bind(inst, ETCP_RT_ID_CALL, call_etcp_recv) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: etcp_router_bind(0x%02x) failed", CALL_ID, ETCP_RT_ID_CALL);
queue_free(ctx->sessions);
u_free(ctx); inst->call = NULL;
return -1;
}
etcp_add_conn_status_cbk(inst, call_on_conn_status, inst);
ctx->initialized = 1;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized (svc=0x%02x)", CALL_ID, ETCP_RT_ID_CALL);
return 0;
}
void call_destroy(struct UTUN_INSTANCE* inst) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized) return;
/* завершить все активные сессии (без отправки — мы выключаемся) */
while (ctx->sessions && ctx->sessions->head) {
struct ll_entry* e = ctx->sessions->head;
struct call_session* s = (struct call_session*)e->data;
s->state = CALL_ST_ENDED;
if (s->ring_timer) { uasync_cancel_timeout(inst->ua, s->ring_timer); s->ring_timer = NULL; }
if (s->traffic_timer) { uasync_cancel_timeout(inst->ua, s->traffic_timer); s->traffic_timer = NULL; }
if (s->stats_timer) { uasync_cancel_timeout(inst->ua, s->stats_timer); s->stats_timer = NULL; }
if (s->teardown_timer) { uasync_cancel_timeout(inst->ua, s->teardown_timer); s->teardown_timer = NULL; }
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; }
call_session_cleanup(ctx, s);
queue_remove_data(ctx->sessions, e);
queue_entry_free(e);
}
queue_free(ctx->sessions);
etcp_router_unbind(inst, ETCP_RT_ID_CALL);
etcp_remove_conn_status_cbk(inst, call_on_conn_status, inst);
inst->call = NULL;
uint32_t started = ctx->c_calls_started, ended = ctx->c_calls_ended;
u_free(ctx);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed (started=%u ended=%u)", CALL_ID, started, ended);
}
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id) {
if (!inst || !ch_id || !ch_id[0] || !inst->topo_groups) return 0;
if (peer_node_id == inst->node_id) return 1;
uint64_t group_id = strtoull(ch_id, NULL, 10);
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, group_id);
if (!g) return 0;
return topo_node_find_by_id(g, peer_node_id) != NULL;
}
void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, void* arg) {
struct call_ctx* ctx = call_of(inst);
if (!ctx) return;
ctx->media_recv_fn = fn;
ctx->media_recv_arg = arg;
}
void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops) {
struct call_ctx* ctx = call_of(inst);
if (!ctx) return;
ctx->audio_ops = ops;
}
/* ─────────────────── TX медиа: tx_q + backpressure ─────────────────── */
static void call_tx_flush(struct call_session* s);
static void call_tx_drain_cb(struct ll_queue* q, void* arg) {
(void)q;
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
if (s->state == CALL_ST_ENDED) return;
call_tx_flush(s);
}
/* Дренируем tx_q, пока роутер готов; иначе — одноразовый waiter (re-arm в drain_cb). */
static void call_tx_flush(struct call_session* s) {
struct call_ctx* ctx = s->ctx;
if (s->state == CALL_ST_ENDED) return;
/* гарантируем router conn — иначе has_room/on_send_ready не видят его */
etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL);
while (s->tx_q && s->tx_q->head) {
if (!etcp_router_send_q_has_room(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL)) {
etcp_router_on_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL,
&s->send_waiter, call_tx_drain_cb, s);
return;
}
struct ll_entry* e = queue_data_get(s->tx_q);
if (etcp_route_send(ctx->inst, s->group_id, s->peer_node_id, e, 1, 0) == 0)
s->c_tx_frames++;
}
}
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized || !opus || len <= 0) return -1;
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state != CALL_ST_ACTIVE) return -1;
if (CALL_MEDIA_HDR_SIZE + (size_t)len > 1024) return -1;
uint8_t buf[1024];
struct call_pkt_media* m = (struct call_pkt_media*)buf;
m->subcmd = CALL_SUBCMD_MEDIA;
m->call_id = call_id;
m->track_id = CALL_TRACK_AUDIO;
m->seq = s->tx_media_seq++;
m->ts_ms = (uint32_t)(get_time_tb() / 10);
memcpy(buf + CALL_MEDIA_HDR_SIZE, opus, (size_t)len);
size_t mlen = CALL_MEDIA_HDR_SIZE + (size_t)len;
/* entry: [svc_id][media-пакет] — кладём в tx_q (переполнение → дроп старейшего) */
struct ll_entry* e = queue_entry_new(0);
if (!e) return -1;
e->dgram = u_malloc(1 + mlen);
if (!e->dgram) { queue_entry_free(e); return -1; }
e->dgram[0] = ETCP_RT_ID_CALL;
memcpy(e->dgram + 1, buf, mlen);
e->len = (uint16_t)(1 + mlen);
if (queue_entry_count(s->tx_q) >= CALL_MAX_TX_FRAMES) {
struct ll_entry* old = queue_data_get(s->tx_q);
if (old) { queue_dgram_free(old); queue_entry_free(old); s->c_tx_dropped++; }
}
queue_data_put(s->tx_q, e);
call_tx_flush(s);
return 0;
}
void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized || !ch_id || !ch_id[0]) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: call_start bad args (ctx=%p ch_id=%s)", CALL_ID, (void*)ctx, ch_id ? ch_id : "(null)");
return;
}
if (peer_node_id == inst->node_id) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: call_start self-call rejected", CALL_ID);
return;
}
uint64_t group_id = strtoull(ch_id, NULL, 10);
if (!call_is_online(inst, ch_id, peer_node_id)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: peer 0x%016llx offline in group 0x%016llx — call not started",
CALL_ID, (unsigned long long)peer_node_id, (unsigned long long)group_id);
uint8_t buf[9];
memcpy(buf, &out_call_id, 8);
buf[8] = 1; /* err: peer offline */
chat_event_post(inst, CHAT_EVT_CALL_ERROR, buf, sizeof(buf));
return;
}
struct call_session* s = call_session_new(ctx, out_call_id, group_id, peer_node_id, CALL_ROLE_CALLER);
if (!s) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: session alloc failed id=%016llx", CALL_ID, (unsigned long long)out_call_id);
return;
}
s->state = CALL_ST_OUTGOING;
ctx->c_calls_started++;
uint8_t buf[sizeof(struct call_pkt_invite)];
struct call_pkt_invite* inv = (struct call_pkt_invite*)buf;
inv->subcmd = CALL_SUBCMD_INVITE;
inv->call_id = out_call_id;
inv->group_id = group_id;
inv->num_tracks = 1;
inv->tracks[0].type = CALL_TRACK_AUDIO;
inv->tracks[0].codec = CALL_CODEC_OPUS;
inv->tracks[0].sample_rate = CALL_OPUS_SAMPLE_RATE;
inv->tracks[0].channels = 1;
inv->tracks[0].bitrate = 32000;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_start id=%016llx peer=0x%016llx group=0x%016llx",
CALL_ID, (unsigned long long)out_call_id, (unsigned long long)peer_node_id, (unsigned long long)group_id);
if (call_send(ctx, group_id, peer_node_id, buf, sizeof(buf), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT) != 0) {
call_end(ctx, s, CALL_REASON_CONNECT_FAIL);
return;
}
s->ring_timer = uasync_set_timeout(inst->ua, CALL_RING_TIMEOUT_TB, s, call_ring_timeout_cb, "call_ring");
}
void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized) return;
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->role != CALL_ROLE_CALLEE || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_accept id=%016llx -> ACCEPT + conn_mgr_open",
CALL_ID, (unsigned long long)call_id);
s->state = CALL_ST_CONNECTING;
uint8_t pkt[sizeof(struct call_pkt_accept)];
struct call_pkt_accept* a = (struct call_pkt_accept*)pkt;
a->subcmd = CALL_SUBCMD_ACCEPT;
a->call_id = call_id;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
int rc = conn_mgr_open(inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle);
if (rc != 0 && s->state != CALL_ST_ENDED) {
call_end(ctx, s, CALL_REASON_CONNECT_FAIL);
}
}
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized) return;
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->role != CALL_ROLE_CALLEE || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_decline id=%016llx", CALL_ID, (unsigned long long)call_id);
uint8_t pkt[sizeof(struct call_pkt_decline)];
struct call_pkt_decline* d = (struct call_pkt_decline*)pkt;
d->subcmd = CALL_SUBCMD_DECLINE;
d->call_id = call_id;
d->reason = CALL_REASON_DECLINE;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_event_id(ctx, CHAT_EVT_CALL_DECLINED, call_id, CALL_REASON_DECLINE);
call_end(ctx, s, CALL_REASON_DECLINE);
}
void call_hangup(struct UTUN_INSTANCE* inst, uint64_t call_id) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized) return;
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->state == CALL_ST_ENDED) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_hangup id=%016llx", CALL_ID, (unsigned long long)call_id);
uint8_t pkt[sizeof(struct call_pkt_hangup)];
struct call_pkt_hangup* hu = (struct call_pkt_hangup*)pkt;
hu->subcmd = CALL_SUBCMD_HANGUP;
hu->call_id = call_id;
hu->reason = CALL_REASON_LOCAL_HANGUP;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_end(ctx, s, CALL_REASON_LOCAL_HANGUP);
}
/* ─────────────────── trampoline-функции (GUI → uasync) ─────────────────── */
void call_start_trampoline(void* arg) {
struct call_start_arg* a = (struct call_start_arg*)arg;
call_start(a->inst, a->ch_id, a->peer_node_id, a->call_id);
u_free(a);
}
void call_accept_trampoline(void* arg) {
struct call_id_arg* a = (struct call_id_arg*)arg;
call_accept(a->inst, a->call_id);
u_free(a);
}
void call_decline_trampoline(void* arg) {
struct call_id_arg* a = (struct call_id_arg*)arg;
call_decline(a->inst, a->call_id);
u_free(a);
}
void call_hangup_trampoline(void* arg) {
struct call_id_arg* a = (struct call_id_arg*)arg;
call_hangup(a->inst, a->call_id);
u_free(a);
}
void call_send_media_trampoline(void* arg) {
struct call_media_arg* a = (struct call_media_arg*)arg;
call_send_media(a->inst, a->call_id, a->opus, a->len);
u_free(a);
}
+98
View File
@@ -0,0 +1,98 @@
// call.h — P2P аудио-звонок (сигналинг + медиа), per-instance
//
// Модуль оперирует (group_id, peer_node_id): звонок можно инициировать из
// CHAT-группы или из DM (group_id — группа маршрутизации, как в dm_core).
// Сигналинг идёт через etcp_router (ROUTE_CRYPTO_SIGN|ENCRYPT), соединение
// устанавливается через conn_mgr (3-фазное DIRECT→REVERSE→INDIRECT).
//
// Все функции — в uasync-потоке. GUI/headless вызывают через trampoline
// (gui_bridge_post_uasync_fn / uasync_post).
#ifndef CALL_H
#define CALL_H
#include <stdint.h>
struct UTUN_INSTANCE;
#ifdef __cplusplus
extern "C" {
#endif
/* ── жизненный цикл ── */
int call_init(struct UTUN_INSTANCE* inst); /* из utun_instance.c после dm_core_init */
void call_destroy(struct UTUN_INSTANCE* inst); /* перед dm_core_destroy */
/* ── сигналинг (uasync-поток) ── */
/* Caller: начать звонок. out_call_id — случайный uint64, генерит вызывающий. */
void call_start (struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id);
/* Callee: ответить/отклонить. hangup — любая сторона в любой момент. */
void call_accept (struct UTUN_INSTANCE* inst, uint64_t call_id);
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id);
void call_hangup (struct UTUN_INSTANCE* inst, uint64_t call_id);
/* Read-only онлайн-проверка (GUI может звать из любого потока). */
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id);
/* ── медиа (релей Opus-кадров, uasync-поток) ── */
/* Отправить закодированный Opus-кадр пиру (оборачивает в CALL_MEDIA).
* Только когда звонок ACTIVE. seq/ts выставляются автоматически. */
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len);
/* Коллбэк приёма медиа: вызывается в uasync-потоке на каждый Opus-кадр пира.
* Используется headless-аудио-сокетом (и в фазе 3 — декодером GUI). */
typedef void (*call_media_recv_fn)(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg);
void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, void* arg);
/* ── аудио-движок (опционально, GUI-сборки) ──
* Сервисный аудио-контур (codec + джиттер-буфер + тоны) регистрируется через
* call_set_audio_ops. call.c зовёт on_media на каждый принятый медиа-кадр (с seq/ts)
* и get_stats для события CALL_STATS. Демон без аудио (headless) — ops не ставит. */
struct call_audio_ops {
/* uasync-поток: принятый Opus-кадр пира (декод + jitter-буфер). */
void (*on_media)(struct UTUN_INSTANCE* inst, uint64_t call_id,
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len);
/* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100,
* dropped/underruns — опционально, NULL допустим). */
int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100,
uint32_t* dropped, uint32_t* underruns);
};
void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops);
/* Отправить Opus-кадр из ДРУГОГО потока (GUI-аудио): post в uasync через trampoline. */
struct call_media_arg {
struct UTUN_INSTANCE* inst;
uint64_t call_id;
uint16_t len;
uint8_t opus[256];
};
void call_send_media_trampoline(void* arg);
/* ── trampoline-структуры для gui_bridge_post_uasync_fn / uasync_post ── */
struct call_start_arg {
struct UTUN_INSTANCE* inst;
char ch_id[64];
uint64_t peer_node_id;
uint64_t call_id;
};
struct call_id_arg {
struct UTUN_INSTANCE* inst;
uint64_t call_id;
};
void call_start_trampoline (void* arg);
void call_accept_trampoline (void* arg);
void call_decline_trampoline(void* arg);
void call_hangup_trampoline (void* arg);
#ifdef __cplusplus
}
#endif
#endif /* CALL_H */
+298
View File
@@ -0,0 +1,298 @@
// call_audio.c — сервисный аудио-движок P2P-звонка (Opus + джиттер + тоны).
//
// Один активный звонок: encoder (аудио-поток), decoder + адаптивный джиттер-буфер
// vj (кодированные кадры + SoundTouch stretch на pull) и генератор тонов.
//
// RX: call.c → call_audio_on_media (uasync) → vj_push (кодированный кадр);
// аудио-поток call_audio_pull_pcm → vj_pull (декод+stretch) (+ глитч/завершение).
// TX: аудио-поток call_audio_feed_pcm → encode → uasync_post(call_send_media).
#include "call_audio.h"
#include "call.h"
#include "voice_jitter.h"
#include "call_tones.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 <pthread.h>
#include <string.h>
#define CALL_AUDIO_ID "call_audio"
#define CALL_AUDIO_BITRATE 32000
#define CALL_AJ_MAX_FRAMES 50 /* ~1000мс @20мс */
#define CALL_STALL_TB 5000 /* 500мс без медиа → глитч-тон */
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static struct UTUN_INSTANCE* g_inst = NULL;
static int g_active = 0;
static int g_ending = 0;
static int g_ended_started = 0; /* только аудио-поток: тон завершения запущен */
static uint64_t g_call_id = 0;
static uint64_t g_last_media_tb = 0;
static opus_codec_encoder_t* g_encoder = NULL;
static opus_codec_decoder_t* g_decoder = NULL;
static struct vj* g_vj = NULL;
static struct call_tones* g_tones = NULL;
static const struct call_audio_ops g_ops = {
.on_media = call_audio_on_media,
.get_stats = call_audio_get_stats,
};
/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */
static int call_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);
}
int call_audio_init(struct UTUN_INSTANCE* inst) {
if (!inst) return -1;
if (g_inst == inst) return 0;
g_inst = inst;
call_set_audio_ops(inst, &g_ops);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized", CALL_AUDIO_ID);
return 0;
}
void call_audio_destroy(struct UTUN_INSTANCE* inst) {
if (!inst || g_inst != inst) return;
call_set_audio_ops(inst, NULL);
g_inst = NULL;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed", CALL_AUDIO_ID);
}
/* ── uasync-поток: приём медиа (кладём кодированный кадр в jitter-буфер) ── */
void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id,
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len) {
(void)inst; (void)seq; (void)ts_ms;
pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id && g_vj && opus && len > 0) {
vj_push(g_vj, opus, len);
g_last_media_tb = get_time_tb();
}
pthread_mutex_unlock(&g_mtx);
}
/* ── GUI-поток: lifecycle ── */
int call_audio_start(uint64_t call_id) {
if (call_id == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: start bad call_id=0", CALL_AUDIO_ID);
return -1;
}
pthread_mutex_lock(&g_mtx);
if (g_active && g_call_id == call_id) {
pthread_mutex_unlock(&g_mtx);
return 0;
}
if (g_active) {
pthread_mutex_unlock(&g_mtx);
call_audio_release(g_call_id);
pthread_mutex_lock(&g_mtx);
}
opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
if (!enc) {
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: encoder create failed", CALL_AUDIO_ID);
return -1;
}
opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE);
opus_codec_decoder_t* dec = opus_codec_decoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
if (!dec) {
opus_codec_encoder_destroy(enc);
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: decoder create failed", CALL_AUDIO_ID);
return -1;
}
struct vj* j = vj_create(CALL_AUDIO_SAMPLE_RATE, 1, CALL_AUDIO_FRAME_SAMPLES, CALL_AUDIO_FRAME_MS,
CALL_AJ_MAX_FRAMES, call_audio_decode_cb, dec);
if (!j) {
opus_codec_decoder_destroy(dec);
opus_codec_encoder_destroy(enc);
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: vj create failed", CALL_AUDIO_ID);
return -1;
}
struct call_tones* tones = call_tones_create();
if (!tones) {
vj_destroy(j);
opus_codec_decoder_destroy(dec);
opus_codec_encoder_destroy(enc);
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: tones create failed", CALL_AUDIO_ID);
return -1;
}
g_encoder = enc;
g_decoder = dec;
g_vj = j;
g_tones = tones;
g_call_id = call_id;
g_active = 1;
g_ending = 0;
g_ended_started = 0;
g_last_media_tb = 0;
pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id);
return 0;
}
void call_audio_begin_end(uint64_t call_id) {
pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id && !g_ending) {
g_ending = 1;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: begin_end call=0x%016llx (ended tone)", CALL_AUDIO_ID, (unsigned long long)call_id);
}
pthread_mutex_unlock(&g_mtx);
}
void call_audio_release(uint64_t call_id) {
opus_codec_encoder_t* enc = NULL;
opus_codec_decoder_t* dec = NULL;
struct vj* j = NULL;
struct call_tones* tones = NULL;
int was_active = 0;
pthread_mutex_lock(&g_mtx);
if (g_active && g_call_id == call_id) {
was_active = 1;
g_active = 0;
g_ending = 0;
g_ended_started = 0;
g_call_id = 0;
g_last_media_tb = 0;
enc = g_encoder; g_encoder = NULL;
dec = g_decoder; g_decoder = NULL;
j = g_vj; g_vj = NULL;
tones = g_tones; g_tones = NULL;
}
pthread_mutex_unlock(&g_mtx);
if (!was_active) return;
/* дальше on_media не тронет объекты (active=0, указатели NULL) */
if (enc) opus_codec_encoder_destroy(enc);
if (dec) opus_codec_decoder_destroy(dec);
if (j) vj_destroy(j);
if (tones) call_tones_destroy(tones);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: released call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id);
}
void call_audio_stop(void) {
uint64_t cid = 0;
pthread_mutex_lock(&g_mtx);
if (g_active) cid = g_call_id;
pthread_mutex_unlock(&g_mtx);
if (cid) call_audio_release(cid);
}
/* ── аудио-поток: TX / RX ── */
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) {
if (!pcm || count != CALL_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 || call_id != g_call_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_CALL, "%s: feed_pcm: instance not set (call_audio_init not called)", CALL_AUDIO_ID);
return -1;
}
struct call_media_arg* a = (struct call_media_arg*)u_malloc(sizeof(*a));
if (!a) return -1;
a->inst = inst;
a->call_id = call_id;
a->len = (uint16_t)len;
memcpy(a->opus, opus, (size_t)len);
uasync_post(ua, call_send_media_trampoline, a);
return 0;
}
int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) {
if (!out || max_samples <= 0) return 0;
int active = 0, ending = 0;
struct vj* j = NULL;
struct call_tones* tones = NULL;
uint64_t last_media = 0;
pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id) {
active = 1;
ending = g_ending;
j = g_vj;
tones = g_tones;
last_media = g_last_media_tb;
}
pthread_mutex_unlock(&g_mtx);
if (!active || !j || !tones) return 0;
/* завершение: доигрываем тон (450мс), затем тишина. Тон стартует лениво
* здесь (аудио-поток) — begin_end лишь выставляет флаг, чтобы не гонять
* генератор тонов с GUI-потоком. */
if (ending) {
if (!g_ended_started) {
call_tones_ended_start(tones);
g_ended_started = 1;
}
if (call_tones_ended_pending(tones))
return call_tones_render(tones, out, max_samples);
return 0;
}
int n = vj_pull(j, out, max_samples);
if (n >= max_samples) return n;
/* недобор: либо сталь (глитч-тон), либо тишина */
uint64_t now = get_time_tb();
int stalled = (last_media != 0) && (now - last_media > CALL_STALL_TB);
if (stalled) {
call_tones_glitch_start(tones);
int m = call_tones_render(tones, out + n, max_samples - n);
n += m;
} else {
call_tones_glitch_stop(tones);
}
for (int i = n; i < max_samples; i++) out[i] = 0;
return max_samples;
}
/* ── uasync-поток: метрики ── */
int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100,
uint32_t* dropped, uint32_t* underruns) {
int ok = 0;
pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id && g_vj) {
vj_get_stats(g_vj, buffer_ms, tempo_x100, dropped, underruns);
ok = 1;
}
pthread_mutex_unlock(&g_mtx);
return ok ? 0 : -1;
}
+69
View File
@@ -0,0 +1,69 @@
#ifndef CALL_AUDIO_H
#define CALL_AUDIO_H
// call_audio.h — сервисный аудио-движок P2P-звонка (единый для desktop/Android/headless).
//
// Владеет Opus codec, адаптивным джиттер-буфером (voice_jitter: кодированные
// кадры + SoundTouch time-stretch на pull) и тонами (глитч при сталле / завершение).
// Излишек голоса держится до декодирования — декодируется только то, что готово
// к воспроизведению. Потребитель (desktop miniaudio / Android AudioTrack) получает
// финальный PCM через call_audio_pull_pcm и отдаёт захваченный PCM через
// call_audio_feed_pcm.
//
// Потоки:
// - call_audio_start / begin_end / release — GUI-поток (по ACCEPTED/ENDED);
// - call_audio_feed_pcm / pull_pcm — аудио-поток (single-writer);
// - call_audio_on_media / get_stats — uasync-поток (из call.c).
// Общие указатели (encoder/decoder/vj/tones) защищены мьютексом; encoder и
// decoder — только аудио-поток (decoder дёргается из vj_pull через коллбэк),
// vj_push — только uasync-поток.
#include <stdint.h>
struct UTUN_INSTANCE;
#ifdef __cplusplus
extern "C" {
#endif
#define CALL_AUDIO_SAMPLE_RATE 48000
#define CALL_AUDIO_FRAME_MS 20
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000) /* 960 */
/* Зарегистрировать аудио-движок в call.c (идемпотентно). */
int call_audio_init(struct UTUN_INSTANCE* inst);
void call_audio_destroy(struct UTUN_INSTANCE* inst);
/* GUI-поток: создать codec + jitter + tones для звонка. 0 = успех. */
int call_audio_start(uint64_t call_id);
/* GUI-поток: по CALL_ENDED — начать тон завершения (pull_pcm выдаст 450мс тона). */
void call_audio_begin_end(uint64_t call_id);
/* GUI-поток (после остановки аудиоустройства): освободить все объекты. */
void call_audio_release(uint64_t call_id);
/* GUI-поток: остановить текущий активный звонок (удобная обёртка release). */
void call_audio_stop(void);
/* аудио-поток: закодировать PCM-кадр (ровно CALL_AUDIO_FRAME_SAMPLES) и отправить пиру. */
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count);
/* аудио-поток: выдать до max_samples финального PCM (jitter + stretch + тоны).
* Возвращает число сэмплов (может быть < max — андерфлоу/конец тона). */
int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples);
/* uasync-поток (из call.c): принятый Opus-кадр — кладём в джиттер-буфер (vj_push,
* декодирование произойдёт на pull в аудио-потоке). */
void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id,
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len);
/* uasync-поток: метрики джиттер-буфера для CALL_STATS (dropped/underruns — необязательные). */
int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100,
uint32_t* dropped, uint32_t* underruns);
#ifdef __cplusplus
}
#endif
#endif /* CALL_AUDIO_H */
+358
View File
@@ -0,0 +1,358 @@
// call_headless.c — headless-аудио-сокет для P2P-звонков (PCM, без тактирования)
//
// TCP-сокет с бинарным фреймингом [type:1][call_id:8][len:2][data:len].
// Клиент привязывается к звонку (HELLO) и обменивается ГОТОВЫМ PCM (int16 mono 48k):
// клиент → ядро: PCM-кадр (960 сэмплов) — кодируется в Opus и уходит пиру;
// ядро → клиент: Opus пира декодируется в PCM и отдаётся клиенту.
// Тактирование/джиттер — на стороне клиента (ядро лишь кодирует/декодирует).
// Сигналинг — на control-сокете (chat_headless_control), медиа — здесь.
#include "call_headless.h"
#include "call.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 CH_ID "call_audio"
#define AUDIO_RECV_BUF_SIZE 65536
#define AUDIO_SEND_BUF_SIZE 65536
#define AUDIO_MAX_CLIENTS 16
#define AUDIO_MAX_FRAME 2048
#define CA_SAMPLE_RATE 48000
#define CA_FRAME_SAMPLES 960 /* 20мс @48k */
#define CA_PCM_FRAME_BYTES (CA_FRAME_SAMPLES * (int)sizeof(int16_t)) /* 1920 */
#define CA_BITRATE 32000
struct call_audio_client {
socket_t fd;
void* socket_id;
uint64_t call_id; /* 0 = не привязан */
uint8_t recv_buf[AUDIO_RECV_BUF_SIZE];
int recv_len;
uint8_t send_buf[AUDIO_SEND_BUF_SIZE];
int send_len;
int send_offset;
int closing;
struct call_audio* ca;
struct call_audio_client* next;
};
struct call_audio {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst;
struct call_audio_client* clients;
int client_count;
socket_t listen_fd;
void* listen_sock_id;
int running;
/* Opus-кодеки (одна активная звонка за раз — busy в call.c) */
uint64_t enc_call_id;
opus_codec_encoder_t* enc;
uint64_t dec_call_id;
opus_codec_decoder_t* dec;
/* диагностика */
uint32_t c_enc_frames;
uint32_t c_dec_frames;
uint32_t c_enc_dropped;
uint32_t c_dec_dropped;
};
static struct call_audio* ca_of(struct UTUN_INSTANCE* inst) {
return inst ? (struct call_audio*)inst->call_audio : NULL;
}
/* ── отправка фрейма клиенту (с буферизацией как в control-сокете) ── */
static void ca_send_raw(struct call_audio_client* cli, const uint8_t* data, size_t len) {
struct call_audio* ca = cli->ca;
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 (ca && ca->ua) uasync_set_socket_write(ca->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 (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 1);
}
}
static void ca_write_frame(struct call_audio_client* cli, uint8_t type, uint64_t call_id,
const uint8_t* data, uint16_t len) {
uint8_t hdr[CALL_AUDIO_HDR_SIZE];
hdr[0] = type;
memcpy(hdr + 1, &call_id, 8);
hdr[9] = (uint8_t)(len >> 8);
hdr[10] = (uint8_t)(len & 0xFF);
ca_send_raw(cli, hdr, sizeof(hdr));
if (len) ca_send_raw(cli, data, len);
}
/* ── Opus-кодеки (одна активная звонка за раз) ── */
static opus_codec_encoder_t* ca_encoder(struct call_audio* ca, uint64_t call_id) {
if (ca->enc && ca->enc_call_id == call_id) return ca->enc;
if (ca->enc) { opus_codec_encoder_destroy(ca->enc); ca->enc = NULL; }
ca->enc = opus_codec_encoder_create(CA_SAMPLE_RATE, 1);
if (ca->enc) {
opus_codec_encoder_bitrate_set(ca->enc, CA_BITRATE);
ca->enc_call_id = call_id;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: encoder created for call_id=%016llx", CH_ID, (unsigned long long)call_id);
} else {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: encoder create failed", CH_ID);
}
return ca->enc;
}
static opus_codec_decoder_t* ca_decoder(struct call_audio* ca, uint64_t call_id) {
if (ca->dec && ca->dec_call_id == call_id) return ca->dec;
if (ca->dec) { opus_codec_decoder_destroy(ca->dec); ca->dec = NULL; }
ca->dec = opus_codec_decoder_create(CA_SAMPLE_RATE, 1);
if (ca->dec) {
ca->dec_call_id = call_id;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: decoder created for call_id=%016llx", CH_ID, (unsigned long long)call_id);
} else {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: decoder create failed", CH_ID);
}
return ca->dec;
}
/* ── приём медиа от пира → PCM клиенту ── */
static void ca_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg) {
struct call_audio* ca = (struct call_audio*)arg;
(void)inst;
if (!ca || !opus || len <= 0) return;
opus_codec_decoder_t* dec = ca_decoder(ca, call_id);
if (!dec) return;
int16_t pcm[CA_FRAME_SAMPLES];
int n = opus_codec_decode(dec, opus, len, pcm, CA_FRAME_SAMPLES);
if (n <= 0) {
ca->c_dec_dropped++;
return;
}
ca->c_dec_frames++;
for (struct call_audio_client* c = ca->clients; c; c = c->next) {
if (c->call_id == call_id && !c->closing) {
ca_write_frame(c, CALL_AUDIO_FRAME, call_id, (const uint8_t*)pcm, (uint16_t)(n * (int)sizeof(int16_t)));
break;
}
}
}
/* ── разбор входящих фреймов ── */
static void ca_close_client(struct call_audio_client* cli);
static void ca_parse_frames(struct call_audio_client* cli) {
struct call_audio* ca = cli->ca;
uint8_t* p = cli->recv_buf;
int avail = cli->recv_len;
int consumed = 0;
while (avail - consumed >= CALL_AUDIO_HDR_SIZE) {
uint8_t type = p[consumed];
uint64_t call_id; memcpy(&call_id, p + consumed + 1, 8);
uint16_t flen = (uint16_t)((p[consumed + 9] << 8) | p[consumed + 10]);
if (flen > AUDIO_MAX_FRAME) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: frame too big %u — closing", CH_ID, flen);
ca_close_client(cli);
return;
}
if (avail - consumed < (int)(CALL_AUDIO_HDR_SIZE + flen)) break; /* нужен ещё кусок */
const uint8_t* data = p + consumed + CALL_AUDIO_HDR_SIZE;
if (type == CALL_AUDIO_HELLO) {
cli->call_id = call_id;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client bound to call_id=%016llx", CH_ID, (unsigned long long)call_id);
} else if (type == CALL_AUDIO_FRAME) {
if (flen != CA_PCM_FRAME_BYTES) {
ca->c_enc_dropped++;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: bad PCM frame size=%u (expect %d) — dropped",
CH_ID, flen, CA_PCM_FRAME_BYTES);
} else {
opus_codec_encoder_t* enc = ca_encoder(ca, call_id);
uint8_t opus[256];
int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, CA_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1;
if (olen <= 0) {
ca->c_enc_dropped++;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: encode failed (olen=%d) call_id=%016llx",
CH_ID, olen, (unsigned long long)call_id);
} else {
ca->c_enc_frames++;
if (call_send_media(ca->inst, call_id, opus, olen) != 0)
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: send_media failed call_id=%016llx len=%d",
CH_ID, (unsigned long long)call_id, olen);
}
}
} else {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: unknown frame type=0x%02x — closing", CH_ID, type);
ca_close_client(cli);
return;
}
consumed += CALL_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 ca_write_callback(socket_t fd, void* arg) {
struct call_audio_client* cli = (struct call_audio_client*)arg;
struct call_audio* ca = cli ? cli->ca : NULL;
if (!cli || cli->closing || fd != cli->fd) return;
if (cli->send_len <= 0) { if (ca && ca->ua) uasync_set_socket_write(ca->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) { cli->send_len = 0; cli->send_offset = 0; if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 0); return; }
cli->send_offset += (int)r;
if (cli->send_offset >= cli->send_len) { cli->send_len = 0; cli->send_offset = 0; if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 0); }
}
static void ca_close_client(struct call_audio_client* cli) {
struct call_audio* ca = cli ? cli->ca : NULL;
if (!cli || cli->closing) return;
cli->closing = 1;
if (ca && ca->ua && cli->socket_id) uasync_remove_socket_t(ca->ua, cli->fd);
if (cli->fd != SOCKET_INVALID) { socket_close_wrapper(cli->fd); cli->fd = SOCKET_INVALID; }
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client closed call_id=%016llx", CH_ID, (unsigned long long)cli->call_id);
}
static void ca_read_callback(socket_t fd, void* arg) {
struct call_audio_client* cli = (struct call_audio_client*)arg;
if (!cli || cli->closing) return;
uint8_t tmp[4096];
ssize_t r = recv(fd, tmp, (int)sizeof(tmp), 0);
if (r <= 0) { ca_close_client(cli); return; }
if (cli->recv_len + (int)r > AUDIO_RECV_BUF_SIZE) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: recv buffer overflow — closing", CH_ID);
ca_close_client(cli);
return;
}
memcpy(cli->recv_buf + cli->recv_len, tmp, (size_t)r);
cli->recv_len += (int)r;
ca_parse_frames(cli);
}
static void ca_accept_callback(socket_t fd, void* arg) {
struct call_audio* ca = (struct call_audio*)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_CALL, "%s: accept failed: %s", CH_ID, socket_strerror(e));
return;
}
socket_set_nonblocking(cfd);
if (ca->client_count >= AUDIO_MAX_CLIENTS) { socket_close_wrapper(cfd); DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: max clients reached", CH_ID); return; }
struct call_audio_client* cli = u_calloc(1, sizeof(*cli));
if (!cli) { socket_close_wrapper(cfd); return; }
cli->fd = cfd;
cli->ca = ca;
cli->socket_id = uasync_add_socket_t(ca->ua, cfd, ca_read_callback, ca_write_callback, NULL, cli);
if (!cli->socket_id) { u_free(cli); socket_close_wrapper(cfd); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: failed to register client fd", CH_ID); return; }
cli->next = ca->clients; ca->clients = cli; ca->client_count++;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client connected fd=%d total=%d", CH_ID, (int)cfd, ca->client_count);
}
/* ── периодическая очистка закрытых клиентов ── */
static void ca_cleanup(void* arg) {
struct call_audio* ca = (struct call_audio*)arg;
struct call_audio_client** prev = &ca->clients;
while (*prev) {
if ((*prev)->closing) {
struct call_audio_client* dead = *prev; *prev = dead->next;
if (dead->fd != SOCKET_INVALID) socket_close_wrapper(dead->fd);
u_free(dead); ca->client_count--;
} else prev = &(*prev)->next;
}
if (ca->ua) uasync_set_timeout(ca->ua, 5000, ca, ca_cleanup, "ca_cleanup");
}
/* ── init / destroy ── */
int call_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 call_audio* ca = ca_of(inst);
if (ca && ca->running) return 0;
if (!ca) {
ca = u_calloc(1, sizeof(*ca));
if (!ca) return -1;
inst->call_audio = ca;
}
ca->ua = ua; ca->inst = inst;
ca->listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (ca->listen_fd == SOCKET_INVALID) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: socket() failed: %s", CH_ID, socket_strerror(socket_get_error())); return -1; }
socket_set_reuseaddr(ca->listen_fd, 1);
socket_set_nonblocking(ca->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(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; }
if (bind(ca->listen_fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: bind(%s:%d) failed: %s", CH_ID, bind_ip, port, socket_strerror(socket_get_error()));
socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1;
}
if (listen(ca->listen_fd, 5) < 0) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; }
ca->listen_sock_id = uasync_add_socket_t(ua, ca->listen_fd, ca_accept_callback, NULL, NULL, ca);
if (!ca->listen_sock_id) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; }
call_set_media_recv_cb(inst, ca_media_recv, ca);
uasync_set_timeout(ua, 5000, ca, ca_cleanup, "ca_cleanup");
ca->running = 1;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket listening on %s:%d", CH_ID, bind_ip, port);
return 0;
}
void call_headless_destroy(struct UTUN_INSTANCE* inst) {
struct call_audio* ca = ca_of(inst);
if (!ca || !ca->running) return;
ca->running = 0;
call_set_media_recv_cb(inst, NULL, NULL);
if (ca->listen_sock_id && ca->ua) uasync_remove_socket_t(ca->ua, ca->listen_fd);
if (ca->listen_fd != SOCKET_INVALID) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; }
if (ca->enc) { opus_codec_encoder_destroy(ca->enc); ca->enc = NULL; }
if (ca->dec) { opus_codec_decoder_destroy(ca->dec); ca->dec = NULL; }
struct call_audio_client* c = ca->clients;
while (c) {
struct call_audio_client* n = c->next;
if (c->fd != SOCKET_INVALID) socket_close_wrapper(c->fd);
u_free(c);
c = n;
}
ca->clients = NULL; ca->client_count = 0;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)",
CH_ID, ca->c_enc_frames, ca->c_dec_frames, ca->c_enc_dropped, ca->c_dec_dropped);
}
+38
View File
@@ -0,0 +1,38 @@
// call_headless.h — headless-аудио-сокет для P2P-звонков (PCM, без тактирования)
//
// Отдельный TCP-сокет, через который headless-клиент (CLI/скрипт) обменивается
// ГОТОВЫМ PCM с ядром звонка. Ядро кодирует PCM→Opus (TX) и декодирует Opus→PCM
// (RX); тактирование и джиттер — на стороне клиента. Сигналинг идёт по
// control-сокету (chat_headless_control), аудио — по этому сокету.
//
// Бинарный фрейм (обе стороны):
// [type:1][call_id:8][len:2][data:len]
// type = 0x01 HELLO (клиент → ядро: привязать соединение к call_id, data пусто)
// type = 0x02 FRAME (PCM int16 mono 48k, 960 сэмплов = 1920 байт)
// клиент → ядро = PCM захвата (кодируется и шлётся пиру);
// ядро → клиент = PCM воспроизведения (декодированный Opus пира)
#ifndef CALL_HEADLESS_H
#define CALL_HEADLESS_H
#include <stdint.h>
struct UASYNC;
struct UTUN_INSTANCE;
#define CALL_AUDIO_HELLO 0x01
#define CALL_AUDIO_FRAME 0x02
#define CALL_AUDIO_HDR_SIZE 11 /* type(1) + call_id(8) + len(2) */
#define CALL_AUDIO_PCM_FRAME_BYTES (960 * (int)sizeof(int16_t)) /* 1920 — PCM int16 mono 48k 20мс */
#ifdef __cplusplus
extern "C" {
#endif
int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst,
const char* bind_ip, int port);
void call_headless_destroy(struct UTUN_INSTANCE* inst);
#ifdef __cplusplus
}
#endif
#endif /* CALL_HEADLESS_H */
+95
View File
@@ -0,0 +1,95 @@
// call_proto.h — wire-формат и константы P2P аудио-звонка (svc_id ETCP_RT_ID_CALL 0x36)
//
// Приём/отправка через etcp_router. На проводе первый байт payload = subcmd.
// Сигналинг идёт с ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT, медиа — без подписи
// (уже внутри шифрованного канала etcp_router).
#ifndef CALL_PROTO_H
#define CALL_PROTO_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ── подкоманды (первый байт payload после svc_id) ── */
enum {
CALL_SUBCMD_INVITE = 0x01, /* {call_id:8, group_id:8, tracks:[]} */
CALL_SUBCMD_RINGING = 0x02, /* {call_id:8} */
CALL_SUBCMD_ACCEPT = 0x03, /* {call_id:8} */
CALL_SUBCMD_DECLINE = 0x04, /* {call_id:8, reason:1} */
CALL_SUBCMD_BUSY = 0x05, /* {call_id:8} */
CALL_SUBCMD_CANCEL = 0x06, /* {call_id:8} */
CALL_SUBCMD_HANGUP = 0x07, /* {call_id:8, reason:1} */
CALL_SUBCMD_MEDIA = 0x10, /* {call_id:8, track_id:1, seq:2, ts_ms:4, payload[]} */
};
/* ── треки и кодеки (модель расширяема под видео) ── */
enum { CALL_TRACK_AUDIO = 0x01, CALL_TRACK_VIDEO = 0x02 };
enum { CALL_CODEC_OPUS = 0x01 };
/* ── причины завершения (ENDED) ── */
enum {
CALL_REASON_LOCAL_HANGUP = 0,
CALL_REASON_REMOTE_HANGUP = 1,
CALL_REASON_DECLINE = 2,
CALL_REASON_BUSY = 3,
CALL_REASON_CANCEL = 4,
CALL_REASON_NO_TRAFFIC = 5,
CALL_REASON_RING_TIMEOUT = 6,
CALL_REASON_CONNECT_FAIL = 7,
};
#pragma pack(push, 1)
struct call_pkt_track {
uint8_t type; /* CALL_TRACK_AUDIO / CALL_TRACK_VIDEO */
uint8_t codec; /* CALL_CODEC_OPUS */
uint32_t sample_rate;
uint8_t channels;
uint32_t bitrate;
};
struct call_pkt_invite {
uint8_t subcmd; /* CALL_SUBCMD_INVITE */
uint64_t call_id;
uint64_t group_id;
uint8_t num_tracks;
struct call_pkt_track tracks[1]; /* tracks[num_tracks] */
};
struct call_pkt_ringing { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_accept { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_decline { uint8_t subcmd; uint64_t call_id; uint8_t reason; };
struct call_pkt_busy { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_cancel { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_hangup { uint8_t subcmd; uint64_t call_id; uint8_t reason; };
struct call_pkt_media {
uint8_t subcmd; /* CALL_SUBCMD_MEDIA */
uint64_t call_id;
uint8_t track_id;
uint16_t seq;
uint32_t ts_ms;
/* payload[] */
};
#pragma pack(pop)
#define CALL_MEDIA_HDR_SIZE sizeof(struct call_pkt_media) /* 16 */
/* ── параметры аудио (Opus 48kHz mono, 20ms) ── */
#define CALL_OPUS_SAMPLE_RATE 48000
#define CALL_OPUS_FRAME_MS 20
#define CALL_OPUS_FRAME_SAMPLES (CALL_OPUS_SAMPLE_RATE * CALL_OPUS_FRAME_MS / 1000) /* 960 */
/* ── таймауты и ёмкости (timebase 0.1ms) ── */
#define CALL_RING_TIMEOUT_TB 450000 /* 45с — нет ответа → CANCEL */
#define CALL_NO_TRAFFIC_TB 200000 /* 20с — нет входящих медиа → HANGUP */
#define CALL_TEARDOWN_DELAY_TB 5000 /* 500мс — задержка разрыва conn_mgr после завершения */
#define CALL_MAX_TX_FRAMES 50 /* ~1с буфер отправки (50 кадров × 20мс) */
#ifdef __cplusplus
}
#endif
#endif /* CALL_PROTO_H */
+165
View File
@@ -0,0 +1,165 @@
// call_tones.c — генератор тонов звонка (48kHz mono int16).
//
// Глитч (сталл >500мс): 1000Гц, паттерн из doc/chat_call_arch.md §8 —
// пи-пи (2×150мс, gap 100мс) … тишина до 1.0с … пи(200мс) пауза(200мс) пи(200мс),
// цикл 2с, повторяется пока длится сталл.
// Завершение: 3 нисходящих тона 700/500/400 Гц × 150мс (один раз, 450мс).
//
// Генерация — синус с коротким fade in/out (без кликов). Только аудио-поток.
#include "call_tones.h"
#include <math.h>
#include <string.h>
#include <stdlib.h>
#define TONES_SAMPLE_RATE 48000
#define TONES_AMPLITUDE 0.25f /* от полной шкалы int16 */
#define TONES_FADE_SAMPLES (TONES_SAMPLE_RATE * 5 / 1000) /* 5мс */
enum tones_mode { TONES_NONE = 0, TONES_GLITCH, TONES_ENDED };
struct tone_seg {
int tone; /* 1 = звук, 0 = тишина */
int dur_ms;
int freq_hz;
};
static const struct tone_seg GLITCH_SEGS[] = {
{1, 150, 1000}, {0, 100, 0}, {1, 150, 1000}, {0, 600, 0},
{1, 200, 1000}, {0, 200, 0}, {1, 200, 1000}, {0, 400, 0},
};
#define GLITCH_SEG_COUNT ((int)(sizeof(GLITCH_SEGS) / sizeof(GLITCH_SEGS[0])))
static const struct tone_seg ENDED_SEGS[] = {
{1, 150, 700}, {1, 150, 500}, {1, 150, 400},
};
#define ENDED_SEG_COUNT ((int)(sizeof(ENDED_SEGS) / sizeof(ENDED_SEGS[0])))
struct call_tones {
enum tones_mode mode;
const struct tone_seg* segs;
int seg_count;
int seg_idx;
int seg_pos; /* сэмплов отыграно в текущем сегменте */
int ended_done;
double phase; /* фаза синуса (радианы) */
};
struct call_tones* call_tones_create(void) {
struct call_tones* t = (struct call_tones*)calloc(1, sizeof(*t));
if (!t) return NULL;
t->mode = TONES_NONE;
t->segs = NULL;
t->seg_count = 0;
t->seg_idx = 0;
t->seg_pos = 0;
t->ended_done = 0;
t->phase = 0.0;
return t;
}
void call_tones_destroy(struct call_tones* t) {
free(t);
}
void call_tones_reset(struct call_tones* t) {
if (!t) return;
t->mode = TONES_NONE;
t->segs = NULL;
t->seg_count = 0;
t->seg_idx = 0;
t->seg_pos = 0;
t->ended_done = 0;
t->phase = 0.0;
}
void call_tones_glitch_start(struct call_tones* t) {
if (!t || t->mode == TONES_GLITCH) return;
t->mode = TONES_GLITCH;
t->segs = GLITCH_SEGS;
t->seg_count = GLITCH_SEG_COUNT;
t->seg_idx = 0;
t->seg_pos = 0;
t->phase = 0.0;
}
void call_tones_glitch_stop(struct call_tones* t) {
if (!t || t->mode != TONES_GLITCH) return;
t->mode = TONES_NONE;
t->segs = NULL;
t->seg_count = 0;
t->seg_idx = 0;
t->seg_pos = 0;
t->phase = 0.0;
}
void call_tones_ended_start(struct call_tones* t) {
if (!t) return;
t->mode = TONES_ENDED;
t->segs = ENDED_SEGS;
t->seg_count = ENDED_SEG_COUNT;
t->seg_idx = 0;
t->seg_pos = 0;
t->ended_done = 0;
t->phase = 0.0;
}
int call_tones_ended_pending(struct call_tones* t) {
return t && t->mode == TONES_ENDED && !t->ended_done;
}
/* Перейти к следующему сегменту (или завершить тон). */
static void tones_advance(struct call_tones* t) {
t->seg_idx++;
t->seg_pos = 0;
t->phase = 0.0;
if (t->seg_idx >= t->seg_count) {
if (t->mode == TONES_GLITCH) {
t->seg_idx = 0; /* цикл глитча */
} else if (t->mode == TONES_ENDED) {
t->ended_done = 1;
t->mode = TONES_NONE;
t->segs = NULL;
}
}
}
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples) {
if (!t || !out || max_samples <= 0) return 0;
int written = 0;
while (written < max_samples) {
if (t->mode == TONES_NONE) {
out[written++] = 0; /* тишина до конца запроса */
continue;
}
const struct tone_seg* s = &t->segs[t->seg_idx];
int seg_len = s->dur_ms * TONES_SAMPLE_RATE / 1000;
int remain = seg_len - t->seg_pos;
int n = max_samples - written;
if (n > remain) n = remain;
for (int i = 0; i < n; i++) {
int pos = t->seg_pos + i;
float v = 0.0f;
if (s->tone) {
v = (float)sin(t->phase) * TONES_AMPLITUDE * 32767.0f;
t->phase += 2.0 * M_PI * (double)s->freq_hz / TONES_SAMPLE_RATE;
/* fade in/out по краям сегмента (без кликов) */
int fade = pos < TONES_FADE_SAMPLES ? pos
: (seg_len - pos - 1) < TONES_FADE_SAMPLES ? (seg_len - pos - 1)
: TONES_FADE_SAMPLES;
if (fade < TONES_FADE_SAMPLES)
v *= (float)fade / (float)TONES_FADE_SAMPLES;
}
int iv = (int)v;
out[written + i] = (int16_t)(iv > 32767 ? 32767 : (iv < -32768 ? -32768 : iv));
}
written += n;
t->seg_pos += n;
if (t->seg_pos >= seg_len) tones_advance(t);
}
return written;
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef CALL_TONES_H
#define CALL_TONES_H
// call_tones.h — генератор тонов звонка (48kHz mono int16): глитч при сталле
// и тон завершения. Используется только аудио-потоком (single-writer).
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
struct call_tones;
struct call_tones* call_tones_create(void);
void call_tones_destroy(struct call_tones* t);
void call_tones_reset(struct call_tones* t); /* в тишину */
void call_tones_glitch_start(struct call_tones* t); /* запустить/продолжить цикл глитча */
void call_tones_glitch_stop(struct call_tones* t); /* прекратить глитч (тишина) */
void call_tones_ended_start(struct call_tones* t); /* 3 нисходящих тона, один раз */
int call_tones_ended_pending(struct call_tones* t); /* 1 = тон завершения ещё звучит */
/* Выдать до max_samples сэмплов (тон или тишина). Возвращает записанное число. */
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples);
#ifdef __cplusplus
}
#endif
#endif /* CALL_TONES_H */
+187
View File
@@ -0,0 +1,187 @@
// voice_jitter.cpp — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch).
//
// Кодированные кадры (Opus, до VJ_MAX_ENC байт) кладутся из uasync-потока в кольцо
// и вытаскиваются аудио-потоком: кадр декодируется (decode-коллбэк) и подаётся в
// SoundTouch с плавно меняющимся темпом: глубина < target → 1.0x, глубина растёт →
// ускорение до 1.6x (догон live). Декодируется только то, что готово к воспроизведению.
// Кольцо защищено мьютексом (SPSC); decode и SoundTouch трогает только аудио-поток.
#include "voice_jitter.h"
#include "soundtouch/SoundTouch.h"
#include <atomic>
#include <cmath>
#include <cstring>
#include <mutex>
#include <vector>
static const int VJ_MAX_ENC = 256; /* макс. размер кодированного кадра (Opus 20мс) */
static const double kTargetMs = 60.0; /* целевая глубина буфера */
static const double kMaxTempo = 1.6;
static const double kMinTempo = 1.0;
static const double kMaxDepthMs = 1000.0; /* при этой глубине tempo == kMaxTempo */
static const double kEmaAlpha = 0.2; /* сглаживание глубины */
static const double kTempoStep = 0.01; /* макс. изменение темпа за один pull (без кликов) */
struct vj {
int sr = 0;
int ch = 0;
int frame_samples = 0;
int frame_ms = 0;
int cap = 0;
vj_decode_fn decode = nullptr;
void* decode_arg = nullptr;
std::mutex mtx;
std::vector<uint8_t> ring; /* cap * VJ_MAX_ENC — кодированные кадры */
std::vector<int> lens; /* cap — длины кадров */
int head = 0;
int count = 0;
uint32_t dropped = 0;
uint32_t underruns = 0;
soundtouch::SoundTouch st; /* только аудио-поток */
uint8_t enc_buf[VJ_MAX_ENC]; /* scratch: вытащенный кодированный кадр */
std::vector<int16_t> pcm_buf; /* scratch: декодированный кадр */
std::vector<float> fin; /* scratch: кадр в float */
std::vector<float> fout; /* scratch: выход float */
double ema_depth_ms = 0.0;
double tempo = 1.0;
std::atomic<int> tempo_x100{100};
};
static double vj_compute_tempo(double depth_ms) {
if (depth_ms <= kTargetMs) return kMinTempo;
double x = (depth_ms - kTargetMs) / (kMaxDepthMs - kTargetMs);
if (x > 1.0) x = 1.0;
return kMinTempo + (kMaxTempo - kMinTempo) * x;
}
/* Вытащить один кодированный кадр из кольца (под мьютексом). Возвращает 0 если пусто. */
static int vj_pop_enc(struct vj* j, uint8_t* out, int* len_out) {
std::lock_guard<std::mutex> lk(j->mtx);
if (j->count == 0) return 0;
*len_out = j->lens[(size_t)j->head];
std::memcpy(out, &j->ring[(size_t)j->head * VJ_MAX_ENC], (size_t)*len_out);
j->head = (j->head + 1) % j->cap;
j->count--;
return 1;
}
extern "C" {
struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms,
int max_frames, vj_decode_fn decode, void* decode_arg) {
if (sample_rate <= 0 || channels <= 0 || frame_samples <= 0 || frame_ms <= 0 ||
max_frames <= 0 || !decode)
return nullptr;
vj* j = new vj();
j->sr = sample_rate;
j->ch = channels;
j->frame_samples = frame_samples;
j->frame_ms = frame_ms;
j->cap = max_frames;
j->decode = decode;
j->decode_arg = decode_arg;
j->ring.assign((size_t)max_frames * VJ_MAX_ENC, 0);
j->lens.assign(max_frames, 0);
j->pcm_buf.assign(frame_samples, 0);
j->fin.assign(frame_samples, 0.0f);
j->st.setSampleRate((uint)sample_rate);
j->st.setChannels((uint)channels);
j->st.setTempo(kMinTempo);
j->st.setSetting(SETTING_USE_QUICKSEEK, 1);
j->st.setSetting(SETTING_SEQUENCE_MS, 20);
j->st.setSetting(SETTING_SEEKWINDOW_MS, 10);
j->st.setSetting(SETTING_OVERLAP_MS, 4);
j->st.setSetting(SETTING_USE_AA_FILTER, 0);
return j;
}
void vj_destroy(struct vj* j) {
if (!j) return;
delete j;
}
void vj_push(struct vj* j, const uint8_t* enc, int len) {
if (!j || !enc || len <= 0 || len > VJ_MAX_ENC) return;
std::lock_guard<std::mutex> lk(j->mtx);
if (j->count == j->cap) {
j->head = (j->head + 1) % j->cap; /* дроп старейшего */
j->count--;
j->dropped++;
}
int idx = (j->head + j->count) % j->cap;
std::memcpy(&j->ring[(size_t)idx * VJ_MAX_ENC], enc, (size_t)len);
j->lens[(size_t)idx] = len;
j->count++;
}
int vj_pull(struct vj* j, int16_t* out, int max_samples) {
if (!j || !out || max_samples <= 0) return 0;
/* 1) глубина (сглаженная) → целевой темп (плавно) */
int cnt;
{
std::lock_guard<std::mutex> lk(j->mtx);
cnt = j->count;
}
double depth_ms = (double)cnt * j->frame_ms;
j->ema_depth_ms = kEmaAlpha * depth_ms + (1.0 - kEmaAlpha) * j->ema_depth_ms;
double target = vj_compute_tempo(j->ema_depth_ms);
double d = target - j->tempo;
if (d > kTempoStep) d = kTempoStep;
else if (d < -kTempoStep) d = -kTempoStep;
j->tempo += d;
j->st.setTempo(j->tempo);
j->tempo_x100.store((int)std::lround(j->tempo * 100.0));
/* 2) декодируем и кормим SoundTouch, пока не сможет выдать max_samples (или кольцо пусто) */
int guard = 0;
while (j->st.numSamples() < (uint)max_samples && guard++ < j->cap + 4) {
int elen = 0;
if (!vj_pop_enc(j, j->enc_buf, &elen)) break;
int n = j->decode(j->decode_arg, j->enc_buf, elen, j->pcm_buf.data(), j->frame_samples);
if (n <= 0) continue; /* битый/пустой кадр — пропускаем */
if (n > j->frame_samples) n = j->frame_samples;
for (int i = 0; i < n; i++)
j->fin[(size_t)i] = (float)j->pcm_buf[(size_t)i] / 32768.0f;
j->st.putSamples(j->fin.data(), (uint)n);
}
/* 3) забираем готовое */
j->fout.resize((size_t)max_samples);
uint n = j->st.receiveSamples(j->fout.data(), (uint)max_samples);
for (uint i = 0; i < n; i++) {
float v = j->fout[i] * 32767.0f;
if (v > 32767.0f) v = 32767.0f;
else if (v < -32768.0f) v = -32768.0f;
out[i] = (int16_t)std::lrintf(v);
}
if ((int)n < max_samples) j->underruns++;
return (int)n;
}
void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns) {
if (!j) return;
if (depth_ms) {
std::lock_guard<std::mutex> lk(j->mtx);
*depth_ms = j->count * j->frame_ms;
}
if (tempo_x100) *tempo_x100 = j->tempo_x100.load();
if (dropped) *dropped = j->dropped;
if (underruns) *underruns = j->underruns;
}
uint32_t vj_dropped(struct vj* j) {
if (!j) return 0;
std::lock_guard<std::mutex> lk(j->mtx);
return j->dropped;
}
} // extern "C"
+54
View File
@@ -0,0 +1,54 @@
#ifndef VOICE_JITTER_H
#define VOICE_JITTER_H
// voice_jitter.h — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch), C API.
//
// Хранит КОДИРОВАННЫЕ (Opus) кадры: uasync-поток кладёт кодированные кадры,
// аудио-поток на pull декодирует их (через коллбэк decode) и пропускает через
// SoundTouch с плавно меняющимся темпом. Излишек голоса держится до декодирования —
// декодируется только то, что готово к воспроизведению (экономия памяти ~48x и
// разгрузка uasync-потока).
//
// Потоки:
// - vj_push — uasync-поток (producer);
// - vj_pull — аудио-поток (consumer; единственный владелец decode/SoundTouch);
// - vj_get_stats — любой поток.
// Кольцо кодированных кадров защищено мьютексом (SPSC, дёшево: ~50 кадров/с).
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
struct vj;
/* decode: закодированный кадр → PCM. Возвращает число декодированных сэмплов. */
typedef int (*vj_decode_fn)(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples);
/* Создать буфер. frame_samples — сэмплов в кадре (960 @20мс/48k), frame_ms —
* длительность кадра в мс, max_frames — ёмкость кодированных кадров (50 = 1000мс). */
struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms,
int max_frames, vj_decode_fn decode, void* decode_arg);
void vj_destroy(struct vj* j);
/* producer: положить кодированный кадр (копируется). При переполнении дропает старейший. */
void vj_push(struct vj* j, const uint8_t* enc, int len);
/* consumer: выдать до max_samples декодированного+растянутого PCM. Может вернуть
* < max_samples при нехватке данных (андерфлоу). */
int vj_pull(struct vj* j, int16_t* out, int max_samples);
/* Диагностика: глубина буфера (мс), темп (×100, 100 = 1.0x), дропнуто кадров
* при переполнении, число underrun-ов (pull вернул меньше запрошенного). */
void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns);
/* Число дропнутых кадров при переполнении (для диагностики/тестов). */
uint32_t vj_dropped(struct vj* j);
#ifdef __cplusplus
}
#endif
#endif /* VOICE_JITTER_H */
+52
View File
@@ -0,0 +1,52 @@
# voice_sources.cmake — голосовой стек звонка (единый для desktop/Android/headless).
#
# SoundTouch (time-stretch) + voice_jitter (кодированные кадры + decode-on-pull)
# + call_audio (Opus/тоны) + call_tones. C++ внутри, наружу — C-интерфейс
# (call_audio.h). Экспортирует UTUN_VOICE_SOURCES и UTUN_VOICE_INCLUDE_DIRS.
#
# Использование:
# set(LIB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../lib)
# set(CALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/call)
# include(${CALL_DIR}/voice_sources.cmake)
# 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})
# target_link_libraries(utun_voice PUBLIC opus) # opus_codec
function(utun_setup_voice LIB_DIR CALL_DIR)
set(_st_dir "${LIB_DIR}/soundtouch-master")
set(_src_dir "${CALL_DIR}/..")
set(UTUN_VOICE_SOURCES
${CALL_DIR}/call_audio.c
${CALL_DIR}/call_tones.c
${CALL_DIR}/voice_jitter.cpp
${_st_dir}/source/SoundTouch/SoundTouch.cpp
${_st_dir}/source/SoundTouch/TDStretch.cpp
${_st_dir}/source/SoundTouch/RateTransposer.cpp
${_st_dir}/source/SoundTouch/FIFOSampleBuffer.cpp
${_st_dir}/source/SoundTouch/AAFilter.cpp
${_st_dir}/source/SoundTouch/FIRFilter.cpp
${_st_dir}/source/SoundTouch/InterpolateLinear.cpp
${_st_dir}/source/SoundTouch/InterpolateCubic.cpp
${_st_dir}/source/SoundTouch/InterpolateShannon.cpp
${_st_dir}/source/SoundTouch/cpu_detect_x86.cpp
${_st_dir}/source/SoundTouch/mmx_optimized.cpp
${_st_dir}/source/SoundTouch/sse_optimized.cpp
PARENT_SCOPE
)
set(UTUN_VOICE_INCLUDE_DIRS
${CALL_DIR}
${_src_dir}
${_src_dir}/transport_layer
${_src_dir}/routing_layer
${_src_dir}/chat
${_src_dir}/proxy
${_src_dir}/media_delivery
${_src_dir}/media_async
${LIB_DIR}
${LIB_DIR}/libopus/include
${_st_dir}/include
${_st_dir}/include/soundtouch
PARENT_SCOPE
)
endfunction()
+8
View File
@@ -54,6 +54,14 @@ extern "C" {
#define CHAT_EVT_CHANNEL_DELETED 31 /* [ch_id_len:1][ch_id:var] — канал удалён локально */
#define CHAT_EVT_DM_MSG_RECEIVED 32 /* [conv_id_len:1][conv_id:var][author_node_id:8] */
#define CHAT_EVT_DM_CONV_UPDATED 33 /* [conv_id_len:1][conv_id:var] — беседа создана/изменена */
#define CHAT_EVT_CALL_INCOMING 34 /* [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
#define CHAT_EVT_CALL_RINGING 35 /* [call_id:8] */
#define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8] */
#define CHAT_EVT_CALL_ENDED 37 /* [call_id:8][reason:1] */
#define CHAT_EVT_CALL_DECLINED 38 /* [call_id:8][reason:1] */
#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]* — расшифрованные сообщения беседы */
typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len);
+71 -2
View File
@@ -19,6 +19,8 @@
#include "invite_build.h"
#include "../dm/dm_core.h"
#include "../dm/dm_crypto.h"
#include "../call/call.h"
#include "../call/call_proto.h"
#include "../transport_layer/secure_channel.h"
#include "../transport_layer/etcp.h"
#include "../transport_layer/etcp_connections.h"
@@ -201,6 +203,27 @@ static void hc_on_chat_event(struct UTUN_INSTANCE* inst, int type, const uint8_t
} else if (type == 33 && data && len >= 2) { /* DM_CONV_UPDATED */
uint8_t cl = data[0];
snprintf(json, sizeof(json), "{\"event\":\"dm_conv\",\"conv\":\"%.*s\"}", (int)cl, data + 1);
} else if (type == CHAT_EVT_CALL_INCOMING && data && len >= 1 + 8 + 8) { /* CALL_INCOMING */
uint8_t cl = data[0];
if (len >= 1 + (int)cl + 8 + 8) {
uint64_t caller; memcpy(&caller, data + 1 + cl, 8);
uint64_t cid; memcpy(&cid, data + 1 + cl + 8, 8);
snprintf(json, sizeof(json),
"{\"event\":\"call_incoming\",\"ch\":\"%.*s\",\"caller_id\":\"0x%016llx\",\"call_id\":\"0x%016llx\"}",
(int)cl, data + 1, (unsigned long long)caller, (unsigned long long)cid);
}
} else if (type == CHAT_EVT_CALL_RINGING && data && len >= 8) { /* CALL_RINGING */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_ringing\",\"call_id\":\"0x%016llx\"}", (unsigned long long)cid);
} else if (type == CHAT_EVT_CALL_ACCEPTED && data && len >= 8) { /* CALL_ACCEPTED */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_accepted\",\"call_id\":\"0x%016llx\"}", (unsigned long long)cid);
} else if (type == CHAT_EVT_CALL_ENDED && data && len >= 9) { /* CALL_ENDED */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_ended\",\"call_id\":\"0x%016llx\",\"reason\":%d}", (unsigned long long)cid, data[8]);
} else if (type == CHAT_EVT_CALL_DECLINED && data && len >= 9) { /* CALL_DECLINED */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_declined\",\"call_id\":\"0x%016llx\",\"reason\":%d}", (unsigned long long)cid, data[8]);
}
if (json[0]) hc_broadcast_event(hc, json);
}
@@ -650,12 +673,58 @@ static void hc_handle_dm_send(struct headless_client* cli, int id, const char* j
send_response(cli, id, "{\"sent\":true}", NULL);
}
/* ── Звонки (P2P аудио) ── */
static uint64_t hc_gen_call_id(void) {
uint64_t id = ((uint64_t)rand() << 32) ^ (uint64_t)rand();
return id ? id : 1;
}
static void hc_handle_call_start(struct headless_client* cli, int id, const char* json) {
char ch[64], node_id_str[32], call_id_str[32];
if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; }
if (json_get_str(json, "node_id", node_id_str, sizeof(node_id_str)) < 0) { send_response(cli, id, NULL, "missing 'node_id' param"); return; }
uint64_t peer = strtoull(node_id_str, NULL, 16);
if (!peer || !cli->hc->inst) { send_response(cli, id, NULL, "invalid node_id"); return; }
uint64_t call_id = 0;
if (json_get_str(json, "call_id", call_id_str, sizeof(call_id_str)) == 0) call_id = strtoull(call_id_str, NULL, 16);
if (!call_id) call_id = hc_gen_call_id();
call_start(cli->hc->inst, ch, peer, call_id);
char resp[256]; snprintf(resp, sizeof(resp), "{\"call_id\":\"0x%016llx\",\"calling\":true}", (unsigned long long)call_id);
send_response(cli, id, resp, NULL);
}
static void hc_handle_call_accept(struct headless_client* cli, int id, const char* json) {
char call_id_str[32];
if (json_get_str(json, "call_id", call_id_str, sizeof(call_id_str)) < 0) { send_response(cli, id, NULL, "missing 'call_id' param"); return; }
uint64_t call_id = strtoull(call_id_str, NULL, 16);
call_accept(cli->hc->inst, call_id);
send_response(cli, id, "{\"accepted\":true}", NULL);
}
static void hc_handle_call_decline(struct headless_client* cli, int id, const char* json) {
char call_id_str[32];
if (json_get_str(json, "call_id", call_id_str, sizeof(call_id_str)) < 0) { send_response(cli, id, NULL, "missing 'call_id' param"); return; }
uint64_t call_id = strtoull(call_id_str, NULL, 16);
call_decline(cli->hc->inst, call_id);
send_response(cli, id, "{\"declined\":true}", NULL);
}
static void hc_handle_call_hangup(struct headless_client* cli, int id, const char* json) {
char call_id_str[32];
if (json_get_str(json, "call_id", call_id_str, sizeof(call_id_str)) < 0) { send_response(cli, id, NULL, "missing 'call_id' param"); return; }
uint64_t call_id = strtoull(call_id_str, NULL, 16);
call_hangup(cli->hc->inst, call_id);
send_response(cli, id, "{\"hungup\":true}", 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", "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_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", "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 void hc_handle_command(struct headless_client* cli, const char* json) {
if (!cli || !json) return;
-4
View File
@@ -43,10 +43,6 @@ static uint8_t parse_adm_tags_flags(const char* adm_tags) {
if (json_flat_get(adm_tags, "supernode", buf, sizeof(buf)) == 0
&& strcmp(buf, "yes") == 0) flags |= CHAT_MEMBER_FLAG_SUPERNODE;
/* dm_storage: узел хранит offline-сообщения прямого чата (DM) */
if (json_flat_get(adm_tags, "dm_storage", buf, sizeof(buf)) == 0
&& strcmp(buf, "yes") == 0) flags |= CHAT_MEMBER_FLAG_DM_STORAGE;
/* admin: значение — накопительная строка ed...ed..., последний символ e/d */
if (json_flat_get(adm_tags, "admin", buf, sizeof(buf)) == 0) {
const char* last = buf;
-1
View File
@@ -30,7 +30,6 @@ extern "C" {
#define CHAT_MEMBER_FLAG_ADMIN 0x02
#define CHAT_MEMBER_FLAG_MODER 0x04
#define CHAT_MEMBER_FLAG_DELETED 0x08
#define CHAT_MEMBER_FLAG_DM_STORAGE 0x10
#define CHAT_MEMBER_DISPLAY_SIZE 79
+5
View File
@@ -833,6 +833,11 @@ static int parse_chatserver(const char *key, const char *value, struct global_co
global->headless_control_bind[sizeof(global->headless_control_bind) - 1] = '\0';
return 0;
}
if (strcmp(key, "call_audio_bind") == 0) {
strncpy(global->call_audio_bind, value, sizeof(global->call_audio_bind) - 1);
global->call_audio_bind[sizeof(global->call_audio_bind) - 1] = '\0';
return 0;
}
return -1;
}
+1
View File
@@ -222,6 +222,7 @@ struct global_config {
uint64_t chatserver_storage_total_size; // bytes, общий лимит хранилища (0 = безлимит)
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 аудио-сокета звонков (пусто = отключено)
struct chat_setting_state chat_settings; // chat-настройки из [chatserver] (per-instance)
// Reality-камуфляж ([reality] section)
+372 -58
View File
@@ -109,8 +109,9 @@ static int dm_node_pubkeys(struct dm_state* dm, uint64_t node_id, uint8_t x25519
return rc;
}
/* Есть ли общая группа с пиром (anti-spam: принимаем DM только от знакомых из общих каналов). */
static int dm_share_group(struct dm_state* dm, uint64_t peer_id) {
/* Общий CHAT-канал с пиром. Возвращает 1 и кладёт group_id (== channel_id) в *out_group_id. */
static int dm_shared_group_id(struct dm_state* dm, uint64_t peer_id, uint64_t* out_group_id) {
if (!dm || !out_group_id) return 0;
uint64_t* ch_ids = NULL;
int ch_count = 0;
if (topo_node_sqlite_get_member_channels(dm->db, dm->inst->node_id, &ch_ids, &ch_count) != 0)
@@ -118,12 +119,49 @@ static int dm_share_group(struct dm_state* dm, uint64_t peer_id) {
int shared = 0;
for (int i = 0; i < ch_count; i++) {
char ch[64]; snprintf(ch, sizeof(ch), "%llu", (unsigned long long)ch_ids[i]);
if (topo_node_sqlite_member_in_channel(dm->db, ch, peer_id)) { shared = 1; break; }
if (topo_node_sqlite_member_in_channel(dm->db, ch, peer_id)) {
*out_group_id = ch_ids[i];
shared = 1;
break;
}
}
if (ch_ids) u_free(ch_ids);
return shared;
}
/* Есть ли общая группа с пиром (anti-spam: принимаем DM только от знакомых из общих каналов). */
static int dm_share_group(struct dm_state* dm, uint64_t peer_id) {
uint64_t gid = 0;
return dm_shared_group_id(dm, peer_id, &gid);
}
/* Собрать node_id всех мемберов общих групп (без self и дублей). До max штук.
* Используется для offline-pull: потенциальные отправители почты = мемберы общих групп. */
static int dm_shared_peers(struct dm_state* dm, uint64_t* out, int max) {
if (!dm || !out || max <= 0) return 0;
uint64_t* ch_ids = NULL;
int ch_count = 0;
if (topo_node_sqlite_get_member_channels(dm->db, dm->inst->node_id, &ch_ids, &ch_count) != 0)
return 0;
int found = 0;
for (int c = 0; c < ch_count && found < max; c++) {
char tbl[80]; snprintf(tbl, sizeof(tbl), "peers_%llu", (unsigned long long)ch_ids[c]);
char sql[256]; snprintf(sql, sizeof(sql), "SELECT node_id FROM \"%s\"", tbl);
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(dm->db, sql, -1, &st, NULL) != SQLITE_OK) continue;
while (sqlite3_step(st) == SQLITE_ROW && found < max) {
uint64_t nid = (uint64_t)sqlite3_column_int64(st, 0);
if (nid == dm->inst->node_id) continue;
int dup = 0;
for (int i = 0; i < found; i++) if (out[i] == nid) { dup = 1; break; }
if (!dup) out[found++] = nid;
}
sqlite3_finalize(st);
}
if (ch_ids) u_free(ch_ids);
return found;
}
/* Доступен ли пир для прямой доставки: живой ETCP-линк или присутствие в BGP-группе. */
static int dm_peer_reachable(struct dm_state* dm, uint64_t peer_id) {
struct ETCP_CONN* conn = instance_find_conn(dm->inst, peer_id);
@@ -191,6 +229,12 @@ static int dm_conv_load(struct dm_state* dm, const char* conv_id, struct dm_conv
c->group_id = (uint64_t)sqlite3_column_int64(st, 4);
c->last_out_seq = (uint64_t)sqlite3_column_int64(st, 5);
c->last_in_seq = (uint64_t)sqlite3_column_int64(st, 6);
/* Старые беседы могли быть авто-созданы с group_id=0 (маршрутизация только напрямую) —
* подтягиваем общую группу, чтобы DM шёл через неё (indirect/BGP). */
if (c->group_id == 0) {
uint64_t gid = 0;
if (dm_shared_group_id(dm, c->peer_node_id, &gid)) c->group_id = gid;
}
dm_derive_content_key(dm->inst->my_keys.private_key, c->peer_x25519, c->content_key);
rc = 0;
}
@@ -299,22 +343,33 @@ static void dm_on_msg(struct dm_state* dm, const uint8_t* msg, size_t mlen) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: conv_id mismatch from 0x%016llx", DM_ID, (unsigned long long)author);
return;
}
if (!dm_share_group(dm, author)) {
uint64_t gid = 0;
if (!dm_shared_group_id(dm, author, &gid)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: no shared group with 0x%016llx — reject DM", DM_ID, (unsigned long long)author);
return;
}
memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
c.peer_node_id = author;
c.group_id = gid;
if (dm_node_pubkeys(dm, author, c.peer_x25519, c.peer_ed25519) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: no pubkeys for 0x%016llx", DM_ID, (unsigned long long)author);
return;
}
chat_core_get_node_name(dm->inst, author, c.peer_name, sizeof(c.peer_name));
dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key);
c.group_id = 0;
dm_conv_save(dm, &c);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: auto-created conversation conv=%s peer=0x%016llx",
DM_ID, conv_id, (unsigned long long)author);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: auto-created conversation conv=%s peer=0x%016llx name=%s group=%llu",
DM_ID, conv_id, (unsigned long long)author, c.peer_name, (unsigned long long)c.group_id);
/* событие GUI: появилась новая беседа (чтобы GUI добавил её в список) */
{
size_t cl = strlen(conv_id);
uint8_t evt[1 + 64];
evt[0] = (uint8_t)cl;
memcpy(evt + 1, conv_id, cl);
chat_event_post(dm->inst, CHAT_EVT_DM_CONV_UPDATED, evt, 1 + (int)cl);
}
}
/* проверка подписи автора по всем полям до sig */
@@ -415,7 +470,54 @@ static void dm_on_hello(struct dm_state* dm, uint64_t conv_num, uint64_t from_pe
snprintf(conv_id, sizeof(conv_id), "%llu", (unsigned long long)conv_num);
struct dm_conv c;
if (dm_conv_load(dm, conv_id, &c) != 0) return;
if (dm_conv_load(dm, conv_id, &c) != 0) {
/* Беседы нет локально (первое сообщение ушло, пока мы были offline):
* автосоздать её (anti-spam: conv_id + общая группа) и ответить HELLO,
* чтобы отправитель дослал пропущенные сообщения. */
if (dm_derive_conv_id(dm->inst->node_id, from_peer) != conv_num) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello conv_id mismatch from 0x%016llx", DM_ID, (unsigned long long)from_peer);
return;
}
uint64_t gid = 0;
if (!dm_shared_group_id(dm, from_peer, &gid)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello no shared group with 0x%016llx — ignore", DM_ID, (unsigned long long)from_peer);
return;
}
memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
c.peer_node_id = from_peer;
c.group_id = gid;
if (dm_node_pubkeys(dm, from_peer, c.peer_x25519, c.peer_ed25519) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: hello no pubkeys for 0x%016llx", DM_ID, (unsigned long long)from_peer);
return;
}
chat_core_get_node_name(dm->inst, from_peer, c.peer_name, sizeof(c.peer_name));
dm_derive_content_key(dm->inst->my_keys.private_key, c.peer_x25519, c.content_key);
dm_conv_save(dm, &c);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: hello auto-created conversation conv=%s peer=0x%016llx name=%s group=%llu",
DM_ID, conv_id, (unsigned long long)from_peer, c.peer_name, (unsigned long long)c.group_id);
{
size_t cl = strlen(conv_id);
uint8_t evt[1 + 64];
evt[0] = (uint8_t)cl;
memcpy(evt + 1, conv_id, cl);
chat_event_post(dm->inst, CHAT_EVT_DM_CONV_UPDATED, evt, 1 + (int)cl);
}
/* ответить HELLO (out=0,in=0) — отправитель увидит peer_in_seq < last_out_seq и дослал сообщения */
uint8_t hello[24];
uint64_t z = 0;
memcpy(hello, &conv_num, 8);
memcpy(hello + 8, &z, 8);
memcpy(hello + 16, &z, 8);
if (dm_route_send(dm, 0, from_peer, DM_SUBCMD_HELLO, hello, sizeof(hello)) != 0)
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello reply send failed peer=0x%016llx", DM_ID, (unsigned long long)from_peer);
else
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: hello reply → 0x%016llx conv=%s (auto-created)",
DM_ID, (unsigned long long)from_peer, conv_id);
return;
}
if (peer_in_seq < c.last_out_seq) {
sqlite3_stmt* st = NULL;
@@ -506,33 +608,60 @@ static void dm_on_conn_status(struct ETCP_CONN* conn, int status, void* arg) {
/* На подъём ЛЮБОГО соединения: HELLO для беседы с подключившимся пиром +
* PULL mailbox для бесед, чей пир не подключён напрямую (offline-доставка). */
uint64_t conv_peers[64];
int nconv = 0;
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(dm->db,
"SELECT conv_id, peer_node_id, last_out_seq, last_in_seq FROM dm_conversations",
-1, &st, NULL) != SQLITE_OK) return;
while (sqlite3_step(st) == SQLITE_ROW) {
const char* conv_id = (const char*)sqlite3_column_text(st, 0);
uint64_t conv_peer = (uint64_t)sqlite3_column_int64(st, 1);
uint64_t out_seq = (uint64_t)sqlite3_column_int64(st, 2);
uint64_t in_seq = (uint64_t)sqlite3_column_int64(st, 3);
uint64_t conv_num = strtoull(conv_id, NULL, 10);
-1, &st, NULL) == SQLITE_OK) {
while (sqlite3_step(st) == SQLITE_ROW) {
const char* conv_id = (const char*)sqlite3_column_text(st, 0);
uint64_t conv_peer = (uint64_t)sqlite3_column_int64(st, 1);
uint64_t out_seq = (uint64_t)sqlite3_column_int64(st, 2);
uint64_t in_seq = (uint64_t)sqlite3_column_int64(st, 3);
uint64_t conv_num = strtoull(conv_id, NULL, 10);
if (conv_peer == peer) {
uint8_t hello[24];
memcpy(hello, &conv_num, 8);
memcpy(hello + 8, &out_seq, 8);
memcpy(hello + 16, &in_seq, 8);
/* group_id берём из строки беседы — для простоты HELLO по 0, маршрутизация глобальная */
dm_route_send(dm, 0, peer, DM_SUBCMD_HELLO, hello, sizeof(hello));
}
if (nconv < (int)(sizeof(conv_peers) / sizeof(conv_peers[0])))
conv_peers[nconv++] = conv_peer;
/* пир не подключён напрямую — тянем mailbox со storage-узлов */
struct ETCP_CONN* pc = instance_find_conn(inst, conv_peer);
if (!pc || !pc->links_up || !pc->initialized) {
dm_mailbox_pull(inst, conv_peer, in_seq);
if (conv_peer == peer) {
uint8_t hello[24];
memcpy(hello, &conv_num, 8);
memcpy(hello + 8, &out_seq, 8);
memcpy(hello + 16, &in_seq, 8);
/* group_id берём из строки беседы — для простоты HELLO по 0, маршрутизация глобальная */
if (dm_route_send(dm, 0, peer, DM_SUBCMD_HELLO, hello, sizeof(hello)) != 0)
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: hello send failed conv=%s peer=0x%016llx",
DM_ID, conv_id, (unsigned long long)peer);
else
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: hello → 0x%016llx conv=%s out=%llu in=%llu",
DM_ID, (unsigned long long)peer, conv_id,
(unsigned long long)out_seq, (unsigned long long)in_seq);
}
/* пир не подключён напрямую — тянем mailbox со storage-узлов */
struct ETCP_CONN* pc = instance_find_conn(inst, conv_peer);
if (!pc || !pc->links_up || !pc->initialized) {
dm_mailbox_pull(inst, conv_peer, in_seq);
}
}
sqlite3_finalize(st);
}
/* Первое сообщение могло прийти, пока мы были offline: мемберы общих групп,
* с которыми ещё нет беседы, тоже опрашиваем mailbox (отправитель мог уйти
* offline, тогда HELLO-восстановление недоступно). */
uint64_t peers[128];
int np = dm_shared_peers(dm, peers, (int)(sizeof(peers) / sizeof(peers[0])));
for (int i = 0; i < np; i++) {
uint64_t p = peers[i];
struct ETCP_CONN* pc = instance_find_conn(inst, p);
if (pc && pc->links_up && pc->initialized) continue; /* online — HELLO-путь */
int has_conv = 0;
for (int j = 0; j < nconv; j++) if (conv_peers[j] == p) { has_conv = 1; break; }
if (has_conv) continue; /* уже обработан циклом выше */
dm_mailbox_pull(inst, p, 0);
}
sqlite3_finalize(st);
}
/* ── публичный API ── */
@@ -553,28 +682,39 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id,
struct dm_conv c;
if (dm_conv_load(dm, conv_id, &c) == 0) {
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: conversation already exists conv=%s", DM_ID, conv_id);
return 0;
/* Беседа уже есть, но могла быть авто-создана с group_id=0 — дописываем из source_ch_id,
* чтобы маршрутизация шла через группу (а не только прямое соединение). */
if (c.group_id == 0 && source_ch_id && source_ch_id[0]) {
uint64_t gid = strtoull(source_ch_id, NULL, 10);
if (gid != 0) {
c.group_id = gid;
dm_conv_save(dm, &c);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: conversation exists conv=%s — updated group=%llu",
DM_ID, conv_id, (unsigned long long)gid);
}
} else {
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: conversation already exists conv=%s", DM_ID, conv_id);
}
} else {
memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
c.peer_node_id = peer_node_id;
memcpy(c.peer_x25519, peer_x25519, 32);
memcpy(c.peer_ed25519, peer_ed25519, 32);
if (peer_name) snprintf(c.peer_name, sizeof(c.peer_name), "%s", peer_name);
c.group_id = source_ch_id ? strtoull(source_ch_id, NULL, 10) : 0;
dm_derive_content_key(inst->my_keys.private_key, c.peer_x25519, c.content_key);
if (dm_conv_save(dm, &c) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: conv_save failed conv=%s", DM_ID, conv_id);
return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: started conv=%s peer=0x%016llx name=%s group=%llu",
DM_ID, conv_id, (unsigned long long)peer_node_id, c.peer_name,
(unsigned long long)c.group_id);
}
memset(&c, 0, sizeof(c));
snprintf(c.conv_id, sizeof(c.conv_id), "%s", conv_id);
c.peer_node_id = peer_node_id;
memcpy(c.peer_x25519, peer_x25519, 32);
memcpy(c.peer_ed25519, peer_ed25519, 32);
if (peer_name) snprintf(c.peer_name, sizeof(c.peer_name), "%s", peer_name);
c.group_id = source_ch_id ? strtoull(source_ch_id, NULL, 10) : 0;
dm_derive_content_key(inst->my_keys.private_key, c.peer_x25519, c.content_key);
if (dm_conv_save(dm, &c) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: conv_save failed conv=%s", DM_ID, conv_id);
return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: started conv=%s peer=0x%016llx name=%s group=%llu",
DM_ID, conv_id, (unsigned long long)peer_node_id, c.peer_name,
(unsigned long long)c.group_id);
/* событие GUI */
/* событие GUI (всегда, чтобы GUI мог открыть/выделить беседу) */
{
size_t cl = strlen(conv_id);
uint8_t evt[1 + 64];
@@ -634,15 +774,32 @@ int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content
/* доставка: доступен → прямой send, иначе → mailbox */
if (dm_peer_reachable(dm, c.peer_node_id)) {
dm_route_send(dm, c.group_id, c.peer_node_id, DM_SUBCMD_MSG, body, body_len);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: sent direct conv=%s seq=%llu → 0x%016llx",
DM_ID, conv_id, (unsigned long long)seq, (unsigned long long)c.peer_node_id);
if (dm_route_send(dm, c.group_id, c.peer_node_id, DM_SUBCMD_MSG, body, body_len) != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: direct send failed conv=%s seq=%llu → 0x%016llx",
DM_ID, conv_id, (unsigned long long)seq, (unsigned long long)c.peer_node_id);
else
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: sent direct conv=%s seq=%llu → 0x%016llx",
DM_ID, conv_id, (unsigned long long)seq, (unsigned long long)c.peer_node_id);
} else {
dm_mailbox_put(inst, c.peer_node_id, inst->node_id, body, body_len);
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: peer offline conv=%s seq=%llu → mailbox",
DM_ID, conv_id, (unsigned long long)seq);
if (dm_mailbox_put(inst, c.peer_node_id, inst->node_id, body, body_len) != 0)
DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: mailbox put failed conv=%s seq=%llu (peer offline, no storage)",
DM_ID, conv_id, (unsigned long long)seq);
else
DEBUG_INFO(DEBUG_CATEGORY_DM, "%s: peer offline conv=%s seq=%llu → mailbox",
DM_ID, conv_id, (unsigned long long)seq);
}
u_free(body);
/* событие GUI (своё сообщение тоже, чтобы GUI обновил список) */
{
size_t cl = strlen(conv_id);
uint8_t evt[1 + 64 + 8];
evt[0] = (uint8_t)cl;
memcpy(evt + 1, conv_id, cl);
uint64_t me = inst->node_id;
memcpy(evt + 1 + cl, &me, 8);
chat_event_post(inst, CHAT_EVT_DM_MSG_RECEIVED, evt, 1 + (int)cl + 8);
}
return 0;
}
@@ -689,6 +846,26 @@ void dm_core_destroy(struct UTUN_INSTANCE* inst) {
/* ── JSON для GUI/headless ── */
/* Экранирование строки для вставки в JSON-строку: " \ \n \r \t, прочие <0x20 — drop. */
static void json_escape(const char* src, char* dst, size_t dst_sz) {
size_t i = 0;
if (!src) src = "";
for (; *src && i + 2 < dst_sz; src++) {
unsigned char c = (unsigned char)*src;
switch (c) {
case '"': dst[i++] = '\\'; dst[i++] = '"'; break;
case '\\': dst[i++] = '\\'; dst[i++] = '\\'; break;
case '\n': dst[i++] = '\\'; dst[i++] = 'n'; break;
case '\r': dst[i++] = '\\'; dst[i++] = 'r'; break;
case '\t': dst[i++] = '\\'; dst[i++] = 't'; break;
default:
if (c < 0x20) break;
dst[i++] = (char)c;
}
}
dst[i] = '\0';
}
/* Список бесед в JSON. */
int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len) {
struct dm_state* dm = dm_of(inst);
@@ -708,11 +885,19 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf
uint64_t last_ts = (uint64_t)sqlite3_column_int64(st, 3);
uint64_t out_seq = (uint64_t)sqlite3_column_int64(st, 4);
uint64_t in_seq = (uint64_t)sqlite3_column_int64(st, 5);
char resolved[128];
if (!name || !name[0]) {
resolved[0] = '\0';
chat_core_get_node_name(inst, peer, resolved, sizeof(resolved));
name = resolved;
}
char esc_name[512];
json_escape(name ? name : "", esc_name, sizeof(esc_name));
off += snprintf(buf + off, buf_size - (size_t)off,
"%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\","
"\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu}",
first ? "" : ",", conv_id, (unsigned long long)peer,
name ? name : "", (unsigned long long)last_ts,
esc_name, (unsigned long long)last_ts,
(unsigned long long)out_seq, (unsigned long long)in_seq);
first = 0;
}
@@ -735,11 +920,11 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c
char sql[256];
if (count > 0)
snprintf(sql, sizeof(sql),
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY seq LIMIT %d OFFSET %d",
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY ts, seq LIMIT %d OFFSET %d",
count, offset);
else
snprintf(sql, sizeof(sql),
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY seq");
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY ts, seq");
int off = 0;
off += snprintf(buf + off, buf_size - (size_t)off, "[");
@@ -771,10 +956,13 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c
}
u_free(plain);
}
char esc_ct[64], esc_data[DM_DATA_MAX * 2 + 8];
json_escape(ct ? ct : "", esc_ct, sizeof(esc_ct));
json_escape(text, esc_data, sizeof(esc_data));
off += snprintf(buf + off, buf_size - (size_t)off,
"%s{\"dir\":%d,\"seq\":%llu,\"ts\":%llu,\"author\":%llu,\"ct\":\"%s\",\"data\":\"%s\"}",
first ? "" : ",", dir, (unsigned long long)seq, (unsigned long long)ts,
(unsigned long long)author, ct ? ct : "", text);
(unsigned long long)author, esc_ct, esc_data);
first = 0;
}
}
@@ -783,3 +971,129 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c
*out_len = (size_t)off;
return 0;
}
/* ── трамплины для gui_bridge (GUI → uasync) ── */
/* Начать беседу с узлом из общей группы: находим pubkey пира в nodes, зовём dm_start. */
void dm_start_trampoline(void* arg) {
struct dm_start_req* req = (struct dm_start_req*)arg;
if (!req) return;
struct UTUN_INSTANCE* inst = req->inst;
uint64_t peer = req->peer_node_id;
char src[64]; strncpy(src, req->source_ch_id, sizeof(src) - 1); src[sizeof(src) - 1] = '\0';
char name[128]; strncpy(name, req->peer_name, sizeof(name) - 1); name[sizeof(name) - 1] = '\0';
u_free(arg);
struct dm_state* dm = dm_of(inst);
if (!dm || !dm->initialized || !inst || !peer) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_start_trampoline — not initialized", DM_ID);
return;
}
if (peer == inst->node_id) return;
uint8_t x25519[32], ed25519[32];
if (dm_node_pubkeys(dm, peer, x25519, ed25519) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_start_trampoline — no pubkeys for peer=0x%016llx",
DM_ID, (unsigned long long)peer);
return;
}
if (dm_start(inst, peer, x25519, ed25519, name[0] ? name : NULL, src[0] ? src : NULL) != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_start_trampoline — dm_start failed peer=0x%016llx",
DM_ID, (unsigned long long)peer);
}
/* Отправить текстовое сообщение в беседу. */
void dm_send_trampoline(void* arg) {
struct dm_send_req* req = (struct dm_send_req*)arg;
if (!req) return;
struct UTUN_INSTANCE* inst = req->inst;
char conv[64]; strncpy(conv, req->conv_id, sizeof(conv) - 1); conv[sizeof(conv) - 1] = '\0';
int rc = dm_send(inst, conv, "text", req->data, req->data_len);
u_free(arg);
if (rc != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_send_trampoline — dm_send failed conv=%s",
DM_ID, conv);
}
/* Собрать расшифрованные сообщения беседы и отправить в GUI бинарным событием:
* [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])* */
void dm_messages_trampoline(void* arg) {
struct dm_messages_req* req = (struct dm_messages_req*)arg;
if (!req) return;
struct UTUN_INSTANCE* inst = req->inst;
char conv_id[64]; strncpy(conv_id, req->conv_id, sizeof(conv_id) - 1); conv_id[sizeof(conv_id) - 1] = '\0';
u_free(arg);
struct dm_state* dm = dm_of(inst);
if (!dm || !dm->initialized) return;
struct dm_conv c;
if (dm_conv_load(dm, conv_id, &c) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: dm_messages_trampoline — no conversation %s", DM_ID, conv_id);
return;
}
uint64_t conv_num = (uint64_t)strtoull(conv_id, NULL, 10);
sqlite3_stmt* st = NULL;
uint8_t* msgs = NULL;
size_t msgs_len = 0, msgs_cap = 0;
int count = 0;
if (sqlite3_prepare_v2(dm->db,
"SELECT dir,seq,ts,author,ct,data FROM dm_messages WHERE conv_id=? ORDER BY ts, seq",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, conv_id, -1, SQLITE_STATIC);
while (sqlite3_step(st) == SQLITE_ROW) {
uint8_t dir = (uint8_t)sqlite3_column_int(st, 0);
uint64_t seq = (uint64_t)sqlite3_column_int64(st, 1);
uint64_t ts = (uint64_t)sqlite3_column_int64(st, 2);
uint64_t author = (uint64_t)sqlite3_column_int64(st, 3);
const char* ct = (const char*)sqlite3_column_text(st, 4);
const uint8_t* enc = (const uint8_t*)sqlite3_column_blob(st, 5);
int enc_len = sqlite3_column_bytes(st, 5);
size_t ct_len = ct ? strnlen(ct, 255) : 0;
uint8_t plain[DM_DATA_MAX];
size_t plen = 0;
if (enc && enc_len >= (int)DM_TAG_SIZE) {
uint8_t nonce[DM_NONCE_SIZE];
dm_build_nonce(conv_num, author, seq, nonce);
if (dm_decrypt(c.content_key, nonce, enc, (size_t)enc_len, plain, &plen) != 0)
plen = 0;
}
size_t need = 1 + 8 + 8 + 8 + 1 + ct_len + 2 + plen;
if (msgs_len + need > msgs_cap) {
msgs_cap = msgs_cap ? msgs_cap * 2 : 4096;
while (msgs_cap < msgs_len + need) msgs_cap *= 2;
uint8_t* tmp = u_realloc(msgs, msgs_cap);
if (!tmp) break;
msgs = tmp;
}
uint16_t plen16 = (uint16_t)plen;
msgs[msgs_len] = dir; msgs_len += 1;
memcpy(msgs + msgs_len, &seq, 8); msgs_len += 8;
memcpy(msgs + msgs_len, &ts, 8); msgs_len += 8;
memcpy(msgs + msgs_len, &author, 8); msgs_len += 8;
msgs[msgs_len] = (uint8_t)ct_len; msgs_len += 1;
if (ct_len) { memcpy(msgs + msgs_len, ct, ct_len); msgs_len += ct_len; }
memcpy(msgs + msgs_len, &plen16, 2); msgs_len += 2;
if (plen) { memcpy(msgs + msgs_len, plain, plen); msgs_len += plen; }
count++;
}
sqlite3_finalize(st);
}
size_t cl = strlen(conv_id);
size_t evt_sz = 1 + cl + 2 + msgs_len;
uint8_t* evt = u_malloc(evt_sz);
if (!evt) { if (msgs) u_free(msgs); return; }
uint16_t u16cnt = (uint16_t)count;
evt[0] = (uint8_t)cl;
memcpy(evt + 1, conv_id, cl);
memcpy(evt + 1 + cl, &u16cnt, 2);
if (msgs_len) memcpy(evt + 1 + cl + 2, msgs, msgs_len);
chat_event_post(inst, CHAT_EVT_DM_MESSAGES, evt, (int)evt_sz);
u_free(evt);
if (msgs) u_free(msgs);
}
+30
View File
@@ -70,6 +70,36 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf
int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int count, int offset,
char* buf, size_t buf_size, size_t* out_len);
/* ── Трамплины для gui_bridge (GUI → uasync) ── */
/* Начать беседу с узлом из общей группы. Трамплин сам находит x25519/ed25519
* пира в таблице nodes (как headless hc_handle_dm_start), после чего вызывает
* dm_start(). Результат — событие CHAT_EVT_DM_CONV_UPDATED. */
struct dm_start_req {
struct UTUN_INSTANCE* inst;
uint64_t peer_node_id;
char source_ch_id[64];
char peer_name[128];
};
void dm_start_trampoline(void* arg);
/* Отправить текстовое сообщение в беседу (content_type="text"). */
struct dm_send_req {
struct UTUN_INSTANCE* inst;
char conv_id[64];
uint32_t data_len;
uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */
};
void dm_send_trampoline(void* arg);
/* Запросить расшифрованные сообщения беседы. Результат — CHAT_EVT_DM_MESSAGES:
* [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])* */
struct dm_messages_req {
struct UTUN_INSTANCE* inst;
char conv_id[64];
};
void dm_messages_trampoline(void* arg);
#ifdef __cplusplus
}
#endif
+2 -2
View File
@@ -269,7 +269,7 @@ void dm_mailbox_destroy(struct UTUN_INSTANCE* inst) {
/* ── Публичные операции ── */
/* Собрать storage-узлы (dm_storage=yes) из общих групп без дублей. */
/* Собрать storage-узлы (storage=yes в adm_tags) из общих групп без дублей. */
int dm_mailbox_get_storage_nodes(struct UTUN_INSTANCE* inst, uint64_t* out, int max) {
struct dm_mb_state* mb = mb_of(inst);
if (!inst || !out || max <= 0 || !mb || !mb->db) return 0;
@@ -291,7 +291,7 @@ int dm_mailbox_get_storage_nodes(struct UTUN_INSTANCE* inst, uint64_t* out, int
uint64_t nid = (uint64_t)sqlite3_column_int64(st, 0);
if (nid == my_id) continue;
const char* tags = (const char*)sqlite3_column_text(st, 1);
if (tags && json_flat_get(tags, "dm_storage", buf, sizeof(buf)) == 0
if (tags && json_flat_get(tags, "storage", buf, sizeof(buf)) == 0
&& strcmp(buf, "yes") == 0) {
int dup = 0;
for (int i = 0; i < found; i++) if (out[i] == nid) { dup = 1; break; }
+3 -3
View File
@@ -2,7 +2,7 @@
* dm_mailbox.h — offline-хранение DM-сообщений на storage-узлах
*
* Когда пир недоступен (нет прямой связи), отправитель кладёт зашифрованное
* сообщение на storage-узлы (мемберы общих групп с флагом dm_storage=yes).
* сообщение на storage-узлы (мемберы общих групп с флагом storage=yes).
* Получатель при выходе в online вытягивает накопившуюся почту, проверяет
* подпись, расшифровывает и вставляет в dm_messages (dedup по seq).
*
@@ -39,8 +39,8 @@ int dm_mailbox_put(struct UTUN_INSTANCE* inst, uint64_t recipient, uint64_t send
* всех известных storage-узлов. Вызывается из uasync-потока. */
void dm_mailbox_pull(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, uint64_t since_seq);
/* Собрать node_id storage-узлов (dm_storage=yes) из общих групп. До max штук.
* Возвращает количество найденных. out — буфер на max*8 байт. */
/* Собрать node_id storage-узлов (storage=yes в adm_tags) из общих групп. До max
* штук. Возвращает количество найденных. out — буфер на max*8 байт. */
int dm_mailbox_get_storage_nodes(struct UTUN_INSTANCE* inst, uint64_t* out, int max);
#ifdef __cplusplus
+1 -1
View File
@@ -107,7 +107,7 @@ static void nat_transport_etcp_recv_cb(struct ETCP_CONN* conn, struct ll_entry*
if (!inst) { queue_dgram_free(entry); queue_entry_free(entry); return; }
struct nat_transport_ctx* tr = &inst->nat_tr;
struct eim_nat_ctx* ctx = &inst->nat;
if (!ctx->initialized) { queue_dgram_free(entry); queue_entry_free(entry); return; }
if (!tr->initialized || !ctx->initialized) { queue_dgram_free(entry); queue_entry_free(entry); return; }
uint64_t src_node_id;
memcpy(&src_node_id, entry->dgram + ROUTER_SVC_SRC_OFF, 8);
+18 -4
View File
@@ -28,6 +28,8 @@ static void on_read_cb(struct ll_queue* q, void* arg);
static void on_fin_cb(struct tcp_conn* tc, void* arg);
static void on_error_cb(struct tcp_conn* tc, int err, void* arg);
static void on_closed_cb(struct tcp_conn* tc, void* arg);
static void socks_proxy_conn_free_deferred(void* arg);
static void socks_proxy_conn_free_soon(struct socks_proxy_conn* c);
static void tx_waiter_cb(struct ll_queue* q, void* arg);
static void tx_retry_timer_cb(void* arg);
static void socks_issue_dns(struct socks_proxy_conn* c, const char* host, uint8_t kind);
@@ -119,8 +121,8 @@ static int write_to_client(struct socks_proxy_conn* c, const uint8_t* data, uint
}
memcpy(buf, data, len);
e->dgram = buf; e->len = len;
queue_data_put(c->tc->write_queue, e);
c->bytes_to_client += len;
queue_data_put(c->tc->write_queue, e);
return 0;
}
@@ -556,7 +558,7 @@ static void on_error_cb(struct tcp_conn* tc, int err, void* arg) {
struct UTUN_INSTANCE* inst = c->inst;
uint64_t via = c->via_node_id;
uint32_t sid = c->stream_id;
socks_proxy_conn_free(c);
socks_proxy_conn_free_soon(c);
if (notify && inst) send_msg(inst, TOPO_GROUP_UTUN, via, TCP_PROXY_SUBCMD_ERROR, sid, NULL, 0, 1);
}
@@ -565,7 +567,7 @@ static void on_closed_cb(struct tcp_conn* tc, void* arg) {
DEBUG_INFO(DEBUG_CATEGORY_PROXY, "socks_proxy: tcp closed sid=%08x state=%d fm_r=%d fm_l=%d rem_cl=%d bytes_client=%u bytes_exit=%u",
c->stream_id, c->state, tc->fin_remote, tc->fin_local, c->rem_closed, c->bytes_to_client, c->bytes_from_exit);
if (!c->close_sent && !c->close_pending) send_close(c);
socks_proxy_conn_free(c);
socks_proxy_conn_free_soon(c);
}
// ====================================================================
@@ -627,8 +629,8 @@ int socks_proxy_handle_etcp(struct socks_proxy_conn** head, int* count,
uint8_t* buf = memory_pool_alloc(c->tc->data_pool);
if (e && buf) {
memcpy(buf, data, data_len); e->dgram = buf; e->len = (uint16_t)data_len;
queue_data_put(c->tc->write_queue, e);
c->bytes_to_client += (uint32_t)data_len;
queue_data_put(c->tc->write_queue, e);
} else {
DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "socks_proxy: handle_data alloc failed sid=%08x wq=%d bytes_in=%u bytes_out=%u",
stream_id, c->tc->write_queue->count, c->bytes_from_exit, c->bytes_to_client);
@@ -665,10 +667,22 @@ int socks_proxy_handle_etcp(struct socks_proxy_conn** head, int* count,
return 1;
}
static void socks_proxy_conn_free_deferred(void* arg) {
struct socks_proxy_conn* c = (struct socks_proxy_conn*)arg;
c->free_soon_id = NULL;
socks_proxy_conn_free(c);
}
static void socks_proxy_conn_free_soon(struct socks_proxy_conn* c) {
if (!c || c->free_soon_id) return;
c->free_soon_id = uasync_call_soon(c->ua, c, socks_proxy_conn_free_deferred);
}
void socks_proxy_conn_free(struct socks_proxy_conn* c) {
if (!c) return;
if (c->freed) return;
c->freed = 1;
if (c->free_soon_id) { uasync_call_soon_cancel(c->ua, c->free_soon_id); c->free_soon_id = NULL; }
struct socks_proxy_conn** head = c->head; int* count = c->count;
DEBUG_INFO(DEBUG_CATEGORY_PROXY, "socks_proxy: FREE sid=%08x state=%d total=%d", c->stream_id, c->state, count ? *count : 0);
if (c->close_pending && c->inst) {
+1
View File
@@ -52,6 +52,7 @@ struct socks_proxy_conn {
uint8_t is_http;
uint8_t state;
uint8_t freed;
void* free_soon_id; // handle отложенного освобождения (uasync_call_soon)
uint8_t buf[1024];
uint16_t buf_len;
uint8_t* tx_buf; // буфер при backpressure (retry в tx_waiter_cb)
+29 -9
View File
@@ -33,7 +33,7 @@ static void router_schedule_ack(struct ETCP_ROUTER_CONN* rconn);
static void router_try_assembly(struct ETCP_ROUTER_CONN* rconn, struct ETCP_CONN* conn);
static void router_deliver(struct ETCP_ROUTER_CONN* rconn, struct ETCP_CONN* conn, const uint8_t* wire, size_t wire_len);
static void router_send_ctrl(struct ETCP_ROUTER_CONN* rconn, uint8_t flag_bits);
static void router_send_rst(struct ETCP_ROUTER_CONN* rconn);
static void router_send_rst(struct ETCP_ROUTER_CONN* rconn, struct ETCP_CONN* conn);
static void router_restart_send(struct ETCP_ROUTER_CONN* rconn);
static struct ETCP_CONN* router_send_conn(struct ETCP_ROUTER_CONN* rconn);
static void router_send_kick(struct ETCP_ROUTER_CONN* rconn);
@@ -50,7 +50,7 @@ static void router_conn_reset(struct ETCP_ROUTER_CONN* rconn);
static void free_entry(struct ll_entry* e) { queue_dgram_free(e); queue_entry_free(e); }
static void router_handle_ack(struct UTUN_INSTANCE* inst, struct ETCP_ROUTER_CONN* rconn, uint32_t seq, uint64_t src_node_id, uint16_t ack_ts);
static void router_forward_transit(struct UTUN_INSTANCE* inst, struct ll_entry* entry, struct SVC_ROUTE_HDR* hdr);
static int router_check_peer_restart(struct UTUN_INSTANCE* inst, struct ETCP_ROUTER_CONN** prconn, struct SVC_ROUTE_HDR* hdr, size_t wire_len);
static int router_check_peer_restart(struct UTUN_INSTANCE* inst, struct ETCP_ROUTER_CONN** prconn, struct SVC_ROUTE_HDR* hdr, size_t wire_len, struct ETCP_CONN* conn);
static void router_handle_data_packet(struct ETCP_ROUTER_CONN* rconn, struct ETCP_CONN* conn, uint32_t seq, const uint8_t* wire, size_t wire_len);
static void router_retransmit_one(struct ETCP_ROUTER_CONN* rconn, struct ROUTER_INFLIGHT* inf);
static void router_retrans_schedule(struct ETCP_ROUTER_CONN* rconn);
@@ -153,6 +153,27 @@ void etcp_router_transit_queues_destroy(struct ETCP_CONN* conn) {
conn->transit_queues = NULL;
}
// Сбросить recv_conn у всех ROUTER_CONN, ссылающихся на уничтожаемый ETCP_CONN.
// recv_conn — заимствованный указатель; без очистки он висит после u_free(conn) (UAF).
void etcp_router_conn_destroyed(struct ETCP_CONN* conn) {
if (!conn || !conn->instance || !conn->instance->router_conns) return;
struct ll_queue* q = conn->instance->router_conns;
for (uint32_t slot = 0; slot < q->hash_size; slot++) {
struct ll_entry* entry = q->hash_table[slot];
while (entry) {
struct ll_entry* next = entry->hash_next;
struct ETCP_ROUTER_CONN* rconn = (struct ETCP_ROUTER_CONN*)entry;
if (rconn->recv_conn == conn) {
DEBUG_DEBUG(DEBUG_CATEGORY_ETCPROUTE,
"router: clear recv_conn remote=%016llx svc_id=%u (conn destroyed)",
(unsigned long long)rconn->remote_node_id, rconn->svc_id);
rconn->recv_conn = NULL;
}
entry = next;
}
}
}
// ====================================================================
// Управление ROUTER_CONN
// ====================================================================
@@ -251,10 +272,9 @@ static void router_send_ctrl(struct ETCP_ROUTER_CONN* rconn, uint8_t flag_bits)
}
// Отправка RST удалённой стороне — сброс чужой «старой» сессии.
// Идёт через recv_conn: в асимметричной топологии route-lookup до remote вернёт NULL.
static void router_send_rst(struct ETCP_ROUTER_CONN* rconn) {
// conn — живой физический conn, доставивший пакет (recv_conn может быть уже устаревшим).
static void router_send_rst(struct ETCP_ROUTER_CONN* rconn, struct ETCP_CONN* conn) {
struct UTUN_INSTANCE* inst = rconn->inst;
struct ETCP_CONN* conn = rconn->recv_conn;
if (!conn) conn = topo_group_find_conn_for_node(topo_groups_find(inst->topo_groups, rconn->group_id), rconn->remote_node_id);
if (!conn) {
DEBUG_WARN(DEBUG_CATEGORY_ETCPROUTE, "router_rst: no route to %016llx svc_id=%u",
@@ -1069,7 +1089,7 @@ static void router_forward_transit(struct UTUN_INSTANCE* inst, struct ll_entry*
}
static int router_check_peer_restart(struct UTUN_INSTANCE* inst, struct ETCP_ROUTER_CONN** prconn,
struct SVC_ROUTE_HDR* hdr, size_t wire_len) {
struct SVC_ROUTE_HDR* hdr, size_t wire_len, struct ETCP_CONN* conn) {
(void)wire_len;
struct ETCP_ROUTER_CONN* rconn = *prconn;
if (!(hdr->flags & ROUTER_FLAG_START)) return 0; // не START — не рестарт
@@ -1091,7 +1111,7 @@ static int router_check_peer_restart(struct UTUN_INSTANCE* inst, struct ETCP_ROU
"router: peer reset_id=%016llx != mine=%016llx — master keeps epoch, RST to %016llx svc_id=%u",
(unsigned long long)peer_rid, (unsigned long long)rconn->reset_id,
(unsigned long long)hdr->src_node_id, hdr->svc_id);
router_send_rst(rconn);
router_send_rst(rconn, conn);
return 0;
} else {
// slave принимает эпоху мастера и ресетится.
@@ -1207,11 +1227,11 @@ static void etcp_router_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry)
rconn = etcp_router_conn_get(inst, hdr->group_id, hdr->src_node_id, hdr->svc_id);
if (!rconn) { free_entry(entry); return; }
}
if (router_check_peer_restart(inst, &rconn, hdr, entry->len) != 0) { free_entry(entry); return; }
if (router_check_peer_restart(inst, &rconn, hdr, entry->len, conn) != 0) { free_entry(entry); return; }
// Сервер после рестарта «ждёт START»: пришёл не-START пакет от старой сессии → RST.
if (!rconn->peer_sync_done && !(hdr->flags & ROUTER_FLAG_START)) {
router_send_rst(rconn);
router_send_rst(rconn, conn);
free_entry(entry); return;
}
+3
View File
@@ -245,6 +245,9 @@ void etcp_router_conn_restart(struct UTUN_INSTANCE* inst, uint64_t group_id, uin
// Удалить все транзитные очереди соединения (вызывается при закрытии ETCP_CONN)
void etcp_router_transit_queues_destroy(struct ETCP_CONN* conn);
// Сбросить recv_conn у всех ROUTER_CONN, ссылающихся на уничтожаемый ETCP_CONN.
void etcp_router_conn_destroyed(struct ETCP_CONN* conn);
#ifdef __cplusplus
}
+1 -18
View File
@@ -267,22 +267,5 @@ int parse_subnet(const char *subnet_str, uint32_t *network, uint8_t *prefix_leng
}
int is_local_subnet(uint32_t ip) {
uint8_t* bip=(uint8_t*)&ip;
uint8_t first_octet = bip[0];
uint8_t second_octet = bip[1];
DEBUG_TRACE(DEBUG_CATEGORY_BGP, "check ip %d.%d : ", first_octet, second_octet);
if (first_octet == 0) return 1;
if (first_octet == 10) return 1;
if (first_octet == 127) return 1;
if (first_octet == 169 && second_octet == 254) return 1;
if (first_octet >= 224 && first_octet <= 239) return 1;
if (first_octet == 172 && second_octet >= 16 && second_octet <= 31) return 1;
if (first_octet == 100 && second_octet >= 64 && second_octet <= 127) return 1; /* 100.64.0.0/10 (CGNAT) */
if (first_octet == 192 && second_octet == 168) return 1;
if (ip==0xffffffff) return 1;
DEBUG_TRACE(DEBUG_CATEGORY_BGP, "result: global ip %d.%d : ", first_octet, second_octet);
return 0;
return !ip_is_public(ip);
}
+21 -27
View File
@@ -8,27 +8,11 @@
#include "etcp_connections.h"
#include "config_parser.h"
#include "topo_node.h"
#include "ntp_time.h"
/* Нормализация NAT-типа: сырые (NAT_TYPE_*) или проверенные (NAT_VERIFIED_*) значения. */
static int nat_is_eim(uint8_t n) { return n == NAT_TYPE_EIM || n == NAT_VERIFIED_EIM; }
static int nat_is_direct(uint8_t n) { return n == NAT_TYPE_DIRECT || n == NAT_VERIFIED_DIRECT; }
uint8_t sock_role_of(uint8_t type, uint8_t nat_type, int ntp_reachable) {
uint8_t role;
if (type == CFG_SERVER_TYPE_LOCAL)
role = SOCK_ROLE_LOCAL; /* без интернета (из конфига) */
else if (type == CFG_SERVER_TYPE_PUBLIC || type == CFG_SERVER_TYPE_UNKNOWN)
role = SOCK_ROLE_DIRECT;
else if (type == CFG_SERVER_TYPE_NAT)
role = (nat_is_eim(nat_type) || nat_is_direct(nat_type)) ? SOCK_ROLE_DIRECT : SOCK_ROLE_NAT;
else
role = SOCK_ROLE_NAT; /* PRIVATE — скрытый, только исходящие */
if (!ntp_reachable && role != SOCK_ROLE_LOCAL)
role = SOCK_ROLE_LOCAL; /* NTP недоступен — интернета нет */
return role;
}
uint8_t sock_target_class(uint8_t a_type, uint8_t tcfg, uint8_t tnat) {
if (tcfg == CFG_SERVER_TYPE_PUBLIC || tcfg == CFG_SERVER_TYPE_UNKNOWN) return SOCK_TC_PUB;
if (tcfg == CFG_SERVER_TYPE_PRIVATE || tcfg == CFG_SERVER_TYPE_LOCAL) return SOCK_TC_PRIV;
@@ -42,7 +26,7 @@ int sock_match(const struct sock_view* v, uint8_t a_type, uint8_t tcfg, uint8_t
uint8_t tc = sock_target_class(a_type, tcfg, tnat);
if (tc == SOCK_TC_UNREACH) return 0; /* strict/unknown NAT-адрес — напрямую не пробьём */
if (tc == SOCK_TC_PUB) {
if (v->role == SOCK_ROLE_LOCAL) return 0; /* у нашего сокета нет интернета */
if (!v->has_inet) return 0; /* у нашего сокета нет интернета */
if (a_type == TOPO_ADDR_NAT && v->is_eim && nat_is_eim(tnat)
&& v->public_ip4 && v->public_ip4 == target_ip4) return 0; /* оба EIM за одним NAT — hairpin */
return 1;
@@ -52,27 +36,37 @@ int sock_match(const struct sock_view* v, uint8_t a_type, uint8_t tcfg, uint8_t
int sock_collect_views(struct UTUN_INSTANCE* inst, struct sock_view* out, int max) {
if (!inst || !out || max <= 0) return 0;
int ntp_reachable = inst->ntp.reachable;
int n = 0;
struct ETCP_SOCKET* s = inst->etcp_sockets;
while (s && n < max) {
if (s->local_addr.ss_family != AF_INET) { s = s->next; continue; }
struct sock_view* v = &out[n++];
v->sock = s;
v->role = sock_role_of(s->type, s->nat_type, ntp_reachable);
v->has_inet = (uint8_t)(v->role != SOCK_ROLE_LOCAL);
v->has_priv = (uint8_t)(s->type != CFG_SERVER_TYPE_PUBLIC && s->type != CFG_SERVER_TYPE_UNKNOWN);
v->is_eim = (uint8_t)(s->type == CFG_SERVER_TYPE_NAT && nat_is_eim(s->nat_type));
v->family = AF_INET;
v->is_tcp = s->is_tcp;
v->family = AF_INET;
v->is_tcp = s->is_tcp;
v->local_ip4 = 0; v->public_ip4 = 0;
/* Локальный адрес (interface, иначе local). */
{ const struct sockaddr_storage* ia = s->interface_addr.ss_family ? &s->interface_addr : &s->local_addr;
if (ia && ia->ss_family == AF_INET) v->local_ip4 = ((const struct sockaddr_in*)ia)->sin_addr.s_addr; }
/* Публичный адрес (NAT): nat_addr, иначе локальный если он публичный. */
{ const struct sockaddr_in* na = (const struct sockaddr_in*)&s->nat_addr;
if (s->nat_addr.ss_family == AF_INET && na->sin_addr.s_addr != 0)
v->public_ip4 = na->sin_addr.s_addr;
else if (s->type == CFG_SERVER_TYPE_PUBLIC || s->type == CFG_SERVER_TYPE_UNKNOWN || nat_is_direct(s->nat_type))
v->public_ip4 = v->local_ip4; }
v->public_ip4 = na->sin_addr.s_addr; }
/* Классификация по адресу, а не по type из конфига:
* приватный локальный адрес → можем ходить в LAN (has_priv);
* исходящие считаем доступными (интернет + локалка). */
int local_public = v->local_ip4 && ip_is_public(v->local_ip4);
v->has_priv = (uint8_t)!local_public;
v->has_inet = 1;
/* NAT-результат (главный критерий если есть) — для hairpin-фильтра. */
v->is_eim = (uint8_t)nat_is_eim(s->nat_type);
if (!v->public_ip4 && local_public) v->public_ip4 = v->local_ip4;
v->role = local_public ? SOCK_ROLE_DIRECT : (v->is_eim ? SOCK_ROLE_DIRECT : SOCK_ROLE_NAT);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "sock_view[%d] sock=%s type=%d nat=%d role=%d inet=%d priv=%d eim=%d tcp=%d local=%08x public=%08x",
n - 1, s->name, s->type, s->nat_type, v->role, v->has_inet, v->has_priv, v->is_eim, v->is_tcp,
v->local_ip4, v->public_ip4);
+10 -7
View File
@@ -65,22 +65,25 @@ struct ETCP_SOCKET;
* Плоское представление нашего сокета для сопоставления.
* "ip сокета" — local_ip4 (interface), "публичный" — public_ip4 (NAT/прямой),
* "тип" — role + флаги has_inet/has_priv/is_eim.
*
* Флаги вычисляются из КЛАССИФИКАЦИИ АДРЕСА и результата NAT-детекции,
* а не из type сокета в конфиге:
* has_priv — локальный IPv4 приватный/не-маршрутизируемый (можем ходить в LAN);
* has_inet — исходящие считаем доступными в интернет (всегда 1);
* is_eim — результат NAT-детекции EIM (для hairpin-фильтра).
*/
struct sock_view {
struct ETCP_SOCKET* sock;
uint8_t role; /* SOCK_ROLE_* */
uint8_t has_inet; /* 1 — есть интернет (role != LOCAL) */
uint8_t has_priv; /* 1 — есть приватный интерфейс (type != PUBLIC/UNKNOWN) */
uint8_t role; /* SOCK_ROLE_* (производное, для логов) */
uint8_t has_inet; /* 1 — интернет доступен */
uint8_t has_priv; /* 1 — есть приватный (LAN) интерфейс */
uint8_t is_eim; /* 1 — NAT + EIM (для hairpin) */
uint8_t family; /* AF_INET / AF_INET6 */
uint8_t is_tcp;
uint32_t local_ip4; /* interface_addr, network order (0 если нет) */
uint32_t public_ip4; /* nat_addr / interface если direct, network order (0 если нет) */
uint32_t public_ip4; /* nat_addr / interface если public, network order (0 если нет) */
};
/* Роль нашего сокета по конфиг-типу, NAT-типу и доступности интернета. */
uint8_t sock_role_of(uint8_t type, uint8_t nat_type, int ntp_reachable);
/* Класс адреса пира по типу адреса (TOPO_ADDR_*), конфиг-типу и NAT-типу сокета. */
uint8_t sock_target_class(uint8_t a_type, uint8_t tcfg, uint8_t tnat);
+47 -3
View File
@@ -23,6 +23,7 @@
#include "route_connectivity.h"
#include "control_server.h"
#include "conn_mgr.h"
#include "../transport_layer/node_conn_direct.h"
#include "topo_group_connect.h"
#include "topo_recovery.h"
#include "broadcast.h"
@@ -324,7 +325,11 @@ static void topo_group_destroy(struct TOPO_GROUP* group) {
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[GRP_DESTROY] 2b broadcast_destroy done");
struct ll_entry* e;
while ((e = queue_data_get(group->senders_list)) != NULL) queue_entry_free(e);
while ((e = queue_data_get(group->senders_list)) != NULL) {
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)e;
if (item->handle) { node_conn_direct_close(item->handle); item->handle = NULL; }
queue_entry_free(e);
}
queue_free(group->senders_list);
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[GRP_DESTROY] 3 senders_list done");
@@ -597,6 +602,24 @@ void topo_group_new_conn(struct TOPO_GROUP* group, struct ETCP_CONN* conn) {
}
topo_group_add_to_senders(group, conn);
/* BGP — владелец conn: держим NCD-handle (если ещё нет), чтобы соединение
* не рвалось другими владельцами (conn_mgr) при их закрытии. cb=NULL —
* события BGP уже получает через conn-status callback. */
{ 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->conn == conn) {
if (item->handle == NULL) {
node_conn_direct_open(conn->instance, conn->peer_node_id, NULL, NULL, &item->handle, NULL);
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "topo_group_new_conn: BGP handle acquired node=0x%016llx h=%p",
(unsigned long long)conn->peer_node_id, (void*)item->handle);
}
break;
}
se = se->next;
}
}
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "topo_group_new_conn: peer=%016llx group=%016llx type=%d ch=%s", (unsigned long long)conn->peer_node_id, (unsigned long long)group->group_id, group->group_type, group->channel_id);
topo_group_send_table_request(group, conn);
@@ -658,8 +681,24 @@ void topo_group_remove_conn(struct TOPO_GROUP* group, struct ETCP_CONN* conn) {
node_entry = next;
}
/* освобождаем владение BGP: сначала убираем item из senders_list (чтобы
ре-ентрантный remove_conn от conn DOWN не нашёл его), затем закрываем handle. */
e = group->senders_list->head;
while (e) { struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)e->data; if (item->conn == conn) { queue_remove_data(group->senders_list, e); queue_entry_free(e); break; } e = e->next; }
while (e) {
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)e->data;
if (item->conn == conn) {
struct NODE_CONN_DIRECT* h = item->handle;
item->handle = NULL;
queue_remove_data(group->senders_list, e);
queue_entry_free(e);
if (h) {
node_conn_direct_close(h);
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "topo_group_remove_conn: BGP handle released node=0x%016llx", (unsigned long long)conn->peer_node_id);
}
break;
}
e = e->next;
}
if (cascaded > 0) topo_recovery_start(group);
@@ -1019,7 +1058,12 @@ static void topo_group_add_to_senders(struct TOPO_GROUP* group, struct ETCP_CONN
while (e) { if (((struct TOPO_GROUP_CONN_ITEM*)e->data)->conn == conn) { already = true; break; } e = e->next; }
if (!already) {
struct ll_entry* item_entry = queue_entry_new(sizeof(struct TOPO_GROUP_CONN_ITEM));
if (item_entry) { ((struct TOPO_GROUP_CONN_ITEM*)item_entry->data)->conn = conn; queue_data_put(group->senders_list, item_entry); }
if (item_entry) {
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)item_entry->data;
item->conn = conn;
item->handle = NULL;
queue_data_put(group->senders_list, item_entry);
}
}
}
+1
View File
@@ -164,6 +164,7 @@ struct TOPOMSG_ERR_GROUP_MISMATCH {
struct TOPO_GROUP_CONN_ITEM {
struct ll_entry ll;
struct ETCP_CONN* conn;
struct NODE_CONN_DIRECT* handle; // владение BGP этим conn (NCD-handle)
};
struct ETCP_LINK;
+15 -5
View File
@@ -275,6 +275,18 @@ void topo_group_connect_on_down(struct TOPO_GROUP* group, struct ETCP_CONN* conn
* В Phase 2/3 — успех завершает цикл; провал закрывает handle (чтобы не оставить
* залипшее состояние) и ставит паузу перед следующей попыткой.
*/
/* Передача владения: после topo_group_new_conn (который взял свой NCD-handle)
* закрываем connect-фазу handle, чтобы владельцем conn остался один BGP-хэндл
* (в senders_list), без двойного счёта. */
static void tgc_release_handle(struct TOPO_GROUP_CONNECT* gc, struct NODE_CONN_DIRECT* h) {
if (!gc || !h) return;
for (int i = 0; i < gc->handle_count; i++) {
if (gc->handles[i] == h) { gc->handles[i] = NULL; node_conn_direct_close(h); return; }
}
if (gc->cur_handle == h) { gc->cur_handle = NULL; node_conn_direct_close(h); return; }
node_conn_direct_close(h);
}
static void tgc_callback(struct NODE_CONN_DIRECT* h, enum ncd_event event, void* arg) {
struct TOPO_GROUP_CONNECT* gc = (struct TOPO_GROUP_CONNECT*)arg;
struct ETCP_CONN* conn = node_conn_direct_get_conn(h);
@@ -283,10 +295,9 @@ static void tgc_callback(struct NODE_CONN_DIRECT* h, enum ncd_event event, void*
int ok = (event == NCD_EVENT_UP);
if (ok) {
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(gc->group, node_id);
if (nq) nq->handle = h;
/* запуск BGP: узел соединён с группой */
/* запуск BGP: узел соединён с группой (topo_group_new_conn берёт владение NCD-handle) */
if (conn) topo_group_new_conn(gc->group, conn);
tgc_release_handle(gc, h);
} else if (event == NCD_EVENT_TIMEOUT) {
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(gc->group, node_id);
if (nq) nq->handle = NULL;
@@ -540,10 +551,9 @@ static void tgc_once_cb(struct NODE_CONN_DIRECT* h, enum ncd_event ev, void* arg
struct ETCP_CONN* conn = node_conn_direct_get_conn(h);
if (ev == NCD_EVENT_UP && conn) {
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(c->group, c->node_id);
if (nq) nq->handle = h;
node_conn_direct_set_callback(h, NULL, NULL);
topo_group_new_conn(c->group, conn);
node_conn_direct_close(h); /* владение перешло к topo_group_new_conn */
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: once-connect UP node=0x%016llx ch=%s",
TGC_ID, (unsigned long long)c->node_id, c->group->channel_id);
} else if (ev == NCD_EVENT_TIMEOUT) {
+11 -9
View File
@@ -343,15 +343,16 @@ static void tgi_handle_info_resp(struct ETCP_CONN* conn, const uint8_t* data, si
topo_group_new_conn(bgp_grp, conn);
}
/* ── успех: переводим ncd-handle во владение узла, доставляем JOIN ── */
/* ── успех: владение conn перешло к topo_group_new_conn (item->handle),
закрываем connect-фазу handle и доставляем JOIN ── */
{
uint64_t node_id = inv->node_id, group_id = inv->group_id;
tgi_cb_t cb = inv->cb; void* cb_arg = inv->cb_arg;
if (inv->ncd_handle) node_conn_direct_set_callback(inv->ncd_handle, NULL, NULL);
struct TOPO_GROUP* g = topo_groups_find(inv->inst->topo_groups, group_id);
struct TOPO_GROUP_NODE* nq = g ? topo_node_find_by_id(g, node_id) : NULL;
if (nq) nq->handle = inv->ncd_handle;
inv->ncd_handle = NULL; /* не закрываем — соединение переходит узлу */
if (inv->ncd_handle) {
node_conn_direct_set_callback(inv->ncd_handle, NULL, NULL);
node_conn_direct_close(inv->ncd_handle);
inv->ncd_handle = NULL;
}
inv->temp_nq = NULL; /* не удаляем узел из группы */
tgi_cancel(inv);
if (cb) cb(node_id, group_id, TGI_EVENT_JOIN, cb_arg);
@@ -511,10 +512,11 @@ struct tgi_conn_ctx { struct UTUN_INSTANCE* inst; uint64_t node_id, group_id; };
static void tgi_to_channel_cb(struct NODE_CONN_DIRECT* h, enum ncd_event ev, void* arg) {
struct tgi_conn_ctx* c = (struct tgi_conn_ctx*)arg;
if (ev == NCD_EVENT_UP) {
struct ETCP_CONN* conn = node_conn_direct_get_conn(h);
struct TOPO_GROUP* g = topo_groups_find(c->inst->topo_groups, c->group_id);
struct TOPO_GROUP_NODE* nq = g ? topo_node_find_by_id(g, c->node_id) : NULL;
if (nq) nq->handle = h;
node_conn_direct_set_callback(h, NULL, NULL);
/* владение переходит к topo_group_new_conn (item->handle), connect-фазу закрываем */
if (g && conn) topo_group_new_conn(g, conn);
node_conn_direct_close(h);
u_free(c);
} else if (ev == NCD_EVENT_TIMEOUT) {
node_conn_direct_force_close(h);
+3 -3
View File
@@ -68,9 +68,9 @@ static void topo_recovery_callback(struct NODE_CONN_DIRECT* h, enum ncd_event ev
ctx->current_node_id = 0;
ctx->current_handle = NULL;
if (event == NCD_EVENT_UP) {
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(ctx->group, node_id);
if (nq) nq->handle = h;
node_conn_direct_set_callback(h, NULL, NULL);
/* владение переходит к topo_group_new_conn (item->handle), connect-фазу закрываем */
if (conn) topo_group_new_conn(ctx->group, conn);
node_conn_direct_close(h);
DEBUG_INFO(DEBUG_CATEGORY_BGP, "recovery: next=%016llx reconnected to %016llx", (unsigned long long)ctx->next_hop_id, (unsigned long long)node_id);
} else {
node_conn_direct_close(h);
+18 -19
View File
@@ -87,23 +87,6 @@
#define AS_PROTO_UDP 0
#define AS_PROTO_TCP 1
/* Классифицирует IPv4-адрес: 1 = глобальный/публичный (можно использовать как PUBLIC-сокет),
* 0 = приватный, CGNAT, loopback, link-local, multicast или зарезервированный.
* Определяет тип создаваемого сокета по адресам на интерфейсе. */
static int is_ipv4_public(uint32_t addr_be) {
uint32_t a = ntohl(addr_be);
if ((a & 0xFF000000u) == 0x0A000000u) return 0; /* 10.0.0.0/8 */
if ((a & 0xFFF00000u) == 0xAC100000u) return 0; /* 172.16.0.0/12 */
if ((a & 0xFFFF0000u) == 0xC0A80000u) return 0; /* 192.168.0.0/16 */
if ((a & 0xFF000000u) == 0x7F000000u) return 0; /* 127.0.0.0/8 */
if ((a & 0xFFFF0000u) == 0xA9FE0000u) return 0; /* 169.254.0.0/16 */
if ((a & 0xFF000000u) == 0x00000000u) return 0; /* 0.0.0.0/8 */
if ((a & 0xF0000000u) == 0xE0000000u) return 0; /* 224.0.0.0/4 */
if ((a & 0xF0000000u) == 0xF0000000u) return 0; /* 240.0.0.0/4 */
if ((a & 0xFFC00000u) == 0x64400000u) return 0; /* 100.64.0.0/10 (CGNAT) */
return 1;
}
/* Классифицирует IPv6-адрес для определения типа сокета:
* AS_V6_LL = link-local (fe80::) — создаём NAT-сокет
* AS_V6_LOC = ULA (fc00::/fd00::) — локальный, NAT-сокет
@@ -648,7 +631,7 @@ static int scan_and_classify_iface(uint32_t ifindex, const char* ifname,
struct sockaddr_in* sin = (struct sockaddr_in*)ifa->ifa_addr;
if (sin->sin_addr.s_addr == INADDR_ANY) continue;
*out_has_v4 = 1;
if (is_ipv4_public(sin->sin_addr.s_addr))
if (ip_is_public(sin->sin_addr.s_addr))
*out_v4_type = CFG_SERVER_TYPE_PUBLIC;
} else if (ifa->ifa_addr->sa_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)ifa->ifa_addr;
@@ -754,7 +737,7 @@ static int scan_and_classify_iface(uint32_t ifindex, const char* ifname,
const struct sockaddr_in* sin = (const struct sockaddr_in*)u->Address.lpSockaddr;
if (sin->sin_addr.s_addr == INADDR_ANY) continue;
*out_has_v4 = 1;
if (is_ipv4_public(sin->sin_addr.s_addr)) *out_v4_type = CFG_SERVER_TYPE_PUBLIC;
if (ip_is_public(sin->sin_addr.s_addr)) *out_v4_type = CFG_SERVER_TYPE_PUBLIC;
} else if (u->Address.lpSockaddr->sa_family == AF_INET6) {
const struct sockaddr_in6* sin6 = (const struct sockaddr_in6*)u->Address.lpSockaddr;
int cls = v6_classify(sin6->sin6_addr.s6_addr);
@@ -1437,6 +1420,22 @@ static void auto_socket_reconcile_config(struct AUTO_SOCKET* as) {
struct UTUN_INSTANCE* inst = as->instance;
if (!inst || !inst->config) return;
/* Тип сокета определяем по фактическому адресу (не доверяем type из конфига):
* публичный адрес → PUBLIC, приватный/link-local → NAT. */
for (struct CFG_SERVER* s = inst->config->servers; s; s = s->next) {
uint8_t by_addr = CFG_SERVER_TYPE_NAT;
if (s->ip.ss_family == AF_INET) {
if (ip_is_public(((const struct sockaddr_in*)&s->ip)->sin_addr.s_addr))
by_addr = CFG_SERVER_TYPE_PUBLIC;
} else if (s->ip.ss_family == AF_INET6) {
if (v6_classify(((const struct sockaddr_in6*)&s->ip)->sin6_addr.s6_addr) == AS_V6_DIR)
by_addr = CFG_SERVER_TYPE_PUBLIC;
}
if (s->type != by_addr)
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] server %s type %d → %d (by address)", s->name, s->type, by_addr);
s->type = by_addr;
}
/* Проход 1: удалить сокеты, которых нет в новом списке, либо которые изменились. */
struct ETCP_SOCKET* sock = inst->etcp_sockets;
while (sock) {
+1
View File
@@ -407,6 +407,7 @@ void etcp_connection_close(struct ETCP_CONN* etcp) {
routing_del_conn(etcp);
etcp_router_transit_queues_destroy(etcp);
etcp_router_conn_destroyed(etcp);
if (etcp->normalizer) { pn_deinit((struct PKTNORM*)etcp->normalizer); etcp->normalizer = NULL; etcp->send_input_q = NULL; }
+1
View File
@@ -49,6 +49,7 @@ extern "C" {
#define ETCP_RT_ID_ADMIN_KEY 0x32 // chat_admin — передача канального приватного ключа (encrypted+signed)
#define ETCP_RT_ID_DM 0x34 // dm_core — прямой p2p чат (msg/ack/hello)
#define ETCP_RT_ID_DM_MAILBOX 0x35 // dm_mailbox — offline-хранение на storage-узлах (put/pull/ack)
#define ETCP_RT_ID_CALL 0x36 // call — P2P аудио-звонок (сигналинг + медиа)
// Connection status events (instance-level callback)
#define ETCP_CONN_STATUS_NEW 0 // соединение создано
+30 -10
View File
@@ -54,6 +54,7 @@ struct NODE_CONN_DIRECT {
ncd_callback cb;
void* cb_arg;
struct NODE_CONN_DIRECT* next;
void* deliver_up_token; /* handle uasync_call_soon отложенной доставки UP (для отмены при close) */
};
/* ─── forward declarations ─── */
@@ -379,9 +380,21 @@ static void ncd_connect_timeout_cb(void* arg) {
static void ncd_deliver_up_cb(void* arg) {
struct NODE_CONN_DIRECT* h = (struct NODE_CONN_DIRECT*)arg;
h->deliver_up_token = NULL; /* токен consumed — close больше не отменит освобождённый узел */
if (h->cb) h->cb(h, NCD_EVENT_UP, h->cb_arg);
}
/* Планирует отложенную доставку UP и сохраняет токен для возможной отмены при close. */
static void ncd_schedule_deliver_up(struct UASYNC* ua, struct NODE_CONN_DIRECT* h) {
h->deliver_up_token = uasync_call_soon(ua, h, ncd_deliver_up_cb);
}
/* Отменяет pending-доставку UP (если ещё не сработала). Без этого h освобождается,
* а отложенный ncd_deliver_up_cb читает freed-память — UAF. */
static void ncd_cancel_deliver_up(struct NODE_CONN_DIRECT* h, struct UASYNC* ua) {
if (h->deliver_up_token) { uasync_call_soon_cancel(ua, h->deliver_up_token); h->deliver_up_token = NULL; }
}
/* ═══════════ FIN_WAIT ═══════════ */
/*
* fin_wait — состояние ожидания подтверждения закрытия от пира.
@@ -573,7 +586,7 @@ int node_conn_direct_open(struct UTUN_INSTANCE* inst, uint64_t node_id,
entry->handle_count++;
*out_handle = h;
if (entry->conn && entry->conn->state == 1 && entry->up) {
uasync_call_soon(inst->ua, h, ncd_deliver_up_cb);
ncd_schedule_deliver_up(inst->ua, h);
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open REUSED (ready) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count);
} else if (entry->timed_out) {
struct TOPO_NODE* ni = ncd_lookup_node(inst, node_id);
@@ -613,7 +626,7 @@ int node_conn_direct_open(struct UTUN_INSTANCE* inst, uint64_t node_id,
etcp_conn_add_cbk(conn, ncd_down_cb, entry, ETCP_CBK_EVENT_DOWN);
if (conn->state == 1 && entry->up) {
uasync_call_soon(inst->ua, h, ncd_deliver_up_cb);
ncd_schedule_deliver_up(inst->ua, h);
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open REUSED new-entry (ready) node=0x%016llx conn=%p", (unsigned long long)node_id, (void*)conn);
} else {
{ struct TOPO_NODE* ni = ncd_lookup_node(inst, node_id);
@@ -732,7 +745,7 @@ int node_conn_direct_open_node(struct UTUN_INSTANCE* inst, uint64_t node_id,
entry->handle_count++;
*out_handle = h;
if (entry->conn && entry->conn->state == 1 && entry->up) {
uasync_call_soon(inst->ua, h, ncd_deliver_up_cb);
ncd_schedule_deliver_up(inst->ua, h);
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open_node REUSED (ready) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count);
} else if (entry->timed_out) {
ncd_revive_entry(entry, inst, ni, specific_sock);
@@ -769,7 +782,7 @@ int node_conn_direct_open_node(struct UTUN_INSTANCE* inst, uint64_t node_id,
etcp_conn_add_cbk(conn, ncd_down_cb, entry, ETCP_CBK_EVENT_DOWN);
if (conn->state == 1 && entry->up) {
uasync_call_soon(inst->ua, h, ncd_deliver_up_cb);
ncd_schedule_deliver_up(inst->ua, h);
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open_node REUSED new-entry (ready) node=0x%016llx conn=%p", (unsigned long long)node_id, (void*)conn);
} else {
ncd_create_links(entry, ni, specific_sock);
@@ -863,22 +876,28 @@ void node_conn_direct_close(struct NODE_CONN_DIRECT* h) {
if (!h) return;
struct ncd_entry* entry = h->entry;
if (!entry) { u_free(h); return; }
ncd_cancel_deliver_up(h, entry->ua);
uint64_t node_id = entry->node_id;
struct ETCP_CONN* conn = entry->conn;
/* Удаляем handle из списка */
struct NODE_CONN_DIRECT** pp = &entry->handles;
while (*pp) { if (*pp == h) { *pp = h->next; break; } pp = &(*pp)->next; }
int found = 0;
while (*pp) { if (*pp == h) { *pp = h->next; found = 1; break; } pp = &(*pp)->next; }
if (!found) {
DEBUG_ERROR(DEBUG_CATEGORY_NCD, "[ncd] close: h=%p NOT in handles list node=0x%016llx (double-close?)",
(void*)h, (unsigned long long)node_id);
}
entry->handle_count--;
if (entry->handle_count < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_NCD, "[ncd] FATAL: handle_count=%d < 0 node=0x%016llx",
entry->handle_count, (unsigned long long)node_id);
DEBUG_ERROR(DEBUG_CATEGORY_NCD, "[ncd] FATAL: handle_count=%d < 0 h=%p node=0x%016llx",
entry->handle_count, (void*)h, (unsigned long long)node_id);
h->entry = NULL; u_free(h); return;
}
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] close node=0x%016llx remaining=%d", (unsigned long long)node_id, entry->handle_count);
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] close h=%p node=0x%016llx remaining=%d", (void*)h, (unsigned long long)node_id, entry->handle_count);
if (entry->handle_count == 0) {
if (entry->handles) {
@@ -948,6 +967,7 @@ void node_conn_direct_force_close(struct NODE_CONN_DIRECT* h) {
if (!h) return;
if (!h->entry) { DEBUG_ERROR(DEBUG_CATEGORY_NCD, "[ncd] force_close: h=%p h->entry=NULL", h); u_free(h); return; }
struct ncd_entry* entry = h->entry;
ncd_cancel_deliver_up(h, entry->ua);
DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] force_close h=%p entry=%p node=0x%016llx handles=%d",
h, entry, (unsigned long long)entry->node_id, entry->handle_count);
@@ -959,8 +979,8 @@ void node_conn_direct_force_close(struct NODE_CONN_DIRECT* h) {
entry->handle_count--;
if (entry->handle_count < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_NCD, "[ncd] FATAL: force_close handle_count=%d < 0 node=0x%016llx",
entry->handle_count, (unsigned long long)node_id);
DEBUG_ERROR(DEBUG_CATEGORY_NCD, "[ncd] FATAL: force_close handle_count=%d < 0 h=%p node=0x%016llx",
entry->handle_count, (void*)h, (unsigned long long)node_id);
h->entry = NULL; u_free(h); return;
}
+1 -1
View File
@@ -451,7 +451,7 @@ ssize_t tun_read_packet(struct tun_if* tun, uint8_t* buf, size_t len)
tun->bytes_written += ret;
tun->packets_written++;
if (debug_should_output(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_TUN)) {
DEBUG_DEBUG(DEBUG_CATEGORY_TUN, "[TUN->] %s", dump_ip_packet_to_buffer(buf + 1, pkt->len));
DEBUG_DEBUG(DEBUG_CATEGORY_TUN, "[TUN->] %s", dump_ip_packet_to_buffer(buf + 1, ret - 1));
}
return ret;
}
+27 -3
View File
@@ -20,6 +20,8 @@
#include "chat/chat_headless_control.h"
#include "dm/dm_core.h"
#include "dm/dm_mailbox.h"
#include "call/call.h"
#include "call/call_headless.h"
#include "broadcast.h"
#include "stcp_server.h"
#include "control_server.h"
@@ -462,6 +464,12 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
broadcast_destroy_instance(instance);
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] F done — BGP + broadcast");
/* Phase F2: NAT transport — отвязываем и закрываем nat_tun ДО разборки
сокетов/соединений, чтобы nat_transport_etcp_recv_cb не был вызван с
уже освобождённым буфером пакета (use-after-free при teardown). */
if (instance->nat_tr.initialized) nat_transport_destroy(instance);
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] F2 done — NAT transport");
/* Phase G: ETCP sockets */
{
struct ETCP_SOCKET* sock = instance->etcp_sockets;
@@ -484,6 +492,8 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
chat_media_startup_backfill_destroy(instance);
dm_mailbox_destroy(instance);
dm_core_destroy(instance);
call_headless_destroy(instance);
call_destroy(instance);
chat_sync_destroy(instance);
chat_core_destroy(instance);
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] H done — chat");
@@ -542,7 +552,7 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
}
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] L done — deferred+pings");
/* Phase M: TUN + proxy + routing + NAT */
/* Phase M: TUN + proxy + routing */
if (instance->tun) {
if (instance->tun->ifindex && instance->route_subnets)
tun_route_del_all(instance->tun->ifindex, instance->tun->ifname, instance->route_subnets);
@@ -556,8 +566,7 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
routing_destroy(instance);
if (instance->md.initialized) media_delivery_destroy(instance);
media_async_destroy(instance->media_async); instance->media_async = NULL;
if (instance->nat_tr.initialized) nat_transport_destroy(instance);
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] M done — TUN/proxy/routing/NAT");
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] M done — TUN/proxy/routing");
/* Phase N: etcp_router + nat_det + firewall */
etcp_router_destroy(instance);
@@ -681,6 +690,7 @@ int utun_instance_init(struct UTUN_INSTANCE *instance) {
/* dm_mailbox до dm_core: dm_core_init ставит deliver-cb в mailbox */
dm_mailbox_init(instance);
dm_core_init(instance);
call_init(instance);
}
}
@@ -753,6 +763,20 @@ int utun_instance_init(struct UTUN_INSTANCE *instance) {
if (chat_headless_control_init(instance->ua, instance, ip_str, port) != 0)
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to init headless chat control (non-fatal)");
}
// Initialize headless call audio socket if configured in [chatserver]
if (instance->config->global.call_audio_bind[0] != '\0') {
char ip_str[64] = "127.0.0.1"; int port = 10001;
const char* colon = strrchr(instance->config->global.call_audio_bind, ':');
if (colon) {
size_t ip_len = (size_t)(colon - instance->config->global.call_audio_bind);
if (ip_len < sizeof(ip_str)) { memcpy(ip_str, instance->config->global.call_audio_bind, ip_len); ip_str[ip_len] = '\0'; }
port = atoi(colon + 1);
if (port <= 0 || port > 65535) port = 10001;
}
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)");
}
// Start the main loop
instance->running = 1;
+3
View File
@@ -39,6 +39,7 @@ struct chat_sync;
struct join_key_entry; /* chat_join.c */
struct dm_state;
struct dm_mb_state;
struct call_ctx; /* call/call.c */
struct uasync_s;
struct ROUTE_TABLE;
struct ETCP_CONN;
@@ -199,6 +200,8 @@ struct UTUN_INSTANCE {
struct join_key_entry* join_keys; // chat_join.c (was g_keys; собственный init/destroy)
struct dm_state* dm; // dm/dm_core.c (was g_dm)
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 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)
+11 -1
View File
@@ -63,6 +63,8 @@ check_PROGRAMS = \
test_chat_join \
test_chat_join_e2e \
test_dm_e2e \
test_call \
test_call_headless \
test_stcp_traffic \
test_bbr_integration \
test_audio_compressor \
@@ -113,7 +115,7 @@ endif
CRYPTO_LIBS = -lcrypto
# Common libraries (lib/dns.c собирается с -DDNS_RANDOM=RAND_bytes → нужен -lcrypto)
COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@
COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@ $(top_builddir)/lib/libopus/libopus_internal.a -lm
# Test definitions
test_etcp_bbr_SOURCES = test_etcp_bbr.c
@@ -387,6 +389,14 @@ test_dm_e2e_SOURCES = test_dm_e2e.c
test_dm_e2e_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/src/dm -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib
test_dm_e2e_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_call_SOURCES = test_call.c
test_call_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_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
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_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)
+465
View File
@@ -0,0 +1,465 @@
// test_call.c — интеграционный тест P2P аудио-звонка (сигналинг).
//
// Однопоточный: 2 узла (A=caller, B=callee) в одном процессе, один UASYNC.
// Прямое соединение A←B, оба в CHAT-группе. Master state-machine на таймере.
//
// Сценарий:
// 1. happy path: A call_start → B INCOMING → B accept → обе ACTIVE (ACCEPTED)
// → A hangup → обе ENDED.
// 2. decline: A call_start → B INCOMING → B decline → обе ENDED(DECLINE).
#include "chat_core.h"
#include "chat_sync.h"
#include "chat_event.h"
#include "member_sync.h"
#include "../call/call.h"
#include "../call/call_proto.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 TICK_TB 100 /* 10 ms на такт */
#define MAX_TICKS 3000 /* глобальный таймаут ≈ 30 c */
enum call_phase {
P_WAIT_CONN,
P_SETUP,
P_WAIT_BGP,
P_CALL1_START, /* A: call_start */
P_WAIT_INCOMING1, /* B: INCOMING */
P_ACCEPT, /* B: call_accept */
P_WAIT_ACCEPTED, /* обе ACTIVE */
P_SEND_MEDIA, /* A: call_send_media (тестовый Opus-кадр) */
P_WAIT_MEDIA, /* B: media_recv_cb получил кадр */
P_HANGUP, /* A: call_hangup */
P_WAIT_ENDED1, /* обе ENDED */
P_CALL2_START, /* A: call_start (2-й звонок) */
P_WAIT_INCOMING2, /* B: INCOMING */
P_DECLINE, /* B: call_decline */
P_WAIT_ENDED2, /* обе ENDED + A DECLINED */
P_DONE,
};
struct call_ctx {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst[2];
enum call_phase phase;
int result; /* 0=running, 1=ok, 2=fail */
int ticks;
void* timer;
uint64_t call_id; /* текущий call_id */
};
/* записи событий CHAT_EVT_CALL_* на узел */
struct call_evt {
int incoming, ringing, accepted, ended, declined;
uint64_t incoming_call_id;
uint64_t accepted_call_id;
uint64_t ended_call_id;
uint8_t ended_reason;
};
static struct call_evt g_ev[2]; /* [A], [B] */
/* записи принятых медиа-кадров (media_recv_cb) */
struct media_evt {
int frames;
uint64_t last_call_id;
int last_len;
uint8_t last[64];
};
static struct media_evt g_media[2];
static void media_recv_cb(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg) {
(void)inst;
struct media_evt* m = (struct media_evt*)arg;
if (!m) return;
m->frames++;
m->last_call_id = call_id;
m->last_len = len;
if (len > 0) memcpy(m->last, opus, (size_t)(len < 64 ? len : 64));
}
struct call_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 call_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", "call-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 < 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) {
/* B → A (клиент к 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]);
}
wf(path,
"[global]\ntun_ip=10.97.%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 void call_evt_handler(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
struct call_evt* ev = (struct call_evt*)inst->test_user_ptr;
if (!ev) return;
switch (type) {
case CHAT_EVT_CALL_INCOMING: {
if (len < 1 + 8 + 8) return;
int ch_len = data[0];
if (len < 1 + ch_len + 8 + 8) return;
ev->incoming = 1;
memcpy(&ev->incoming_call_id, data + 1 + ch_len + 8, 8);
break;
}
case CHAT_EVT_CALL_RINGING:
ev->ringing = 1;
break;
case CHAT_EVT_CALL_ACCEPTED:
ev->accepted = 1;
if (len >= 8) memcpy(&ev->accepted_call_id, data, 8);
break;
case CHAT_EVT_CALL_ENDED:
ev->ended = 1;
if (len >= 9) { memcpy(&ev->ended_call_id, data, 8); ev->ended_reason = data[8]; }
break;
case CHAT_EVT_CALL_DECLINED:
ev->declined = 1;
break;
default:
break;
}
}
/* ── проверки ── */
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 void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) {
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "call-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_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;
}
}
/* ── master state-machine ── */
static void call_tick(void* arg) {
struct call_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];
struct call_evt* ea = &g_ev[IDX_A];
struct call_evt* eb = &g_ev[IDX_B];
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);
t->phase = P_WAIT_BGP;
break;
case P_WAIT_BGP:
if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CALL1_START;
break;
case P_CALL1_START:
memset(ea, 0, sizeof(*ea));
memset(eb, 0, sizeof(*eb));
t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF);
call_start(A, CH_ID, nb, t->call_id);
t->phase = P_WAIT_INCOMING1;
break;
case P_WAIT_INCOMING1:
if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_ACCEPT;
break;
case P_ACCEPT:
call_accept(B, t->call_id);
t->phase = P_WAIT_ACCEPTED;
break;
case P_WAIT_ACCEPTED:
if (ea->accepted && eb->accepted && ea->accepted_call_id == t->call_id) t->phase = P_SEND_MEDIA;
break;
case P_SEND_MEDIA: {
/* тестовый «Opus-кадр» — релей произвольных байт, не валидируем кодек */
static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d };
if (call_send_media(A, t->call_id, opus, sizeof(opus)) != 0) {
fprintf(stderr, "A: call_send_media failed\n");
t->result = 2;
} else {
t->phase = P_WAIT_MEDIA;
}
break;
}
case P_WAIT_MEDIA:
if (g_media[IDX_B].frames > 0 && g_media[IDX_B].last_call_id == t->call_id
&& g_media[IDX_B].last_len == 16) t->phase = P_HANGUP;
break;
case P_HANGUP:
call_hangup(A, t->call_id);
t->phase = P_WAIT_ENDED1;
break;
case P_WAIT_ENDED1:
if (ea->ended && eb->ended && ea->ended_call_id == t->call_id) t->phase = P_CALL2_START;
break;
case P_CALL2_START:
memset(ea, 0, sizeof(*ea));
memset(eb, 0, sizeof(*eb));
t->call_id = ((uint64_t)2 << 40) | (uint64_t)(rand() & 0xFFFFF);
call_start(A, CH_ID, nb, t->call_id);
t->phase = P_WAIT_INCOMING2;
break;
case P_WAIT_INCOMING2:
if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_DECLINE;
break;
case P_DECLINE:
call_decline(B, t->call_id);
t->phase = P_WAIT_ENDED2;
break;
case P_WAIT_ENDED2:
if (ea->ended && eb->ended && ea->declined) 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 evA:inc=%d acc=%d end=%d dec=%d evB:inc=%d acc=%d end=%d dec=%d)\n",
(int)t->phase, conn_up(A, nb), conn_up(B, na),
ea->incoming, ea->accepted, ea->ended, ea->declined,
eb->incoming, eb->accepted, eb->ended, eb->declined);
t->result = 2;
uasync_stop(t->ua);
return;
}
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, call_tick, "call_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("call", "trace");
debug_set_category_level_by_name("bgp", "info");
debug_set_category_level_by_name("etcp_route", "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_call_XXXXXX");
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
setenv("UTUN_TEST_DIR", dir, 1);
fill_shared(56000 + (getpid() % 2000));
write_configs(dir);
printf("=== test_call ===\n"); fflush(stdout);
TEST("single-thread: call happy path + decline (2 nodes)"); {
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; }
struct call_ctx t;
memset(&t, 0, sizeof(t));
t.ua = ua;
t.phase = P_WAIT_CONN;
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.inst[IDX_A]->test_user_ptr = &g_ev[IDX_A];
t.inst[IDX_B]->test_user_ptr = &g_ev[IDX_B];
chat_event_set_handler(t.inst[IDX_A], call_evt_handler);
chat_event_set_handler(t.inst[IDX_B], call_evt_handler);
call_set_media_recv_cb(t.inst[IDX_A], media_recv_cb, &g_media[IDX_A]);
call_set_media_recv_cb(t.inst[IDX_B], media_recv_cb, &g_media[IDX_B]);
t.timer = uasync_set_timeout(ua, TICK_TB, &t, call_tick, "call_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]);
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;
}
+513
View File
@@ -0,0 +1,513 @@
// test_call_headless.c — полноценный e2e-тест звонка через headless API.
//
// Два узла (A=caller, B=callee) в одном процессе, один UASYNC. Тест выступает
// «headless-клиентом» обоих узлов: подключается к control-сокетам (JSON-команды
// и события) и аудио-сокетам (бинарные Opus-фреймы) каждого узла.
//
// Сценарий (через реальные TCP-сокеты):
// A: call_start → B: call_incoming → B: call_accept → обе: call_accepted
// A: audio HELLO+FRAME → B: audio FRAME (релей медиа)
// A: call_hangup → обе: call_ended
#include "chat_core.h"
#include "chat_sync.h"
#include "chat_event.h"
#include "member_sync.h"
#include "chat_headless_control.h"
#include "../call/call.h"
#include "../call/call_headless.h"
#include "../call/call_proto.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 hs_phase {
P_WAIT_CONN,
P_SETUP, /* канал/мемберы + headless control/audio на A и B */
P_WAIT_BGP,
P_CONNECT_CLIENTS, /* подключить 4 клиентских сокета */
P_SUBSCRIBE, /* subscribe на обоих control */
P_CALL_START, /* A: call_start */
P_WAIT_INCOMING, /* B: call_incoming */
P_ACCEPT, /* B: call_accept */
P_WAIT_ACCEPTED, /* обе: call_accepted */
P_BIND_AUDIO, /* HELLO на обоих audio */
P_SEND_MEDIA, /* A: audio FRAME */
P_WAIT_MEDIA, /* B: audio FRAME получен */
P_HANGUP, /* A: call_hangup */
P_WAIT_ENDED, /* обе: call_ended */
P_DONE,
};
/* клиентский сокет (к control или audio узла) */
struct hs_conn {
socket_t fd;
void* socket_id;
char buf[16384];
int len;
};
struct hs_ctx {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst[2];
enum hs_phase phase;
int result;
int ticks;
void* timer;
uint64_t call_id;
struct hs_conn ctl[2]; /* control-клиенты A, B */
struct hs_conn aud[2]; /* audio-клиенты A, B */
};
struct hs_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]; /* etcp-порты */
};
static struct hs_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-call") + 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.97.%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 void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) {
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "hl-call", 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 hs_recv_cb(socket_t fd, void* arg) {
struct hs_conn* c = (struct hs_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;
c->buf[c->len] = '\0';
}
}
static int hs_connect(struct hs_ctx* t, struct hs_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, hs_recv_cb, NULL, NULL, c);
if (!c->socket_id) { socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; }
return 0;
}
static void hs_send(struct hs_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 hs_has(struct hs_conn* c, const char* substr) {
return c && strstr(c->buf, substr) != NULL;
}
/* вытащить call_id из JSON-события (формат "call_id":"0x...") */
static int hs_extract_call_id(struct hs_conn* c, const char* evname, uint64_t* out) {
if (!c) return -1;
const char* p = strstr(c->buf, evname);
if (!p) return -1;
p = strstr(p, "\"call_id\"");
if (!p) return -1;
p = strstr(p, "0x");
if (!p) return -1;
*out = strtoull(p, NULL, 16);
return 0;
}
/* ── master state-machine ── */
static void hs_tick(void* arg) {
struct hs_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];
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 (call_headless_init(A->ua, A, "127.0.0.1", 9710 + (int)(getpid() % 100)) != 0
|| call_headless_init(B->ua, B, "127.0.0.1", 9810 + (int)(getpid() % 100)) != 0) {
fprintf(stderr, "call 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 (hs_connect(t, &t->ctl[IDX_A], 9700 + (int)(getpid() % 100)) != 0
|| hs_connect(t, &t->ctl[IDX_B], 9800 + (int)(getpid() % 100)) != 0
|| hs_connect(t, &t->aud[IDX_A], 9710 + (int)(getpid() % 100)) != 0
|| hs_connect(t, &t->aud[IDX_B], 9810 + (int)(getpid() % 100)) != 0) {
fprintf(stderr, "client connect failed\n"); t->result = 2; break;
}
t->phase = P_SUBSCRIBE;
break;
case P_SUBSCRIBE: {
const char* sub = "{\"id\":1,\"cmd\":\"subscribe\",\"enable\":1}\n";
hs_send(&t->ctl[IDX_A], sub, strlen(sub));
hs_send(&t->ctl[IDX_B], sub, strlen(sub));
t->phase = P_CALL_START;
break;
}
case P_CALL_START: {
t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF);
char json[512];
snprintf(json, sizeof(json), "{\"id\":2,\"cmd\":\"call_start\",\"ch\":\"%s\",\"node_id\":\"0x%016llx\"}\n",
CH_ID, (unsigned long long)nb);
hs_send(&t->ctl[IDX_A], json, strlen(json));
t->phase = P_WAIT_INCOMING;
break;
}
case P_WAIT_INCOMING: {
uint64_t cid = 0;
if (hs_extract_call_id(&t->ctl[IDX_B], "call_incoming", &cid) == 0) {
t->call_id = cid;
t->phase = P_ACCEPT;
}
break;
}
case P_ACCEPT: {
char json[256];
snprintf(json, sizeof(json), "{\"id\":3,\"cmd\":\"call_accept\",\"call_id\":\"0x%016llx\"}\n",
(unsigned long long)t->call_id);
hs_send(&t->ctl[IDX_B], json, strlen(json));
t->phase = P_WAIT_ACCEPTED;
break;
}
case P_WAIT_ACCEPTED:
if (hs_has(&t->ctl[IDX_A], "call_accepted") && hs_has(&t->ctl[IDX_B], "call_accepted"))
t->phase = P_BIND_AUDIO;
break;
case P_BIND_AUDIO: {
uint8_t hdr[CALL_AUDIO_HDR_SIZE];
hdr[0] = CALL_AUDIO_HELLO;
memcpy(hdr + 1, &t->call_id, 8);
hdr[9] = 0; hdr[10] = 0;
hs_send(&t->aud[IDX_A], hdr, sizeof(hdr));
hs_send(&t->aud[IDX_B], hdr, sizeof(hdr));
t->phase = P_SEND_MEDIA;
break;
}
case P_SEND_MEDIA: {
/* A: отправляем PCM-кадр (тишина) — ядро кодирует в Opus и шлёт пиру */
uint8_t frm[CALL_AUDIO_HDR_SIZE + CALL_AUDIO_PCM_FRAME_BYTES];
frm[0] = CALL_AUDIO_FRAME;
memcpy(frm + 1, &t->call_id, 8);
frm[9] = (uint8_t)(CALL_AUDIO_PCM_FRAME_BYTES >> 8);
frm[10] = (uint8_t)(CALL_AUDIO_PCM_FRAME_BYTES & 0xFF);
memset(frm + CALL_AUDIO_HDR_SIZE, 0, CALL_AUDIO_PCM_FRAME_BYTES);
hs_send(&t->aud[IDX_A], frm, sizeof(frm));
t->phase = P_WAIT_MEDIA;
break;
}
case P_WAIT_MEDIA: {
/* ищем PCM-фрейм от пира в буфере audio-клиента B: type=0x02 + call_id */
const char* buf = t->aud[IDX_B].buf;
int len = t->aud[IDX_B].len;
if (len >= CALL_AUDIO_HDR_SIZE) {
for (int i = 0; i + CALL_AUDIO_HDR_SIZE <= len; i++) {
if ((uint8_t)buf[i] != CALL_AUDIO_FRAME) continue;
uint64_t cid; memcpy(&cid, buf + i + 1, 8);
uint16_t flen = (uint16_t)(((uint8_t)buf[i+9] << 8) | (uint8_t)buf[i+10]);
if (cid == t->call_id && flen == CALL_AUDIO_PCM_FRAME_BYTES && i + CALL_AUDIO_HDR_SIZE + flen <= len) {
t->phase = P_HANGUP;
break;
}
}
}
break;
}
case P_HANGUP: {
char json[256];
snprintf(json, sizeof(json), "{\"id\":4,\"cmd\":\"call_hangup\",\"call_id\":\"0x%016llx\"}\n",
(unsigned long long)t->call_id);
hs_send(&t->ctl[IDX_A], json, strlen(json));
t->phase = P_WAIT_ENDED;
break;
}
case P_WAIT_ENDED:
if (hs_has(&t->ctl[IDX_A], "call_ended") && hs_has(&t->ctl[IDX_B], "call_ended"))
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)\n", (int)t->phase,
conn_up(A, nb), conn_up(B, na));
t->result = 2;
uasync_stop(t->ua);
return;
}
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, hs_tick, "hs_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("call", "trace");
debug_set_category_level_by_name("bgp", "info");
debug_set_category_level_by_name("etcp_route", "info");
debug_set_category_level_by_name("member_sync", "info");
debug_set_category_level_by_name("chat_sync", "info");
debug_set_category_level_by_name("general", "info");
debug_set_category_level_by_name("chat", "info");
}
utun_instance_set_tun_init_enabled(0);
srand((unsigned)time(NULL));
char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_call_hl_XXXXXX");
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
setenv("UTUN_TEST_DIR", dir, 1);
fill_shared(56000 + (getpid() % 2000));
write_configs(dir);
printf("=== test_call_headless ===\n"); fflush(stdout);
TEST("headless: call_start/accept/hangup + audio stream через сокеты"); {
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; }
struct hs_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, hs_tick, "hs_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;
}
+155 -3
View File
@@ -7,7 +7,7 @@
// Сценарий:
// фаза 1 (прямая): A,B,C online. A dm_start(B) → dm_send("hello1") → B получает напрямую.
// фаза 2 (offline): B1 завершается (A теряет B). A dm_send("hello2") → недоступен →
// кладёт в mailbox на C (dm_storage=yes). C видит блоб в dm_mail.
// кладёт в mailbox на C (storage=yes). C видит блоб в dm_mail.
// фаза 3 (reconnect): B2 переподключается к C → dm_on_conn_status → PULL mailbox → получает "hello2".
#include "chat_core.h"
@@ -244,10 +244,10 @@ static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const ch
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
}
/* мембер C со флагом dm_storage=yes (adm_tags подписан канальным ключом) */
/* мембер C со флагом storage=yes (adm_tags подписан канальным ключом) */
static void member_put_storage(struct UTUN_INSTANCE* inst, int idx, const char* name) {
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
char adm_tags[64]; snprintf(adm_tags, sizeof(adm_tags), "{\"dm_storage\":\"yes\",\"ver\":\"1\"}");
char adm_tags[64]; snprintf(adm_tags, sizeof(adm_tags), "{\"storage\":\"yes\",\"ver\":\"1\"}");
uint8_t ats[64];
uint8_t amsg[256]; size_t off = strlen(adm_tags);
memcpy(amsg, adm_tags, off);
@@ -378,6 +378,108 @@ static void dm_tick(void* arg) {
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, dm_tick, "dm_tick");
}
/* ── сценарий 2: первое сообщение в offline (у получателя нет беседы) ──
* A,B,C online → B уходит offline → A шлёт ПЕРВОЕ сообщение "first" → mailbox →
* B2 переподключается (без беседы) → тянет mailbox → получает "first". */
enum dm2_phase {
S2_WAIT_CONN,
S2_SETUP,
S2_WAIT_BGP,
S2_B_OFFLINE,
S2_WAIT_B_OFFLINE,
S2_SEND, /* A: dm_start + dm_send "first" → mailbox (B offline, первое) */
S2_WAIT_MAIL, /* C: блоб в dm_mail */
S2_RECONNECT, /* create B2 (та же БД B, но без беседы) */
S2_WAIT_B2, /* B2: получил "first" */
S2_DONE,
};
struct dm2_ctx {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst[3];
enum dm2_phase phase;
int result;
int ticks;
void* timer;
};
static void dm2_tick(void* arg) {
struct dm2_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];
switch (t->phase) {
case S2_WAIT_CONN:
if (conn_up(A, nc) && conn_up(C, na) && conn_up(B, nc) && conn_up(C, nb))
t->phase = S2_SETUP;
break;
case S2_SETUP:
do_setup(A, IDX_A);
do_setup(B, IDX_B);
do_setup(C, IDX_C);
t->phase = S2_WAIT_BGP;
break;
case S2_WAIT_BGP:
if (bgp_has(A, nc) && bgp_has(A, nb) && bgp_has(C, na) && bgp_has(C, nb) && bgp_has(B, na))
t->phase = S2_B_OFFLINE;
break;
case S2_B_OFFLINE:
utun_instance_destroy(B);
t->inst[IDX_B] = NULL;
t->phase = S2_WAIT_B_OFFLINE;
break;
case S2_WAIT_B_OFFLINE:
if (!bgp_has(A, nb)) t->phase = S2_SEND;
break;
case S2_SEND:
if (dm_start(A, nb, g_sh.x_pub[IDX_B], g_sh.ed_pub[IDX_B], "B", CH_ID) != 0
|| dm_send(A, g_sh.conv_id, "text", (const uint8_t*)"first", 5) != 0) {
fprintf(stderr, "A: dm_start/send first failed\n");
t->result = 2;
} else {
t->phase = S2_WAIT_MAIL;
}
break;
case S2_WAIT_MAIL:
if (dm_mail_count(C) > 0) t->phase = S2_RECONNECT;
break;
case S2_RECONNECT: {
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/b.conf", getenv("UTUN_TEST_DIR") ? getenv("UTUN_TEST_DIR") : ".");
struct UTUN_INSTANCE* b2 = utun_instance_create(t->ua, cfg);
if (!b2) { fprintf(stderr, "B2 create failed\n"); t->result = 2; break; }
utun_instance_init(b2);
t->inst[IDX_B] = b2;
t->phase = S2_WAIT_B2;
break;
}
case S2_WAIT_B2:
if (dm_msg_has(B, "first") && dm_mail_count(C) == 0) t->phase = S2_DONE;
break;
case S2_DONE:
t->result = 1;
uasync_stop(t->ua);
return;
}
if (t->phase == S2_DONE) { t->result = 1; uasync_stop(t->ua); return; }
if (++t->ticks > MAX_TICKS) {
fprintf(stderr, "s2: timeout in phase %d (A_conn=%d C_conn=%d B_conn=%d)\n",
(int)t->phase,
conn_up(A, nc), conn_up(C, na), B ? conn_up(B, nc) : -1);
t->result = 2;
uasync_stop(t->ua);
return;
}
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, dm2_tick, "dm2_tick");
}
/* ── main ── */
int main(int argc, char** argv) {
@@ -447,6 +549,56 @@ int main(int argc, char** argv) {
char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd);
/* сценарий 2: отдельная топология (свежие ключи/БД) */
char dir2[512]; snprintf(dir2, sizeof(dir2), "/tmp/utun_dm_e2e2_XXXXXX");
if (!mkdtemp(dir2)) { fprintf(stderr, "mkdtemp dir2 failed\n"); return 1; }
setenv("UTUN_TEST_DIR", dir2, 1);
fill_shared(70000 + (getpid() % 2000));
write_configs(dir2);
TEST("first message offline: mailbox delivery without prior conversation"); {
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; }
struct dm2_ctx t;
memset(&t, 0, sizeof(t));
t.ua = ua;
t.phase = S2_WAIT_CONN;
char cfgA[512], cfgB[512], cfgC[512];
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir2);
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir2);
snprintf(cfgC, sizeof(cfgC), "%s/c.conf", dir2);
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_C]);
utun_instance_init(t.inst[IDX_B]);
t.timer = uasync_set_timeout(ua, TICK_TB, &t, dm2_tick, "dm2_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);
}
snprintf(cmd, sizeof(cmd), "rm -rf %s", dir2); (void)!system(cmd);
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED);
return G_FAILED > 0 ? 1 : 0;
}
+17 -16
View File
@@ -11,6 +11,7 @@
#include "../src/config_parser.h"
#include "../src/transport_layer/etcp_connections.h"
#include "../src/routing_layer/topo_node.h"
#include "../lib/platform_compat.h"
static int fails = 0;
static int checks = 0;
@@ -23,21 +24,21 @@ static struct sock_view mk_view(uint8_t role, uint8_t has_inet, uint8_t has_priv
return v;
}
static void test_role_of(void) {
/* config -> role, при ntp_reachable=1 */
CHECK(sock_role_of(CFG_SERVER_TYPE_PUBLIC, NAT_TYPE_UNKNOWN, 1) == SOCK_ROLE_DIRECT, "public -> DIRECT");
CHECK(sock_role_of(CFG_SERVER_TYPE_UNKNOWN, NAT_TYPE_UNKNOWN, 1) == SOCK_ROLE_DIRECT, "unknown -> DIRECT");
CHECK(sock_role_of(CFG_SERVER_TYPE_NAT, NAT_VERIFIED_EIM, 1) == SOCK_ROLE_DIRECT, "nat+EIM -> DIRECT");
CHECK(sock_role_of(CFG_SERVER_TYPE_NAT, NAT_VERIFIED_DIRECT,1) == SOCK_ROLE_DIRECT, "nat+DIRECT -> DIRECT");
CHECK(sock_role_of(CFG_SERVER_TYPE_NAT, NAT_VERIFIED_STRICT,1) == SOCK_ROLE_NAT, "nat+STRICT -> NAT");
CHECK(sock_role_of(CFG_SERVER_TYPE_NAT, NAT_TYPE_UNKNOWN, 1) == SOCK_ROLE_NAT, "nat+unknown -> NAT");
CHECK(sock_role_of(CFG_SERVER_TYPE_PRIVATE, NAT_TYPE_UNKNOWN, 1) == SOCK_ROLE_NAT, "private -> NAT");
CHECK(sock_role_of(CFG_SERVER_TYPE_LOCAL, NAT_TYPE_UNKNOWN, 1) == SOCK_ROLE_LOCAL, "local -> LOCAL");
/* NTP offline: все кроме LOCAL демотируются */
CHECK(sock_role_of(CFG_SERVER_TYPE_PUBLIC, NAT_TYPE_UNKNOWN, 0) == SOCK_ROLE_LOCAL, "offline public -> LOCAL");
CHECK(sock_role_of(CFG_SERVER_TYPE_NAT, NAT_VERIFIED_EIM, 0) == SOCK_ROLE_LOCAL, "offline nat+EIM -> LOCAL");
CHECK(sock_role_of(CFG_SERVER_TYPE_PRIVATE, NAT_TYPE_UNKNOWN, 0) == SOCK_ROLE_LOCAL, "offline private -> LOCAL");
CHECK(sock_role_of(CFG_SERVER_TYPE_LOCAL, NAT_TYPE_UNKNOWN, 0) == SOCK_ROLE_LOCAL, "offline local -> LOCAL");
/* Классификация IPv4-адреса (network byte order): публичный/маршрутизируемый или нет.
* Адрес задаём «логически» (старший байт = первый октет) и переводим в s_addr через htonl. */
static void test_ip_is_public(void) {
CHECK(ip_is_public(htonl(0x08080808u)) == 1, "8.8.8.8 public");
CHECK(ip_is_public(htonl(0x01020304u)) == 1, "1.2.3.4 public");
CHECK(ip_is_public(htonl(0xC0A80101u)) == 0, "192.168.1.1 private");
CHECK(ip_is_public(htonl(0x0A000001u)) == 0, "10.0.0.1 private");
CHECK(ip_is_public(htonl(0xAC100001u)) == 0, "172.16.0.1 private");
CHECK(ip_is_public(htonl(0xAC1F0001u)) == 0, "172.31.0.1 private");
CHECK(ip_is_public(htonl(0x64400001u)) == 0, "100.64.0.1 CGNAT");
CHECK(ip_is_public(htonl(0x7F000001u)) == 0, "127.0.0.1 loopback");
CHECK(ip_is_public(htonl(0xA9FE0101u)) == 0, "169.254.1.1 link-local");
CHECK(ip_is_public(htonl(0x00000000u)) == 0, "0.0.0.0");
CHECK(ip_is_public(htonl(0xE0000001u)) == 0, "224.0.0.1 multicast");
CHECK(ip_is_public(htonl(0xFFFFFFFFu)) == 0, "255.255.255.255 reserved");
}
static void test_target_class(void) {
@@ -78,7 +79,7 @@ static void test_match(void) {
}
int main(void) {
test_role_of();
test_ip_is_public();
test_target_class();
test_match();
if (fails) { printf("RESULT: %d/%d failed\n", fails, checks); return 1; }
+2
View File
@@ -252,11 +252,13 @@ log_udp_port = 9999
- `libutun_lite/invite_link_c.h/c` — Кодирование и декодирование invite-ссылок `utun://` в бинарный формат. Совместим с десктопной версией
- `libutun_lite/utun_config_api.h/c` — Поставщик конфигурации из Kotlin в C-ядро через callback-интерфейс (get_string, get_int64, get_int)
- `libutun_lite/voice_recorder.h/c` — Запись голосовых сообщений: накопление PCM-сэмплов с компрессором, кодирование в Opus-файл, отправка в канал через chat_core
- `src/call/call_audio.h/c` (+ `voice_jitter.cpp`, `call_tones.c`, SoundTouch) — единый голосовой стек звонка (`libutun_voice`), собирается через `src/call/voice_sources.cmake`; Opus encode (PCM→peer) и decode + адаптивный джиттер-буфер + time-stretch (PCM отдаётся через `nativeCallAudioPull`). Аудио I/O в Kotlin (AudioRecord/AudioTrack)
- `libutun_lite/attachment_sender.h/c` — Отправка файлов в канал: копирование в media-директорию и регистрация через chat_core (media_index → db_sync)
- `libutun_lite/utun_sources.cmake` — Список всех .c файлов, компилируемых в libutun_lite. Новые файлы добавлять сюда
### Kotlin-слой
- `data/NativeLib.kt` — Kotlin-обёртка над C-библиотекой: все вызовы из Kotlin транслируются в JNI-функции
- `data/CallAudioEngine.kt` — Аудио-движок звонка: AudioRecord/AudioTrack, audio-focus, потоки захвата (feed) / воспроизведения (pull из C-стека)
- `data/ChatRepository.kt` — Хранилище данных: буферизация сообщений и каналов между C-ядром и UI через StateFlow
- `data/InviteLink.kt` — Разбор invite-ссылок: извлекает ID канала, публичный ключ, адреса для подключения
- `data/ConfigProvider.kt` — Поставщик конфигурации из Android DataStore в C-ядро через callback-интерфейс
+1 -1
View File
@@ -26,7 +26,7 @@ android {
externalNativeBuild {
cmake {
arguments("-DANDROID_STL=none")
arguments("-DANDROID_STL=c++_static")
}
}
ndk {
@@ -5,9 +5,17 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<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.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -21,6 +29,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@@ -37,5 +46,9 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="p2p_chat_network_service" />
</service>
<receiver
android:name=".data.IncomingCallActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -28,6 +28,20 @@ list(APPEND UTUN_INCLUDE_DIRS "${LIB_DIR}/libopus" "${LIB_DIR}/libopus/include"
# Opus requires these to be defined (or HAVE_CONFIG_H + config.h with VAR_ARRAYS)
list(APPEND UTUN_DEFINES OPUS_BUILD VAR_ARRAYS USE_ALLOCA)
# Голосовой стек звонка (SoundTouch + voice_jitter + call_audio + call_tones),
# C++ внутри, C-интерфейс наружу (call_audio.h).
set(CALL_DIR ${SRC_DIR}/call)
include(${CALL_DIR}/voice_sources.cmake)
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_target_properties(utun_voice PROPERTIES
C_STANDARD 99 C_STANDARD_REQUIRED ON
CXX_STANDARD 11 CXX_STANDARD_REQUIRED ON
POSITION_INDEPENDENT_CODE ON
)
# Prebuilt OpenSSL (cross-compiled for Android)
set(OPENSSL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/openssl/${ANDROID_ABI})
add_library(openssl_crypto STATIC IMPORTED)
@@ -57,4 +71,4 @@ target_compile_definitions(utun PRIVATE ${UTUN_DEFINES} __ANDROID__)
set_target_properties(utun PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON)
find_library(log-lib log)
target_link_libraries(utun ${log-lib} openssl_crypto)
target_link_libraries(utun ${log-lib} openssl_crypto utun_voice)
@@ -6,6 +6,7 @@ import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.ChatEventCallback
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -37,6 +38,9 @@ class ChatApplication : Application() {
/* One-time event callback registration — survives ViewModel recreation */
NativeLib.setEventCallback(AppEventHandler)
/* Incoming-call system effects (wake + ringtone + notification) — app-scoped */
IncomingCallManager.init()
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
@@ -1,15 +1,23 @@
package com.utun.chat
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
@@ -19,21 +27,28 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import com.utun.chat.data.Channel
import com.utun.chat.data.IncomingCallManager
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.headless.HeadlessService
import com.utun.chat.ui.components.ActiveCallBanner
import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord }
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call, Dm }
private var overLock = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermission()
handleIncomingCallIntent(intent)
/* Edge-to-edge with dark status/navigation bar icons (light app theme) */
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
@@ -63,9 +78,53 @@ class MainActivity : ComponentActivity() {
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
LaunchedEffect(callState) {
val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED
if (inCall) {
hadCall = true
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (hadCall) releaseLockScreen()
}
}
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call
}
null -> if (screen == Screen.Call) screen = Screen.ChannelList
else -> {}
}
}
/* Автооткрытие DM-беседы после нажатия «Message» в списке участников. */
LaunchedEffect(Unit) {
vm.dmOpen.collect { convId ->
if (convId != null) {
vm.consumeDmOpen()
screen = Screen.Dm
}
}
}
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
@@ -81,6 +140,8 @@ class MainActivity : ComponentActivity() {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Dm -> { vm.closeDm(); screen = Screen.ChannelList }
Screen.Call -> screen = screenBeforeCall ?: Screen.ChannelList
else -> screen = Screen.ChannelList
}
}
@@ -101,106 +162,147 @@ class MainActivity : ComponentActivity() {
onDispose { NativeLib.subscribeNativeLogs(false) }
}
Box(modifier = Modifier.fillMaxSize()) {
when (screen) {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val stuck by vm.isStuck.collectAsState()
ChannelListScreen(
channels = channels,
connected = connected,
isStuck = stuck,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
)
}
Screen.Chat -> {
val messages by vm.messages.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
ChatScreen(
channel = channel,
messages = messages,
Column(modifier = Modifier.fillMaxSize()) {
val callPhase = callState?.phase
val showCallBanner = screen != Screen.Call &&
(callPhase == ChatViewModel.CallPhase.OUTGOING ||
callPhase == ChatViewModel.CallPhase.RINGING ||
callPhase == ChatViewModel.CallPhase.ACTIVE)
if (showCallBanner) {
ActiveCallBanner(
viewModel = vm,
onOpen = { screen = Screen.Call },
onHangup = { vm.hangupCall() }
)
}
Box(
modifier = Modifier.weight(1f)
.then(if (showCallBanner) Modifier.consumeWindowInsets(WindowInsets.statusBars) else Modifier)
) {
when (screen) {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.collectAsState()
val stuck by vm.isStuck.collectAsState()
ChannelListScreen(
channels = channels,
dmConversations = dmConversations,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
isStuck = stuck,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
)
}
}
Screen.MemberList -> {
val members by vm.members.collectAsState()
val selectedMember by vm.selectedMember.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
MemberListScreen(
channel = channel,
members = members,
selectedMember = selectedMember,
viewModel = vm,
onBack = { screen = Screen.Chat }
)
Screen.Chat -> {
val messages by vm.messages.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
ChatScreen(
channel = channel,
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
)
}
}
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onBack = { screen = Screen.ChannelList }
Screen.MemberList -> {
val members by vm.members.collectAsState()
val selectedMember by vm.selectedMember.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
MemberListScreen(
channel = channel,
members = members,
selectedMember = selectedMember,
viewModel = vm,
onBack = { screen = Screen.Chat }
)
}
}
Screen.Dm -> {
val dmMessages by vm.dmMessages.collectAsState()
val dm = vm.currentDm.collectAsState().value
if (dm != null) {
DmChatScreen(
conv = dm,
messages = dmMessages,
onSendMessage = { vm.sendDmMessage(it) },
onBack = { vm.closeDm(); screen = Screen.ChannelList }
)
}
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
viewModel = vm,
onClose = { vm.dismissCall(); screen = Screen.ChannelList },
onMinimize = { screen = screenBeforeCall ?: Screen.ChannelList }
)
}
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onClose = { screen = Screen.Chat }
onBack = { screen = Screen.ChannelList }
)
}
}
Screen.PhotoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
PhotoRecordScreen(
onSend = { path, width, height ->
vm.sendPhotoMessage(path, width, height)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
Screen.PhotoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
PhotoRecordScreen(
onSend = { path, width, height ->
vm.sendPhotoMessage(path, width, height)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
}
}
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
}
}
}
photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
}
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
}
}
}
@@ -250,6 +352,49 @@ class MainActivity : ComponentActivity() {
if (NativeLib.isReady()) NativeLib.setActive(true)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingCallIntent(intent)
}
/** Full-screen intent входящего звонка: показать activity поверх блокировки. */
private fun handleIncomingCallIntent(intent: Intent?) {
val callId = intent?.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L) ?: 0L
if (callId == 0L) return
LogManager.addLog("INFO", "MAIN", "launched for incoming call 0x%016x".format(callId))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
overLock = true
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
}
/** Снять «показ поверх блокировки», выставленный для входящего звонка. */
private fun releaseLockScreen() {
if (!overLock) return
overLock = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
}
override fun onStop() {
super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false)
@@ -0,0 +1,220 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
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
/**
* Полнодуплексный аудио-движок P2P-звонка (аналог десктопного CallAudioEngine).
*
* Потоки:
* - capture-поток: AudioRecord.read(960) → NativeLib.callAudioFeed (C: Opus encode → peer)
* - play-поток: NativeLib.callAudioPull (C: джиттер-буфер → декод → SoundTouch) → AudioTrack.write
*
* Воспроизведение тянет PCM из единого голосового стека в C (call_audio): адаптивный
* джиттер-буфер (кодированные кадры) + time-stretch держат задержку ~60мс и догоняют
* после сетевых бёрстов. На Kotlin-стороне своего джиттера больше нет.
*/
class CallAudioEngine {
companion object {
const val SAMPLE_RATE = 48000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
}
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
/** Маршрутизация аудио-выхода (динамик/громкая/гарнитура). */
val router = CallAudioRouter()
@Volatile private var active = false
@Volatile private var callId = 0L
@Volatile private var muted = false
fun isActive(): Boolean = active
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
fun setMuted(m: Boolean) {
if (muted == m) return
muted = m
LogManager.addLog("INFO", "CallAudio", "mute ${if (m) "on" else "off"}")
}
fun isMuted(): Boolean = muted
/** Запустить полнодуплекс для звонка. Возвращает true при успехе. */
fun start(id: Long): Boolean {
stop()
if (!NativeLib.callAudioStart(id)) {
LogManager.addLog("ERROR", "CallAudio", "native callAudioStart failed id=$id")
return false
}
callId = id
muted = 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", "CallAudio", "set audio mode failed: ${e.message}")
}
router.start()
// ── 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", "CallAudio", "AudioRecord create failed: ${e.message}")
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized")
audioRecord?.release(); audioRecord = null
NativeLib.callAudioStop(); router.stop(); 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", "CallAudio", "AudioTrack create failed: ${e.message}")
audioRecord?.release(); audioRecord = null
audioTrack = null
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной
глобальной маршрутизации на ряде устройств). */
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice)
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", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf")
return true
}
fun stop() {
if (!active) return
active = 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.callAudioStop()
router.onOutputDevice = null
router.stop()
restoreAudioMode()
callId = 0L
muted = false
LogManager.addLog("INFO", "CallAudio", "stopped")
}
private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = 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
}
val out = if (muted) silence else buf
if (!NativeLib.callAudioFeed(callId, out)) {
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
}
}
LogManager.addLog("DEBUG", "CallAudio", "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.callAudioPull(callId, 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", "CallAudio", "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", "CallAudio", "restore audio mode failed: ${e.message}")
}
}
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) {
val track = audioTrack ?: return
if (dev == null) return
try {
track.setPreferredDevice(dev)
LogManager.addLog("DEBUG", "CallAudio", "track preferred device -> type=${dev.type}")
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "setPreferredDevice failed: ${e.message}")
}
}
}
@@ -0,0 +1,320 @@
package com.utun.chat.data
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
/** Аудио-выход звонка: динамик (earpiece) ↔ громкая связь (speaker) ↔ гарнитура. */
enum class AudioRoute { EARPIECE, SPEAKER, HEADSET }
/**
* Маршрутизация аудио звонка поверх AudioManager + мониторинг гарнитуры.
*
* - EARPIECE: разговорный динамик (isSpeakerphoneOn=false).
* - SPEAKER: громкая связь (isSpeakerphoneOn=true).
* - HEADSET: проводная/USB гарнитура или Bluetooth (SCO) — если подключена.
*
* Гарнитура детектится через AudioDeviceCallback (проводная, USB, SCO) и
* BluetoothHeadset profile (классический BT до установки SCO). Без BLUETOOTH_CONNECT
* (Android 12+) BT-часть деградирует до «не видно» — остаётся проводная.
*
* Владелец — CallAudioEngine (start/stop в начале/конце звонка), т.к. именно он
* управляет режимом AudioManager (MODE_IN_COMMUNICATION).
*/
class CallAudioRouter {
@Volatile var route = AudioRoute.SPEAKER
private set
@Volatile var preferredRoute = AudioRoute.SPEAKER
private set
@Volatile var headsetAvailable = false
private set
@Volatile var hasEarpiece = true
private set
/** Уведомление об изменении маршрута или доступности гарнитуры. */
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
/** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */
var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null
@Volatile var currentOutputDevice: AudioDeviceInfo? = null
private set
private val am: AudioManager =
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var started = false
private var deviceCallback: AudioDeviceCallback? = null
private var btHeadset: BluetoothHeadset? = null
private var btProfileProxyBound = false
fun start() {
if (started) return
started = true
val cb = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
}
am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper()))
deviceCallback = cb
bindBtHeadsetProfile()
hasEarpiece = findBuiltinEarpiece() != null
route = preferredRoute
applyRoute(route)
refresh()
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)")
}
fun stop() {
if (!started) return
started = false
deviceCallback?.let { am.unregisterAudioDeviceCallback(it) }
deviceCallback = null
unbindBtHeadsetProfile()
try {
stopBluetoothSco()
am.isBluetoothScoOn = false
clearCommunicationDevice()
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}")
}
LogManager.addLog("INFO", "CallAudioRouter", "stopped")
}
fun setRoute(r: AudioRoute) {
if (r == AudioRoute.HEADSET && !headsetAvailable) {
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
return
}
if (route == r) return
route = r
preferredRoute = r
applyRoute(r)
LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable)")
onChanged?.invoke(route, headsetAvailable)
}
/** Инжекция сохранённого источника аудио (из DataStore) перед стартом звонка. */
fun setPreferredRoute(r: AudioRoute) {
preferredRoute = r
LogManager.addLog("INFO", "CallAudioRouter", "preferredRoute -> $r")
}
/* ── применение маршрута ── */
private fun applyRoute(r: AudioRoute) {
try {
var dev: AudioDeviceInfo? = null
when (r) {
AudioRoute.EARPIECE -> {
stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dev = findBuiltinEarpiece() ?: findBuiltinSpeaker()
if (dev != null) am.setCommunicationDevice(dev)
else am.isSpeakerphoneOn = false
} else {
am.isSpeakerphoneOn = false
}
}
AudioRoute.SPEAKER -> {
stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dev = findBuiltinSpeaker()
if (dev != null) am.setCommunicationDevice(dev)
else am.isSpeakerphoneOn = true
} else {
am.isSpeakerphoneOn = true
}
}
AudioRoute.HEADSET -> {
am.isSpeakerphoneOn = false
val bt = findBtScoDevice()
if (bt != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.setCommunicationDevice(bt)
} else {
startBluetoothSco()
}
dev = bt
} else if (isBtHeadsetProfileConnected()) {
/* классический BT: SCO ещё не поднят — запускаем и ждём
* AudioDeviceCallback, который пере-применит маршрут */
startBluetoothSco()
} else {
val wired = findWiredDevice()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) {
am.setCommunicationDevice(wired)
} else {
/* API<31: проводная гарнитура роутится автоматически при speakerphone=off */
am.isSpeakerphoneOn = false
}
dev = wired
}
}
}
currentOutputDevice = dev
onOutputDevice?.invoke(dev)
LogManager.addLog("INFO", "CallAudioRouter",
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
}
}
private fun findBuiltinSpeaker(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
}
private fun findBuiltinEarpiece(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
}
/* ── мониторинг гарнитуры ── */
private fun onDevicesChanged() {
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут,
* иначе SCO не замаршрутизируется автоматически */
if (route == AudioRoute.HEADSET) applyRoute(route)
refresh()
}
private fun refresh() {
val now = isHeadsetConnected()
val prev = headsetAvailable
headsetAvailable = now
if (now != prev) {
LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now")
}
/* гарнитуру отключили — откатываемся на динамик */
if (!now && route == AudioRoute.HEADSET) {
route = AudioRoute.EARPIECE
applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE")
}
/* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */
if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) {
route = AudioRoute.HEADSET
applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET")
}
if (now != prev || route == AudioRoute.HEADSET) {
onChanged?.invoke(route, now)
}
}
private fun isHeadsetConnected(): Boolean =
findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected()
private fun findWiredDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { isWiredType(it.type) }
private fun findBtScoDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
private fun isWiredType(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY -> true
else -> false
}
/* ── Bluetooth headset profile ── */
private fun bindBtHeadsetProfile() {
try {
val adapter = BluetoothAdapter.getDefaultAdapter() ?: return
adapter.getProfileProxy(ChatApplication.instance, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
btHeadset = proxy as BluetoothHeadset
btProfileProxyBound = true
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile connected")
onDevicesChanged()
}
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.HEADSET) {
btHeadset = null
btProfileProxyBound = false
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile disconnected")
onDevicesChanged()
}
}
}, BluetoothProfile.HEADSET)
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "bt profile proxy failed: ${e.message}")
}
}
private fun unbindBtHeadsetProfile() {
try {
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter != null && btProfileProxyBound) {
adapter.closeProfileProxy(BluetoothProfile.HEADSET, btHeadset)
}
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "unbind bt profile failed: ${e.message}")
}
btHeadset = null
btProfileProxyBound = false
}
private fun hasBtConnectPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ChatApplication.instance.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
private fun isBtHeadsetProfileConnected(): Boolean {
if (!hasBtConnectPermission()) return false
return try {
btHeadset?.connectedDevices?.isNotEmpty() == true
} catch (e: SecurityException) {
LogManager.addLog("WARN", "CallAudioRouter", "bt connectedDevices denied: ${e.message}")
false
}
}
/* ── helpers ── */
private fun clearCommunicationDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.clearCommunicationDevice()
}
}
private fun startBluetoothSco() {
try {
am.startBluetoothSco()
am.isBluetoothScoOn = true
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "startBluetoothSco failed: ${e.message}")
}
}
private fun stopBluetoothSco() {
try { am.stopBluetoothSco() } catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stopBluetoothSco failed: ${e.message}")
}
}
}
@@ -24,7 +24,7 @@ data class NodeStatus(
val bgpRtt: Int = 0,
val bgpNodes: Int = 0
) {
val online: Boolean get() = (directPresence && directUp) || (indirectPresence && indirectUp)
val online: Boolean get() = hasPresence
val hasPresence: Boolean get() = directPresence || indirectPresence || bgpPresence
}
@@ -54,6 +54,11 @@ fun parseNodeStatus(data: ByteArray): NodeStatus? {
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
val peersOnline: Int = 0)
data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "",
val lastTs: Long = 0) {
val displayName: String get() = peerName.ifBlank { "0x%016x".format(peerNodeId) }
}
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
val isOutgoing: Boolean = false, val contentType: String = "text/plain",
val filePath: String = "", val waveform: List<Float> = emptyList(),
@@ -524,4 +529,50 @@ class ChatRepository {
}
fun close() {}
/* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) =
NativeLib.dmStart(nodeId, sourceChId, peerName)
fun dmSend(convId: String, text: String) = NativeLib.dmSend(convId, text)
fun getDmConversations(): List<DmConversation> {
val list = mutableListOf<DmConversation>()
try {
val arr = JSONArray(NativeLib.dmListConversations())
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
list.add(DmConversation(
convId = obj.getString("conv_id"),
peerNodeId = obj.optLong("peer_node_id", 0),
peerName = obj.optString("peer_name", ""),
lastTs = obj.optLong("last_ts", 0)
))
}
} catch (e: Exception) { Log.w("utun-gui", "getDmConversations error", e) }
return list
}
fun getDmMessages(convId: String, peerName: String = "", limit: Int = 50): List<Message> {
val list = mutableListOf<Message>()
try {
val arr = JSONArray(NativeLib.dmListMessages(convId, limit, 0))
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
val dir = obj.optInt("dir", 0)
val outgoing = dir == 1
val author = if (outgoing) "Вы" else peerName.ifBlank { "0x%016x".format(obj.optLong("author", 0)) }
list.add(Message(
id = obj.optLong("seq", 0),
author = author,
text = obj.optString("data", ""),
ts = obj.optLong("ts", 0),
isOutgoing = outgoing,
contentType = obj.optString("ct", "text/plain")
))
}
} catch (e: Exception) { Log.w("utun-gui", "getDmMessages error", e) }
return list
}
}
@@ -52,6 +52,7 @@ class ConfigProvider(private val context: Context) {
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@@ -247,6 +248,17 @@ class ConfigProvider(private val context: Context) {
}
}
/* ── Audio route for calls (запоминание источника аудио) ── */
fun getCallAudioRoute(): AudioRoute = runBlocking {
val s = context.dataStore.data.first()[callAudioRouteKey]
AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.SPEAKER
}
suspend fun setCallAudioRoute(r: AudioRoute) {
context.dataStore.edit { prefs -> prefs[callAudioRouteKey] = r.name }
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
@@ -0,0 +1,25 @@
package com.utun.chat.data
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Кнопка «Завершить» на уведомлении входящего звонка. */
class IncomingCallActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_DECLINE = "com.utun.chat.action.DECLINE_CALL"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val callId = intent.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L)
if (callId == 0L) {
LogManager.addLog("ERROR", "InCall", "DECLINE action: bad call_id")
return
}
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId))
NativeLib.callDecline(callId)
IncomingCallManager.stopRinging()
}
}
@@ -0,0 +1,216 @@
package com.utun.chat.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.utun.chat.AppEventHandler
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Системные эффекты входящего звонка, независимые от жизненного цикла Activity:
* пробуждение устройства, полноэкранное уведомление (с кнопкой «Завершить») и мелодия
* через профиль звонка. Подписывается на AppEventHandler.events в app-скопе — работает
* и при убитом Activity, пока жив foreground-сервис с C-ядром.
*
* UI-состоянием (accept/decline/отрисовка) по-прежнему владеет ChatViewModel;
* этот объект лишь будит устройство и хранит отложенный входящий звонок,
* который VM забирает через consumePendingIncoming() при (пере)создании.
*/
object IncomingCallManager {
const val CHANNEL_CALL = "incoming_call"
const val NOTIF_ID = 1001
const val EXTRA_CALL_ID = "call_id"
const val EXTRA_CHANNEL_ID = "channel_id"
const val EXTRA_PEER_NODE_ID = "peer_node_id"
data class PendingCall(val callId: Long, val channelId: String, val peerNodeId: Long)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val ringtone = RingtonePlayer()
@Volatile private var initialized = false
@Volatile private var activeCallId = 0L
@Volatile private var pendingCall: PendingCall? = null
private var wakeLock: PowerManager.WakeLock? = null
fun init() {
if (initialized) return
initialized = true
createChannel(ChatApplication.instance)
scope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
LogManager.addLog("INFO", "InCall", "manager initialized")
}
fun currentIncomingCallId(): Long = activeCallId
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */
fun canFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val nm = ChatApplication.instance.getSystemService(NotificationManager::class.java)
return nm.canUseFullScreenIntent()
}
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */
@Synchronized
fun consumePendingIncoming(): PendingCall? {
val p = pendingCall
pendingCall = null
return p
}
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */
fun stopRinging() {
val id = activeCallId
if (id == 0L) return
activeCallId = 0L
ringtone.stop()
releaseWakeLock()
cancelNotification()
pendingCall = null
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id))
}
private fun handleEvent(type: Int, data: ByteArray?) {
when (type) {
34 -> onIncoming(data) /* CALL_INCOMING */
36, 37, 38, 40 -> onCallStop(data) /* ACCEPTED/ENDED/DECLINED/ERROR */
}
}
private fun onIncoming(data: ByteArray?) {
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 16) return
val chId = String(data, 1, cl)
val buf = ByteBuffer.wrap(data, 1 + cl, 16).order(ByteOrder.LITTLE_ENDIAN)
val caller = buf.long
val callId = buf.long
if (activeCallId != 0L) {
LogManager.addLog("WARN", "InCall", "busy (0x%016x), skip incoming 0x%016x".format(activeCallId, callId))
return
}
activeCallId = callId
pendingCall = PendingCall(callId, chId, caller)
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
acquireWakeLock()
ringtone.play()
showNotification(callId, chId, caller)
}
private fun onCallStop(data: ByteArray?) {
if (data == null || data.size < 8) return
val callId = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
if (activeCallId == 0L || callId != activeCallId) return
stopRinging()
}
/* ── пробуждение устройства ── */
private fun acquireWakeLock() {
val pm = ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = try {
pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP
or PowerManager.ON_AFTER_RELEASE,
"utun:incoming_call"
).apply { acquire(30_000) }
} catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "wake lock failed: ${e.message}")
null
}
}
private fun releaseWakeLock() {
wakeLock?.let {
try { if (it.isHeld) it.release() } catch (_: Exception) {}
}
wakeLock = null
}
/* ── уведомление ── */
private fun createChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_CALL, "Входящие звонки", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Уведомление о входящем звонке"
enableVibration(false)
setSound(null, null)
}
ctx.getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
}
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long) {
val ctx = ChatApplication.instance
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
LogManager.addLog("WARN", "InCall", "notifications disabled — skip (still ringing)")
return
}
val launchIntent = Intent(ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(EXTRA_CALL_ID, callId)
putExtra(EXTRA_CHANNEL_ID, channelId)
putExtra(EXTRA_PEER_NODE_ID, peerNodeId)
}
val contentPi = PendingIntent.getActivity(ctx, 0, launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val declinePi = PendingIntent.getBroadcast(ctx, 0,
Intent(ctx, IncomingCallActionReceiver::class.java).apply {
action = IncomingCallActionReceiver.ACTION_DECLINE
putExtra(EXTRA_CALL_ID, callId)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(ctx, CHANNEL_CALL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Входящий звонок")
.setContentText("0x%016x".format(peerNodeId))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(contentPi)
.addAction(0, "Завершить", declinePi)
val nm = ctx.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent()) {
builder.setFullScreenIntent(contentPi, true)
} else {
LogManager.addLog("WARN", "InCall", "full-screen intent not allowed — heads-up only")
}
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, builder.build())
LogManager.addLog("INFO", "InCall", "notification posted callId=0x%016x".format(callId))
} catch (e: SecurityException) {
LogManager.addLog("WARN", "InCall", "notify denied: ${e.message}")
}
}
private fun cancelNotification() {
try {
NotificationManagerCompat.from(ChatApplication.instance).cancel(NOTIF_ID)
} catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "cancel notification failed: ${e.message}")
}
}
}
@@ -139,6 +139,26 @@ object NativeLib {
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) }
/* ── P2P аудио-звонок (сигналинг) ── */
fun callStart(channelId: String, peerNodeId: Long, callId: Long) { nativeCallStart(channelId, peerNodeId, callId) }
fun callAccept(callId: Long) { nativeCallAccept(callId) }
fun callDecline(callId: Long) { nativeCallDecline(callId) }
fun callHangup(callId: Long) { nativeCallHangup(callId) }
fun callIsOnline(channelId: String, peerNodeId: Long): Boolean = nativeCallIsOnline(channelId, peerNodeId)
/* ── P2P аудио-звонок (медиа) ── */
fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId)
fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples)
fun callAudioPull(callId: Long, out: ShortArray): Int = nativeCallAudioPull(callId, out)
fun callAudioStop() { nativeCallAudioStop() }
/* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) }
fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) }
fun dmListConversations(): String = nativeDmListConversations() ?: "[]"
fun dmListMessages(convId: String, count: Int = 50, offset: Int = 0): String =
nativeDmListMessages(convId, count, offset) ?: "[]"
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
@@ -215,4 +235,21 @@ object NativeLib {
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
private external fun nativeVoiceDecodeClose(handle: Long)
/* ── P2P аудио-звонок JNI ── */
private external fun nativeCallStart(channelId: String, peerNodeId: Long, callId: Long)
private external fun nativeCallAccept(callId: Long)
private external fun nativeCallDecline(callId: Long)
private external fun nativeCallHangup(callId: Long)
private external fun nativeCallIsOnline(channelId: String, peerNodeId: Long): Boolean
private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean
private external fun nativeCallAudioPull(callId: Long, out: ShortArray): Int
private external fun nativeCallAudioStop()
/* ── DM JNI ── */
private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String)
private external fun nativeDmSend(convId: String, text: String)
private external fun nativeDmListConversations(): String?
private external fun nativeDmListMessages(convId: String, count: Int, offset: Int): String?
}
@@ -0,0 +1,158 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.provider.Settings
import com.utun.chat.ChatApplication
import com.utun.chat.R
/**
* Мелодия входящего звонка через профиль звонка (STREAM_RING):
* - громкость — настройка «громкость вызова»;
* - вибрация — по системной настройке «вибрировать при вызове» (VIBRATE_WHEN_RINGING).
*
* API 28+ : Ringtone (RingtoneManager) — громкость и вибрация автоматически.
* API 26–27: MediaPlayer(STREAM_RING, loop) + ручная вибрация по системной настройке.
*/
class RingtonePlayer {
private var ringtone: Ringtone? = null
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var focusRequest: AudioFocusRequest? = null
fun play() {
stop()
val ctx = ChatApplication.instance
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val uri = Uri.parse("android.resource://${ctx.packageName}/${R.raw.ringtone}")
val r = try {
RingtoneManager.getRingtone(ctx, uri)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "getRingtone failed: ${e.message}")
null
} ?: return
try {
r.isLooping = true
r.play()
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "play failed: ${e.message}")
return
}
ringtone = r
LogManager.addLog("INFO", "Ringtone", "started (Ringtone, STREAM_RING + system vibrate)")
} else {
val mp = try {
MediaPlayer.create(ctx, R.raw.ringtone)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create failed: ${e.message}")
null
} ?: return
try {
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_RING)
.build()
)
mp.isLooping = true
requestAudioFocus()
mp.start()
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer start failed: ${e.message}")
try { mp.release() } catch (_: Exception) {}
abandonAudioFocus()
return
}
mediaPlayer = mp
startVibrate()
LogManager.addLog("INFO", "Ringtone", "started (MediaPlayer fallback, STREAM_RING)")
}
}
fun stop() {
ringtone?.let { try { it.stop() } catch (_: Exception) {} }
ringtone = null
mediaPlayer?.let {
try { it.stop() } catch (_: Exception) {}
try { it.release() } catch (_: Exception) {}
}
mediaPlayer = null
stopVibrate()
abandonAudioFocus()
LogManager.addLog("INFO", "Ringtone", "stopped")
}
/* ── вибрация (fallback API 26–27) по системной настройке ── */
private fun systemVibrateWhenRinging(): Boolean = try {
Settings.System.getInt(
ChatApplication.instance.contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0
} catch (e: Exception) {
false
}
private fun startVibrate() {
if (!systemVibrateWhenRinging()) return
val v = try {
ChatApplication.instance.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrator unavailable: ${e.message}")
null
} ?: return
if (!v.hasVibrator()) return
try {
v.vibrate(VibrationEffect.createWaveform(longArrayOf(1000, 1000), 0))
vibrator = v
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrate failed: ${e.message}")
}
}
private fun stopVibrate() {
vibrator?.let { try { it.cancel() } catch (_: Exception) {} }
vibrator = null
}
/* ── audio focus (fallback API 26–27) ── */
private fun requestAudioFocus() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attrs)
.build()
am.requestAudioFocus(req)
focusRequest = req
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "audio focus failed: ${e.message}")
}
}
private fun abandonAudioFocus() {
val req = focusRequest ?: return
focusRequest = null
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.abandonAudioFocusRequest(req)
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "abandon focus failed: ${e.message}")
}
}
}
@@ -0,0 +1,94 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
private fun fmtDuration(sec: Int): String {
val m = sec / 60
val s = sec % 60
return "%02d:%02d".format(m, s)
}
/* Постоянная полоса активного звонка: показывается поверх интерфейса, когда
* звонок свёрнут. Клик по полосе открывает CallScreen, красная кнопка — завершить. */
@Composable
fun ActiveCallBanner(
viewModel: ChatViewModel,
onOpen: () -> Unit,
onHangup: () -> Unit
) {
val st = viewModel.callState.collectAsState().value ?: return
val phase = st.phase
if (phase != CallPhase.OUTGOING && phase != CallPhase.RINGING && phase != CallPhase.ACTIVE) return
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
val status = when (phase) {
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationMs)
CallPhase.RINGING -> "Гудки…"
else -> "Звоним…"
}
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.background(Color(0xFF1F6FEB))
.clickable(onClick = onOpen)
.padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.PhoneInTalk,
contentDescription = "Звонок",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
name,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
status,
color = Color(0xFFD5E4FF),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier.size(40.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = onHangup) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(22.dp))
}
}
}
}
@@ -0,0 +1,205 @@
package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.utun.chat.data.AudioRoute
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
private fun fmtDuration(sec: Int): String {
val m = sec / 60
val s = sec % 60
return "%02d:%02d".format(m, s)
}
private fun callStatusText(phase: CallPhase): String = when (phase) {
CallPhase.OUTGOING -> "Звоним…"
CallPhase.RINGING -> "Гудки…"
CallPhase.ACTIVE -> "Разговор"
CallPhase.ENDED -> "Завершён"
else -> ""
}
@Composable
private fun CallControlButton(
icon: ImageVector,
contentDescription: String,
label: String,
active: Boolean,
activeColor: Color = Color(0xFF1F6FEB),
onClick: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(60.dp)
.background(if (active) activeColor else Color(0xFF24323D), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = onClick) {
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
}
}
Spacer(Modifier.height(6.dp))
Text(label, color = if (active) Color.White else Color(0xFF9BA6AD), fontSize = 11.sp)
}
}
@Composable
fun CallScreen(
viewModel: ChatViewModel,
onClose: () -> Unit,
onMinimize: () -> Unit
) {
val st = viewModel.callState.collectAsState().value
val muted by viewModel.muted.collectAsState()
val route by viewModel.audioRoute.collectAsState()
val headsetAvailable by viewModel.headsetAvailable.collectAsState()
val earpieceAvailable by viewModel.earpieceAvailable.collectAsState()
val phase = st?.phase ?: CallPhase.NONE
val duration = st?.durationMs ?: 0
val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val context = LocalContext.current
val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
val btPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET)
}
Box(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onMinimize,
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(8.dp)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Свернуть звонок", tint = Color.White, modifier = Modifier.size(24.dp))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(
name.take(1).uppercase(),
fontSize = 40.sp,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(24.dp))
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase),
color = Color(0xFF9BA6AD),
fontSize = 16.sp
)
Spacer(Modifier.height(40.dp))
if (phase == CallPhase.ENDED) {
Button(onClick = { viewModel.dismissCall(); onClose() }) {
Text("Закрыть")
}
} else {
if (phase == CallPhase.ACTIVE) {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
if (earpieceAvailable) {
CallControlButton(
icon = Icons.Filled.PhoneInTalk,
contentDescription = "Динамик",
label = "Динамик",
active = route == AudioRoute.EARPIECE,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
)
}
CallControlButton(
icon = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Громкая связь",
label = "Громкая",
active = route == AudioRoute.SPEAKER,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
)
if (headsetAvailable) {
CallControlButton(
icon = Icons.Filled.Headset,
contentDescription = "Гарнитура",
label = "Гарнитура",
active = route == AudioRoute.HEADSET,
onClick = {
if (hasBtPermission) {
viewModel.setAudioRoute(AudioRoute.HEADSET)
} else {
btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
)
}
}
Spacer(Modifier.height(28.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically) {
if (phase == CallPhase.ACTIVE) {
CallControlButton(
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон",
label = if (muted) "Вкл. микрофон" else "Mute",
active = muted,
activeColor = Color(0xFFE53935),
onClick = { viewModel.toggleMute() }
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(72.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.hangupCall() }) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(36.dp))
}
}
Spacer(Modifier.height(6.dp))
Text("Завершить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
}
}
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.DmConversation
import com.utun.chat.data.NotificationSound
import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch
@@ -31,9 +32,11 @@ import kotlinx.coroutines.launch
@Composable
fun ChannelListScreen(
channels: List<Channel>,
dmConversations: List<DmConversation> = emptyList(),
connected: Boolean,
isStuck: Boolean = false,
onSelectChannel: (Channel) -> Unit,
onSelectDm: (DmConversation) -> Unit = {},
onCreateChannel: (String) -> Unit,
onJoinChannel: () -> Unit,
onInviteClick: (Channel) -> Unit,
@@ -72,7 +75,7 @@ fun ChannelListScreen(
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
if (channels.isEmpty()) {
if (channels.isEmpty() && dmConversations.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f)) {
Text(
"No channels yet.\nTap + to create one.",
@@ -147,6 +150,35 @@ fun ChannelListScreen(
}
HorizontalDivider()
}
if (dmConversations.isNotEmpty()) {
item {
Text(
"Личные",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
items(dmConversations) { dm ->
Surface(modifier = Modifier.clickable { onSelectDm(dm) }) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFF8E44AD)))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Text("@", fontSize = 22.sp, color = Color(0xFF8E44AD))
}
Spacer(Modifier.width(10.dp))
Column {
Text(dm.displayName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
}
HorizontalDivider()
}
}
}
}
}
@@ -0,0 +1,97 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.utun.chat.data.DmConversation
import com.utun.chat.data.Message
import com.utun.chat.ui.components.MessageBubble
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DmChatScreen(
conv: DmConversation,
messages: List<Message>,
onSendMessage: (String) -> Unit,
onBack: () -> Unit
) {
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.lastOrNull()?.isOutgoing == true) {
listState.scrollToItem(0)
}
}
Column(modifier = Modifier.fillMaxSize().imePadding()) {
TopAppBar(
title = { Text(conv.displayName) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}
)
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState,
reverseLayout = true
) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed()) { msg ->
MessageBubble(msg)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.navigationBarsPadding(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Сообщение") },
maxLines = 4
)
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
if (text.isNotBlank()) {
onSendMessage(text)
text = ""
}
}) {
Icon(Icons.AutoMirrored.Filled.Send, "Send", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
@@ -0,0 +1,76 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.utun.chat.viewmodel.ChatViewModel
@Composable
fun IncomingCallDialog(
viewModel: ChatViewModel
) {
val st = viewModel.callState.collectAsState().value ?: return
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
Dialog(onDismissRequest = { }) {
Surface(
shape = MaterialTheme.shapes.large,
color = Color(0xFF1B2733)
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.size(80.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(name.take(1).uppercase(), fontSize = 34.sp, color = Color.White, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(20.dp))
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(6.dp))
Text("Входящий звонок…", color = Color(0xFF9BA6AD), fontSize = 14.sp)
Spacer(Modifier.height(28.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.declineCall() }) {
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Отклонить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.acceptCall() }) {
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Принять", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
}
}
}
}
}
@@ -84,6 +84,8 @@ fun MemberListScreen(
isSelected = selectedMember?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) },
onEdit = {
editMember = m
showEditDialog = true
@@ -135,21 +137,15 @@ private fun MemberListItem(
isSelected: Boolean = false,
onClick: () -> Unit,
onConnect: () -> Unit,
onCall: () -> Unit,
onMessage: () -> Unit,
onEdit: () -> Unit
) {
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
member.directUp || member.indirectUp -> Color(0xFF4CAF50) // green: up
member.directPresence || member.indirectPresence || member.bgpPresence -> Color(0xFFFFA726) // orange: presence but down
member.online -> Color(0xFF4CAF50) // green: reachable (direct/indirect/relay)
else -> Color(0xFF9E9E9E) // gray: offline
}
val connLabel = when {
member.connecting -> "connecting"
member.directUp -> "direct"
member.indirectUp -> "indirect"
member.directPresence || member.indirectPresence || member.bgpPresence -> "offline"
else -> ""
}
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
Surface(
@@ -211,14 +207,14 @@ private fun MemberListItem(
else if (member.connecting) parts.add("connecting…")
else if (member.directUp) parts.add("direct↑")
else if (member.indirectUp) parts.add("indirect↑")
else if (member.directPresence || member.indirectPresence || member.bgpPresence) parts.add("down")
else if (member.online) parts.add("relay")
if (member.rtt > 0) parts.add("${member.rtt}ms")
if (parts.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = parts.joinToString(" "),
fontSize = 11.sp,
color = if (member.isSelf || member.directUp || member.indirectUp)
color = if (member.isSelf || member.online)
Color(0xFF4CAF50) else Color.Gray
)
}
@@ -227,6 +223,11 @@ private fun MemberListItem(
if (!member.isSelf) {
TextButton(onClick = onConnect) { Text("Connect") }
TextButton(
onClick = onCall,
enabled = member.online
) { Text("Call") }
TextButton(onClick = onMessage) { Text("Message") }
}
TextButton(onClick = onEdit) { Text("Edit") }
}
@@ -18,12 +18,18 @@ import androidx.compose.ui.unit.dp
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.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -519,6 +525,43 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Входящие звонки поверх блокировки ── */
Text("Incoming calls", style = MaterialTheme.typography.titleMedium)
var fsiEnabled by remember { mutableStateOf(IncomingCallManager.canFullScreenIntent()) }
LifecycleResumeEffect(Unit) {
fsiEnabled = IncomingCallManager.canFullScreenIntent()
onPauseOrDispose { }
}
Row(
modifier = Modifier.fillMaxWidth().clickable(
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !fsiEnabled
) {
val ctx = ChatApplication.instance
try {
ctx.startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:${ctx.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open full-screen-intent settings failed: ${e.message}")
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("Show over lock screen")
Text(
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "Always allowed"
fsiEnabled -> "Allowed"
else -> "Disabled — tap to enable"
},
style = MaterialTheme.typography.bodySmall,
color = if (fsiEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Microphone test ── */
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -22,6 +22,8 @@ import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
class ChatViewModel : ViewModel() {
data class PlaybackState(
@@ -33,9 +35,41 @@ class ChatViewModel : ViewModel() {
val durMs: Int = 0
)
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallState(
val callId: Long = 0,
val channelId: String = "",
val peerNodeId: Long = 0,
val peerName: String = "",
val phase: CallPhase = CallPhase.NONE,
val reason: Int = -1,
val durationMs: Int = 0
)
private var repo: ChatRepository? = null
private var dbReady = false
private val _callState = MutableStateFlow<CallState?>(null)
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
private var callStartedMs = 0L
private var callIncomingAtMs = 0L
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val audioRoute: StateFlow<AudioRoute> = _audioRoute
private val _headsetAvailable = MutableStateFlow(false)
val headsetAvailable: StateFlow<Boolean> = _headsetAvailable
private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState
@@ -87,6 +121,22 @@ class ChatViewModel : ViewModel() {
private val _inviteLinkPending = MutableStateFlow(false)
val inviteLinkPending: StateFlow<Boolean> = _inviteLinkPending
private val _dmConversations = MutableStateFlow<List<DmConversation>>(emptyList())
val dmConversations: StateFlow<List<DmConversation>> = _dmConversations
private val _dmMessages = MutableStateFlow<List<Message>>(emptyList())
val dmMessages: StateFlow<List<Message>> = _dmMessages
private val _currentDm = MutableStateFlow<DmConversation?>(null)
val currentDm: StateFlow<DmConversation?> = _currentDm
/* Одноразовый сигнал навигации: MainActivity открывает экран DM и сбрасывает. */
private val _dmOpen = MutableStateFlow<String?>(null)
val dmOpen: StateFlow<String?> = _dmOpen
/* peer-данные начатой беседы — для автооткрытия по событию DM_CONV_UPDATED. */
private var pendingStartPeer: Pair<Long, String>? = null
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
@@ -138,6 +188,17 @@ class ChatViewModel : ViewModel() {
refreshChannels()
}
}
viewModelScope.launch {
delay(300)
val p = IncomingCallManager.consumePendingIncoming() ?: return@launch
val cur = _callState.value
if (cur != null && cur.callId == p.callId) return@launch
LogManager.addLog("INFO", "VM", "restore pending incoming call 0x%016x ch=%s".format(p.callId, p.channelId))
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
resolveCallerName(p.channelId, p.peerNodeId)
}
viewModelScope.launch {
_foreground.collectLatest { fg ->
while (fg && isActive) {
@@ -180,6 +241,30 @@ class ChatViewModel : ViewModel() {
}
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.ACTIVE) {
val dur = ((System.currentTimeMillis() - callStartedMs) / 1000).toInt()
_callState.value = st.copy(durationMs = dur)
}
delay(500)
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.INCOMING &&
callIncomingAtMs > 0 &&
System.currentTimeMillis() - callIncomingAtMs > CALL_INCOMING_TIMEOUT_MS) {
LogManager.addLog("WARN", "VM", "INCOMING watchdog: no resolution, clearing call 0x%016x".format(st.callId))
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_callState.value = null
}
delay(1000)
}
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
@@ -417,9 +502,210 @@ class ChatViewModel : ViewModel() {
}
refreshChannels()
}
33 -> { /* DM_CONV_UPDATED: [conv_id_len:1][conv_id:var] */
if (data == null || data.size < 2) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl) return
val convId = String(data, 1, cl)
LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId")
refreshDmConversations()
_currentDm.value?.let { if (it.convId == convId) refreshDmMessages() }
val pend = pendingStartPeer
if (pend != null) {
pendingStartPeer = null
_currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second)
refreshDmMessages()
_dmOpen.value = convId
LogManager.addLog("INFO", "VM", "DM auto-open conv=$convId")
}
}
32 -> { /* DM_MSG_RECEIVED: [conv_id_len:1][conv_id:var][author_node_id:8] */
if (data == null || data.size < 1 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 8) return
val convId = String(data, 1, cl)
val author = java.nio.ByteBuffer.wrap(data, 1 + cl, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
LogManager.addLog("INFO", "VM", "DM_MSG_RECEIVED conv=$convId author=0x%016x".format(author))
if (author != NativeLib.getMyNodeId()) NotificationSound.play("dm:$convId")
val cur = _currentDm.value
if (cur != null && cur.convId == convId) refreshDmMessages()
refreshDmConversations()
}
34 -> { /* CALL_INCOMING: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 8 + 8) return
val chId = String(data, 1, cl)
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val caller = buf.getLong(); val callId = buf.getLong()
LogManager.addLog("INFO", "VM", "CALL_INCOMING ch=$chId caller=0x%016x id=0x%016x".format(caller, callId))
val cur = _callState.value
if (cur != null && cur.callId == callId) return // уже обрабатываем этот звонок (replay/restore)
if (cur != null && cur.callId != callId && cur.phase != CallPhase.ENDED) {
// заняты другим звонком — авто-отклоняем
LogManager.addLog("WARN", "VM", "busy with another call, auto-decline id=0x%016x".format(callId))
NativeLib.callDecline(callId)
return
}
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
resolveCallerName(chId, caller)
}
35 -> { /* CALL_RINGING: [call_id:8] */
if (data == null || data.size < 8) return
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val st = _callState.value
if (st != null && st.callId == callId) {
_callState.value = st.copy(phase = CallPhase.RINGING)
LogManager.addLog("INFO", "VM", "CALL_RINGING id=0x%016x".format(callId))
}
}
36 -> { /* CALL_ACCEPTED: [call_id:8] */
if (data == null || data.size < 8) return
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val st = _callState.value
if (st != null && st.callId == callId) {
LogManager.addLog("INFO", "VM", "CALL_ACCEPTED id=0x%016x".format(callId))
callAudio.router.setPreferredRoute(
ChatApplication.instance.configProvider.getCallAudioRoute())
if (!callAudio.start(callId)) {
LogManager.addLog("ERROR", "VM", "call audio engine start failed")
NativeLib.callHangup(callId)
_callState.value = st.copy(phase = CallPhase.ENDED, reason = 7)
} else {
callStartedMs = System.currentTimeMillis()
_muted.value = false
callAudio.router.onChanged = { r, headset ->
_audioRoute.value = r
_headsetAvailable.value = headset
}
_audioRoute.value = callAudio.router.route
_headsetAvailable.value = callAudio.router.headsetAvailable
_earpieceAvailable.value = callAudio.router.hasEarpiece
_callState.value = st.copy(phase = CallPhase.ACTIVE, durationMs = 0)
}
}
}
37 -> { /* CALL_ENDED: [call_id:8][reason:1] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val reason = data[8].toInt() and 0xFF
finishCall(callId, reason, "ENDED")
}
38 -> { /* CALL_DECLINED: [call_id:8][reason:1] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val reason = data[8].toInt() and 0xFF
finishCall(callId, reason, "DECLINED")
}
40 -> { /* CALL_ERROR: [call_id:8][err:1][text:var] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val err = data[8].toInt() and 0xFF
val text = if (data.size > 9) String(data, 9, data.size - 9) else ""
LogManager.addLog("ERROR", "VM", "CALL_ERROR id=0x%016x err=$err $text".format(callId))
finishCall(callId, 7, "ERROR")
}
}
}
private fun resolveCallerName(chId: String, caller: Long) {
val r = repo ?: return
viewModelScope.launch {
val name = withContext(Dispatchers.IO) { r.getMembers(chId) }
.firstOrNull { it.nodeId == caller }?.name?.takeIf { it.isNotEmpty() }
val st = _callState.value
if (name != null && st != null && st.peerNodeId == caller && st.phase == CallPhase.INCOMING) {
_callState.value = st.copy(peerName = name)
}
}
}
private fun finishCall(callId: Long, reason: Int, tag: String) {
val st = _callState.value
LogManager.addLog("INFO", "VM", "CALL_$tag id=0x%016x reason=$reason".format(callId))
if (st == null || st.callId != callId) return
callAudio.stop()
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_muted.value = false
_audioRoute.value = AudioRoute.SPEAKER
_headsetAvailable.value = false
_earpieceAvailable.value = true
_callState.value = st.copy(phase = CallPhase.ENDED, reason = reason)
viewModelScope.launch {
delay(2500)
if (_callState.value?.callId == callId) _callState.value = null
}
}
fun startCall(channelId: String, peerNodeId: Long, peerName: String) {
val existing = _callState.value
if (existing != null && existing.phase != CallPhase.ENDED) {
LogManager.addLog("WARN", "VM", "startCall: call already active")
return
}
var callId = java.security.SecureRandom().nextLong()
if (callId == 0L) callId = 1
val name = peerName.ifEmpty { "0x%016x".format(peerNodeId) }
_callState.value = CallState(callId = callId, channelId = channelId, peerNodeId = peerNodeId,
peerName = name, phase = CallPhase.OUTGOING)
NativeLib.callStart(channelId, peerNodeId, callId)
LogManager.addLog("INFO", "VM", "startCall ch=$channelId peer=0x%016x id=0x%016x".format(peerNodeId, callId))
}
fun acceptCall() {
val st = _callState.value ?: return
if (st.phase != CallPhase.INCOMING) {
LogManager.addLog("WARN", "VM", "acceptCall ignored: phase=${st.phase} id=0x%016x".format(st.callId))
return
}
callIncomingAtMs = 0L
IncomingCallManager.stopRinging()
NativeLib.callAccept(st.callId)
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
}
fun declineCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.INCOMING) {
NativeLib.callDecline(st.callId)
finishCall(st.callId, 2, "DECLINE")
} else {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun hangupCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.OUTGOING || st.phase == CallPhase.RINGING || st.phase == CallPhase.ACTIVE) {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun dismissCall() {
callAudio.stop()
callIncomingAtMs = 0L
_callState.value = null
}
fun toggleMute() {
val m = !callAudio.isMuted()
callAudio.setMuted(m)
_muted.value = m
LogManager.addLog("INFO", "VM", "toggleMute -> ${if (m) "on" else "off"}")
}
fun setAudioRoute(r: AudioRoute) {
callAudio.router.setRoute(r)
viewModelScope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(r) }
LogManager.addLog("INFO", "VM", "setAudioRoute -> $r")
}
fun requestRegenerateKeys() {
_generatingKeys.value = true
NativeLib.regenerateKeys()
@@ -461,6 +747,7 @@ class ChatViewModel : ViewModel() {
val r = repo ?: return
viewModelScope.launch {
_channels.value = withContext(Dispatchers.IO) { r.getChannels() }
_dmConversations.value = withContext(Dispatchers.IO) { r.getDmConversations() }
}
}
@@ -478,6 +765,58 @@ class ChatViewModel : ViewModel() {
refreshMessages(channel.id)
}
/* ── DM (личные сообщения) ── */
fun refreshDmConversations() {
val r = repo ?: return
viewModelScope.launch {
_dmConversations.value = withContext(Dispatchers.IO) { r.getDmConversations() }
}
}
fun refreshDmMessages() {
val r = repo ?: return
val dm = _currentDm.value ?: return
viewModelScope.launch {
_dmMessages.value = withContext(Dispatchers.IO) { r.getDmMessages(dm.convId, dm.peerName) }
}
}
fun startDm(channelId: String, nodeId: Long, name: String) {
val r = repo ?: return
if (nodeId == 0L || nodeId == NativeLib.getMyNodeId()) return
LogManager.addLog("INFO", "VM", "startDm ch=$channelId peer=0x%016x name=$name".format(nodeId))
pendingStartPeer = nodeId to name
r.dmStart(nodeId, channelId, name)
}
fun consumeDmOpen() { _dmOpen.value = null }
fun openDm(convId: String, peerNodeId: Long, peerName: String) {
val r = repo ?: return
val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName)
_currentDm.value = dm
viewModelScope.launch {
_dmMessages.value = withContext(Dispatchers.IO) { r.getDmMessages(convId, peerName) }
}
}
fun closeDm() {
_currentDm.value = null
_dmMessages.value = emptyList()
}
fun sendDmMessage(text: String) {
val r = repo ?: return
val dm = _currentDm.value ?: return
if (text.isBlank()) return
r.dmSend(dm.convId, text)
viewModelScope.launch {
_dmMessages.value = withContext(Dispatchers.IO) { r.getDmMessages(dm.convId, dm.peerName) }
refreshDmConversations()
}
}
fun refreshMembers(chId: String) {
val r = repo ?: return
val statuses = _nodeStatuses.value
@@ -811,6 +1150,8 @@ class ChatViewModel : ViewModel() {
}
fun clearState() {
callAudio.stop()
_callState.value = null
repo?.close()
repo = null
dbReady = false
@@ -16,6 +16,9 @@
#include "../libutun_lite/video_sender.h"
#include "../libutun_lite/photo_sender.h"
#include "../libutun_lite/standby.h"
#include "../../../src/call/call_audio.h"
#include "../../../src/call/call.h"
#include "../../../src/dm/dm_core.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/u_async.h"
#include "../../../lib/mem.h"
@@ -600,6 +603,63 @@ uint64_t utun_bridge_get_my_node_id(void) {
return (inst && chat_core_is_initialized(inst)) ? inst->node_id : 0;
}
/* ── DM (личные сообщения) ── */
void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name) {
if (!node_id) { bridge_log(BLEV_ERROR, "dm_start: invalid node_id=0"); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "dm_start: uasync not running"); return; }
struct dm_start_req* req = u_calloc(1, sizeof(*req));
if (!req) { bridge_log(BLEV_ERROR, "dm_start: OOM"); return; }
req->inst = instance_lite_get_instance();
req->peer_node_id = node_id;
if (source_ch_id) strncpy(req->source_ch_id, source_ch_id, sizeof(req->source_ch_id) - 1);
if (peer_name) strncpy(req->peer_name, peer_name, sizeof(req->peer_name) - 1);
bridge_log(BLEV_INFO, "dm_start queued peer=0x%016llx ch=%s",
(unsigned long long)node_id, source_ch_id ? source_ch_id : "");
uasync_post(ua, dm_start_trampoline, req);
}
void utun_bridge_dm_send(const char* conv_id, const char* text) {
if (!conv_id || !conv_id[0] || !text) { bridge_log(BLEV_ERROR, "dm_send: invalid args"); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "dm_send: uasync not running"); return; }
size_t len = strlen(text);
struct dm_send_req* req = u_calloc(1, sizeof(struct dm_send_req) + len);
if (!req) { bridge_log(BLEV_ERROR, "dm_send: OOM"); return; }
req->inst = instance_lite_get_instance();
strncpy(req->conv_id, conv_id, sizeof(req->conv_id) - 1);
req->data_len = (uint32_t)len;
req->data = (uint8_t*)(req + 1);
memcpy(req->data, text, len);
bridge_log(BLEV_DEBUG, "dm_send queued conv=%s len=%zu", conv_id, len);
uasync_post(ua, dm_send_trampoline, req);
}
char* utun_bridge_dm_list_conversations_json(void) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
char buf[16384]; size_t len = 0;
if (!inst || dm_list_conversations_json(inst, buf, sizeof(buf), &len) < 0)
return u_strdup("[]");
return u_strdup(buf);
}
char* utun_bridge_dm_list_messages_json(const char* conv_id, int count, int offset) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst || !conv_id || !conv_id[0]) return u_strdup("[]");
size_t cap = 262144;
char* buf = u_malloc(cap);
if (!buf) { bridge_log(BLEV_ERROR, "dm_list_messages: OOM"); return u_strdup("[]"); }
size_t len = 0;
if (dm_list_messages_json(inst, conv_id, count, offset, buf, cap, &len) < 0) {
u_free(buf);
return u_strdup("[]");
}
char* out = u_strdup(buf);
u_free(buf);
return out;
}
void utun_bridge_set_debug_level(const char* category, const char* level) {
if (!level) return;
if (!category || !category[0]) {
@@ -1320,6 +1380,82 @@ void utun_bridge_voice_decode_close(void* handle) {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "voice_decode_close");
}
/* ──────────────────────────────────────────────────────────────────
* P2P аудио-звонок (сигналинг)
* ────────────────────────────────────────────────────────────────── */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id) {
if (!ch_id || !ch_id[0] || peer_node_id == 0 || call_id == 0) {
bridge_log(BLEV_ERROR, "call_start: bad args ch=%s peer=0x%llx id=0x%llx",
ch_id ? ch_id : "(null)",
(unsigned long long)peer_node_id, (unsigned long long)call_id);
return;
}
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "call_start: uasync not running"); return; }
struct call_start_arg* a = (struct call_start_arg*)u_calloc(1, sizeof(*a));
if (!a) { bridge_log(BLEV_ERROR, "call_start: OOM"); return; }
a->inst = instance_lite_get_instance();
snprintf(a->ch_id, sizeof(a->ch_id), "%s", ch_id);
a->peer_node_id = peer_node_id;
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_start ch=%s peer=0x%llx id=0x%llx",
ch_id, (unsigned long long)peer_node_id, (unsigned long long)call_id);
uasync_post(ua, call_start_trampoline, a);
}
static void bridge_call_id_action(uint64_t call_id, const char* name,
void (*trampoline)(void*)) {
if (call_id == 0) { bridge_log(BLEV_ERROR, "call_%s: bad call_id=0", name); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "call_%s: uasync not running", name); return; }
struct call_id_arg* a = (struct call_id_arg*)u_calloc(1, sizeof(*a));
if (!a) { bridge_log(BLEV_ERROR, "call_%s: OOM", name); return; }
a->inst = instance_lite_get_instance();
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_%s id=0x%llx", name, (unsigned long long)call_id);
uasync_post(ua, trampoline, a);
}
void utun_bridge_call_accept(uint64_t call_id) { bridge_call_id_action(call_id, "accept", call_accept_trampoline); }
void utun_bridge_call_decline(uint64_t call_id) { bridge_call_id_action(call_id, "decline", call_decline_trampoline); }
void utun_bridge_call_hangup(uint64_t call_id) { bridge_call_id_action(call_id, "hangup", call_hangup_trampoline); }
int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst) return 0;
return call_is_online(inst, ch_id, peer_node_id);
}
/* ──────────────────────────────────────────────────────────────────
* P2P аудио-звонок (медиа: call_audio.c)
* ────────────────────────────────────────────────────────────────── */
int utun_bridge_call_audio_start(uint64_t call_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: no instance");
return -1;
}
if (call_audio_init(inst) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: call_audio_init failed");
return -1;
}
return call_audio_start(call_id);
}
int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count) {
return call_audio_feed_pcm(call_id, pcm, count);
}
void utun_bridge_call_audio_stop(void) {
call_audio_stop();
}
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);
}
/* ──────────────────────────────────────────────────────────────────
* JNI functions (compiled only for Android via NDK)
* ────────────────────────────────────────────────────────────────── */
@@ -1396,6 +1532,8 @@ void instance_event_handler(int type, const uint8_t* data, int len) {
jni_on_event(type, data, len);
}
/* ── JNI call-audio: PCM отдаётся через nativeCallAudioPull (см. ниже) ── */
static jstring jni_get_string_callback(JNIEnv* env, const char* key) {
if (!g_config_callback) return NULL;
jclass cls = (*env)->GetObjectClass(env, g_config_callback);
@@ -1631,6 +1769,46 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages(
return result;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmStart(
JNIEnv* env, jobject thiz, jlong nodeId, jstring sourceChId, jstring peerName) {
(void)thiz;
const char* ch = sourceChId ? (*env)->GetStringUTFChars(env, sourceChId, NULL) : NULL;
const char* nm = peerName ? (*env)->GetStringUTFChars(env, peerName, NULL) : NULL;
utun_bridge_dm_start((uint64_t)nodeId, ch, nm);
if (nm) (*env)->ReleaseStringUTFChars(env, peerName, nm);
if (ch) (*env)->ReleaseStringUTFChars(env, sourceChId, ch);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmSend(
JNIEnv* env, jobject thiz, jstring convId, jstring text) {
(void)thiz;
const char* c = (*env)->GetStringUTFChars(env, convId, NULL);
const char* t = (*env)->GetStringUTFChars(env, text, NULL);
utun_bridge_dm_send(c, t);
if (t) (*env)->ReleaseStringUTFChars(env, text, t);
if (c) (*env)->ReleaseStringUTFChars(env, convId, c);
}
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDmListConversations(
JNIEnv* env, jobject thiz) {
(void)thiz;
char* json = utun_bridge_dm_list_conversations_json();
jstring result = (*env)->NewStringUTF(env, json);
u_free(json);
return result;
}
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDmListMessages(
JNIEnv* env, jobject thiz, jstring convId, jint count, jint offset) {
(void)thiz;
const char* c = (*env)->GetStringUTFChars(env, convId, NULL);
char* json = utun_bridge_dm_list_messages_json(c, (int)count, (int)offset);
jstring result = (*env)->NewStringUTF(env, json);
u_free(json);
if (c) (*env)->ReleaseStringUTFChars(env, convId, c);
return result;
}
JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMyNodeId(
JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;
@@ -2045,4 +2223,80 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCollectConnMetric
utun_bridge_collect_conn_metrics((uint64_t)peerNodeId);
}
/* ── P2P аудио-звонок (сигналинг) ── */
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallStart(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId, jlong callId) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
utun_bridge_call_start(ch, (uint64_t)peerNodeId, (uint64_t)callId);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAccept(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_accept((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallDecline(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_decline((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallHangup(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_hangup((uint64_t)callId);
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallIsOnline(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
int online = utun_bridge_call_is_online(ch, (uint64_t)peerNodeId);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
return online ? JNI_TRUE : JNI_FALSE;
}
/* ── P2P аудио-звонок (медиа) ── */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStart(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
return (utun_bridge_call_audio_start((uint64_t)callId) == 0) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioFeed(
JNIEnv* env, jobject thiz, jlong callId, jshortArray samples) {
(void)env; (void)thiz;
if (!samples) return JNI_FALSE;
jsize len = (*env)->GetArrayLength(env, samples);
jshort* data = (*env)->GetShortArrayElements(env, samples, NULL);
int rc = utun_bridge_call_audio_feed((uint64_t)callId, (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_nativeCallAudioStop(
JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;
utun_bridge_call_audio_stop();
}
/* ── nativeCallAudioPull(callId, out) → возвращает число сэмплов ──
* out — предвыделенный ShortArray (960), заполняется из аудио-потока Kotlin. */
JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioPull(
JNIEnv* env, jobject thiz, jlong callId, 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_call_audio_pull((uint64_t)callId, (int16_t*)data, (int)len);
(*env)->ReleaseShortArrayElements(env, out, data, 0);
return (jint)n;
}
#endif /* __ANDROID__ */
@@ -118,6 +118,21 @@ void* utun_bridge_voice_decode_open(const char* file_path,
int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples);
void utun_bridge_voice_decode_close(void* handle);
/* ── P2P аудио-звонок (сигналинг + медиа) ── */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id);
void utun_bridge_call_accept(uint64_t call_id);
void utun_bridge_call_decline(uint64_t call_id);
void utun_bridge_call_hangup(uint64_t call_id);
int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id);
/* Аудио-движок звонка (call_audio.c, единый голосовой стек): PCM из AudioRecord →
* Opus → peer (feed); принятый Opus → джиттер+декод+stretch → PCM (pull). */
int utun_bridge_call_audio_start(uint64_t call_id);
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);
/* ── Invite link (for sharing) — асинхронно через uasync-поток.
Результат приходит событиями CHAT_EVT_INVITE_CANDIDATES / CHAT_EVT_INVITE_LINK_READY. ── */
void utun_bridge_request_invite_candidates(const char* channel_id);
@@ -171,6 +186,14 @@ void utun_bridge_notify_network_active(void);
void utun_bridge_collect_conn_list(void);
void utun_bridge_collect_conn_metrics(uint64_t peer_node_id);
/* ── DM (личные сообщения) ──
* dm_start/dm_send выполняются асинхронно через uasync (трамплины dm_core).
* Результат dm_start — событие CHAT_EVT_DM_CONV_UPDATED. */
void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name);
void utun_bridge_dm_send(const char* conv_id, const char* text);
char* utun_bridge_dm_list_conversations_json(void);
char* utun_bridge_dm_list_messages_json(const char* conv_id, int count, int offset);
#ifdef __cplusplus
}
#endif
@@ -29,6 +29,10 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
list(FILTER _src_src EXCLUDE REGEX "/lwip_orig/")
list(FILTER _src_src EXCLUDE REGEX "/uip/")
list(FILTER _src_src EXCLUDE REGEX "/transport_layer/BBR/")
# голосовой стек (call_audio/call_tones/voice_jitter+SoundTouch) собирается отдельной
# 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(REMOVE_ITEM _src_src "${SRC_DIR}/utun.c")
# ── Our added files ──
+24 -2
View File
@@ -83,6 +83,18 @@ endif()
# uTun core library (C, built from ../src/ and ../lib/)
add_subdirectory(libutun)
# Голосовой стек звонка (SoundTouch + voice_jitter + call_audio + call_tones),
# C++ внутри, C-интерфейс наружу (call_audio.h).
set(LIB_DIR "${CMAKE_SOURCE_DIR}/../../lib")
set(CALL_DIR "${CMAKE_SOURCE_DIR}/../../src/call")
include(${CALL_DIR}/voice_sources.cmake)
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_target_properties(utun_voice PROPERTIES POSITION_INDEPENDENT_CODE ON)
add_executable(vibechat
src/main.cpp
src/mainwindow.cpp
@@ -127,6 +139,8 @@ add_executable(vibechat
src/audiodevicesettingspage.cpp
src/storagesettingspage.cpp
src/connmonitorwindow.cpp
src/callwindow.cpp
src/call_audio_engine.cpp
transport/utun_node.cpp
transport/node_config.cpp
transport/config_updater.cpp
@@ -144,9 +158,9 @@ if(FFMPEG_FOUND)
endif()
set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C)
if(WIN32)
target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
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 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 ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
endif()
# ============================================================================
@@ -183,6 +197,14 @@ target_include_directories(test_config_updater PRIVATE ${CMAKE_SOURCE_DIR})
target_link_libraries(test_config_updater PRIVATE ${QT_CORE} pthread)
add_test(NAME test_config_updater COMMAND test_config_updater)
# тест адаптивного джиттер-буфера голоса (кодированные кадры + SoundTouch time-stretch)
add_executable(test_voice_jitter
tests/test_voice_jitter.cpp
)
target_include_directories(test_voice_jitter PRIVATE ${CMAKE_SOURCE_DIR}/../../src)
target_link_libraries(test_voice_jitter PRIVATE utun_voice pthread)
add_test(NAME test_voice_jitter COMMAND test_voice_jitter)
# smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine <mp4>
add_executable(test_video_engine
tests/test_video_engine.cpp
+23
View File
@@ -302,6 +302,29 @@ MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const {
return m;
}
/* ── DM-беседы (UI, read-only) ── */
QList<DmConversation> DbManager::getDmConversations() const {
QList<DmConversation> list;
if (!m_db) return list;
sqlite3_stmt* stmt = prepareOrNull(
"SELECT conv_id, peer_node_id, COALESCE(peer_name,''), last_ts, last_out_seq, last_in_seq"
" FROM dm_conversations ORDER BY last_ts DESC");
if (!stmt) return list;
while (sqlite3_step(stmt) == SQLITE_ROW) {
DmConversation c;
c.convId = colText(stmt, 0);
c.peerNodeId = (quint64)sqlite3_column_int64(stmt, 1);
c.peerName = colText(stmt, 2);
c.lastTs = sqlite3_column_int64(stmt, 3);
c.lastOutSeq = sqlite3_column_int64(stmt, 4);
c.lastInSeq = sqlite3_column_int64(stmt, 5);
list.append(c);
}
sqlite3_finalize(stmt);
return list;
}
/* ── peers (UI) ── */
QMap<quint64, QString> DbManager::getLocalNicknames(const QString& chId) const {
+12
View File
@@ -67,6 +67,15 @@ struct IntegrityResult {
int elapsedMs = 0;
};
struct DmConversation {
QString convId;
quint64 peerNodeId = 0;
QString peerName;
qint64 lastTs = 0;
qint64 lastOutSeq = 0;
qint64 lastInSeq = 0;
};
class DbManager : public QObject {
Q_OBJECT
public:
@@ -93,6 +102,9 @@ public:
QList<MessageRow> getMessagesSince(const QString& chId, qint64 sinceId) const;
MessageRow getMessageById(const QString& chId, qint64 msgId) const;
/* ── DM-беседы (UI, read-only) ── */
QList<DmConversation> getDmConversations() const;
/* ── Участники канала (UI) ── */
void cleanOrphanedPeers(const QString& chId) const;
QString getDisplayName(quint64 nodeId) const;
+4
View File
@@ -77,6 +77,9 @@ 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
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_audio\\.c$")
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_tones\\.c$")
list(REMOVE_ITEM UTUN_COMMON_SOURCES "${SRC_DIR}/utun.c")
# Platform-specific TUN implementation
@@ -98,6 +101,7 @@ target_include_directories(utun PUBLIC
${SRC_DIR}/routing_layer
${SRC_DIR}/media_delivery
${SRC_DIR}/media_async
${SRC_DIR}/call
${LIB_DIR}
${SRC_DIR}/uip
${CMAKE_SOURCE_DIR}/db
+1
View File
@@ -10,6 +10,7 @@
<file>icons/mic.png</file>
<file>icons/send.png</file>
<file>sounds/msg_incoming.mp3</file>
<file>sounds/call_incoming.mp3</file>
<file>bg.jpg</file>
</qresource>
</RCC>
+14
View File
@@ -471,6 +471,10 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QString name = m_model->data(index, Qt::DisplayRole).toString();
QMenu menu(m_listView);
QAction* pmAction = menu.addAction(QString::fromUtf8("Написать сообщение"));
if (nodeId == m_db->myNodeId()) pmAction->setEnabled(false);
QAction* callAction = menu.addAction(QString::fromUtf8("Позвонить"));
if (nodeId == m_db->myNodeId() || !m_model->isOnlineAt(index.row())) callAction->setEnabled(false);
QAction* connectAction = menu.addAction("Connect");
if (nodeId == m_db->myNodeId()) connectAction->setEnabled(false);
QAction* renameAction = menu.addAction("Rename");
@@ -478,6 +482,16 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QAction* editAction = menu.addAction("Edit");
QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos));
if (chosen == pmAction) {
emit pmRequested(m_channelId, nodeId, name);
return;
}
if (chosen == callAction) {
emit callRequested(m_channelId, nodeId, name);
return;
}
if (chosen == connectAction) {
struct chat_connect_node_req* r = (struct chat_connect_node_req*)u_malloc(sizeof(*r));
if (!r) return;
+2
View File
@@ -67,6 +67,8 @@ public:
signals:
void memberRenamed();
void callRequested(const QString& channelId, quint64 nodeId, const QString& name);
void pmRequested(const QString& channelId, quint64 nodeId, const QString& name);
private slots:
void onMemberClicked(const QModelIndex& index);
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
/* Кольцевой буфер кадров переменной длины (SPSC, mutex-protected).
*
* Producer (uasync-поток) и consumer (аудио-поток). Память предвыделена одним
* блоком — ноль аллокаций на кадр. При переполнении дропается старейший кадр
* (семантика джиттер-буфера).
*/
class AudioFrameRing {
public:
AudioFrameRing(int capacity, int slotSize)
: cap_(capacity), slotSize_(slotSize),
buf_((size_t)capacity * (size_t)slotSize, 0),
lens_((size_t)capacity, 0) {}
/* Producer: положить кадр. Возвращает false если len > slotSize.
* При переполнении дропает старейший кадр (успех). */
bool push(const uint8_t* data, int len) {
if (len <= 0 || len > slotSize_) return false;
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == cap_) {
head_ = (head_ + 1) % cap_; /* drop oldest */
count_--;
}
std::memcpy(buf_.data() + (size_t)tail_ * slotSize_, data, (size_t)len);
lens_[(size_t)tail_] = (uint16_t)len;
tail_ = (tail_ + 1) % cap_;
count_++;
return true;
}
/* Consumer: взять старейший кадр. Возвращает длину (0 = пусто). */
int pop(uint8_t* out, int outCap) {
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == 0) return 0;
int len = (int)lens_[(size_t)head_];
if (out && len > 0 && len <= outCap)
std::memcpy(out, buf_.data() + (size_t)head_ * slotSize_, (size_t)len);
head_ = (head_ + 1) % cap_;
count_--;
return len;
}
void clear() {
std::lock_guard<std::mutex> lk(mtx_);
head_ = tail_ = count_ = 0;
}
int size() const {
std::lock_guard<std::mutex> lk(mtx_);
return count_;
}
int capacity() const { return cap_; }
private:
int cap_, slotSize_;
int head_ = 0, tail_ = 0, count_ = 0;
std::vector<uint8_t> buf_;
std::vector<uint16_t> lens_;
mutable std::mutex mtx_;
};
+183
View File
@@ -0,0 +1,183 @@
#include "call_audio_engine.h"
#include "sound_manager.h"
#include "../transport/gui_bridge.h"
#include "miniaudio.h"
extern "C" {
#include "call/call_audio.h"
#include "../../lib/debug_config.h"
}
#include <QTimer>
#include <cstring>
static CallAudioEngine* g_callAudioEngine = nullptr;
static void callAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) {
CallAudioEngine* e = (CallAudioEngine*)dev->pUserData;
if (e) e->dataCallback(in, out, frames);
}
CallAudioEngine* CallAudioEngine::instance() {
if (!g_callAudioEngine) g_callAudioEngine = new CallAudioEngine();
return g_callAudioEngine;
}
int CallAudioEngine::start(quint64 callId) {
if (m_active) {
if (m_callId == callId) return 0;
stop();
}
m_callId = callId;
struct UTUN_INSTANCE* inst = gui_bridge_get_inst();
if (!inst) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no instance, cannot start");
return -1;
}
if (call_audio_init(inst) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_init failed");
return -1;
}
if (call_audio_start(callId) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_start failed call=%016llx", (unsigned long long)callId);
return -1;
}
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0);
m_capturePos = 0;
m_muted = false;
if (initDevice() != 0) {
call_audio_release(callId);
return -1;
}
m_active = true;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: started call=%016llx", (unsigned long long)callId);
return 0;
}
int CallAudioEngine::initDevice() {
ma_context* ctx = SoundManager::instance()->context();
if (!ctx) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: 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 = CALL_AUDIO_SAMPLE_RATE;
config.periodSizeInFrames = 480;
config.dataCallback = callAudioDataCallback;
config.pUserData = this;
if (m_playbackDeviceIndex >= 0) {
ma_device_info* pPlaybackDevices = nullptr;
ma_uint32 playbackCount = 0;
if (ma_context_get_devices(ctx, &pPlaybackDevices, &playbackCount, nullptr, nullptr) == MA_SUCCESS &&
(ma_uint32)m_playbackDeviceIndex < playbackCount) {
config.playback.pDeviceID = &pPlaybackDevices[m_playbackDeviceIndex].id;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "CallAudio: using playback device[%d]: %s",
m_playbackDeviceIndex, pPlaybackDevices[m_playbackDeviceIndex].name);
} else {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: playback device[%d] not found, using default",
m_playbackDeviceIndex);
}
}
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device init failed, trying default");
config.playback.pDeviceID = nullptr;
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: 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, "CallAudio: 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 CallAudioEngine::setMuted(bool muted) {
if (m_muted.load() == muted) return;
m_muted = muted;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: mute %s", muted ? "on" : "off");
}
void CallAudioEngine::setPlaybackDeviceIndex(int index) {
if (index == m_playbackDeviceIndex) return;
int prev = m_playbackDeviceIndex;
m_playbackDeviceIndex = index;
if (m_active) {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device %d -> %d", prev, index);
teardownDevice();
if (initDevice() != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device failed (kept index=%d)", index);
}
}
void CallAudioEngine::beginEnd(quint64 callId) {
if (!m_active || m_callId != callId) return;
call_audio_begin_end(callId); /* старт тона завершения */
QTimer::singleShot(500, this, [this, callId]() {
if (m_callId == callId) stop();
});
}
void CallAudioEngine::stop() {
if (!m_active && !m_device && !m_deviceStarted) return;
m_active = false;
m_muted = false;
quint64 cid = m_callId.load();
teardownDevice();
call_audio_release(cid);
m_callId = 0;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: stopped");
}
void CallAudioEngine::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 CallAudioEngine::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 (сервисный слой). mute → тишина. */
if (in16) {
bool muted = m_muted.load();
for (unsigned int i = 0; i < frames; i++) {
m_captureBuf[m_capturePos++] = muted ? 0 : in16[i];
if (m_capturePos >= (int)m_captureBuf.size()) {
call_audio_feed_pcm(m_callId.load(), m_captureBuf.data(), (int)m_captureBuf.size());
m_capturePos = 0;
}
}
}
/* воспроизведение: финальный PCM из сервисного слоя (jitter+stretch+тоны) */
int n = call_audio_pull_pcm(m_callId.load(), out16, (int)frames);
if (n < (int)frames)
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t));
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <QObject>
#include <atomic>
#include <cstdint>
#include <vector>
struct ma_device;
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM.
*
* Потоки:
* - аудио-поток (miniaudio callback): capture → call_audio_feed_pcm,
* call_audio_pull_pcm → playback;
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */
class CallAudioEngine : public QObject {
Q_OBJECT
public:
static CallAudioEngine* instance();
/* Запустить полнодуплекс для звонка (после ACCEPTED). 0 = успех. */
int start(quint64 callId);
/* По CALL_ENDED: тон завершения (~450мс), затем авто-teardown. */
void beginEnd(quint64 callId);
/* Немедленный teardown (device + освобождение call_audio). */
void stop();
bool isActive() const { return m_active.load(); }
quint64 callId() const { return m_callId.load(); }
/* Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
void setMuted(bool muted);
bool isMuted() const { return m_muted.load(); }
/* Устройство вывода звонка (-1 = default). Смена на лету пересоздаёт ma_device. */
void setPlaybackDeviceIndex(int index);
int playbackDeviceIndex() const { return m_playbackDeviceIndex; }
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */
void dataCallback(const void* in, void* out, unsigned int frames);
private:
CallAudioEngine() = default;
void teardownDevice();
int initDevice();
std::atomic<bool> m_active{false};
std::atomic<uint64_t> m_callId{0};
std::atomic<bool> m_muted{false};
int m_playbackDeviceIndex = -1; /* только GUI-поток */
ma_device* m_device = nullptr;
bool m_deviceStarted = false;
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
int m_capturePos = 0;
};
+316
View File
@@ -0,0 +1,316 @@
#include "callwindow.h"
#include "../transport/gui_bridge.h"
#include "call_audio_engine.h"
#include "sound_manager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QComboBox>
#include <QTimer>
#include <QDateTime>
#include <QCloseEvent>
#include <QPainter>
#include <QPainterPath>
#include <cstring>
extern "C" {
#include "call/call.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
}
/* ── рисование иконок звонка (QPainter, без внешних ассетов) ── */
enum class Glyph { Phone, PhoneDown, Mic, MicOff };
static QColor colorForName(const QString& name) {
static const QColor palette[] = {
QColor("#5B9BD5"), QColor("#70AD47"), QColor("#ED7D31"),
QColor("#9B59B6"), QColor("#F4B400"), QColor("#E67E22"),
QColor("#16A085"), QColor("#D35400"),
};
quint32 h = 0;
for (const QChar c : name) h = h * 31u + c.unicode();
return palette[h % 8];
}
static QPixmap makeAvatar(const QString& name, int size) {
QPixmap pm(size, size);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setBrush(colorForName(name));
p.setPen(Qt::NoPen);
p.drawEllipse(0, 0, size, size);
QString initial = name.trimmed().isEmpty() ? QStringLiteral("?") : name.trimmed().left(1).toUpper();
p.setPen(Qt::white);
QFont f = p.font();
f.setBold(true);
f.setPixelSize(size / 2);
p.setFont(f);
p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, initial);
p.end();
return pm;
}
static void drawPhoneGlyph(QPainter* p, const QRectF& r, bool down) {
const qreal s = r.width();
p->save();
p->translate(r.center());
p->rotate(down ? 135.0 : -45.0);
const qreal w = s * 0.36, h = s * 0.64;
QPainterPath path;
path.addRoundedRect(QRectF(-w / 2, -h / 2, w, h), w * 0.5, w * 0.5);
p->setPen(Qt::NoPen);
p->setBrush(Qt::white);
p->drawPath(path);
p->restore();
}
static void drawMicGlyph(QPainter* p, const QRectF& r, bool off) {
const qreal s = r.width();
const QPointF c = r.center();
p->save();
QPen pen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
p->setPen(pen);
p->setBrush(Qt::NoBrush);
const qreal cw = s * 0.30;
p->drawRoundedRect(QRectF(c.x() - cw / 2, r.top() + s * 0.10, cw, s * 0.40), cw / 2, cw / 2);
p->drawArc(QRectF(c.x() - s * 0.20, r.top() + s * 0.42, s * 0.40, s * 0.26), 180 * 16, 180 * 16);
p->drawLine(QPointF(c.x(), r.top() + s * 0.60), QPointF(c.x(), r.top() + s * 0.68));
p->drawLine(QPointF(c.x() - s * 0.17, r.top() + s * 0.68), QPointF(c.x() + s * 0.17, r.top() + s * 0.68));
if (off) {
p->setPen(QPen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap));
p->drawLine(QPointF(c.x() - s * 0.26, c.y() - s * 0.26), QPointF(c.x() + s * 0.26, c.y() + s * 0.26));
}
p->restore();
}
static QIcon makeRoundIcon(const QColor& bg, Glyph glyph, int size) {
QPixmap pm(size, size);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(Qt::NoPen);
p.setBrush(bg);
p.drawEllipse(0, 0, size, size);
const QRectF glyphRect(size * 0.24, size * 0.24, size * 0.52, size * 0.52);
switch (glyph) {
case Glyph::Phone: drawPhoneGlyph(&p, glyphRect, false); break;
case Glyph::PhoneDown: drawPhoneGlyph(&p, glyphRect, true); break;
case Glyph::Mic: drawMicGlyph(&p, glyphRect, false); break;
case Glyph::MicOff: drawMicGlyph(&p, glyphRect, true); break;
}
p.end();
return QIcon(pm);
}
static QPushButton* makeRoundBtn(const QIcon& icon, const QString& tooltip, QWidget* parent) {
auto* btn = new QPushButton(parent);
btn->setIcon(icon);
btn->setIconSize(QSize(52, 52));
btn->setFixedSize(64, 64);
btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(tooltip);
btn->setStyleSheet(
"QPushButton { border: none; border-radius: 32px; background: transparent; }"
"QPushButton:pressed { background: rgba(255,255,255,0.15); }");
return btn;
}
static QWidget* wrapWithCaption(QWidget* btn, const QString& caption, QLabel** captionOut = nullptr) {
auto* w = new QWidget(btn->parentWidget());
auto* l = new QVBoxLayout(w);
l->setContentsMargins(0, 0, 0, 0);
l->setSpacing(6);
l->addWidget(btn, 0, Qt::AlignHCenter);
auto* cap = new QLabel(caption, w);
cap->setAlignment(Qt::AlignCenter);
cap->setStyleSheet("color: palette(text); font-size: 11px; background: transparent;");
l->addWidget(cap, 0, Qt::AlignHCenter);
if (captionOut) *captionOut = cap;
return w;
}
CallWindow::CallWindow(QWidget* parent)
: QWidget(parent, Qt::Window) {
setWindowTitle(QStringLiteral("Звонок"));
setFixedSize(340, 400);
setAttribute(Qt::WA_DeleteOnClose, false);
m_avatarLabel = new QLabel(this);
m_avatarLabel->setFixedSize(80, 80);
m_avatarLabel->setAlignment(Qt::AlignCenter);
m_peerLabel = new QLabel(this);
m_peerLabel->setAlignment(Qt::AlignCenter);
m_peerLabel->setStyleSheet("font-size: 17px; font-weight: bold; color: palette(text);");
m_statusLabel = new QLabel(this);
m_statusLabel->setAlignment(Qt::AlignCenter);
m_statusLabel->setStyleSheet("color: gray; font-size: 13px;");
m_durationLabel = new QLabel(QStringLiteral("00:00"), this);
m_durationLabel->setAlignment(Qt::AlignCenter);
m_durationLabel->setStyleSheet("font-size: 15px; color: palette(text); font-family: monospace;");
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);
m_muteBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Mic, 96), QStringLiteral("Mute"), this);
m_muteBtn->setCheckable(true);
m_deviceCombo = new QComboBox(this);
m_deviceCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
QVBoxLayout* lay = new QVBoxLayout(this);
lay->setContentsMargins(16, 20, 16, 16);
lay->setSpacing(8);
lay->addWidget(m_avatarLabel, 0, Qt::AlignHCenter);
lay->addWidget(m_peerLabel);
lay->addWidget(m_statusLabel);
lay->addWidget(m_durationLabel);
lay->addStretch(1);
m_acceptCell = wrapWithCaption(m_acceptBtn, QStringLiteral("Принять"));
m_declineCell = wrapWithCaption(m_declineBtn, QStringLiteral("Отклонить"));
m_hangupCell = wrapWithCaption(m_hangupBtn, QStringLiteral("Завершить"));
m_muteCell = wrapWithCaption(m_muteBtn, QStringLiteral("Mute"), &m_muteCaption);
QHBoxLayout* btn = new QHBoxLayout();
btn->setSpacing(10);
btn->addStretch(1);
btn->addWidget(m_acceptCell);
btn->addWidget(m_declineCell);
btn->addWidget(m_hangupCell);
btn->addWidget(m_muteCell);
btn->addStretch(1);
lay->addLayout(btn);
lay->addStretch(1);
lay->addWidget(m_deviceCombo);
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept);
connect(m_declineBtn, &QPushButton::clicked, this, &CallWindow::onDecline);
connect(m_hangupBtn, &QPushButton::clicked, this, &CallWindow::onHangup);
connect(m_muteBtn, &QPushButton::toggled, this, &CallWindow::onMuteToggled);
connect(m_deviceCombo, QOverload<int>::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged);
m_durationTimer = new QTimer(this);
m_durationTimer->setInterval(1000);
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration);
setState(Outgoing);
}
CallWindow::~CallWindow() = default;
void CallWindow::setPeerName(const QString& name) {
m_peerLabel->setText(name);
m_avatarLabel->setPixmap(makeAvatar(name, 80));
}
void CallWindow::setState(State s) {
m_state = s;
switch (s) {
case Outgoing:
m_statusLabel->setText(QStringLiteral("Звоним…"));
m_durationLabel->hide();
break;
case Incoming:
m_statusLabel->setText(QStringLiteral("Входящий звонок…"));
m_durationLabel->hide();
break;
case Active:
m_statusLabel->setText(QStringLiteral("Разговор"));
m_durationLabel->setText(QStringLiteral("00:00"));
m_durationLabel->show();
m_startMs = QDateTime::currentMSecsSinceEpoch();
m_durationTimer->start();
populateDeviceCombo();
m_muteBtn->setChecked(false);
break;
case Ended:
m_statusLabel->setText(QStringLiteral("Завершён"));
m_durationTimer->stop();
break;
}
updateButtons();
}
void CallWindow::populateDeviceCombo() {
m_deviceCombo->blockSignals(true);
m_deviceCombo->clear();
m_deviceCombo->addItem(QStringLiteral("Default (auto)"), -1);
const auto devs = SoundManager::enumeratePlaybackDevices();
for (int i = 0; i < devs.size(); i++) {
QString label = devs[i].name;
if (devs[i].isDefault) label += QStringLiteral(" (default)");
m_deviceCombo->addItem(label, i);
}
int cur = CallAudioEngine::instance()->playbackDeviceIndex();
for (int i = 0; i < m_deviceCombo->count(); i++) {
if (m_deviceCombo->itemData(i).toInt() == cur) { m_deviceCombo->setCurrentIndex(i); break; }
}
m_deviceCombo->blockSignals(false);
}
void CallWindow::updateButtons() {
m_acceptCell->setVisible(m_state == Incoming);
m_declineCell->setVisible(m_state == Incoming);
m_hangupCell->setVisible(m_state == Outgoing || m_state == Active);
m_muteCell->setVisible(m_state == Active);
m_deviceCombo->setVisible(m_state == Active);
}
void CallWindow::postCallFn(void (*fn)(void*), uint64_t callId) {
struct call_id_arg* a = (struct call_id_arg*)u_malloc(sizeof(*a));
if (!a) return;
a->inst = gui_bridge_get_inst();
a->call_id = callId;
gui_bridge_post_uasync_fn(fn, a);
}
void CallWindow::onAccept() {
setState(Active);
postCallFn(call_accept_trampoline, m_callId);
}
void CallWindow::onDecline() {
postCallFn(call_decline_trampoline, m_callId);
}
void CallWindow::onHangup() {
postCallFn(call_hangup_trampoline, m_callId);
}
void CallWindow::onMuteToggled(bool checked) {
CallAudioEngine::instance()->setMuted(checked);
m_muteBtn->setIcon(makeRoundIcon(checked ? QColor("#e74c3c") : QColor("#7f8c8d"),
checked ? Glyph::MicOff : Glyph::Mic, 96));
if (m_muteCaption)
m_muteCaption->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute"));
}
void CallWindow::onDeviceChanged(int index) {
int devIdx = m_deviceCombo->itemData(index).toInt();
CallAudioEngine::instance()->setPlaybackDeviceIndex(devIdx);
}
void CallWindow::closeEvent(QCloseEvent* event) {
if (m_state == Active) postCallFn(call_hangup_trampoline, m_callId);
emit closed(m_callId);
event->accept();
}
void CallWindow::updateDuration() {
if (m_state != Active) return;
qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startMs;
qint64 s = ms / 1000;
m_durationLabel->setText(QStringLiteral("%1:%2")
.arg(s / 60, 2, 10, QChar('0'))
.arg(s % 60, 2, 10, QChar('0')));
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <QWidget>
#include <cstdint>
class QLabel;
class QPushButton;
class QComboBox;
class QTimer;
/* Окно звонка: состояния Outgoing (звоним), Incoming (принять/отклонить),
* Active (идёт разговор, таймер), Ended (завершён). */
class CallWindow : public QWidget {
Q_OBJECT
public:
enum State { Outgoing, Incoming, Active, Ended };
explicit CallWindow(QWidget* parent = nullptr);
~CallWindow() override;
void setCallId(quint64 id) { m_callId = id; }
quint64 callId() const { return m_callId; }
void setPeerName(const QString& name);
void setState(State s);
State state() const { return m_state; }
signals:
void closed(quint64 callId);
protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onAccept();
void onDecline();
void onHangup();
void updateDuration();
void onMuteToggled(bool checked);
void onDeviceChanged(int index);
private:
void updateButtons();
void populateDeviceCombo();
void postCallFn(void (*fn)(void*), uint64_t callId);
quint64 m_callId = 0;
State m_state = Outgoing;
qint64 m_startMs = 0;
QLabel* m_avatarLabel;
QLabel* m_peerLabel;
QLabel* m_statusLabel;
QLabel* m_durationLabel;
QPushButton* m_acceptBtn;
QPushButton* m_declineBtn;
QPushButton* m_hangupBtn;
QPushButton* m_muteBtn;
QComboBox* m_deviceCombo;
QTimer* m_durationTimer;
QWidget* m_acceptCell;
QWidget* m_declineCell;
QWidget* m_hangupCell;
QWidget* m_muteCell;
QLabel* m_muteCaption;
};
+14
View File
@@ -30,6 +30,20 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
const QModelIndex &index) const {
painter->save();
/* ── заголовок секции (например, «Личные») ── */
if (index.data(ChannelIsSectionRole).toBool()) {
painter->fillRect(option.rect, option.palette.window());
painter->setPen(option.palette.mid().color());
QFont f = option.font;
f.setBold(true);
painter->setFont(f);
QRect tr = option.rect.adjusted(12, 0, -8, 0);
painter->drawText(tr, Qt::AlignLeft | Qt::AlignVCenter,
index.data(ChannelNameRole).toString());
painter->restore();
return;
}
const int barWidth = 4;
const int margin = 8;
const int iconSize = 32;
+2 -1
View File
@@ -13,7 +13,8 @@ enum ChannelDataRole {
ChannelInviteByLinkBtnHoveredRole = Qt::UserRole + 8, /* bool: hover for invite-by-link button */
ChannelSettingsBtnHoveredRole = Qt::UserRole + 9, /* bool: hover for per-channel settings gear */
ChannelUnreadCountRole = Qt::UserRole + 6, /* int: unread message count, 0 if none */
ChannelLastMsgAtRole = Qt::UserRole + 7 /* qint64: last message timestamp for sorting */
ChannelLastMsgAtRole = Qt::UserRole + 7, /* qint64: last message timestamp for sorting */
ChannelIsSectionRole = Qt::UserRole + 10 /* bool: 1 = заголовок секции (не выбирается) */
};
class ChannelDelegate : public QStyledItemDelegate {
+32 -1
View File
@@ -28,6 +28,7 @@ protected:
void mousePressEvent(QMouseEvent* e) override {
QModelIndex idx = indexAt(e->pos());
if (!idx.isValid()) return;
if (idx.data(ChannelIsSectionRole).toBool()) return;
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
if (gearRect.contains(e->pos())) {
emit settingsClicked(idx.data(ChannelChannelIdRole).toString());
@@ -53,7 +54,7 @@ protected:
m_hoveredInviteByLinkRow = -1;
m_hoveredSettingsRow = -1;
QModelIndex idx = indexAt(e->pos());
if (idx.isValid()) {
if (idx.isValid() && !idx.data(ChannelIsSectionRole).toBool()) {
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
QRect invRect = ChannelDelegate::inviteByLinkButtonRect(visualRect(idx));
QRect plusRect = ChannelDelegate::inviteButtonRect(visualRect(idx));
@@ -81,6 +82,8 @@ protected:
QListView::mouseMoveEvent(e);
}
QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& idx, const QEvent* e) const override {
if (idx.isValid() && idx.data(ChannelIsSectionRole).toBool())
return QItemSelectionModel::NoUpdate;
if (idx.isValid())
return QItemSelectionModel::ClearAndSelect;
return QItemSelectionModel::NoUpdate;
@@ -236,6 +239,34 @@ void ChannelList::loadChannels() {
(long long)ch.lastMsgAt);
ci++;
}
/* ── секция «Личные» (DM-беседы) ── */
auto dms = m_db->getDmConversations();
if (!dms.isEmpty()) {
auto *section = new QStandardItem();
section->setData(QStringLiteral("Личные"), ChannelNameRole);
section->setData(true, ChannelIsSectionRole);
section->setFlags(Qt::NoItemFlags);
m_model->appendRow(section);
static const QColor dmColor(0x8E, 0x44, 0xAD);
for (const auto& dm : dms) {
QString name = dm.peerName;
if (name.isEmpty()) name = QString("0x%1").arg(dm.peerNodeId, 4, 16, QChar('0')).toUpper();
auto letter = name.mid(0, 1).toUpper();
if (letter.isEmpty()) letter = "@";
auto *item = new QStandardItem();
item->setData(name, ChannelNameRole);
item->setData(QStringLiteral("dm:") + dm.convId, ChannelChannelIdRole);
item->setData(QString(), ChannelLastMessageRole);
item->setData(0, ChannelUnreadCountRole);
item->setData(dm.lastTs, ChannelLastMsgAtRole);
item->setData(makeChannelIcon(dmColor, letter), ChannelIconRole);
m_model->appendRow(item);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "ChannelList::loadChannels dm row=%d conv='%s' peer='%s'",
m_model->rowCount()-1, qPrintable(dm.convId), qPrintable(name));
}
}
}
sel->blockSignals(false);
+5
View File
@@ -277,6 +277,11 @@ void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& c
m_channelIdForRecord = channelId;
}
void InputBar::setMediaEnabled(bool enabled) {
if (m_pttBtn) m_pttBtn->setEnabled(enabled);
if (m_attachBtn) m_attachBtn->setEnabled(enabled);
}
void InputBar::onPttPressed() {
if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording();
+3
View File
@@ -18,6 +18,9 @@ public:
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
/* Включить/выключить медиа-кнопки (PTT/attach). Для DM (text-only) — выключить. */
void setMediaEnabled(bool enabled);
signals:
void sendMessage(const QString &text);
void sendVoiceMessage(const QString& mediaSrc, const QString& mediaDest,
+164 -4
View File
@@ -17,6 +17,8 @@
#include "voiceplayback.h"
#include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "callwindow.h"
#include "call_audio_engine.h"
extern "C" {
#include "../../lib/audio_compressor.h"
@@ -39,10 +41,13 @@ extern "C" {
#include <cmath>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <csignal>
extern "C" {
#include "chat/chat_core.h"
#include "call/call.h"
#include "dm/dm_core.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
void chat_core_attachment_download_trampoline(void* arg);
@@ -287,7 +292,7 @@ void MainWindow::setupSoundFromConfig() {
SoundManager* sm = SoundManager::instance();
if (!sm->init()) return;
QString soundDir, msgInFile, msgOutFile, connectFile, disconnectFile;
QString soundDir, msgInFile, msgOutFile, connectFile, disconnectFile, callInFile;
QFile cfgFile(m_cfgPath);
if (cfgFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
bool inGui = false;
@@ -302,6 +307,7 @@ void MainWindow::setupSoundFromConfig() {
else if (line.startsWith("sound_msg_outgoing=")) msgOutFile = line.mid(19).trimmed();
else if (line.startsWith("sound_connect=")) connectFile = line.mid(14).trimmed();
else if (line.startsWith("sound_disconnect=")) disconnectFile = line.mid(17).trimmed();
else if (line.startsWith("sound_call_incoming=")) callInFile = line.mid(20).trimmed();
}
cfgFile.close();
}
@@ -316,16 +322,21 @@ void MainWindow::setupSoundFromConfig() {
else
sm->loadSound("msg_incoming", resolvePath(msgInFile));
if (callInFile.isEmpty())
sm->loadSound("call_incoming", ":/sounds/call_incoming.mp3");
else
sm->loadSound("call_incoming", resolvePath(callInFile));
if (!msgOutFile.isEmpty()) sm->loadSound("msg_outgoing", resolvePath(msgOutFile));
if (!connectFile.isEmpty()) sm->loadSound("connect", resolvePath(connectFile));
if (!disconnectFile.isEmpty()) sm->loadSound("disconnect", resolvePath(disconnectFile));
if (m_db && m_db->isOpen()) {
sm->setEnabled(m_db->getUiStateInt("sound_enabled", 1));
sm->setVolume(m_db->getUiStateInt("sound_volume", 80) / 100.0f);
QString se[] = {"msg_incoming", "msg_outgoing", "connect", "disconnect"};
for (int i = 0; i < 4; i++) {
QString se[] = {"msg_incoming", "msg_outgoing", "connect", "disconnect", "call_incoming"};
for (int i = 0; i < 5; i++) {
const QString& ek = se[i];
sm->setEvent(ek, m_db->getUiStateInt(QStringLiteral("sound_%1_on").arg(ek), i == 0 ? 1 : 0),
sm->setEvent(ek, m_db->getUiStateInt(QStringLiteral("sound_%1_on").arg(ek), (i == 0 || i == 4) ? 1 : 0),
m_db->getUiStateInt(QStringLiteral("sound_%1_vol").arg(ek), 80) / 100.0f);
}
}
@@ -417,6 +428,38 @@ void MainWindow::setupBridgeCallbacks() {
if (s_mainWindow->m_currentChannelId == chId)
s_mainWindow->m_accountList->setChannel(chId);
});
/* ── звонки ── */
gui_bridge_set_call_incoming_cb([](const char* ch_id, int ch_id_len, uint64_t caller, uint64_t call_id) {
if (!s_mainWindow) return;
s_mainWindow->onCallIncoming(QString::fromUtf8(ch_id, ch_id_len), caller, call_id);
});
gui_bridge_set_call_ringing_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallRinging(call_id);
});
gui_bridge_set_call_accepted_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallAccepted(call_id);
});
gui_bridge_set_call_ended_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
});
gui_bridge_set_call_declined_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
});
/* ── DM (p2p чат) ── */
gui_bridge_set_dm_conv_updated_cb([](const char* conv_id, int conv_id_len) {
if (s_mainWindow)
s_mainWindow->onDmConvUpdated(QString::fromUtf8(conv_id, conv_id_len));
});
gui_bridge_set_dm_msg_received_cb([](const char* conv_id, int conv_id_len, uint64_t author) {
if (s_mainWindow)
s_mainWindow->onDmMsgReceived(QString::fromUtf8(conv_id, conv_id_len), author);
});
gui_bridge_set_dm_messages_cb([](const uint8_t* data, int len) {
if (s_mainWindow)
s_mainWindow->m_messageList->onDmMessages(data, len);
});
}
void MainWindow::setupConnects() {
@@ -425,6 +468,12 @@ void MainWindow::setupConnects() {
saveLastChannelId(channelId);
m_currentChannelId = channelId;
m_messageList->loadChannel(channelId);
if (channelId.startsWith(QStringLiteral("dm:"))) {
/* DM: беседа уже создана/обслуживается dm_core */
m_accountList->setChannel(QString());
m_channelList->setChannelUnreadCount(channelId, 0);
return;
}
m_accountList->setChannel(channelId);
struct chat_create_auto_arg* arg = (struct chat_create_auto_arg*)u_calloc(1, sizeof(*arg));
if (arg) {
@@ -442,6 +491,8 @@ void MainWindow::setupConnects() {
connect(m_channelList, &ChannelList::channelSettingsRequested, this, &MainWindow::showChannelSettings);
connect(m_accountList, &AccountList::memberRenamed,
m_messageList, &MessageList::reloadLocalNicks);
connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested);
connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested);
connect(m_messageList, &MessageList::readPositionChanged,
this, [this](const QString& channelId, qint64 lastReadMsgId) {
Q_UNUSED(lastReadMsgId);
@@ -453,6 +504,115 @@ void MainWindow::setupConnects() {
connect(m_messageList, &MessageList::openImageRequested, this, &MainWindow::openImage);
}
/* ── звонки ── */
void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) {
quint64 callId = QRandomGenerator::global()->generate64();
if (!callId) callId = 1;
struct call_start_arg* a = (struct call_start_arg*)u_malloc(sizeof(*a));
if (!a) return;
memset(a, 0, sizeof(*a));
a->inst = gui_bridge_get_inst();
strncpy(a->ch_id, chId.toUtf8().constData(), sizeof(a->ch_id) - 1);
a->peer_node_id = nodeId;
a->call_id = callId;
gui_bridge_post_uasync_fn(call_start_trampoline, a);
CallWindow* w = new CallWindow(this);
w->setCallId(callId);
w->setPeerName(name);
w->setState(CallWindow::Outgoing);
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
m_activeCalls.insert(callId, w);
w->show();
}
void MainWindow::onCallIncoming(const QString& chId, quint64 caller, quint64 callId) {
Q_UNUSED(chId);
if (m_activeCalls.contains(callId)) return;
QString name = m_db->getDisplayName(caller);
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(caller, 16, 16, QChar('0'));
SoundManager::instance()->playLoop("call_incoming");
if (!isVisible() || isMinimized()) {
m_trayIcon->showMessage("vibechat", QStringLiteral("Incoming call from %1").arg(name),
QSystemTrayIcon::Information, 5000);
}
CallWindow* w = new CallWindow(this);
w->setCallId(callId);
w->setPeerName(name);
w->setState(CallWindow::Incoming);
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
m_activeCalls.insert(callId, w);
w->show();
w->raise();
w->activateWindow();
}
void MainWindow::onCallRinging(quint64 callId) {
/* caller слышит гудок — окно уже показано в Outgoing */
Q_UNUSED(callId);
}
void MainWindow::onCallAccepted(quint64 callId) {
SoundManager::instance()->stopLoop();
CallWindow* w = m_activeCalls.value(callId, nullptr);
if (w) w->setState(CallWindow::Active);
if (m_db)
CallAudioEngine::instance()->setPlaybackDeviceIndex(m_db->getUiStateInt("audio_output_device", -1));
CallAudioEngine::instance()->start(callId);
}
void MainWindow::onCallEnded(quint64 callId, uint8_t reason) {
Q_UNUSED(reason);
/* тон завершения (~450мс) → авто-teardown аудио-движка */
if (CallAudioEngine::instance()->callId() == callId)
CallAudioEngine::instance()->beginEnd(callId);
closeCallWindow(callId);
}
void MainWindow::closeCallWindow(quint64 callId) {
SoundManager::instance()->stopLoop();
CallWindow* w = m_activeCalls.take(callId);
if (!w) return;
w->disconnect(this);
w->deleteLater();
}
/* ── DM (p2p чат) ── */
void MainWindow::onPmRequested(const QString& chId, quint64 nodeId, const QString& name) {
struct dm_start_req* r = (struct dm_start_req*)u_malloc(sizeof(*r));
if (!r) return;
memset(r, 0, sizeof(*r));
r->inst = gui_bridge_get_inst();
r->peer_node_id = nodeId;
strncpy(r->source_ch_id, chId.toUtf8().constData(), sizeof(r->source_ch_id) - 1);
strncpy(r->peer_name, name.toUtf8().constData(), sizeof(r->peer_name) - 1);
gui_bridge_post_uasync_fn(dm_start_trampoline, r);
/* открытие беседы произойдёт по CHAT_EVT_DM_CONV_UPDATED (onDmConvUpdated) */
}
void MainWindow::onDmConvUpdated(const QString& convId) {
if (!m_channelList) return;
QString key = QStringLiteral("dm:") + convId;
m_channelList->loadChannels();
m_channelList->selectChannel(key);
}
void MainWindow::onDmMsgReceived(const QString& convId, quint64 author) {
QString key = QStringLiteral("dm:") + convId;
if (m_currentChannelId == key) {
m_messageList->refresh();
return;
}
if (author == m_db->myNodeId()) return;
if (m_channelList)
m_channelList->setChannelUnreadCount(key, m_channelList->getChannelUnreadCount(key) + 1);
}
void MainWindow::setupTray() {
m_trayIcon = new QSystemTrayIcon(this);
m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_ComputerIcon));
+18
View File
@@ -5,6 +5,7 @@
#include <QMenu>
#include <QQueue>
#include <QSet>
#include <QHash>
#include <QTimer>
class ChannelList;
@@ -17,6 +18,8 @@ class AudioRecorder;
class ConnMonitorWindow;
class VideoPlayerWindow;
class ImageViewerWindow;
class CallWindow;
class CallAudioEngine;
struct AutoPlayItem {
QString channelId;
@@ -63,6 +66,19 @@ public:
void requestShutdown();
UtunNode* utunNode() const { return m_node; }
/* звонки */
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name);
void onCallIncoming(const QString& chId, quint64 caller, quint64 callId);
void onCallRinging(quint64 callId);
void onCallAccepted(quint64 callId);
void onCallEnded(quint64 callId, uint8_t reason);
void closeCallWindow(quint64 callId);
/* DM (p2p чат) */
void onPmRequested(const QString& chId, quint64 nodeId, const QString& name);
void onDmConvUpdated(const QString& convId);
void onDmMsgReceived(const QString& convId, quint64 author);
private:
void setupUi();
void saveWindowGeometry();
@@ -101,4 +117,6 @@ private:
QTimer* m_autoPlayTimer = nullptr;
QTimer* m_shutdownTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
QHash<quint64, CallWindow*> m_activeCalls; /* call_id → окно звонка */
};
+5
View File
@@ -92,6 +92,11 @@ quint64 MemberListModel::nodeIdAt(int row) const {
return it != m_cache.end() ? it->nodeId : 0;
}
bool MemberListModel::isOnlineAt(int row) const {
auto it = m_cache.find(row);
return it != m_cache.end() ? it->online : false;
}
static void deserialize_member(const uint8_t* src, MemberListItem& item) {
memcpy(&item.nodeId, src, 8);
uint8_t online = src[8];
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more