diff --git a/doc/tasks.md b/doc/tasks.md index 79d34471..01598aa9 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -12,7 +12,37 @@ (локальный буфер отправки при переполнении роутера) + адаптивный джиттер-буфер (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мс). + [ ] **DM-звонки** (fallback `group_id==0`) — не сделано: `call_is_online`/`call_start` + требуют реальную группу (`topo_groups_find`), для DM (group_id==0) маршрута нет. ## Рефакторинг: per-instance chat/dm (однопоточный test_dm_e2e) diff --git a/lib/debug_config.c b/lib/debug_config.c index ba23fb73..dc9c16a4 100644 --- a/lib/debug_config.c +++ b/lib/debug_config.c @@ -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} }; diff --git a/lib/debug_config.h b/lib/debug_config.h index 52d478fc..a88e08ea 100644 --- a/lib/debug_config.h +++ b/lib/debug_config.h @@ -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 */ diff --git a/src/Makefile.am b/src/Makefile.am index 30070706..a1436265 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -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 \ diff --git a/src/call/audio_jitter.cpp b/src/call/audio_jitter.cpp new file mode 100644 index 00000000..36257f5f --- /dev/null +++ b/src/call/audio_jitter.cpp @@ -0,0 +1,180 @@ +// audio_jitter.cpp — адаптивный джиттер-буфер с time-stretch (SoundTouch). +// +// PCM-кадры (int16, 960 сэмплов @20мс) кладутся из uasync-потока в кольцо и +// вытаскиваются аудио-потоком через SoundTouch с плавно меняющимся темпом: +// глубина < target → 1.0x, глубина растёт → ускорение до 1.6x (догон live). +// Кольцо защищено мьютексом (SPSC); SoundTouch трогает только аудио-поток. + +#include "audio_jitter.h" + +#include "soundtouch/SoundTouch.h" + +#include +#include +#include +#include +#include + +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 ajb { + int sr = 0; + int ch = 0; + int frame_samples = 0; + int frame_ms = 0; + int cap = 0; + + std::mutex mtx; + std::vector ring; // cap * frame_samples + int head = 0; + int count = 0; + uint32_t dropped = 0; + + soundtouch::SoundTouch st; // только аудио-поток + std::vector frame_buf; // scratch: вытащенный кадр + std::vector fin; // scratch: кадр в float + std::vector fout; // scratch: выход float + + double ema_depth_ms = 0.0; + double tempo = 1.0; + std::atomic tempo_x100{100}; +}; + +static double ajb_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 ajb_pop_frame(struct ajb* j, int16_t* out) { + std::lock_guard lk(j->mtx); + if (j->count == 0) return 0; + std::memcpy(out, &j->ring[(size_t)j->head * j->frame_samples], + (size_t)j->frame_samples * sizeof(int16_t)); + j->head = (j->head + 1) % j->cap; + j->count--; + return 1; +} + +extern "C" { + +struct ajb* ajb_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames) { + if (sample_rate <= 0 || channels <= 0 || frame_samples <= 0 || frame_ms <= 0 || max_frames <= 0) + return nullptr; + + ajb* j = new ajb(); + j->sr = sample_rate; + j->ch = channels; + j->frame_samples = frame_samples; + j->frame_ms = frame_ms; + j->cap = max_frames; + j->ring.assign((size_t)max_frames * frame_samples, 0); + j->frame_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 ajb_destroy(struct ajb* j) { + if (!j) return; + delete j; +} + +void ajb_reset(struct ajb* j) { + if (!j) return; + { + std::lock_guard lk(j->mtx); + j->head = 0; + j->count = 0; + } + j->st.clear(); + j->st.setTempo(kMinTempo); + j->tempo = kMinTempo; + j->ema_depth_ms = 0.0; + j->tempo_x100.store(100); +} + +void ajb_push(struct ajb* j, const int16_t* pcm, int samples) { + if (!j || !pcm || samples != j->frame_samples) return; + std::lock_guard 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 * j->frame_samples], pcm, (size_t)samples * sizeof(int16_t)); + j->count++; +} + +int ajb_pull(struct ajb* j, int16_t* out, int max_samples) { + if (!j || !out || max_samples <= 0) return 0; + + /* 1) глубина (сглаженная) → целевой темп (плавно) */ + int cnt; + { + std::lock_guard 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 = ajb_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) { + if (!ajb_pop_frame(j, j->frame_buf.data())) break; + for (int i = 0; i < j->frame_samples; i++) + j->fin[(size_t)i] = (float)j->frame_buf[(size_t)i] / 32768.0f; + j->st.putSamples(j->fin.data(), (uint)j->frame_samples); + } + + /* 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); + } + return (int)n; +} + +void ajb_get_stats(struct ajb* j, int* buffer_ms, int* tempo_x100) { + if (!j) return; + if (buffer_ms) { + std::lock_guard lk(j->mtx); + *buffer_ms = j->count * j->frame_ms; + } + if (tempo_x100) *tempo_x100 = j->tempo_x100.load(); +} + +uint32_t ajb_dropped(struct ajb* j) { + if (!j) return 0; + std::lock_guard lk(j->mtx); + return j->dropped; +} + +} // extern "C" diff --git a/src/call/audio_jitter.h b/src/call/audio_jitter.h new file mode 100644 index 00000000..4cc882d8 --- /dev/null +++ b/src/call/audio_jitter.h @@ -0,0 +1,51 @@ +#ifndef AUDIO_JITTER_H +#define AUDIO_JITTER_H + +// audio_jitter.h — адаптивный джиттер-буфер с time-stretch (SoundTouch), C API. +// +// Хранит декодированные PCM-кадры (по frame_samples сэмплов). На pull применяет +// tempo: при росте глубины буфера (сетевая пауза → burst) воспроизведение плавно +// ускоряется (до 1.6x) и догоняет live, не теряя данные (до max_frames кадров). +// +// Потоки: +// - ajb_push — uasync-поток (producer); +// - ajb_pull — аудио-поток (consumer, единственный владелец SoundTouch); +// - ajb_get_stats — любой поток. +// Кольцо кадров защищено мьютексом (SPSC, дёшево: ~50 кадров/с). + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct ajb; + +/* Создать буфер. frame_samples — сэмплов в кадре (960 @20мс/48k), frame_ms — + * длительность кадра в мс, max_frames — ёмкость (50 = 1000мс). */ +struct ajb* ajb_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames); + +void ajb_destroy(struct ajb* j); + +/* Сброс: очистить кольцо и внутреннее состояние SoundTouch (темп → 1.0). */ +void ajb_reset(struct ajb* j); + +/* producer: положить декодированный PCM-кадр (ровно frame_samples сэмплов). + * При переполнении дропает старейший кадр (счётчик внутри). */ +void ajb_push(struct ajb* j, const int16_t* pcm, int samples); + +/* consumer: выдать до max_samples PCM с учётом текущего темпа. Возвращает число + * выданных сэмплов; может быть < max_samples при нехватке данных (андерфлоу). */ +int ajb_pull(struct ajb* j, int16_t* out, int max_samples); + +/* Диагностика: текущая глубина буфера (мс) и темп (×100, 100 = 1.0x). */ +void ajb_get_stats(struct ajb* j, int* buffer_ms, int* tempo_x100); + +/* Число дропнутых кадров при переполнении (для диагностики/тестов). */ +uint32_t ajb_dropped(struct ajb* j); + +#ifdef __cplusplus +} +#endif + +#endif /* AUDIO_JITTER_H */ diff --git a/src/call/call.c b/src/call/call.c new file mode 100644 index 00000000..24a36b66 --- /dev/null +++ b/src/call/call.c @@ -0,0 +1,877 @@ +// 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 +#include +#include + +#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; + if (ctx->audio_ops && ctx->audio_ops->get_stats) + ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100); + + 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[14]; + 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); + 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); +} diff --git a/src/call/call.h b/src/call/call.h new file mode 100644 index 00000000..f6ad19c2 --- /dev/null +++ b/src/call/call.h @@ -0,0 +1,96 @@ +// 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 + +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). */ + int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100); +}; +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 */ diff --git a/src/call/call_audio.c b/src/call/call_audio.c new file mode 100644 index 00000000..60a20b98 --- /dev/null +++ b/src/call/call_audio.c @@ -0,0 +1,283 @@ +// call_audio.c — сервисный аудио-движок P2P-звонка (Opus + джиттер + тоны). +// +// Один активный звонок: encoder (аудио-поток), decoder (uasync-поток), +// адаптивный джиттер-буфер ajb (SoundTouch) и генератор тонов. +// +// RX: call.c → call_audio_on_media (uasync) → decode → ajb_push; +// аудио-поток call_audio_pull_pcm → ajb_pull (+ глитч/завершение). +// TX: аудио-поток call_audio_feed_pcm → encode → uasync_post(call_send_media). + +#include "call_audio.h" +#include "call.h" +#include "audio_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 +#include + +#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 ajb* g_ajb = 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, +}; + +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-поток: приём медиа (декод + ajb_push) ── */ + +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; + int16_t pcm[CALL_AUDIO_FRAME_SAMPLES]; + int n = 0; + + pthread_mutex_lock(&g_mtx); + if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) { + n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES); + if (n > 0 && g_ajb) { + ajb_push(g_ajb, pcm, n); + 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 ajb* j = ajb_create(CALL_AUDIO_SAMPLE_RATE, 1, CALL_AUDIO_FRAME_SAMPLES, CALL_AUDIO_FRAME_MS, CALL_AJ_MAX_FRAMES); + if (!j) { + opus_codec_decoder_destroy(dec); + opus_codec_encoder_destroy(enc); + pthread_mutex_unlock(&g_mtx); + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: ajb create failed", CALL_AUDIO_ID); + return -1; + } + + struct call_tones* tones = call_tones_create(); + if (!tones) { + ajb_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_ajb = 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 ajb* 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_ajb; g_ajb = 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) ajb_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); +} + +/* ── аудио-поток: 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 || !ua || !inst) 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 ajb* 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_ajb; + 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 = ajb_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) { + int ok = 0; + pthread_mutex_lock(&g_mtx); + if (g_active && call_id == g_call_id && g_ajb) { + ajb_get_stats(g_ajb, buffer_ms, tempo_x100); + ok = 1; + } + pthread_mutex_unlock(&g_mtx); + return ok ? 0 : -1; +} diff --git a/src/call/call_audio.h b/src/call/call_audio.h new file mode 100644 index 00000000..a941cb70 --- /dev/null +++ b/src/call/call_audio.h @@ -0,0 +1,61 @@ +#ifndef CALL_AUDIO_H +#define CALL_AUDIO_H + +// call_audio.h — сервисный аудио-движок P2P-звонка (GUI-сборки: desktop/Android). +// +// Владеет Opus codec, адаптивным джиттер-буфером (SoundTouch time-stretch) и +// тонами (глитч при сталле / завершение). Потребитель (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/ajb/tones) защищены мьютексом; encoder — +// только аудио-поток, decoder+ajb_push — только uasync-поток. + +#include + +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); + +/* аудио-поток: закодировать 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-кадр — декод → ajb_push. */ +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. */ +int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100); + +#ifdef __cplusplus +} +#endif + +#endif /* CALL_AUDIO_H */ diff --git a/src/call/call_headless.c b/src/call/call_headless.c new file mode 100644 index 00000000..9fe5a4d3 --- /dev/null +++ b/src/call/call_headless.c @@ -0,0 +1,278 @@ +// call_headless.c — headless-аудио-сокет для P2P-звонков +// +// TCP-сокет с бинарным фреймингом [type:1][call_id:8][len:2][data:len]. +// Клиент привязывается к звонку (HELLO) и шлёт/принимает Opus-кадры (FRAME). +// Сигналинг — на control-сокете (chat_headless_control), медиа — здесь. +#include "call_headless.h" +#include "call.h" +#include "../utun_instance.h" +#include "../../lib/u_async.h" +#include "../../lib/mem.h" +#include "../../lib/socket_compat.h" +#include "../../lib/debug_config.h" +#include "../../lib/platform_compat.h" + +#include +#include +#include +#include + +#define 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 + +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; +}; + +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); +} + +/* ── приём медиа от пира → клиенту ── */ + +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) return; + 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, opus, (uint16_t)len); + 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 (call_send_media(ca->inst, call_id, data, flen) != 0) + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: send_media failed call_id=%016llx len=%u", CH_ID, (unsigned long long)call_id, flen); + } 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; } + 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", CH_ID); +} diff --git a/src/call/call_headless.h b/src/call/call_headless.h new file mode 100644 index 00000000..520f2a1b --- /dev/null +++ b/src/call/call_headless.h @@ -0,0 +1,34 @@ +// call_headless.h — headless-аудио-сокет для P2P-звонков +// +// Отдельный TCP-сокет, через который headless-клиент (CLI/скрипт) обменивается +// Opus-кадрами с ядром звонка. Сигналинг идёт по control-сокету (chat_headless_control), +// аудио — по этому сокету. +// +// Бинарный фрейм (обе стороны): +// [type:1][call_id:8][len:2][data:len] +// type = 0x01 HELLO (клиент → ядро: привязать соединение к call_id, data пусто) +// type = 0x02 FRAME (Opus-кадр: клиент → ядро = отправить пиру; ядро → клиент = кадр от пира) +#ifndef CALL_HEADLESS_H +#define CALL_HEADLESS_H + +#include + +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) */ + +#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 */ diff --git a/src/call/call_proto.h b/src/call/call_proto.h new file mode 100644 index 00000000..c48c1269 --- /dev/null +++ b/src/call/call_proto.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 + +#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 */ diff --git a/src/call/call_tones.c b/src/call/call_tones.c new file mode 100644 index 00000000..c9802b64 --- /dev/null +++ b/src/call/call_tones.c @@ -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 +#include +#include + +#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; +} diff --git a/src/call/call_tones.h b/src/call/call_tones.h new file mode 100644 index 00000000..c9817326 --- /dev/null +++ b/src/call/call_tones.h @@ -0,0 +1,32 @@ +#ifndef CALL_TONES_H +#define CALL_TONES_H + +// call_tones.h — генератор тонов звонка (48kHz mono int16): глитч при сталле +// и тон завершения. Используется только аудио-потоком (single-writer). + +#include + +#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 */ diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index dcbeda57..1bbbcfcd 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -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); diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index 5f5cb876..ea8d3e72 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -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; diff --git a/src/chat/chat_member.c b/src/chat/chat_member.c index e740096c..977d34c2 100644 --- a/src/chat/chat_member.c +++ b/src/chat/chat_member.c @@ -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; diff --git a/src/chat/chat_member.h b/src/chat/chat_member.h index 5af94a9b..9770816b 100644 --- a/src/chat/chat_member.h +++ b/src/chat/chat_member.h @@ -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 diff --git a/src/config_parser.c b/src/config_parser.c index 4a04d57c..84b265d4 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -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; } diff --git a/src/config_parser.h b/src/config_parser.h index df592169..8d8e77da 100644 --- a/src/config_parser.h +++ b/src/config_parser.h @@ -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) diff --git a/src/dm/dm_core.c b/src/dm/dm_core.c index aa1e2c99..35d23f8b 100644 --- a/src/dm/dm_core.c +++ b/src/dm/dm_core.c @@ -124,6 +124,33 @@ static int dm_share_group(struct dm_state* dm, uint64_t peer_id) { return shared; } +/* Собрать 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); @@ -315,6 +342,15 @@ static void dm_on_msg(struct dm_state* dm, const uint8_t* msg, size_t mlen) { 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); + + /* событие 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 +451,51 @@ 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; + } + if (!dm_share_group(dm, from_peer)) { + 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; + 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; + } + 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", + DM_ID, conv_id, (unsigned long long)from_peer); + + { + 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 +586,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); - - 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)); - } + -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 (nconv < (int)(sizeof(conv_peers) / sizeof(conv_peers[0]))) + conv_peers[nconv++] = conv_peer; + + 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); + /* пир не подключён напрямую — тянем 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 ── */ @@ -554,27 +661,26 @@ 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; - } - - 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; + } 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); } - 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 +740,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; } @@ -783,3 +906,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 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); +} diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index fbb58fe7..6c3a94f6 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -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 diff --git a/src/dm/dm_mailbox.c b/src/dm/dm_mailbox.c index 5bc55c71..864ba44a 100644 --- a/src/dm/dm_mailbox.c +++ b/src/dm/dm_mailbox.c @@ -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; } diff --git a/src/dm/dm_mailbox.h b/src/dm/dm_mailbox.h index 7fb26112..d8edd0d1 100644 --- a/src/dm/dm_mailbox.h +++ b/src/dm/dm_mailbox.h @@ -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 diff --git a/src/transport_layer/etcp_api.h b/src/transport_layer/etcp_api.h index 75462ccd..367e3859 100644 --- a/src/transport_layer/etcp_api.h +++ b/src/transport_layer/etcp_api.h @@ -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 // соединение создано diff --git a/src/utun_instance.c b/src/utun_instance.c index d8747b90..751ed51f 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -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" @@ -490,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"); @@ -686,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); } } @@ -758,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; diff --git a/src/utun_instance.h b/src/utun_instance.h index 3d4a130d..fdfcdf31 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -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) diff --git a/tests/Makefile.am b/tests/Makefile.am index 1b652f7b..59134a73 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -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 \ @@ -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) diff --git a/tests/test_call.c b/tests/test_call.c new file mode 100644 index 00000000..33a6760e --- /dev/null +++ b/tests/test_call.c @@ -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 +#define OPENSSL_API_COMPAT 0x10100000L +#include +#include +#include +#include +#include +#include + +#define CH_ID "4242424242424242" + +#define IDX_A 0 +#define IDX_B 1 + +#define TICK_TB 100 /* 10 ms на такт */ +#define MAX_TICKS 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; +} diff --git a/tests/test_call_headless.c b/tests/test_call_headless.c new file mode 100644 index 00000000..903bf314 --- /dev/null +++ b/tests/test_call_headless.c @@ -0,0 +1,512 @@ +// 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 +#define OPENSSL_API_COMPAT 0x10100000L +#include +#include +#include +#include +#include +#include +#include + +#define CH_ID "4242424242424242" + +#define IDX_A 0 +#define IDX_B 1 + +#define TICK_TB 100 /* 10 ms на такт */ +#define MAX_TICKS 4000 /* глобальный таймаут ≈ 40 c */ + +enum 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: { + static const uint8_t opus[16] = { 0xf8,0xff,0xfe,1,2,3,4,5,6,7,8,9,0x0a,0x0b,0x0c,0x0d }; + uint8_t frm[CALL_AUDIO_HDR_SIZE + sizeof(opus)]; + frm[0] = CALL_AUDIO_FRAME; + memcpy(frm + 1, &t->call_id, 8); + frm[9] = 0; frm[10] = (uint8_t)sizeof(opus); + memcpy(frm + CALL_AUDIO_HDR_SIZE, opus, sizeof(opus)); + hs_send(&t->aud[IDX_A], frm, sizeof(frm)); + t->phase = P_WAIT_MEDIA; + break; + } + case P_WAIT_MEDIA: { + /* ищем FRAME от пира в буфере 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 == 16 && 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; +} diff --git a/tests/test_dm_e2e.c b/tests/test_dm_e2e.c index e4b47d9f..8e66c773 100644 --- a/tests/test_dm_e2e.c +++ b/tests/test_dm_e2e.c @@ -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; } diff --git a/tools/chatgui-android/AGENTS.md b/tools/chatgui-android/AGENTS.md index 9deb3640..4a3355ae 100644 --- a/tools/chatgui-android/AGENTS.md +++ b/tools/chatgui-android/AGENTS.md @@ -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 +- `libutun_lite/call_audio.h/c` — Аудио-движок P2P-звонка: Opus encode (PCM→peer через call_send_media) и decode (peer→PCM→коллбэк). Аудио I/O в Kotlin (AudioRecord/AudioTrack), кодеки в C - `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, jitter-буфер, audio-focus, потоки захвата/воспроизведения - `data/ChatRepository.kt` — Хранилище данных: буферизация сообщений и каналов между C-ядром и UI через StateFlow - `data/InviteLink.kt` — Разбор invite-ссылок: извлекает ID канала, публичный ключ, адреса для подключения - `data/ConfigProvider.kt` — Поставщик конфигурации из Android DataStore в C-ядро через callback-интерфейс diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index 608b1574..f88dfe60 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -5,9 +5,17 @@ + + + + + + + + @@ -21,6 +29,7 @@ @@ -37,5 +46,9 @@ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="p2p_chat_network_service" /> + + diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 07fa39fa..1621ad5d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -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 { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 8a0620d2..b1579485 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -1,7 +1,11 @@ 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 @@ -19,6 +23,7 @@ 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 @@ -29,11 +34,16 @@ 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 } + + 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 +73,38 @@ class MainActivity : ComponentActivity() { var inviteChannelId by remember { mutableStateOf(null) } var videoMessage by remember { mutableStateOf(null) } var photoMessage by remember { mutableStateOf(null) } + var hadCall by remember { mutableStateOf(false) } 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/ENDED → экран Call, + INCOMING → диалог поверх текущего экрана. */ + LaunchedEffect(callState?.phase) { + when (callState?.phase) { + ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING, + ChatViewModel.CallPhase.ACTIVE, ChatViewModel.CallPhase.ENDED -> screen = Screen.Call + null -> if (screen == Screen.Call) screen = Screen.ChannelList + else -> {} + } + } + val showInvite: (Channel) -> Unit = { ch -> if (ch.id.toULongOrNull() == null) { LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}") @@ -81,6 +120,7 @@ class MainActivity : ComponentActivity() { Screen.MemberList -> screen = Screen.Chat Screen.VideoRecord -> screen = Screen.Chat Screen.PhotoRecord -> screen = Screen.Chat + Screen.Call -> { /* блокируем back во время звонка — только явная кнопка */ } else -> screen = Screen.ChannelList } } @@ -156,6 +196,10 @@ class MainActivity : ComponentActivity() { } 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 } + ) Screen.QrScan -> { QrScanScreen( onResult = { link -> @@ -203,6 +247,10 @@ class MainActivity : ComponentActivity() { PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null }) } } + + if (callState?.phase == ChatViewModel.CallPhase.INCOMING) { + IncomingCallDialog(viewModel = vm) + } } /* Show join dialog after QR scan returns */ @@ -250,6 +298,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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt new file mode 100644 index 00000000..eb95603f --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -0,0 +1,263 @@ +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-поток: jitter-буфер → AudioTrack.write (кадры декодированы в C, приходят через onCallPcm) + * - uasync-поток: onCallPcm(callId, PCM) ← C (call_audio media_recv) + * + * Jitter-буфер: очередь PCM-кадров (~50 кадров = 1с), pre-buffer 3 кадра (60мс), + * при опустошении — тишина (комфортный шум) + счётчик gap для диагностики. + */ +class CallAudioEngine { + + companion object { + const val SAMPLE_RATE = 48000 + const val FRAME_MS = 20 + const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 + const val JITTER_TARGET_FRAMES = 3 // pre-buffer 60мс + const val JITTER_MAX_FRAMES = 50 // ~1с + } + + 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() + + private val jitter = ArrayDeque() + private val lock = Object() + + @Volatile private var active = false + @Volatile private var callId = 0L + + @Volatile private var muted = false + + @Volatile private var callbackRegistered = 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 (!callbackRegistered) { + NativeLib.setCallAudioCallback(object : CallAudioCallback { + override fun onCallPcm(callId: Long, samples: ShortArray) = onPcm(callId, samples) + }) + callbackRegistered = true + } + + 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 + } + + synchronized(lock) { jitter.clear() } + + /* Прямое роутирование трека на выбранное устройство вывода (обход глючной + глобальной маршрутизации на ряде устройств). */ + 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 + + synchronized(lock) { jitter.clear() } + + NativeLib.callAudioStop() + router.onOutputDevice = null + router.stop() + restoreAudioMode() + + callId = 0L + muted = false + LogManager.addLog("INFO", "CallAudio", "stopped") + } + + /** uasync-поток: принятый и декодированный в C PCM-кадр пира. */ + private fun onPcm(cid: Long, samples: ShortArray) { + if (!active || cid != callId) return + synchronized(lock) { + if (jitter.size >= JITTER_MAX_FRAMES) jitter.removeFirst() // дроп старейшего + jitter.addLast(samples) + } + } + + 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 silence = ShortArray(FRAME_SAMPLES) + var gaps = 0 + + // pre-buffer: ждём накопления jitter (до 250мс), чтобы сгладить стартовую задержку + val deadline = System.currentTimeMillis() + 250 + while (active && jitter.size < JITTER_TARGET_FRAMES && System.currentTimeMillis() < deadline) { + try { Thread.sleep(10) } catch (_: InterruptedException) { return } + } + + while (active) { + val t0 = System.nanoTime() + val frame = synchronized(lock) { if (jitter.isNotEmpty()) jitter.removeFirst() else null } + val track = audioTrack ?: return + if (frame != null) { + track.write(frame, 0, frame.size) + if (gaps > 0) { + LogManager.addLog("DEBUG", "CallAudio", "jitter recovered after $gaps gaps") + gaps = 0 + } + } else { + track.write(silence, 0, silence.size) + gaps++ + if (gaps == 1) LogManager.addLog("WARN", "CallAudio", "playback underrun (jitter empty)") + } + 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}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt new file mode 100644 index 00000000..b94d8990 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -0,0 +1,305 @@ +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 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?) = onDevicesChanged() + override fun onAudioDevicesRemoved(removedDevices: Array?) = onDevicesChanged() + } + am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper())) + deviceCallback = cb + + bindBtHeadsetProfile() + + hasEarpiece = findBuiltinEarpiece() != null + route = AudioRoute.SPEAKER + 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 + applyRoute(r) + LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable)") + onChanged?.invoke(route, headsetAvailable) + } + + /* ── применение маршрута ── */ + 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 || 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}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt new file mode 100644 index 00000000..f35d41ef --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt @@ -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() + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt new file mode 100644 index 00000000..b79b1066 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt @@ -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}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 4d0fd423..c2d03178 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -6,6 +6,11 @@ interface ChatEventCallback { fun onEvent(type: Int, data: ByteArray?) } +/** Приём декодированного PCM-кадра пира (вызывается из uasync-потока). */ +interface CallAudioCallback { + fun onCallPcm(callId: Long, samples: ShortArray) +} + object NativeLib { init { @@ -38,6 +43,12 @@ object NativeLib { }) } + fun setCallAudioCallback(cb: CallAudioCallback) { + nativeSetCallAudioCallback(object : CallAudioCallback { + override fun onCallPcm(callId: Long, samples: ShortArray) { cb.onCallPcm(callId, samples) } + }) + } + fun destroy() { nativeDestroy() } fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { @@ -139,6 +150,18 @@ 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 callAudioStop() { nativeCallAudioStop() } + 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 +238,15 @@ 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 nativeSetCallAudioCallback(cb: Any) + 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 nativeCallAudioStop() } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt new file mode 100644 index 00000000..2ae7e46a --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt @@ -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}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt new file mode 100644 index 00000000..2aac6664 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -0,0 +1,194 @@ +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.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 +) { + 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 + ) { + 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) + } + } + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt new file mode 100644 index 00000000..e70e51f7 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt @@ -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) + } + } + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index e8ec6d52..49b8e0df 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -84,6 +84,7 @@ 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) }, onEdit = { editMember = m showEditDialog = true @@ -135,6 +136,7 @@ private fun MemberListItem( isSelected: Boolean = false, onClick: () -> Unit, onConnect: () -> Unit, + onCall: () -> Unit, onEdit: () -> Unit ) { val onlineColor = when { @@ -227,6 +229,10 @@ private fun MemberListItem( if (!member.isSelf) { TextButton(onClick = onConnect) { Text("Connect") } + TextButton( + onClick = onCall, + enabled = member.online + ) { Text("Call") } } TextButton(onClick = onEdit) { Text("Edit") } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 1306eea4..10499fbd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -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)) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 0529349d..e70025e5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -33,9 +33,40 @@ 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(null) + val callState: StateFlow = _callState + + val callAudio = CallAudioEngine() + + private var callStartedMs = 0L + + private val _muted = MutableStateFlow(false) + val muted: StateFlow = _muted + + private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER) + val audioRoute: StateFlow = _audioRoute + + private val _headsetAvailable = MutableStateFlow(false) + val headsetAvailable: StateFlow = _headsetAvailable + + private val _earpieceAvailable = MutableStateFlow(true) + val earpieceAvailable: StateFlow = _earpieceAvailable + private val _playbackState = MutableStateFlow(PlaybackState()) val playbackState: StateFlow = _playbackState @@ -138,6 +169,16 @@ 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)) + _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 +221,16 @@ 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) + } + } } private fun handleEvent(type: Int, data: ByteArray?) { @@ -417,9 +468,170 @@ class ChatViewModel : ViewModel() { } refreshChannels() } + 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 + } + _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)) + 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() + _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) return + 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() + _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) + LogManager.addLog("INFO", "VM", "setAudioRoute -> $r") + } + fun requestRegenerateKeys() { _generatingKeys.value = true NativeLib.regenerateKeys() @@ -811,6 +1023,8 @@ class ChatViewModel : ViewModel() { } fun clearState() { + callAudio.stop() + _callState.value = null repo?.close() repo = null dbReady = false diff --git a/tools/chatgui-android/app/src/main/res/raw/ringtone.mp3 b/tools/chatgui-android/app/src/main/res/raw/ringtone.mp3 new file mode 100644 index 00000000..54510952 Binary files /dev/null and b/tools/chatgui-android/app/src/main/res/raw/ringtone.mp3 differ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 44080a0b..53c9292a 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -16,6 +16,8 @@ #include "../libutun_lite/video_sender.h" #include "../libutun_lite/photo_sender.h" #include "../libutun_lite/standby.h" +#include "../libutun_lite/call_audio.h" +#include "../../../src/call/call.h" #include "../../../lib/debug_config.h" #include "../../../lib/u_async.h" #include "../../../lib/mem.h" @@ -1320,6 +1322,69 @@ 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) { + 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(); +} + /* ────────────────────────────────────────────────────────────────── * JNI functions (compiled only for Android via NDK) * ────────────────────────────────────────────────────────────────── */ @@ -1331,6 +1396,8 @@ static jobject g_config_callback = NULL; static jobject g_log_callback = NULL; static jobject g_event_callback = NULL; static jmethodID g_event_mid = NULL; +static jobject g_call_audio_callback = NULL; +static jmethodID g_call_audio_mid = NULL; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { (void)reserved; @@ -1396,6 +1463,29 @@ void instance_event_handler(int type, const uint8_t* data, int len) { jni_on_event(type, data, len); } +/* ── JNI call-audio PCM callback → CallAudioEngine.onCallPcm() ── + * Вызывается из uasync-потока (call.c media_recv → call_audio_media_recv). */ +static void jni_call_audio_pcm(uint64_t call_id, const int16_t* pcm, int count, void* arg) { + (void)arg; + if (!g_jvm || !g_call_audio_callback || !g_call_audio_mid || !pcm || count <= 0) return; + JNIEnv* env = NULL; + int attached = 0; + jint res = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6); + if (res == JNI_EDETACHED) { + if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) return; + attached = 1; + } else if (res != JNI_OK) { + return; + } + jshortArray jpcm = (*env)->NewShortArray(env, count); + if (jpcm) { + (*env)->SetShortArrayRegion(env, jpcm, 0, count, (const jshort*)pcm); + (*env)->CallVoidMethod(env, g_call_audio_callback, g_call_audio_mid, (jlong)call_id, jpcm); + (*env)->DeleteLocalRef(env, jpcm); + } + if (attached) (*g_jvm)->DetachCurrentThread(g_jvm); +} + 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); @@ -1445,6 +1535,7 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart( /* Set up event forwarder (bridge config/log are set by nativeInit) */ instance_lite_set_event_handler(instance_event_handler); + call_audio_set_pcm_recv_cb(jni_call_audio_pcm, NULL); int rc = utun_bridge_start(cfg); (*env)->ReleaseStringUTFChars(env, configText, cfg); @@ -1579,6 +1670,8 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy( if (g_config_callback) { (*env)->DeleteGlobalRef(env, g_config_callback); g_config_callback = NULL; } if (g_log_callback) { (*env)->DeleteGlobalRef(env, g_log_callback); g_log_callback = NULL; } if (g_event_callback) { (*env)->DeleteGlobalRef(env, g_event_callback); g_event_callback = NULL; } + if (g_call_audio_callback) { (*env)->DeleteGlobalRef(env, g_call_audio_callback); g_call_audio_callback = NULL; } + g_call_audio_mid = NULL; } JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage( @@ -2045,4 +2138,82 @@ 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(); +} + +/* ── nativeSetCallAudioCallback(cb) ── */ + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetCallAudioCallback( + JNIEnv* env, jobject thiz, jobject callback) { + (void)thiz; + if (g_call_audio_callback) (*env)->DeleteGlobalRef(env, g_call_audio_callback); + g_call_audio_callback = callback ? (*env)->NewGlobalRef(env, callback) : NULL; + if (g_call_audio_callback) { + jclass cls = (*env)->GetObjectClass(env, g_call_audio_callback); + g_call_audio_mid = (*env)->GetMethodID(env, cls, "onCallPcm", "(J[S)V"); + if (!g_call_audio_mid) bridge_log(BLEV_ERROR, "onCallPcm method not found on callback"); + } else { + g_call_audio_mid = NULL; + } +} + #endif /* __ANDROID__ */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index e1a67946..ac2b996d 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -118,6 +118,20 @@ 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; + * принятый Opus → PCM → коллбэк (настраивается через call_audio_set_pcm_recv_cb). */ +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); +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); diff --git a/tools/chatgui-android/libutun_lite/call_audio.c b/tools/chatgui-android/libutun_lite/call_audio.c new file mode 100644 index 00000000..206981c3 --- /dev/null +++ b/tools/chatgui-android/libutun_lite/call_audio.c @@ -0,0 +1,156 @@ +#include "call_audio.h" +#include "instance_lite.h" +#include "../../../lib/opus_codec.h" +#include "../../../lib/debug_config.h" +#include "../../../lib/mem.h" +#include "../../../lib/u_async.h" +#include "../../../src/call/call.h" +#include +#include + +#define CALL_AUDIO_BITRATE 32000 + +static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; +static int g_active = 0; +static uint64_t g_call_id = 0; +static opus_codec_encoder_t* g_encoder = NULL; +static opus_codec_decoder_t* g_decoder = NULL; +static call_audio_pcm_recv_fn g_pcm_fn = NULL; +static void* g_pcm_arg = NULL; + +void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg) { + pthread_mutex_lock(&g_mtx); + g_pcm_fn = fn; + g_pcm_arg = arg; + pthread_mutex_unlock(&g_mtx); +} + +/* uasync-поток: принятый Opus-кадр пира (из call.c media_recv). */ +static void call_audio_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id, + const uint8_t* opus, int len, void* arg) { + (void)inst; (void)arg; + int16_t pcm[CALL_AUDIO_FRAME_SAMPLES]; + call_audio_pcm_recv_fn fn = NULL; + void* fn_arg = NULL; + int n = 0; + + pthread_mutex_lock(&g_mtx); + if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) { + n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES); + fn = g_pcm_fn; + fn_arg = g_pcm_arg; + } + pthread_mutex_unlock(&g_mtx); + + if (n > 0 && fn) + fn(call_id, pcm, n, fn_arg); +} + +int call_audio_start(uint64_t call_id) { + if (call_id == 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: bad call_id=0"); + 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_stop(); + 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, "call_audio_start: encoder create failed"); + 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, "call_audio_start: decoder create failed"); + return -1; + } + + g_encoder = enc; + g_decoder = dec; + g_call_id = call_id; + g_active = 1; + pthread_mutex_unlock(&g_mtx); + + call_set_media_recv_cb(instance_lite_get_instance(), call_audio_media_recv, NULL); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_start: ok call=0x%016llx", + (unsigned long long)call_id); + return 0; +} + +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 = instance_lite_get_uasync(); + inst = instance_lite_get_instance(); + pthread_mutex_unlock(&g_mtx); + + if (len <= 0 || !ua || !uasync_is_running(ua) || !inst) + 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; +} + +void call_audio_stop(void) { + opus_codec_encoder_t* enc = NULL; + opus_codec_decoder_t* dec = NULL; + int was_active = 0; + + pthread_mutex_lock(&g_mtx); + if (g_active) { + was_active = 1; + g_active = 0; + g_call_id = 0; + enc = g_encoder; g_encoder = NULL; + dec = g_decoder; g_decoder = NULL; + } + pthread_mutex_unlock(&g_mtx); + + if (!was_active) + return; + + call_set_media_recv_cb(instance_lite_get_instance(), NULL, NULL); + if (enc) opus_codec_encoder_destroy(enc); + if (dec) opus_codec_decoder_destroy(dec); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_stop"); +} + +int call_audio_is_active(uint64_t call_id) { + pthread_mutex_lock(&g_mtx); + int active = g_active && call_id == g_call_id; + pthread_mutex_unlock(&g_mtx); + return active; +} diff --git a/tools/chatgui-android/libutun_lite/call_audio.h b/tools/chatgui-android/libutun_lite/call_audio.h new file mode 100644 index 00000000..539e07c7 --- /dev/null +++ b/tools/chatgui-android/libutun_lite/call_audio.h @@ -0,0 +1,52 @@ +#ifndef CALL_AUDIO_H +#define CALL_AUDIO_H + +/* call_audio.h — аудио-движок P2P-звонка для Android (аналог десктопного CallAudioEngine) + * + * Держит один активный звонок: Opus encoder (PCM→Opus, отправка пиру) и + * decoder (Opus→PCM, приём от пира). Аудио I/O живёт в Kotlin (AudioRecord/ + * AudioTrack), кодеки — здесь (Opus есть только в C). + * + * Потоки: + * - feed_pcm: из AudioRecord-потока Kotlin (кодирование + uasync_post) + * - media_recv: из uasync-потока (call.c) — декодирование + коллбэк PCM + * - start/stop: из GUI-потока Kotlin (по ACCEPTED/ENDED) + * Все общие поля защищены мьютексом; encoder/decoder используются строго + * одним потоком каждый, но освобождаются в stop под тем же мьютексом. */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Параметры аудио: Opus 48kHz mono, кадр 20мс = 960 сэмплов. */ +#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) + +/* Запустить полнодуплексное аудио для активного звонка (после ACCEPTED). + * Создаёт encoder+decoder, регистрирует приём медиа в call.c. + * Повторный start с тем же call_id — no-op; с другим — сперва stop. + * Возвращает 0 при успехе, <0 при ошибке. */ +int call_audio_start(uint64_t call_id); + +/* Отправить один кадр PCM (ровно CALL_AUDIO_FRAME_SAMPLES) пиру. + * Кодируется в Opus и уходит через call_send_media (uasync-пост). + * Можно звать из любого потока. 0 — успех, <0 — звонок не активен/ошибка. */ +int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); + +/* Остановить аудио: снять recv-cb, освободить encoder/decoder. */ +void call_audio_stop(void); + +/* Активен ли звонок с данным call_id (для логов/диагностики). */ +int call_audio_is_active(uint64_t call_id); + +/* Коллбэк приёма декодированного PCM пира (960 сэмплов/кадр). + * Вызывается в uasync-потоке на каждый принятый Opus-кадр. */ +typedef void (*call_audio_pcm_recv_fn)(uint64_t call_id, const int16_t* pcm, int count, void* arg); +void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg); + +#ifdef __cplusplus +} +#endif +#endif /* CALL_AUDIO_H */ diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake index 8229af2f..5f84bbb0 100644 --- a/tools/chatgui-android/libutun_lite/utun_sources.cmake +++ b/tools/chatgui-android/libutun_lite/utun_sources.cmake @@ -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/audio_jitter) — отдельная C++ задача, + # Android использует собственный libutun_lite/call_audio.c + 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 ── @@ -40,6 +44,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR) "${CONFIG_DIR}/video_sender.c" "${CONFIG_DIR}/photo_sender.c" "${CONFIG_DIR}/standby.c" + "${CONFIG_DIR}/call_audio.c" ) # ── Export sources ── diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 6b1270ab..7f1ff4e8 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -83,6 +83,30 @@ endif() # uTun core library (C, built from ../src/ and ../lib/) add_subdirectory(libutun) +# SoundTouch (time-stretch) — статическая C++ либа для джиттер-буфера звонка +set(SOUNDTOUCH_DIR "${CMAKE_SOURCE_DIR}/../../lib/soundtouch-master") +set(SOUNDTOUCH_SOURCES + ${SOUNDTOUCH_DIR}/source/SoundTouch/SoundTouch.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/TDStretch.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/RateTransposer.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/FIFOSampleBuffer.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/AAFilter.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/FIRFilter.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateLinear.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateCubic.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateShannon.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/cpu_detect_x86.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/mmx_optimized.cpp + ${SOUNDTOUCH_DIR}/source/SoundTouch/sse_optimized.cpp +) +add_library(soundtouch STATIC ${SOUNDTOUCH_SOURCES}) +target_include_directories(soundtouch PUBLIC + ${SOUNDTOUCH_DIR}/include + ${SOUNDTOUCH_DIR}/include/soundtouch +) +set_target_properties(soundtouch PROPERTIES POSITION_INDEPENDENT_CODE ON) + + add_executable(vibechat src/main.cpp src/mainwindow.cpp @@ -127,6 +151,11 @@ add_executable(vibechat src/audiodevicesettingspage.cpp src/storagesettingspage.cpp src/connmonitorwindow.cpp + src/callwindow.cpp + src/call_audio_engine.cpp + ../../src/call/call_audio.c + ../../src/call/call_tones.c + ../../src/call/audio_jitter.cpp transport/utun_node.cpp transport/node_config.cpp transport/config_updater.cpp @@ -142,11 +171,11 @@ target_compile_definitions(vibechat PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE) if(FFMPEG_FOUND) target_compile_definitions(vibechat PRIVATE HAVE_FFMPEG) endif() -set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C) +set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c ../../src/call/call_audio.c ../../src/call/call_tones.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 soundtouch 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 soundtouch pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) endif() # ============================================================================ @@ -183,6 +212,19 @@ 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_audio_jitter + tests/test_audio_jitter.cpp + ../../src/call/audio_jitter.cpp +) +target_include_directories(test_audio_jitter PRIVATE + ${CMAKE_SOURCE_DIR}/../../src + ${SOUNDTOUCH_DIR}/include + ${SOUNDTOUCH_DIR}/include/soundtouch +) +target_link_libraries(test_audio_jitter PRIVATE soundtouch pthread) +add_test(NAME test_audio_jitter COMMAND test_audio_jitter) + # smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine add_executable(test_video_engine tests/test_video_engine.cpp diff --git a/tools/chatgui/db/db_manager.cpp b/tools/chatgui/db/db_manager.cpp index 338e1154..6b23dee0 100644 --- a/tools/chatgui/db/db_manager.cpp +++ b/tools/chatgui/db/db_manager.cpp @@ -302,6 +302,29 @@ MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const { return m; } +/* ── DM-беседы (UI, read-only) ── */ + +QList DbManager::getDmConversations() const { + QList 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 DbManager::getLocalNicknames(const QString& chId) const { diff --git a/tools/chatgui/db/db_manager.h b/tools/chatgui/db/db_manager.h index cacd8382..8dd71a00 100644 --- a/tools/chatgui/db/db_manager.h +++ b/tools/chatgui/db/db_manager.h @@ -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 getMessagesSince(const QString& chId, qint64 sinceId) const; MessageRow getMessageById(const QString& chId, qint64 msgId) const; + /* ── DM-беседы (UI, read-only) ── */ + QList getDmConversations() const; + /* ── Участники канала (UI) ── */ void cleanOrphanedPeers(const QString& chId) const; QString getDisplayName(quint64 nodeId) const; diff --git a/tools/chatgui/libutun/CMakeLists.txt b/tools/chatgui/libutun/CMakeLists.txt index 30b54f12..abd113b7 100644 --- a/tools/chatgui/libutun/CMakeLists.txt +++ b/tools/chatgui/libutun/CMakeLists.txt @@ -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/audio_jitter) — собирается в vibechat +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 diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 012ead4d..ff7ac382 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -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; diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index fee63dea..b5ac2465 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -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); diff --git a/tools/chatgui/src/audio_frame_ring.h b/tools/chatgui/src/audio_frame_ring.h new file mode 100644 index 00000000..40fe58eb --- /dev/null +++ b/tools/chatgui/src/audio_frame_ring.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +/* Кольцевой буфер кадров переменной длины (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 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 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 lk(mtx_); + head_ = tail_ = count_ = 0; + } + + int size() const { + std::lock_guard lk(mtx_); + return count_; + } + + int capacity() const { return cap_; } + +private: + int cap_, slotSize_; + int head_ = 0, tail_ = 0, count_ = 0; + std::vector buf_; + std::vector lens_; + mutable std::mutex mtx_; +}; diff --git a/tools/chatgui/src/call_audio_engine.cpp b/tools/chatgui/src/call_audio_engine.cpp new file mode 100644 index 00000000..ba00ebc7 --- /dev/null +++ b/tools/chatgui/src/call_audio_engine.cpp @@ -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 +#include + +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)); +} diff --git a/tools/chatgui/src/call_audio_engine.h b/tools/chatgui/src/call_audio_engine.h new file mode 100644 index 00000000..58c60ff7 --- /dev/null +++ b/tools/chatgui/src/call_audio_engine.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +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 m_active{false}; + std::atomic m_callId{0}; + std::atomic m_muted{false}; + + int m_playbackDeviceIndex = -1; /* только GUI-поток */ + + ma_device* m_device = nullptr; + bool m_deviceStarted = false; + + std::vector m_captureBuf; /* 960 samples, аудио-поток */ + int m_capturePos = 0; +}; diff --git a/tools/chatgui/src/callwindow.cpp b/tools/chatgui/src/callwindow.cpp new file mode 100644 index 00000000..653007d8 --- /dev/null +++ b/tools/chatgui/src/callwindow.cpp @@ -0,0 +1,179 @@ +#include "callwindow.h" +#include "../transport/gui_bridge.h" +#include "call_audio_engine.h" +#include "sound_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "call/call.h" +#include "../../lib/mem.h" +#include "../../lib/debug_config.h" +} + +CallWindow::CallWindow(QWidget* parent) + : QWidget(parent, Qt::Window) { + setWindowTitle(QStringLiteral("Звонок")); + setFixedSize(320, 300); + setAttribute(Qt::WA_DeleteOnClose, false); + + m_peerLabel = new QLabel(this); + m_peerLabel->setAlignment(Qt::AlignCenter); + m_statusLabel = new QLabel(this); + m_statusLabel->setAlignment(Qt::AlignCenter); + m_durationLabel = new QLabel(QStringLiteral("00:00"), this); + m_durationLabel->setAlignment(Qt::AlignCenter); + + m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this); + m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this); + m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this); + + m_muteBtn = new QPushButton(QStringLiteral("Mute"), this); + m_muteBtn->setCheckable(true); + m_muteBtn->setStyleSheet( + "QPushButton { border: 1px solid palette(mid); border-radius: 4px;" + " padding: 4px 12px; font-size: 12px; background: palette(button); }" + "QPushButton:checked { background: #c0392b; color: white; }"); + + 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->addWidget(m_peerLabel); + lay->addWidget(m_statusLabel); + lay->addWidget(m_durationLabel); + QHBoxLayout* btn = new QHBoxLayout(); + btn->addWidget(m_acceptBtn); + btn->addWidget(m_declineBtn); + btn->addWidget(m_hangupBtn); + lay->addLayout(btn); + lay->addWidget(m_muteBtn); + 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::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); +} + +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_acceptBtn->setVisible(m_state == Incoming); + m_declineBtn->setVisible(m_state == Incoming); + m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active); + m_muteBtn->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->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'))); +} diff --git a/tools/chatgui/src/callwindow.h b/tools/chatgui/src/callwindow.h new file mode 100644 index 00000000..94ee6975 --- /dev/null +++ b/tools/chatgui/src/callwindow.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +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_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; +}; diff --git a/tools/chatgui/src/channeldelegate.cpp b/tools/chatgui/src/channeldelegate.cpp index 57121a78..eeeea696 100644 --- a/tools/chatgui/src/channeldelegate.cpp +++ b/tools/chatgui/src/channeldelegate.cpp @@ -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; diff --git a/tools/chatgui/src/channeldelegate.h b/tools/chatgui/src/channeldelegate.h index 93ddd98a..aacae16a 100644 --- a/tools/chatgui/src/channeldelegate.h +++ b/tools/chatgui/src/channeldelegate.h @@ -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 { diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index cfd705af..91ebee7c 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/tools/chatgui/src/channellist.cpp @@ -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); diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index bb2208bb..f66e4b60 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -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(); diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 46698b16..84870dca 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -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, diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 9d106873..892e4488 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -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 #include #include +#include #include 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); @@ -417,6 +422,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 +462,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 +485,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 +498,107 @@ 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')); + + 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) { + 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) { + 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)); diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index e638fa3c..7e495a9c 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -5,6 +5,7 @@ #include #include #include +#include #include 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 m_autoPlayDownloading; /* msgIds with download in progress */ + + QHash m_activeCalls; /* call_id → окно звонка */ }; diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index bbbd3275..1070c5df 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -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]; diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index ca69f797..6f2f8266 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -34,6 +34,7 @@ public: void refresh(); void refreshRtt(); quint64 nodeIdAt(int row) const; + bool isOnlineAt(int row) const; /* Called from bridge callbacks (GUI thread) */ void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen); diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index f96c1536..b6cc39e8 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -35,6 +35,7 @@ static std::vector> s_icons; extern "C" { #include "../transport/gui_bridge.h" #include "chat/chat_core.h" +#include "dm/dm_core.h" #include "video/video.h" #include "../../lib/u_async.h" #include "../../lib/mem.h" @@ -357,6 +358,21 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent return; } + if (isDmChannel(m_currentChannelId)) { + QByteArray textData = text.toUtf8(); + QString conv = dmConvId(m_currentChannelId); + struct dm_send_req* req = (struct dm_send_req*)u_malloc(sizeof(struct dm_send_req) + textData.size()); + if (!req) return; + memset(req, 0, sizeof(*req)); + req->inst = gui_bridge_get_inst(); + strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1); + req->data = (uint8_t*)(req + 1); + req->data_len = (uint32_t)textData.size(); + memcpy(req->data, textData.constData(), textData.size()); + gui_bridge_post_uasync_fn(dm_send_trampoline, req); + return; + } + QByteArray textData = text.toUtf8(); auto ts = QDateTime::currentMSecsSinceEpoch(); @@ -545,6 +561,19 @@ void MessageList::loadChannel(const QString& channelId) { m_currentChannelId = channelId; m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId); m_readDebounce->stop(); + + /* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */ + if (isDmChannel(channelId)) { + m_inputBar->setMediaEnabled(false); + requestDmMessages(channelId); + if (prevFocus) + prevFocus->setFocus(); + else + m_inputBar->setFocus(); + return; + } + m_inputBar->setMediaEnabled(true); + if (!m_db || !m_db->isOpen()) return; m_localNicks = m_db->getLocalNicknames(channelId); @@ -606,7 +635,14 @@ void MessageList::loadChannel(const QString& channelId) { } void MessageList::refresh() { - if (m_currentChannelId.isEmpty() || !m_db || !m_db->isOpen()) return; + if (m_currentChannelId.isEmpty()) return; + + if (isDmChannel(m_currentChannelId)) { + requestDmMessages(m_currentChannelId); + return; + } + + if (!m_db || !m_db->isOpen()) return; qint64 maxId = 0; for (int r = 0; r < m_model->rowCount(); r++) @@ -909,3 +945,73 @@ void MessageList::onMessageContextMenu(const QPoint& pos) { gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req); } } + +/* ── DM (p2p чат) ── */ + +bool MessageList::isDmChannel(const QString& channelId) { + return channelId.startsWith(QStringLiteral("dm:")); +} + +QString MessageList::dmConvId(const QString& channelId) { + return channelId.mid(3); +} + +void MessageList::requestDmMessages(const QString& channelId) { + QString conv = dmConvId(channelId); + struct dm_messages_req* req = (struct dm_messages_req*)u_malloc(sizeof(*req)); + if (!req) return; + memset(req, 0, sizeof(*req)); + req->inst = gui_bridge_get_inst(); + strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1); + gui_bridge_post_uasync_fn(dm_messages_trampoline, req); +} + +void MessageList::onDmMessages(const uint8_t* data, int len) { + if (len < 3) return; + uint8_t cl = data[0]; + if (len < 1 + (int)cl + 2) return; + QString convId = QString::fromUtf8((const char*)data + 1, cl); + if (m_currentChannelId != QStringLiteral("dm:") + convId) return; /* stale response */ + + uint16_t count; memcpy(&count, data + 1 + cl, 2); + const uint8_t* p = data + 1 + cl + 2; + const uint8_t* end = data + len; + + m_model->clear(); + m_avatarCache.clear(); + + for (int i = 0; i < count; i++) { + if (p + 1 + 8 + 8 + 8 + 1 > end) break; + int dir = p[0]; p += 1; + uint64_t seq; memcpy(&seq, p, 8); p += 8; + uint64_t ts; memcpy(&ts, p, 8); p += 8; + quint64 author; memcpy(&author, p, 8); p += 8; + uint8_t ctl = *p; p += 1; + if (p + ctl > end) break; + p += ctl; /* ct игнорируем: DM v1 — текст */ + if (p + 2 > end) break; + uint16_t dlen; memcpy(&dlen, p, 2); p += 2; + if (p + dlen > end) break; + QString text = QString::fromUtf8((const char*)p, dlen); + p += dlen; + + AccountRow acc = m_db ? m_db->getAccount(author) : AccountRow(); + QString name = authorName(author); + QString letter = acc.avatarLetter.isEmpty() ? name.mid(0, 1) : acc.avatarLetter; + QColor color(acc.avatarColor); + if (!m_avatarCache.contains(author)) + m_avatarCache[author] = color; + auto avatar = makeAvatar(color, letter); + auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm"); + + auto *item = new QStandardItem(); + setMsg(item, name, text, time, avatar, false, + false, QString(), QString(), QVariantList{}); + item->setData((qint64)seq, MsgIdRole); + item->setData(QVariant::fromValue(author), MsgAuthorNodeIdRole); + item->setData(dir == 1 ? 1 : 0, MsgIsOutgoingRole); + m_model->appendRow(item); + } + + m_view->scrollToBottom(); +} diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index 6699b2eb..c7cde907 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -26,6 +26,7 @@ public: static void cleanupAnimations(); void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks); void onAttachmentDownloaded(const QString& chId, int64_t msgId); + void onDmMessages(const uint8_t* data, int len); signals: void readPositionChanged(const QString& channelId, qint64 lastReadMsgId); @@ -52,4 +53,7 @@ private: void restoreScrollPosition(const QString& channelId); void updateReadPosition(); void onMessageContextMenu(const QPoint& pos); + void requestDmMessages(const QString& channelId); + static bool isDmChannel(const QString& channelId); + static QString dmConvId(const QString& channelId); }; diff --git a/tools/chatgui/tests/test_audio_jitter.cpp b/tools/chatgui/tests/test_audio_jitter.cpp new file mode 100644 index 00000000..72e88b0e --- /dev/null +++ b/tools/chatgui/tests/test_audio_jitter.cpp @@ -0,0 +1,101 @@ +// test_audio_jitter.cpp — проверка адаптивного джиттер-буфера (SoundTouch). +// +// 1. Нет потерь до ёмкости (50 кадров = 1000мс), дроп после переполнения. +// 2. Догон: глубокий буфер → темп растёт выше 1.0x (ускоренное воспроизведение). +// 3. Стационар: при неглубоком буфере темп остаётся ~1.0x (без runaway). + +#include "call/audio_jitter.h" + +#include +#include +#include +#include + +static int g_fail = 0; + +#define CHECK(cond, ...) do { \ + if (!(cond)) { g_fail++; printf("FAIL: " __VA_ARGS__); printf("\n"); } \ +} while (0) + +static void make_sine(std::vector& frame, double freq, double phase0) { + for (size_t i = 0; i < frame.size(); i++) + frame[i] = (int16_t)(sin(phase0 + 2.0 * M_PI * freq * (double)i / 48000.0) * 8000.0); +} + +int main(void) { + const int FRAME = 960; + const int CAP = 50; + std::vector frame(FRAME, 0); + std::vector out(480, 0); + int buffer_ms = 0, tempo_x100 = 0; + + /* ── 1. ёмкость и дроп ── */ + { + ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); + CHECK(j != nullptr, "create failed"); + + make_sine(frame, 440.0, 0.0); + for (int i = 0; i < CAP; i++) ajb_push(j, frame.data(), FRAME); + ajb_get_stats(j, &buffer_ms, &tempo_x100); + printf(" capacity: pushed %d frames, buffer_ms=%d dropped=%u\n", CAP, buffer_ms, (unsigned)ajb_dropped(j)); + CHECK(ajb_dropped(j) == 0, "expected no drop at capacity, dropped=%u", (unsigned)ajb_dropped(j)); + CHECK(buffer_ms == CAP * 20, "expected buffer_ms=%d, got %d", CAP * 20, buffer_ms); + + for (int i = 0; i < 5; i++) ajb_push(j, frame.data(), FRAME); + printf(" overflow: dropped=%u (expect 5)\n", (unsigned)ajb_dropped(j)); + CHECK(ajb_dropped(j) == 5, "expected 5 drops, got %u", (unsigned)ajb_dropped(j)); + ajb_destroy(j); + } + + /* ── 2. догон: глубокий буфер → tempo > 1.0x ── */ + { + ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); + make_sine(frame, 440.0, 0.0); + for (int i = 0; i < 40; i++) ajb_push(j, frame.data(), FRAME); /* 800мс burst */ + + int max_tempo = 100; + int total_out = 0; + for (int it = 0; it < 200; it++) { + ajb_get_stats(j, &buffer_ms, &tempo_x100); + if (buffer_ms == 0) break; + if (tempo_x100 > max_tempo) max_tempo = tempo_x100; + int n = ajb_pull(j, out.data(), (int)out.size()); + total_out += n; + } + ajb_get_stats(j, &buffer_ms, &tempo_x100); + printf(" catch-up: max_tempo_x100=%d total_out=%d final_buffer_ms=%d\n", + max_tempo, total_out, buffer_ms); + CHECK(max_tempo > 110, "expected tempo to exceed 1.10x during deep buffer, got %d", max_tempo); + CHECK(total_out < 40 * FRAME, "expected output < input (stretch), out=%d in=%d", total_out, 40 * FRAME); + CHECK(buffer_ms == 0, "expected drained buffer, buffer_ms=%d", buffer_ms); + ajb_destroy(j); + } + + /* ── 3. стационар: feed 1 frame → pull 1 frame, tempo ~1.0x ── */ + { + ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); + make_sine(frame, 440.0, 0.0); + int max_tempo = 100; + for (int i = 0; i < 60; i++) { + ajb_push(j, frame.data(), FRAME); + int got = 0; + while (got < FRAME) { + int n = ajb_pull(j, out.data(), (int)out.size()); + if (n <= 0) break; + got += n; + } + ajb_get_stats(j, &buffer_ms, &tempo_x100); + if (tempo_x100 > max_tempo) max_tempo = tempo_x100; + } + printf(" steady-state: max_tempo_x100=%d (expect ~100)\n", max_tempo); + CHECK(max_tempo <= 110, "tempo should stay ~1.0x in steady state, got %d", max_tempo); + ajb_destroy(j); + } + + if (g_fail == 0) { + printf("TEST PASSED\n"); + return 0; + } + printf("TEST FAILED (%d checks)\n", g_fail); + return 1; +} diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index a6d3b150..68dd3a94 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -39,6 +39,15 @@ struct UTUN_INSTANCE; #define GUI_EVT_ADMIN_KEY_RECEIVED 28 /* data: [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ */ #define GUI_EVT_INVITE_LINK_READY 29 /* data: [ch_id_len:1][ch_id:var][link_len:2][link:var] — пустой link = ошибка */ #define GUI_EVT_INVITE_CANDIDATES 30 /* data: [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */ +#define GUI_EVT_DM_MSG_RECEIVED 32 /* data: [conv_id_len:1][conv_id:var][author_node_id:8] */ +#define GUI_EVT_DM_CONV_UPDATED 33 /* data: [conv_id_len:1][conv_id:var] — беседа создана/изменена */ +#define GUI_EVT_CALL_INCOMING 34 /* data: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */ +#define GUI_EVT_CALL_RINGING 35 /* data: [call_id:8] */ +#define GUI_EVT_CALL_ACCEPTED 36 /* data: [call_id:8] */ +#define GUI_EVT_CALL_ENDED 37 /* data: [call_id:8][reason:1] */ +#define GUI_EVT_CALL_DECLINED 38 /* data: [call_id:8][reason:1] */ +#define GUI_EVT_CALL_STATS 39 /* data: [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] */ +#define GUI_EVT_DM_MESSAGES 41 /* data: [conv_id_len:1][conv_id:var][count:2]([dir:1][seq:8][ts:8][author:8][ct_len:1][ct:var][data_len:2][data:var])* */ /* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */ #define MEMBER_DETAIL_LINK_SIZE 64 @@ -165,6 +174,34 @@ void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb); typedef void (*gui_invite_candidates_fn)(const char* ch_id, int ch_id_len, const uint8_t* data, int len); void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb); +/* Callback для входящего звонка (вызывается из GUI-потока) */ +typedef void (*gui_call_incoming_fn)(const char* ch_id, int ch_id_len, uint64_t caller_node_id, uint64_t call_id); +void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb); + +/* Callback для звонкового события с call_id (ringing/accepted) */ +typedef void (*gui_call_id_fn)(uint64_t call_id); +void gui_bridge_set_call_ringing_cb(gui_call_id_fn cb); +void gui_bridge_set_call_accepted_cb(gui_call_id_fn cb); + +/* Callback для завершения/отклонения звонка (вызывается из GUI-потока) */ +typedef void (*gui_call_ended_fn)(uint64_t call_id, uint8_t reason); +void gui_bridge_set_call_ended_cb(gui_call_ended_fn cb); +void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb); + +/* Callback для входящего DM-сообщения (вызывается из GUI-потока) + * conv_id_len/conv_id — идентификатор беседы (десятичная строка). */ +typedef void (*gui_dm_msg_received_fn)(const char* conv_id, int conv_id_len, uint64_t author); +void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb); + +/* Callback: DM-беседа создана/изменена (вызывается из GUI-потока). */ +typedef void (*gui_dm_conv_updated_fn)(const char* conv_id, int conv_id_len); +void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb); + +/* Callback: расшифрованные сообщения DM-беседы (вызывается из GUI-потока) + * data: [conv_id_len:1][conv_id:var][count:2]([dir:1][seq:8][ts:8][author:8][ct_len:1][ct:var][data_len:2][data:var])* */ +typedef void (*gui_dm_messages_fn)(const uint8_t* data, int len); +void gui_bridge_set_dm_messages_cb(gui_dm_messages_fn cb); + /* uTun nodeinfo callback — registered via utun_add_nodeinfo_cbk, called from uasync thread */ void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 5ecf4fb2..b0b37dcb 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -48,6 +48,14 @@ static gui_bgp_info_fn g_bgp_info_cb = nullptr; static gui_admin_key_received_fn g_admin_key_received_cb = nullptr; static gui_invite_link_ready_fn g_invite_link_ready_cb = nullptr; static gui_invite_candidates_fn g_invite_candidates_cb = nullptr; +static gui_call_incoming_fn g_call_incoming_cb = nullptr; +static gui_call_id_fn g_call_ringing_cb = nullptr; +static gui_call_id_fn g_call_accepted_cb = nullptr; +static gui_call_ended_fn g_call_ended_cb = nullptr; +static gui_call_ended_fn g_call_declined_cb = nullptr; +static gui_dm_msg_received_fn g_dm_msg_received_cb = nullptr; +static gui_dm_conv_updated_fn g_dm_conv_updated_cb = nullptr; +static gui_dm_messages_fn g_dm_messages_cb = nullptr; static struct UASYNC* g_ua = nullptr; static struct UTUN_INSTANCE* g_inst = nullptr; @@ -236,6 +244,79 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { } } break; + case GUI_EVT_DM_MSG_RECEIVED: + if (dlen >= 2) { + uint8_t cl = d[0]; + if (dlen >= 1 + cl + 8) { + uint64_t author; memcpy(&author, d + 1 + cl, 8); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: DM_MSG_RECEIVED conv=%.*s author=%llu", + cl, (const char*)d + 1, (unsigned long long)author); + if (g_dm_msg_received_cb) g_dm_msg_received_cb((const char*)d + 1, cl, author); + } + } + break; + case GUI_EVT_DM_CONV_UPDATED: + if (dlen >= 2) { + uint8_t cl = d[0]; + if (dlen >= 1 + cl) { + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: DM_CONV_UPDATED conv=%.*s", + cl, (const char*)d + 1); + if (g_dm_conv_updated_cb) g_dm_conv_updated_cb((const char*)d + 1, cl); + } + } + break; + case GUI_EVT_DM_MESSAGES: + if (g_dm_messages_cb) g_dm_messages_cb(d, dlen); + break; + case GUI_EVT_CALL_INCOMING: + if (dlen >= 1 + 8 + 8) { + uint8_t chLen = d[0]; + if (dlen >= 1 + chLen + 8 + 8) { + uint64_t caller, callId; + memcpy(&caller, d + 1 + chLen, 8); + memcpy(&callId, d + 1 + chLen + 8, 8); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: CALL_INCOMING ch=%.*s caller=0x%016llx id=0x%016llx", + chLen, (const char*)d + 1, (unsigned long long)caller, (unsigned long long)callId); + if (g_call_incoming_cb) g_call_incoming_cb((const char*)d + 1, chLen, caller, callId); + } + } + break; + case GUI_EVT_CALL_RINGING: + if (dlen >= 8) { + uint64_t callId; memcpy(&callId, d, 8); + if (g_call_ringing_cb) g_call_ringing_cb(callId); + } + break; + case GUI_EVT_CALL_ACCEPTED: + if (dlen >= 8) { + uint64_t callId; memcpy(&callId, d, 8); + if (g_call_accepted_cb) g_call_accepted_cb(callId); + } + break; + case GUI_EVT_CALL_ENDED: + if (dlen >= 9) { + uint64_t callId; memcpy(&callId, d, 8); + if (g_call_ended_cb) g_call_ended_cb(callId, d[8]); + } + break; + case GUI_EVT_CALL_DECLINED: + if (dlen >= 9) { + uint64_t callId; memcpy(&callId, d, 8); + if (g_call_declined_cb) g_call_declined_cb(callId, d[8]); + } + break; + case GUI_EVT_CALL_STATS: + if (dlen >= 14) { + uint64_t callId; memcpy(&callId, d, 8); + uint16_t rtt = (uint16_t)((d[8] << 8) | d[9]); + uint16_t buf = (uint16_t)((d[10] << 8) | d[11]); + uint16_t tempo = (uint16_t)((d[12] << 8) | d[13]); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, + "gui_bridge: CALL_STATS id=0x%016llx rtt=%ums buffer=%ums tempo=%u.%02ux", + (unsigned long long)callId, (unsigned)rtt, (unsigned)buf, + (unsigned)(tempo / 100), (unsigned)(tempo % 100)); + } + break; default: DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType); break; @@ -377,6 +458,38 @@ void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb) { g_invite_candidates_cb = cb; } +void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb) { + g_call_incoming_cb = cb; +} + +void gui_bridge_set_call_ringing_cb(gui_call_id_fn cb) { + g_call_ringing_cb = cb; +} + +void gui_bridge_set_call_accepted_cb(gui_call_id_fn cb) { + g_call_accepted_cb = cb; +} + +void gui_bridge_set_call_ended_cb(gui_call_ended_fn cb) { + g_call_ended_cb = cb; +} + +void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb) { + g_call_declined_cb = cb; +} + +void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb) { + g_dm_msg_received_cb = cb; +} + +void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) { + g_dm_conv_updated_cb = cb; +} + +void gui_bridge_set_dm_messages_cb(gui_dm_messages_fn cb) { + g_dm_messages_cb = cb; +} + } /* extern "C" */ /* ── uTun nodeinfo callback (called from uasync thread) ── */