Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a5c5bcbf | ||
|
|
51d6d22a27 | ||
|
|
21d9a4e5b7 | ||
|
|
5293fdd3a9 | ||
|
|
2de554f8b9 | ||
|
|
de930b21a2 | ||
|
|
e4557b0473 | ||
|
|
b483335c06 | ||
|
|
b11e77e4c0 | ||
|
|
0157f7f96b | ||
|
|
21ec740d82 | ||
|
|
b085715654 |
No files matched your search
@@ -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
@@ -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) его не трогает.
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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"
|
||||
@@ -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 */
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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 // соединение создано
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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-интерфейс
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+25
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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 ──
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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')));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 → окно звонка */
|
||||
};
|
||||
@@ -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
Reference in new issue
Block a user