Browse Source

call: P2P аудиозвонки — сигналинг, релей медиа, аудио-контур, headless/desktop/android

- src/call/: сигналинг (call.c, call_proto.h, ETCP_RT_ID_CALL 0x36) + релей медиа
  + headless аудио-сокет (call_headless.c, call_audio_bind) + Opus/jitter/тоны
- chat: события CHAT_EVT_CALL_* (34–40), CHAT_EVT_DM_MESSAGES (41), DM-трамплины
- chatgui: call window, miniaudio I/O, audio_frame_ring, test_audio_jitter
- chatgui-android: CallScreen/IncomingCallDialog, аудио-роутинг, JNI, рингтон
- tests: test_call, test_call_headless
router-recv-conn-uaf
evgeny 3 weeks ago
parent
commit
b11e77e4c0
  1. 32
      doc/tasks.md
  2. 1
      lib/debug_config.c
  3. 3
      lib/debug_config.h
  4. 5
      src/Makefile.am
  5. 180
      src/call/audio_jitter.cpp
  6. 51
      src/call/audio_jitter.h
  7. 877
      src/call/call.c
  8. 96
      src/call/call.h
  9. 283
      src/call/call_audio.c
  10. 61
      src/call/call_audio.h
  11. 278
      src/call/call_headless.c
  12. 34
      src/call/call_headless.h
  13. 95
      src/call/call_proto.h
  14. 165
      src/call/call_tones.c
  15. 32
      src/call/call_tones.h
  16. 8
      src/chat/chat_event.h
  17. 73
      src/chat/chat_headless_control.c
  18. 4
      src/chat/chat_member.c
  19. 1
      src/chat/chat_member.h
  20. 5
      src/config_parser.c
  21. 1
      src/config_parser.h
  22. 343
      src/dm/dm_core.c
  23. 30
      src/dm/dm_core.h
  24. 4
      src/dm/dm_mailbox.c
  25. 6
      src/dm/dm_mailbox.h
  26. 1
      src/transport_layer/etcp_api.h
  27. 19
      src/utun_instance.c
  28. 3
      src/utun_instance.h
  29. 10
      tests/Makefile.am
  30. 465
      tests/test_call.c
  31. 512
      tests/test_call_headless.c
  32. 158
      tests/test_dm_e2e.c
  33. 2
      tools/chatgui-android/AGENTS.md
  34. 13
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  35. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  36. 93
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  37. 263
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  38. 305
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  39. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt
  40. 216
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
  41. 34
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  42. 158
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt
  43. 194
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  44. 76
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt
  45. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  46. 43
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  47. 214
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  48. BIN
      tools/chatgui-android/app/src/main/res/raw/ringtone.mp3
  49. 171
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  50. 14
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  51. 156
      tools/chatgui-android/libutun_lite/call_audio.c
  52. 52
      tools/chatgui-android/libutun_lite/call_audio.h
  53. 5
      tools/chatgui-android/libutun_lite/utun_sources.cmake
  54. 48
      tools/chatgui/CMakeLists.txt
  55. 23
      tools/chatgui/db/db_manager.cpp
  56. 12
      tools/chatgui/db/db_manager.h
  57. 4
      tools/chatgui/libutun/CMakeLists.txt
  58. 14
      tools/chatgui/src/accountlist.cpp
  59. 2
      tools/chatgui/src/accountlist.h
  60. 67
      tools/chatgui/src/audio_frame_ring.h
  61. 183
      tools/chatgui/src/call_audio_engine.cpp
  62. 62
      tools/chatgui/src/call_audio_engine.h
  63. 179
      tools/chatgui/src/callwindow.cpp
  64. 60
      tools/chatgui/src/callwindow.h
  65. 14
      tools/chatgui/src/channeldelegate.cpp
  66. 3
      tools/chatgui/src/channeldelegate.h
  67. 33
      tools/chatgui/src/channellist.cpp
  68. 5
      tools/chatgui/src/inputbar.cpp
  69. 3
      tools/chatgui/src/inputbar.h
  70. 146
      tools/chatgui/src/mainwindow.cpp
  71. 18
      tools/chatgui/src/mainwindow.h
  72. 5
      tools/chatgui/src/memberlistmodel.cpp
  73. 1
      tools/chatgui/src/memberlistmodel.h
  74. 108
      tools/chatgui/src/messagelist.cpp
  75. 4
      tools/chatgui/src/messagelist.h
  76. 101
      tools/chatgui/tests/test_audio_jitter.cpp
  77. 37
      tools/chatgui/transport/gui_bridge.h
  78. 113
      tools/chatgui/transport/gui_bridge_impl.cpp

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

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

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

5
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 \

180
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 <atomic>
#include <cmath>
#include <cstring>
#include <mutex>
#include <vector>
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<int16_t> ring; // cap * frame_samples
int head = 0;
int count = 0;
uint32_t dropped = 0;
soundtouch::SoundTouch st; // только аудио-поток
std::vector<int16_t> frame_buf; // scratch: вытащенный кадр
std::vector<float> fin; // scratch: кадр в float
std::vector<float> fout; // scratch: выход float
double ema_depth_ms = 0.0;
double tempo = 1.0;
std::atomic<int> tempo_x100{100};
};
static double 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<std::mutex> 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<std::mutex> 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<std::mutex> lk(j->mtx);
if (j->count == j->cap) {
j->head = (j->head + 1) % j->cap; // дроп старейшего
j->count--;
j->dropped++;
}
int idx = (j->head + j->count) % j->cap;
std::memcpy(&j->ring[(size_t)idx * 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<std::mutex> lk(j->mtx);
cnt = j->count;
}
double depth_ms = (double)cnt * j->frame_ms;
j->ema_depth_ms = kEmaAlpha * depth_ms + (1.0 - kEmaAlpha) * j->ema_depth_ms;
double target = 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<std::mutex> 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<std::mutex> lk(j->mtx);
return j->dropped;
}
} // extern "C"

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

877
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 <string.h>
#include <stdlib.h>
#include <stdio.h>
#define CALL_ID "call"
enum call_state {
CALL_ST_IDLE = 0,
CALL_ST_OUTGOING, /* caller: INVITE отправлен */
CALL_ST_RINGING, /* caller: получил RINGING; callee: отправил RINGING */
CALL_ST_CONNECTING, /* обе стороны: conn_mgr_open в процессе */
CALL_ST_ACTIVE, /* обе стороны: соединение поднято */
CALL_ST_ENDED,
};
enum call_role { CALL_ROLE_CALLER = 0, CALL_ROLE_CALLEE };
struct call_session {
struct ll_entry ll; /* dummy-заголовок; структура хранится в entry->data */
uint64_t call_id; /* hash-индекс (8 байт) */
uint64_t group_id;
uint64_t peer_node_id;
uint8_t role; /* CALL_ROLE_* */
uint8_t state; /* CALL_ST_* */
uint8_t reason; /* CALL_REASON_* (при ENDED) */
struct call_ctx* ctx; /* обратная ссылка (для коллбэков conn_mgr/таймеров) */
struct CONN_MGR_HANDLE* cm_handle;
void* ring_timer; /* caller: 45с ожидания ответа */
void* traffic_timer; /* ACTIVE: 20с без медиа (запускается по первому медиа) */
void* teardown_timer; /* отложенный разрыв conn_mgr (500мс после завершения) */
void* stats_timer; /* ACTIVE: раз в 1с — CALL_STATS (rtt/buffer/tempo) */
uint64_t last_media_tb; /* время последнего CALL_MEDIA (0.1ms) */
uint16_t tx_media_seq; /* seq следующего исходящего CALL_MEDIA */
struct ll_queue* tx_q; /* FIFO закодированных CALL_MEDIA (backpressure) */
struct queue_waiter_handle send_waiter; /* waiter на send_q роутера */
uint32_t c_tx_frames; /* отправлено медиа-кадров */
uint32_t c_rx_frames; /* принято медиа-кадров */
uint32_t c_tx_dropped; /* дропнуто при переполнении tx_q */
uint32_t c_rx_dropped; /* дропнуто при переполнении jitter-буфера */
};
struct call_ctx {
struct UTUN_INSTANCE* inst;
int initialized;
struct ll_queue* sessions; /* struct call_session, hash по call_id */
uint32_t c_calls_started; /* суммарно начато звонков */
uint32_t c_calls_ended; /* суммарно завершено звонков */
call_media_recv_fn media_recv_fn; /* приём Opus-кадров пира */
void* media_recv_arg;
const struct call_audio_ops* audio_ops; /* аудио-движок (GUI-сборки), NULL в демоне */
};
/* ───────────────────────────────────────────────────────────────────────── */
static struct call_ctx* call_of(struct UTUN_INSTANCE* inst) {
return inst ? inst->call : NULL;
}
static const char* call_state_name(uint8_t st) {
switch (st) {
case CALL_ST_IDLE: return "IDLE";
case CALL_ST_OUTGOING: return "OUTGOING";
case CALL_ST_RINGING: return "RINGING";
case CALL_ST_CONNECTING: return "CONNECTING";
case CALL_ST_ACTIVE: return "ACTIVE";
case CALL_ST_ENDED: return "ENDED";
default: return "?";
}
}
static const char* call_subcmd_name(uint8_t sc) {
switch (sc) {
case CALL_SUBCMD_INVITE: return "INVITE";
case CALL_SUBCMD_RINGING: return "RINGING";
case CALL_SUBCMD_ACCEPT: return "ACCEPT";
case CALL_SUBCMD_DECLINE: return "DECLINE";
case CALL_SUBCMD_BUSY: return "BUSY";
case CALL_SUBCMD_CANCEL: return "CANCEL";
case CALL_SUBCMD_HANGUP: return "HANGUP";
case CALL_SUBCMD_MEDIA: return "MEDIA";
default: return "?";
}
}
/* ─────────────────── отправка ─────────────────── */
static void call_stats_timer_cb(void* arg);
/* data[0] = subcmd. etcp_route_send забирает владение entry. */
static int call_send(struct call_ctx* ctx, uint64_t group_id, uint64_t dst,
const uint8_t* data, size_t len, int mode) {
struct ll_entry* e = queue_entry_new(0);
if (!e) return -1;
e->dgram = u_malloc(1 + len);
if (!e->dgram) { queue_entry_free(e); return -1; }
e->dgram[0] = ETCP_RT_ID_CALL;
if (len) memcpy(e->dgram + 1, data, len);
e->len = (uint16_t)(1 + len);
int rc = etcp_route_send(ctx->inst, group_id, dst, e, 1, mode);
if (rc != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: send FAIL rc=%d subcmd=%s(0x%02x) dst=0x%016llx group=0x%016llx",
CALL_ID, rc, call_subcmd_name(len ? data[0] : 0), len ? data[0] : 0,
(unsigned long long)dst, (unsigned long long)group_id);
}
return rc;
}
/* ─────────────────── реестр сессий ─────────────────── */
static struct call_session* call_session_find(struct call_ctx* ctx, uint64_t call_id) {
if (!ctx || !ctx->sessions) return NULL;
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &call_id);
return e ? (struct call_session*)e->data : NULL;
}
static struct call_session* call_session_new(struct call_ctx* ctx, uint64_t call_id,
uint64_t group_id, uint64_t peer, uint8_t role) {
if (!ctx || !ctx->sessions) return NULL;
struct ll_entry* qe = queue_entry_new(sizeof(struct call_session));
if (!qe) return NULL;
struct call_session* s = (struct call_session*)qe->data;
memset(s, 0, sizeof(*s));
s->call_id = call_id;
s->group_id = group_id;
s->peer_node_id = peer;
s->role = role;
s->state = CALL_ST_IDLE;
s->ctx = ctx;
s->tx_q = queue_new(ctx->inst->ua, 0, 0, 0, "call_txq");
if (!s->tx_q) { queue_entry_free(qe); return NULL; }
if (queue_data_put_with_index(ctx->sessions, qe) != 0) {
queue_free(s->tx_q);
queue_entry_free(qe);
return NULL;
}
return s;
}
static void call_session_free_cb(void* arg) {
struct ll_entry* e = (struct ll_entry*)arg;
queue_entry_free(e);
}
/* Освободить медиа-объекты сессии: отменить waiter на send_q роутера и
* дренировать tx_q (без отправки). Безопасно вызывать из uasync-потока. */
static void call_session_cleanup(struct call_ctx* ctx, struct call_session* s) {
etcp_router_cancel_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL, &s->send_waiter);
if (s->tx_q) {
struct ll_entry* e;
while ((e = queue_data_get(s->tx_q)) != NULL) {
queue_dgram_free(e);
queue_entry_free(e);
}
queue_free(s->tx_q);
s->tx_q = NULL;
}
}
/* Убрать сессию из реестра и освободить отложенно (не из коллбэков conn_mgr/таймеров). */
static void call_session_remove_and_free(struct call_ctx* ctx, struct call_session* s) {
call_session_cleanup(ctx, s);
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &s->call_id);
if (e) {
queue_remove_data(ctx->sessions, e);
uasync_call_soon(ctx->inst->ua, e, call_session_free_cb);
}
}
/* ─────────────────── события в GUI ─────────────────── */
static void call_event_id(struct call_ctx* ctx, int type, uint64_t call_id, uint8_t reason) {
uint8_t buf[16];
size_t off = 0;
memcpy(buf + off, &call_id, 8); off += 8;
buf[off++] = reason;
chat_event_post(ctx->inst, type, buf, (int)off);
}
static void call_event_incoming(struct call_ctx* ctx, uint64_t group_id, uint64_t caller, uint64_t call_id) {
char ch[32];
snprintf(ch, sizeof(ch), "%llu", (unsigned long long)group_id);
size_t ch_len = strlen(ch);
uint8_t buf[1 + 32 + 8 + 8];
size_t off = 0;
buf[off++] = (uint8_t)ch_len;
memcpy(buf + off, ch, ch_len); off += ch_len;
memcpy(buf + off, &caller, 8); off += 8;
memcpy(buf + off, &call_id, 8); off += 8;
chat_event_post(ctx->inst, CHAT_EVT_CALL_INCOMING, buf, (int)off);
}
/* ─────────────────── завершение ─────────────────── */
/* Отложенный разрыв: единственная точка, где закрывается conn_mgr (после того,
* как финальный сигналинг — HANGUP/DECLINE — гарантированно долетел). */
static void call_teardown_timer_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->teardown_timer = NULL;
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; }
call_session_remove_and_free(ctx, s);
}
static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reason) {
if (!ctx || !s || s->state == CALL_ST_ENDED) return;
s->state = CALL_ST_ENDED;
s->reason = reason;
ctx->c_calls_ended++;
if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; }
if (s->traffic_timer) { uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = NULL; }
if (s->stats_timer) { uasync_cancel_timeout(ctx->inst->ua, s->stats_timer); s->stats_timer = NULL; }
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call ENDED id=%016llx reason=%d role=%s rx=%u tx=%u tx_dropped=%u",
CALL_ID, (unsigned long long)s->call_id, reason,
s->role == CALL_ROLE_CALLER ? "caller" : "callee",
s->c_rx_frames, s->c_tx_frames, s->c_tx_dropped);
call_event_id(ctx, CHAT_EVT_CALL_ENDED, s->call_id, reason);
/* Разрыв conn_mgr — отложенно (500мс), чтобы HANGUP/DECLINE успел долететь.
* Без cm_handle рвать нечего — освобождаем сразу (отложенно). */
if (s->cm_handle) {
s->teardown_timer = uasync_set_timeout(ctx->inst->ua, CALL_TEARDOWN_DELAY_TB, s, call_teardown_timer_cb, "call_teardown");
} else {
call_session_remove_and_free(ctx, s);
}
}
/* ─────────────────── conn_mgr ─────────────────── */
static void call_conn_cb(struct CONN_MGR_HANDLE* h, uint64_t node_id, uint64_t group_id,
enum conn_mgr_event event, void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
if (s->state == CALL_ST_ENDED) {
/* сессия завершена, ждёт teardown: conn_mgr сообщает об обрыве — handle больше
* не трогаем (entry чистится самим conn_mgr / удалённой стороной) */
if (event == CONN_EVENT_DOWN || event == CONN_EVENT_TIMEOUT) s->cm_handle = NULL;
return;
}
if (event == CONN_EVENT_UP) {
s->cm_handle = h;
s->state = CALL_ST_ACTIVE;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: conn UP id=%016llx peer=0x%016llx -> ACTIVE",
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id);
call_event_id(ctx, CHAT_EVT_CALL_ACCEPTED, s->call_id, 0);
if (!s->stats_timer)
s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats");
} else if (event == CONN_EVENT_DOWN) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn DOWN id=%016llx peer=0x%016llx (temporary, keep alive)",
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id);
} else { /* CONN_EVENT_TIMEOUT */
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn TIMEOUT id=%016llx peer=0x%016llx -> fail",
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id);
/* entry уже чистится вызывающим (monitor) — handle не закрываем */
s->cm_handle = NULL;
call_end(ctx, s, CALL_REASON_CONNECT_FAIL);
}
}
/* ─────────────────── таймеры ─────────────────── */
static void call_ring_timeout_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->ring_timer = NULL;
if (s->state == CALL_ST_ENDED) return;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: ring timeout id=%016llx -> CANCEL",
CALL_ID, (unsigned long long)s->call_id);
uint8_t pkt[sizeof(struct call_pkt_cancel)];
struct call_pkt_cancel* c = (struct call_pkt_cancel*)pkt;
c->subcmd = CALL_SUBCMD_CANCEL;
c->call_id = s->call_id;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_end(ctx, s, CALL_REASON_RING_TIMEOUT);
}
static void call_traffic_timeout_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->traffic_timer = NULL;
if (s->state == CALL_ST_ENDED) return;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: no traffic 20s id=%016llx -> HANGUP",
CALL_ID, (unsigned long long)s->call_id);
uint8_t pkt[sizeof(struct call_pkt_hangup)];
struct call_pkt_hangup* hu = (struct call_pkt_hangup*)pkt;
hu->subcmd = CALL_SUBCMD_HANGUP;
hu->call_id = s->call_id;
hu->reason = CALL_REASON_NO_TRAFFIC;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_end(ctx, s, CALL_REASON_NO_TRAFFIC);
}
/* Периодическое событие CALL_STATS (раз в 1с): rtt_ms, buffer_ms, tempo_x100. */
static void call_stats_timer_cb(void* arg) {
struct call_session* s = (struct call_session*)arg;
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->stats_timer = NULL;
if (s->state != CALL_ST_ACTIVE) return;
int buffer_ms = 0, tempo_x100 = 100;
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);
}

96
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 <stdint.h>
struct UTUN_INSTANCE;
#ifdef __cplusplus
extern "C" {
#endif
/* ── жизненный цикл ── */
int call_init(struct UTUN_INSTANCE* inst); /* из utun_instance.c после dm_core_init */
void call_destroy(struct UTUN_INSTANCE* inst); /* перед dm_core_destroy */
/* ── сигналинг (uasync-поток) ── */
/* Caller: начать звонок. out_call_id — случайный uint64, генерит вызывающий. */
void call_start (struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id);
/* Callee: ответить/отклонить. hangup — любая сторона в любой момент. */
void call_accept (struct UTUN_INSTANCE* inst, uint64_t call_id);
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id);
void call_hangup (struct UTUN_INSTANCE* inst, uint64_t call_id);
/* Read-only онлайн-проверка (GUI может звать из любого потока). */
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id);
/* ── медиа (релей Opus-кадров, uasync-поток) ── */
/* Отправить закодированный Opus-кадр пиру (оборачивает в CALL_MEDIA).
* Только когда звонок ACTIVE. seq/ts выставляются автоматически. */
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len);
/* Коллбэк приёма медиа: вызывается в uasync-потоке на каждый Opus-кадр пира.
* Используется headless-аудио-сокетом (и в фазе 3 — декодером GUI). */
typedef void (*call_media_recv_fn)(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg);
void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, void* arg);
/* ── аудио-движок (опционально, GUI-сборки) ──
* Сервисный аудио-контур (codec + джиттер-буфер + тоны) регистрируется через
* call_set_audio_ops. call.c зовёт on_media на каждый принятый медиа-кадр (с seq/ts)
* и get_stats для события CALL_STATS. Демон без аудио (headless) — ops не ставит. */
struct call_audio_ops {
/* uasync-поток: принятый Opus-кадр пира (декод + jitter-буфер). */
void (*on_media)(struct UTUN_INSTANCE* inst, uint64_t call_id,
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len);
/* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100). */
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 */

283
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 <pthread.h>
#include <string.h>
#define CALL_AUDIO_ID "call_audio"
#define CALL_AUDIO_BITRATE 32000
#define CALL_AJ_MAX_FRAMES 50 /* ~1000мс @20мс */
#define CALL_STALL_TB 5000 /* 500мс без медиа → глитч-тон */
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static struct UTUN_INSTANCE* g_inst = NULL;
static int g_active = 0;
static int g_ending = 0;
static int g_ended_started = 0; /* только аудио-поток: тон завершения запущен */
static uint64_t g_call_id = 0;
static uint64_t g_last_media_tb = 0;
static opus_codec_encoder_t* g_encoder = NULL;
static opus_codec_decoder_t* g_decoder = NULL;
static struct 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;
}

61
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 <stdint.h>
struct UTUN_INSTANCE;
#ifdef __cplusplus
extern "C" {
#endif
#define CALL_AUDIO_SAMPLE_RATE 48000
#define CALL_AUDIO_FRAME_MS 20
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000) /* 960 */
/* Зарегистрировать аудио-движок в call.c (идемпотентно). */
int call_audio_init(struct UTUN_INSTANCE* inst);
void call_audio_destroy(struct UTUN_INSTANCE* inst);
/* GUI-поток: создать codec + jitter + tones для звонка. 0 = успех. */
int call_audio_start(uint64_t call_id);
/* GUI-поток: по CALL_ENDED — начать тон завершения (pull_pcm выдаст 450мс тона). */
void call_audio_begin_end(uint64_t call_id);
/* GUI-поток (после остановки аудиоустройства): освободить все объекты. */
void call_audio_release(uint64_t call_id);
/* аудио-поток: закодировать 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 */

278
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 <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#define CH_ID "call_audio"
#define AUDIO_RECV_BUF_SIZE 65536
#define AUDIO_SEND_BUF_SIZE 65536
#define AUDIO_MAX_CLIENTS 16
#define AUDIO_MAX_FRAME 2048
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);
}

34
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 <stdint.h>
struct UASYNC;
struct UTUN_INSTANCE;
#define CALL_AUDIO_HELLO 0x01
#define CALL_AUDIO_FRAME 0x02
#define CALL_AUDIO_HDR_SIZE 11 /* type(1) + call_id(8) + len(2) */
#ifdef __cplusplus
extern "C" {
#endif
int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst,
const char* bind_ip, int port);
void call_headless_destroy(struct UTUN_INSTANCE* inst);
#ifdef __cplusplus
}
#endif
#endif /* CALL_HEADLESS_H */

95
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 <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ── подкоманды (первый байт payload после svc_id) ── */
enum {
CALL_SUBCMD_INVITE = 0x01, /* {call_id:8, group_id:8, tracks:[]} */
CALL_SUBCMD_RINGING = 0x02, /* {call_id:8} */
CALL_SUBCMD_ACCEPT = 0x03, /* {call_id:8} */
CALL_SUBCMD_DECLINE = 0x04, /* {call_id:8, reason:1} */
CALL_SUBCMD_BUSY = 0x05, /* {call_id:8} */
CALL_SUBCMD_CANCEL = 0x06, /* {call_id:8} */
CALL_SUBCMD_HANGUP = 0x07, /* {call_id:8, reason:1} */
CALL_SUBCMD_MEDIA = 0x10, /* {call_id:8, track_id:1, seq:2, ts_ms:4, payload[]} */
};
/* ── треки и кодеки (модель расширяема под видео) ── */
enum { CALL_TRACK_AUDIO = 0x01, CALL_TRACK_VIDEO = 0x02 };
enum { CALL_CODEC_OPUS = 0x01 };
/* ── причины завершения (ENDED) ── */
enum {
CALL_REASON_LOCAL_HANGUP = 0,
CALL_REASON_REMOTE_HANGUP = 1,
CALL_REASON_DECLINE = 2,
CALL_REASON_BUSY = 3,
CALL_REASON_CANCEL = 4,
CALL_REASON_NO_TRAFFIC = 5,
CALL_REASON_RING_TIMEOUT = 6,
CALL_REASON_CONNECT_FAIL = 7,
};
#pragma pack(push, 1)
struct call_pkt_track {
uint8_t type; /* CALL_TRACK_AUDIO / CALL_TRACK_VIDEO */
uint8_t codec; /* CALL_CODEC_OPUS */
uint32_t sample_rate;
uint8_t channels;
uint32_t bitrate;
};
struct call_pkt_invite {
uint8_t subcmd; /* CALL_SUBCMD_INVITE */
uint64_t call_id;
uint64_t group_id;
uint8_t num_tracks;
struct call_pkt_track tracks[1]; /* tracks[num_tracks] */
};
struct call_pkt_ringing { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_accept { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_decline { uint8_t subcmd; uint64_t call_id; uint8_t reason; };
struct call_pkt_busy { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_cancel { uint8_t subcmd; uint64_t call_id; };
struct call_pkt_hangup { uint8_t subcmd; uint64_t call_id; uint8_t reason; };
struct call_pkt_media {
uint8_t subcmd; /* CALL_SUBCMD_MEDIA */
uint64_t call_id;
uint8_t track_id;
uint16_t seq;
uint32_t ts_ms;
/* payload[] */
};
#pragma pack(pop)
#define CALL_MEDIA_HDR_SIZE sizeof(struct call_pkt_media) /* 16 */
/* ── параметры аудио (Opus 48kHz mono, 20ms) ── */
#define CALL_OPUS_SAMPLE_RATE 48000
#define CALL_OPUS_FRAME_MS 20
#define CALL_OPUS_FRAME_SAMPLES (CALL_OPUS_SAMPLE_RATE * CALL_OPUS_FRAME_MS / 1000) /* 960 */
/* ── таймауты и ёмкости (timebase 0.1ms) ── */
#define CALL_RING_TIMEOUT_TB 450000 /* 45с — нет ответа → CANCEL */
#define CALL_NO_TRAFFIC_TB 200000 /* 20с — нет входящих медиа → HANGUP */
#define CALL_TEARDOWN_DELAY_TB 5000 /* 500мс — задержка разрыва conn_mgr после завершения */
#define CALL_MAX_TX_FRAMES 50 /* ~1с буфер отправки (50 кадров × 20мс) */
#ifdef __cplusplus
}
#endif
#endif /* CALL_PROTO_H */

165
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 <math.h>
#include <string.h>
#include <stdlib.h>
#define TONES_SAMPLE_RATE 48000
#define TONES_AMPLITUDE 0.25f /* от полной шкалы int16 */
#define TONES_FADE_SAMPLES (TONES_SAMPLE_RATE * 5 / 1000) /* 5мс */
enum tones_mode { TONES_NONE = 0, TONES_GLITCH, TONES_ENDED };
struct tone_seg {
int tone; /* 1 = звук, 0 = тишина */
int dur_ms;
int freq_hz;
};
static const struct tone_seg GLITCH_SEGS[] = {
{1, 150, 1000}, {0, 100, 0}, {1, 150, 1000}, {0, 600, 0},
{1, 200, 1000}, {0, 200, 0}, {1, 200, 1000}, {0, 400, 0},
};
#define GLITCH_SEG_COUNT ((int)(sizeof(GLITCH_SEGS) / sizeof(GLITCH_SEGS[0])))
static const struct tone_seg ENDED_SEGS[] = {
{1, 150, 700}, {1, 150, 500}, {1, 150, 400},
};
#define ENDED_SEG_COUNT ((int)(sizeof(ENDED_SEGS) / sizeof(ENDED_SEGS[0])))
struct call_tones {
enum tones_mode mode;
const struct tone_seg* segs;
int seg_count;
int seg_idx;
int seg_pos; /* сэмплов отыграно в текущем сегменте */
int ended_done;
double phase; /* фаза синуса (радианы) */
};
struct call_tones* call_tones_create(void) {
struct call_tones* t = (struct call_tones*)calloc(1, sizeof(*t));
if (!t) return NULL;
t->mode = TONES_NONE;
t->segs = NULL;
t->seg_count = 0;
t->seg_idx = 0;
t->seg_pos = 0;
t->ended_done = 0;
t->phase = 0.0;
return t;
}
void call_tones_destroy(struct call_tones* t) {
free(t);
}
void call_tones_reset(struct call_tones* t) {
if (!t) return;
t->mode = TONES_NONE;
t->segs = NULL;
t->seg_count = 0;
t->seg_idx = 0;
t->seg_pos = 0;
t->ended_done = 0;
t->phase = 0.0;
}
void call_tones_glitch_start(struct call_tones* t) {
if (!t || t->mode == TONES_GLITCH) return;
t->mode = TONES_GLITCH;
t->segs = GLITCH_SEGS;
t->seg_count = GLITCH_SEG_COUNT;
t->seg_idx = 0;
t->seg_pos = 0;
t->phase = 0.0;
}
void call_tones_glitch_stop(struct call_tones* t) {
if (!t || t->mode != TONES_GLITCH) return;
t->mode = TONES_NONE;
t->segs = NULL;
t->seg_count = 0;
t->seg_idx = 0;
t->seg_pos = 0;
t->phase = 0.0;
}
void call_tones_ended_start(struct call_tones* t) {
if (!t) return;
t->mode = TONES_ENDED;
t->segs = ENDED_SEGS;
t->seg_count = ENDED_SEG_COUNT;
t->seg_idx = 0;
t->seg_pos = 0;
t->ended_done = 0;
t->phase = 0.0;
}
int call_tones_ended_pending(struct call_tones* t) {
return t && t->mode == TONES_ENDED && !t->ended_done;
}
/* Перейти к следующему сегменту (или завершить тон). */
static void tones_advance(struct call_tones* t) {
t->seg_idx++;
t->seg_pos = 0;
t->phase = 0.0;
if (t->seg_idx >= t->seg_count) {
if (t->mode == TONES_GLITCH) {
t->seg_idx = 0; /* цикл глитча */
} else if (t->mode == TONES_ENDED) {
t->ended_done = 1;
t->mode = TONES_NONE;
t->segs = NULL;
}
}
}
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples) {
if (!t || !out || max_samples <= 0) return 0;
int written = 0;
while (written < max_samples) {
if (t->mode == TONES_NONE) {
out[written++] = 0; /* тишина до конца запроса */
continue;
}
const struct tone_seg* s = &t->segs[t->seg_idx];
int seg_len = s->dur_ms * TONES_SAMPLE_RATE / 1000;
int remain = seg_len - t->seg_pos;
int n = max_samples - written;
if (n > remain) n = remain;
for (int i = 0; i < n; i++) {
int pos = t->seg_pos + i;
float v = 0.0f;
if (s->tone) {
v = (float)sin(t->phase) * TONES_AMPLITUDE * 32767.0f;
t->phase += 2.0 * M_PI * (double)s->freq_hz / TONES_SAMPLE_RATE;
/* fade in/out по краям сегмента (без кликов) */
int fade = pos < TONES_FADE_SAMPLES ? pos
: (seg_len - pos - 1) < TONES_FADE_SAMPLES ? (seg_len - pos - 1)
: TONES_FADE_SAMPLES;
if (fade < TONES_FADE_SAMPLES)
v *= (float)fade / (float)TONES_FADE_SAMPLES;
}
int iv = (int)v;
out[written + i] = (int16_t)(iv > 32767 ? 32767 : (iv < -32768 ? -32768 : iv));
}
written += n;
t->seg_pos += n;
if (t->seg_pos >= seg_len) tones_advance(t);
}
return written;
}

32
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 <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
struct call_tones;
struct call_tones* call_tones_create(void);
void call_tones_destroy(struct call_tones* t);
void call_tones_reset(struct call_tones* t); /* в тишину */
void call_tones_glitch_start(struct call_tones* t); /* запустить/продолжить цикл глитча */
void call_tones_glitch_stop(struct call_tones* t); /* прекратить глитч (тишина) */
void call_tones_ended_start(struct call_tones* t); /* 3 нисходящих тона, один раз */
int call_tones_ended_pending(struct call_tones* t); /* 1 = тон завершения ещё звучит */
/* Выдать до max_samples сэмплов (тон или тишина). Возвращает записанное число. */
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples);
#ifdef __cplusplus
}
#endif
#endif /* CALL_TONES_H */

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

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

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

1
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

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

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

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

30
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

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

6
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

1
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 // соединение создано

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

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

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

465
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 <sqlite3.h>
#define OPENSSL_API_COMPAT 0x10100000L
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <time.h>
#define CH_ID "4242424242424242"
#define IDX_A 0
#define IDX_B 1
#define TICK_TB 100 /* 10 ms на такт */
#define MAX_TICKS 3000 /* глобальный таймаут ≈ 30 c */
enum call_phase {
P_WAIT_CONN,
P_SETUP,
P_WAIT_BGP,
P_CALL1_START, /* A: call_start */
P_WAIT_INCOMING1, /* B: INCOMING */
P_ACCEPT, /* B: call_accept */
P_WAIT_ACCEPTED, /* обе ACTIVE */
P_SEND_MEDIA, /* A: call_send_media (тестовый Opus-кадр) */
P_WAIT_MEDIA, /* B: media_recv_cb получил кадр */
P_HANGUP, /* A: call_hangup */
P_WAIT_ENDED1, /* обе ENDED */
P_CALL2_START, /* A: call_start (2-й звонок) */
P_WAIT_INCOMING2, /* B: INCOMING */
P_DECLINE, /* B: call_decline */
P_WAIT_ENDED2, /* обе ENDED + A DECLINED */
P_DONE,
};
struct call_ctx {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst[2];
enum call_phase phase;
int result; /* 0=running, 1=ok, 2=fail */
int ticks;
void* timer;
uint64_t call_id; /* текущий call_id */
};
/* записи событий CHAT_EVT_CALL_* на узел */
struct call_evt {
int incoming, ringing, accepted, ended, declined;
uint64_t incoming_call_id;
uint64_t accepted_call_id;
uint64_t ended_call_id;
uint8_t ended_reason;
};
static struct call_evt g_ev[2]; /* [A], [B] */
/* записи принятых медиа-кадров (media_recv_cb) */
struct media_evt {
int frames;
uint64_t last_call_id;
int last_len;
uint8_t last[64];
};
static struct media_evt g_media[2];
static void media_recv_cb(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg) {
(void)inst;
struct media_evt* m = (struct media_evt*)arg;
if (!m) return;
m->frames++;
m->last_call_id = call_id;
m->last_len = len;
if (len > 0) memcpy(m->last, opus, (size_t)(len < 64 ? len : 64));
}
struct call_shared {
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64];
uint64_t nid[2];
uint8_t x_pub[2][32], x_priv[2][32], ed_pub[2][32];
int port[2];
};
static struct call_shared g_sh;
static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0;
#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0)
#define OK() do { G_PASSED++; printf("OK\n"); } while(0)
#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0)
/* ── крипто-хелперы ── */
static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) {
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
EVP_PKEY* pkey = NULL;
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
EVP_PKEY_free(pkey);
}
static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) {
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
EVP_PKEY* pkey = NULL;
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
EVP_PKEY_free(pkey);
}
static void to_hex(const uint8_t* bin, size_t n, char* out) {
for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]);
out[n * 2] = '\0';
}
static int wf(const char* p, const char* f, ...) {
va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1;
va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0;
}
/* ── генерация данных сценария + конфигов ── */
static void fill_shared(int base_port) {
memset(&g_sh, 0, sizeof(g_sh));
for (int i = 0; i < 2; i++) {
gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]);
sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]);
g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]);
g_sh.port[i] = base_port + i * 1000;
}
{ uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); }
gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv);
{
uint8_t msg[256]; size_t off = 0;
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1;
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "call-test") + 1;
memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8;
memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32;
memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32;
sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig);
}
}
static void write_configs(const char* dir) {
for (int i = 0; i < 2; i++) {
char priv[65], pub[65], path[512], dbd[512];
to_hex(g_sh.x_priv[i], 32, priv);
to_hex(g_sh.x_pub[i], 32, pub);
snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : "b");
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : "b");
utun_mkdir(dbd, 0755);
char client[512] = "";
if (i == IDX_B) {
/* B → A (клиент к A) */
char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub);
snprintf(client, sizeof(client),
"[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n",
apub, g_sh.port[IDX_A]);
}
wf(path,
"[global]\ntun_ip=10.97.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n"
"my_private_key=%s\nmy_public_key=%s\n"
"[server: s1]\naddr=127.0.0.1:%d\ntype=public\n"
"%s"
"[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n",
i, i, dir, i == IDX_A ? "a" : "b",
priv, pub, g_sh.port[i], client);
}
}
/* ── событийный обработчик ── */
static void call_evt_handler(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
struct call_evt* ev = (struct call_evt*)inst->test_user_ptr;
if (!ev) return;
switch (type) {
case CHAT_EVT_CALL_INCOMING: {
if (len < 1 + 8 + 8) return;
int ch_len = data[0];
if (len < 1 + ch_len + 8 + 8) return;
ev->incoming = 1;
memcpy(&ev->incoming_call_id, data + 1 + ch_len + 8, 8);
break;
}
case CHAT_EVT_CALL_RINGING:
ev->ringing = 1;
break;
case CHAT_EVT_CALL_ACCEPTED:
ev->accepted = 1;
if (len >= 8) memcpy(&ev->accepted_call_id, data, 8);
break;
case CHAT_EVT_CALL_ENDED:
ev->ended = 1;
if (len >= 9) { memcpy(&ev->ended_call_id, data, 8); ev->ended_reason = data[8]; }
break;
case CHAT_EVT_CALL_DECLINED:
ev->declined = 1;
break;
default:
break;
}
}
/* ── проверки ── */
static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) {
struct ETCP_CONN* c = instance_find_conn(inst, nid);
return c && c->initialized && c->links_up;
}
static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) {
uint64_t gid = strtoull(CH_ID, NULL, 10);
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
if (!g) return 0;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid);
return nq && nq->paths && nq->paths->head;
}
/* ── настройка канала и мемберов ── */
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) {
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "call-test", g_sh.nid[IDX_A],
g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig);
chat_core_ensure_channel_ready(inst, CH_ID);
}
static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) {
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst);
uint8_t jm[128];
int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id,
inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm));
uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig);
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
}
static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) {
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx],
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
}
static void do_setup(struct UTUN_INSTANCE* inst, int role) {
switch (role) {
case IDX_A:
channel_put_shared(inst, 1);
member_put_self(inst, "A");
member_put_placeholder(inst, IDX_B, "B");
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL);
break;
case IDX_B:
channel_put_shared(inst, 0);
member_put_self(inst, "B");
member_put_placeholder(inst, IDX_A, "A");
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL);
break;
}
}
/* ── master state-machine ── */
static void call_tick(void* arg) {
struct call_ctx* t = arg;
t->timer = NULL;
if (t->result) return;
struct UTUN_INSTANCE* A = t->inst[IDX_A];
struct UTUN_INSTANCE* B = t->inst[IDX_B];
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B];
struct call_evt* ea = &g_ev[IDX_A];
struct call_evt* eb = &g_ev[IDX_B];
switch (t->phase) {
case P_WAIT_CONN:
if (conn_up(A, nb) && conn_up(B, na)) t->phase = P_SETUP;
break;
case P_SETUP:
do_setup(A, IDX_A);
do_setup(B, IDX_B);
t->phase = P_WAIT_BGP;
break;
case P_WAIT_BGP:
if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CALL1_START;
break;
case P_CALL1_START:
memset(ea, 0, sizeof(*ea));
memset(eb, 0, sizeof(*eb));
t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF);
call_start(A, CH_ID, nb, t->call_id);
t->phase = P_WAIT_INCOMING1;
break;
case P_WAIT_INCOMING1:
if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_ACCEPT;
break;
case P_ACCEPT:
call_accept(B, t->call_id);
t->phase = P_WAIT_ACCEPTED;
break;
case P_WAIT_ACCEPTED:
if (ea->accepted && eb->accepted && ea->accepted_call_id == t->call_id) t->phase = P_SEND_MEDIA;
break;
case P_SEND_MEDIA: {
/* тестовый «Opus-кадр» — релей произвольных байт, не валидируем кодек */
static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d };
if (call_send_media(A, t->call_id, opus, sizeof(opus)) != 0) {
fprintf(stderr, "A: call_send_media failed\n");
t->result = 2;
} else {
t->phase = P_WAIT_MEDIA;
}
break;
}
case P_WAIT_MEDIA:
if (g_media[IDX_B].frames > 0 && g_media[IDX_B].last_call_id == t->call_id
&& g_media[IDX_B].last_len == 16) t->phase = P_HANGUP;
break;
case P_HANGUP:
call_hangup(A, t->call_id);
t->phase = P_WAIT_ENDED1;
break;
case P_WAIT_ENDED1:
if (ea->ended && eb->ended && ea->ended_call_id == t->call_id) t->phase = P_CALL2_START;
break;
case P_CALL2_START:
memset(ea, 0, sizeof(*ea));
memset(eb, 0, sizeof(*eb));
t->call_id = ((uint64_t)2 << 40) | (uint64_t)(rand() & 0xFFFFF);
call_start(A, CH_ID, nb, t->call_id);
t->phase = P_WAIT_INCOMING2;
break;
case P_WAIT_INCOMING2:
if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_DECLINE;
break;
case P_DECLINE:
call_decline(B, t->call_id);
t->phase = P_WAIT_ENDED2;
break;
case P_WAIT_ENDED2:
if (ea->ended && eb->ended && ea->declined) t->phase = P_DONE;
break;
case P_DONE:
t->result = 1;
uasync_stop(t->ua);
return;
}
if (t->phase == P_DONE) { t->result = 1; uasync_stop(t->ua); return; }
if (++t->ticks > MAX_TICKS) {
fprintf(stderr, "test: timeout in phase %d (A_conn=%d B_conn=%d evA:inc=%d acc=%d end=%d dec=%d evB:inc=%d acc=%d end=%d dec=%d)\n",
(int)t->phase, conn_up(A, nb), conn_up(B, na),
ea->incoming, ea->accepted, ea->ended, ea->declined,
eb->incoming, eb->accepted, eb->ended, eb->declined);
t->result = 2;
uasync_stop(t->ua);
return;
}
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, call_tick, "call_tick");
}
/* ── main ── */
int main(int argc, char** argv) {
(void)argc; (void)argv;
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR);
if (getenv("UTUN_TEST_DEBUG")) {
debug_set_category_level_by_name("call", "trace");
debug_set_category_level_by_name("bgp", "info");
debug_set_category_level_by_name("etcp_route", "info");
debug_set_category_level_by_name("member_sync", "info");
debug_set_category_level_by_name("chat_sync", "info");
}
utun_instance_set_tun_init_enabled(0);
srand((unsigned)time(NULL));
char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_call_XXXXXX");
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
setenv("UTUN_TEST_DIR", dir, 1);
fill_shared(56000 + (getpid() % 2000));
write_configs(dir);
printf("=== test_call ===\n"); fflush(stdout);
TEST("single-thread: call happy path + decline (2 nodes)"); {
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; }
struct call_ctx t;
memset(&t, 0, sizeof(t));
t.ua = ua;
t.phase = P_WAIT_CONN;
char cfgA[512], cfgB[512];
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir);
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir);
t.inst[IDX_A] = utun_instance_create(ua, cfgA);
t.inst[IDX_B] = utun_instance_create(ua, cfgB);
if (!t.inst[IDX_A] || !t.inst[IDX_B]) {
FAIL("instance create failed A=%p B=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B]);
uasync_destroy(ua, 0);
return 1;
}
utun_instance_init(t.inst[IDX_A]);
utun_instance_init(t.inst[IDX_B]);
t.inst[IDX_A]->test_user_ptr = &g_ev[IDX_A];
t.inst[IDX_B]->test_user_ptr = &g_ev[IDX_B];
chat_event_set_handler(t.inst[IDX_A], call_evt_handler);
chat_event_set_handler(t.inst[IDX_B], call_evt_handler);
call_set_media_recv_cb(t.inst[IDX_A], media_recv_cb, &g_media[IDX_A]);
call_set_media_recv_cb(t.inst[IDX_B], media_recv_cb, &g_media[IDX_B]);
t.timer = uasync_set_timeout(ua, TICK_TB, &t, call_tick, "call_tick");
uasync_mainloop(ua);
int ok = (t.result == 1);
if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]);
if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]);
uasync_destroy(ua, 0);
if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase);
}
char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd);
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED);
return G_FAILED > 0 ? 1 : 0;
}

512
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 <sqlite3.h>
#define OPENSSL_API_COMPAT 0x10100000L
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <time.h>
#include <errno.h>
#define CH_ID "4242424242424242"
#define IDX_A 0
#define IDX_B 1
#define TICK_TB 100 /* 10 ms на такт */
#define MAX_TICKS 4000 /* глобальный таймаут ≈ 40 c */
enum hs_phase {
P_WAIT_CONN,
P_SETUP, /* канал/мемберы + headless control/audio на A и B */
P_WAIT_BGP,
P_CONNECT_CLIENTS, /* подключить 4 клиентских сокета */
P_SUBSCRIBE, /* subscribe на обоих control */
P_CALL_START, /* A: call_start */
P_WAIT_INCOMING, /* B: call_incoming */
P_ACCEPT, /* B: call_accept */
P_WAIT_ACCEPTED, /* обе: call_accepted */
P_BIND_AUDIO, /* HELLO на обоих audio */
P_SEND_MEDIA, /* A: audio FRAME */
P_WAIT_MEDIA, /* B: audio FRAME получен */
P_HANGUP, /* A: call_hangup */
P_WAIT_ENDED, /* обе: call_ended */
P_DONE,
};
/* клиентский сокет (к control или audio узла) */
struct hs_conn {
socket_t fd;
void* socket_id;
char buf[16384];
int len;
};
struct hs_ctx {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst[2];
enum hs_phase phase;
int result;
int ticks;
void* timer;
uint64_t call_id;
struct hs_conn ctl[2]; /* control-клиенты A, B */
struct hs_conn aud[2]; /* audio-клиенты A, B */
};
struct hs_shared {
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64];
uint64_t nid[2];
uint8_t x_pub[2][32], x_priv[2][32], ed_pub[2][32];
int port[2]; /* etcp-порты */
};
static struct hs_shared g_sh;
static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0;
#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0)
#define OK() do { G_PASSED++; printf("OK\n"); } while(0)
#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0)
/* ── крипто-хелперы ── */
static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) {
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
EVP_PKEY* pkey = NULL;
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
EVP_PKEY_free(pkey);
}
static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) {
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
EVP_PKEY* pkey = NULL;
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
EVP_PKEY_free(pkey);
}
static void to_hex(const uint8_t* bin, size_t n, char* out) {
for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]);
out[n * 2] = '\0';
}
static int wf(const char* p, const char* f, ...) {
va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1;
va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0;
}
static void fill_shared(int base_port) {
memset(&g_sh, 0, sizeof(g_sh));
for (int i = 0; i < 2; i++) {
gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]);
sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]);
g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]);
g_sh.port[i] = base_port + i * 1000;
}
{ uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); }
gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv);
{
uint8_t msg[256]; size_t off = 0;
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1;
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "hl-call") + 1;
memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8;
memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32;
memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32;
sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig);
}
}
static void write_configs(const char* dir) {
for (int i = 0; i < 2; i++) {
char priv[65], pub[65], path[512], dbd[512];
to_hex(g_sh.x_priv[i], 32, priv);
to_hex(g_sh.x_pub[i], 32, pub);
snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : "b");
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : "b");
utun_mkdir(dbd, 0755);
char client[512] = "";
if (i == IDX_B) {
char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub);
snprintf(client, sizeof(client),
"[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n",
apub, g_sh.port[IDX_A]);
}
wf(path,
"[global]\ntun_ip=10.97.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n"
"my_private_key=%s\nmy_public_key=%s\n"
"[server: s1]\naddr=127.0.0.1:%d\ntype=public\n"
"%s"
"[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n",
i, i, dir, i == IDX_A ? "a" : "b",
priv, pub, g_sh.port[i], client);
}
}
/* ── проверки ── */
static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) {
struct ETCP_CONN* c = instance_find_conn(inst, nid);
return c && c->initialized && c->links_up;
}
static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) {
uint64_t gid = strtoull(CH_ID, NULL, 10);
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
if (!g) return 0;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid);
return nq && nq->paths && nq->paths->head;
}
/* ── настройка канала и мемберов ── */
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) {
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "hl-call", g_sh.nid[IDX_A],
g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig);
chat_core_ensure_channel_ready(inst, CH_ID);
}
static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) {
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst);
uint8_t jm[128];
int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id,
inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm));
uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig);
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
}
static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) {
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx],
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
}
static void do_setup(struct UTUN_INSTANCE* inst, int role) {
switch (role) {
case IDX_A:
channel_put_shared(inst, 1);
member_put_self(inst, "A");
member_put_placeholder(inst, IDX_B, "B");
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL);
break;
case IDX_B:
channel_put_shared(inst, 0);
member_put_self(inst, "B");
member_put_placeholder(inst, IDX_A, "A");
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL);
break;
}
}
/* ── клиентские сокеты ── */
static void hs_recv_cb(socket_t fd, void* arg) {
struct hs_conn* c = (struct hs_conn*)arg;
(void)fd;
char tmp[4096];
ssize_t r = recv(fd, tmp, sizeof(tmp) - 1, 0);
if (r <= 0) return;
if (c->len + (int)r < (int)sizeof(c->buf)) {
memcpy(c->buf + c->len, tmp, (size_t)r);
c->len += (int)r;
c->buf[c->len] = '\0';
}
}
static int hs_connect(struct hs_ctx* t, struct hs_conn* c, int port) {
c->fd = socket(AF_INET, SOCK_STREAM, 0);
if (c->fd == SOCKET_INVALID) return -1;
struct sockaddr_in sin; memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET; sin.sin_port = htons((uint16_t)port);
inet_pton(AF_INET, "127.0.0.1", &sin.sin_addr);
if (connect(c->fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1;
}
socket_set_nonblocking(c->fd);
c->socket_id = uasync_add_socket_t(t->ua, c->fd, hs_recv_cb, NULL, NULL, c);
if (!c->socket_id) { socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; }
return 0;
}
static void hs_send(struct hs_conn* c, const void* data, size_t len) {
if (!c || c->fd == SOCKET_INVALID) return;
ssize_t off = 0;
while (off < (ssize_t)len) {
ssize_t r = send(c->fd, (const char*)data + off, len - (size_t)off, 0);
if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) { continue; }
if (r <= 0) break;
off += r;
}
}
/* есть ли подстрока в буфере */
static int hs_has(struct hs_conn* c, const char* substr) {
return c && strstr(c->buf, substr) != NULL;
}
/* вытащить call_id из JSON-события (формат "call_id":"0x...") */
static int hs_extract_call_id(struct hs_conn* c, const char* evname, uint64_t* out) {
if (!c) return -1;
const char* p = strstr(c->buf, evname);
if (!p) return -1;
p = strstr(p, "\"call_id\"");
if (!p) return -1;
p = strstr(p, "0x");
if (!p) return -1;
*out = strtoull(p, NULL, 16);
return 0;
}
/* ── master state-machine ── */
static void hs_tick(void* arg) {
struct hs_ctx* t = arg;
t->timer = NULL;
if (t->result) return;
struct UTUN_INSTANCE* A = t->inst[IDX_A];
struct UTUN_INSTANCE* B = t->inst[IDX_B];
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B];
switch (t->phase) {
case P_WAIT_CONN:
if (conn_up(A, nb) && conn_up(B, na)) t->phase = P_SETUP;
break;
case P_SETUP:
do_setup(A, IDX_A);
do_setup(B, IDX_B);
if (chat_headless_control_init(A->ua, A, "127.0.0.1", 9700 + (int)(getpid() % 100)) != 0
|| chat_headless_control_init(B->ua, B, "127.0.0.1", 9800 + (int)(getpid() % 100)) != 0) {
fprintf(stderr, "headless control init failed\n"); t->result = 2; break;
}
if (call_headless_init(A->ua, A, "127.0.0.1", 9710 + (int)(getpid() % 100)) != 0
|| call_headless_init(B->ua, B, "127.0.0.1", 9810 + (int)(getpid() % 100)) != 0) {
fprintf(stderr, "call headless audio init failed\n"); t->result = 2; break;
}
t->phase = P_WAIT_BGP;
break;
case P_WAIT_BGP:
if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CONNECT_CLIENTS;
break;
case P_CONNECT_CLIENTS:
if (hs_connect(t, &t->ctl[IDX_A], 9700 + (int)(getpid() % 100)) != 0
|| hs_connect(t, &t->ctl[IDX_B], 9800 + (int)(getpid() % 100)) != 0
|| hs_connect(t, &t->aud[IDX_A], 9710 + (int)(getpid() % 100)) != 0
|| hs_connect(t, &t->aud[IDX_B], 9810 + (int)(getpid() % 100)) != 0) {
fprintf(stderr, "client connect failed\n"); t->result = 2; break;
}
t->phase = P_SUBSCRIBE;
break;
case P_SUBSCRIBE: {
const char* sub = "{\"id\":1,\"cmd\":\"subscribe\",\"enable\":1}\n";
hs_send(&t->ctl[IDX_A], sub, strlen(sub));
hs_send(&t->ctl[IDX_B], sub, strlen(sub));
t->phase = P_CALL_START;
break;
}
case P_CALL_START: {
t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF);
char json[512];
snprintf(json, sizeof(json), "{\"id\":2,\"cmd\":\"call_start\",\"ch\":\"%s\",\"node_id\":\"0x%016llx\"}\n",
CH_ID, (unsigned long long)nb);
hs_send(&t->ctl[IDX_A], json, strlen(json));
t->phase = P_WAIT_INCOMING;
break;
}
case P_WAIT_INCOMING: {
uint64_t cid = 0;
if (hs_extract_call_id(&t->ctl[IDX_B], "call_incoming", &cid) == 0) {
t->call_id = cid;
t->phase = P_ACCEPT;
}
break;
}
case P_ACCEPT: {
char json[256];
snprintf(json, sizeof(json), "{\"id\":3,\"cmd\":\"call_accept\",\"call_id\":\"0x%016llx\"}\n",
(unsigned long long)t->call_id);
hs_send(&t->ctl[IDX_B], json, strlen(json));
t->phase = P_WAIT_ACCEPTED;
break;
}
case P_WAIT_ACCEPTED:
if (hs_has(&t->ctl[IDX_A], "call_accepted") && hs_has(&t->ctl[IDX_B], "call_accepted"))
t->phase = P_BIND_AUDIO;
break;
case P_BIND_AUDIO: {
uint8_t hdr[CALL_AUDIO_HDR_SIZE];
hdr[0] = CALL_AUDIO_HELLO;
memcpy(hdr + 1, &t->call_id, 8);
hdr[9] = 0; hdr[10] = 0;
hs_send(&t->aud[IDX_A], hdr, sizeof(hdr));
hs_send(&t->aud[IDX_B], hdr, sizeof(hdr));
t->phase = P_SEND_MEDIA;
break;
}
case P_SEND_MEDIA: {
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;
}

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

2
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-интерфейс

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

@ -5,9 +5,17 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@ -21,6 +29,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@ -37,5 +46,9 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="p2p_chat_network_service" />
</service>
<receiver
android:name=".data.IncomingCallActionReceiver"
android:exported="false" />
</application>
</manifest>

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

93
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<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(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)

263
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<ShortArray>()
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}")
}
}
}

305
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<out AudioDeviceInfo>?) = onDevicesChanged()
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = 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}")
}
}
}

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

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

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

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

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

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

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

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

214
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<CallState?>(null)
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
private var callStartedMs = 0L
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val audioRoute: StateFlow<AudioRoute> = _audioRoute
private val _headsetAvailable = MutableStateFlow(false)
val headsetAvailable: StateFlow<Boolean> = _headsetAvailable
private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState
@ -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

BIN
tools/chatgui-android/app/src/main/res/raw/ringtone.mp3

Binary file not shown.

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

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

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

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

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

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

23
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<DmConversation> DbManager::getDmConversations() const {
QList<DmConversation> list;
if (!m_db) return list;
sqlite3_stmt* stmt = prepareOrNull(
"SELECT conv_id, peer_node_id, COALESCE(peer_name,''), last_ts, last_out_seq, last_in_seq"
" FROM dm_conversations ORDER BY last_ts DESC");
if (!stmt) return list;
while (sqlite3_step(stmt) == SQLITE_ROW) {
DmConversation c;
c.convId = colText(stmt, 0);
c.peerNodeId = (quint64)sqlite3_column_int64(stmt, 1);
c.peerName = colText(stmt, 2);
c.lastTs = sqlite3_column_int64(stmt, 3);
c.lastOutSeq = sqlite3_column_int64(stmt, 4);
c.lastInSeq = sqlite3_column_int64(stmt, 5);
list.append(c);
}
sqlite3_finalize(stmt);
return list;
}
/* ── peers (UI) ── */
QMap<quint64, QString> DbManager::getLocalNicknames(const QString& chId) const {

12
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<MessageRow> getMessagesSince(const QString& chId, qint64 sinceId) const;
MessageRow getMessageById(const QString& chId, qint64 msgId) const;
/* ── DM-беседы (UI, read-only) ── */
QList<DmConversation> getDmConversations() const;
/* ── Участники канала (UI) ── */
void cleanOrphanedPeers(const QString& chId) const;
QString getDisplayName(quint64 nodeId) const;

4
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

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

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

67
tools/chatgui/src/audio_frame_ring.h

@ -0,0 +1,67 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
/* Кольцевой буфер кадров переменной длины (SPSC, mutex-protected).
*
* Producer (uasync-поток) и consumer (аудио-поток). Память предвыделена одним
* блоком — ноль аллокаций на кадр. При переполнении дропается старейший кадр
* (семантика джиттер-буфера).
*/
class AudioFrameRing {
public:
AudioFrameRing(int capacity, int slotSize)
: cap_(capacity), slotSize_(slotSize),
buf_((size_t)capacity * (size_t)slotSize, 0),
lens_((size_t)capacity, 0) {}
/* Producer: положить кадр. Возвращает false если len > slotSize.
* При переполнении дропает старейший кадр (успех). */
bool push(const uint8_t* data, int len) {
if (len <= 0 || len > slotSize_) return false;
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == cap_) {
head_ = (head_ + 1) % cap_; /* drop oldest */
count_--;
}
std::memcpy(buf_.data() + (size_t)tail_ * slotSize_, data, (size_t)len);
lens_[(size_t)tail_] = (uint16_t)len;
tail_ = (tail_ + 1) % cap_;
count_++;
return true;
}
/* Consumer: взять старейший кадр. Возвращает длину (0 = пусто). */
int pop(uint8_t* out, int outCap) {
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == 0) return 0;
int len = (int)lens_[(size_t)head_];
if (out && len > 0 && len <= outCap)
std::memcpy(out, buf_.data() + (size_t)head_ * slotSize_, (size_t)len);
head_ = (head_ + 1) % cap_;
count_--;
return len;
}
void clear() {
std::lock_guard<std::mutex> lk(mtx_);
head_ = tail_ = count_ = 0;
}
int size() const {
std::lock_guard<std::mutex> lk(mtx_);
return count_;
}
int capacity() const { return cap_; }
private:
int cap_, slotSize_;
int head_ = 0, tail_ = 0, count_ = 0;
std::vector<uint8_t> buf_;
std::vector<uint16_t> lens_;
mutable std::mutex mtx_;
};

183
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 <QTimer>
#include <cstring>
static CallAudioEngine* g_callAudioEngine = nullptr;
static void callAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) {
CallAudioEngine* e = (CallAudioEngine*)dev->pUserData;
if (e) e->dataCallback(in, out, frames);
}
CallAudioEngine* CallAudioEngine::instance() {
if (!g_callAudioEngine) g_callAudioEngine = new CallAudioEngine();
return g_callAudioEngine;
}
int CallAudioEngine::start(quint64 callId) {
if (m_active) {
if (m_callId == callId) return 0;
stop();
}
m_callId = callId;
struct UTUN_INSTANCE* inst = gui_bridge_get_inst();
if (!inst) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no instance, cannot start");
return -1;
}
if (call_audio_init(inst) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_init failed");
return -1;
}
if (call_audio_start(callId) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_start failed call=%016llx", (unsigned long long)callId);
return -1;
}
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0);
m_capturePos = 0;
m_muted = false;
if (initDevice() != 0) {
call_audio_release(callId);
return -1;
}
m_active = true;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: started call=%016llx", (unsigned long long)callId);
return 0;
}
int CallAudioEngine::initDevice() {
ma_context* ctx = SoundManager::instance()->context();
if (!ctx) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no audio context");
return -1;
}
m_device = new ma_device;
std::memset(m_device, 0, sizeof(*m_device));
ma_device_config config = ma_device_config_init(ma_device_type_duplex);
config.capture.format = ma_format_s16;
config.capture.channels = 1;
config.playback.format = ma_format_s16;
config.playback.channels = 1;
config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
config.periodSizeInFrames = 480;
config.dataCallback = callAudioDataCallback;
config.pUserData = this;
if (m_playbackDeviceIndex >= 0) {
ma_device_info* pPlaybackDevices = nullptr;
ma_uint32 playbackCount = 0;
if (ma_context_get_devices(ctx, &pPlaybackDevices, &playbackCount, nullptr, nullptr) == MA_SUCCESS &&
(ma_uint32)m_playbackDeviceIndex < playbackCount) {
config.playback.pDeviceID = &pPlaybackDevices[m_playbackDeviceIndex].id;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "CallAudio: using playback device[%d]: %s",
m_playbackDeviceIndex, pPlaybackDevices[m_playbackDeviceIndex].name);
} else {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: playback device[%d] not found, using default",
m_playbackDeviceIndex);
}
}
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device init failed, trying default");
config.playback.pDeviceID = nullptr;
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: default device init also failed");
delete m_device; m_device = nullptr;
return -1;
}
}
if (ma_device_start(m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device start failed");
ma_device_uninit(m_device);
delete m_device; m_device = nullptr;
return -1;
}
m_deviceStarted = true;
m_capturePos = 0;
return 0;
}
void CallAudioEngine::setMuted(bool muted) {
if (m_muted.load() == muted) return;
m_muted = muted;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: mute %s", muted ? "on" : "off");
}
void CallAudioEngine::setPlaybackDeviceIndex(int index) {
if (index == m_playbackDeviceIndex) return;
int prev = m_playbackDeviceIndex;
m_playbackDeviceIndex = index;
if (m_active) {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device %d -> %d", prev, index);
teardownDevice();
if (initDevice() != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device failed (kept index=%d)", index);
}
}
void CallAudioEngine::beginEnd(quint64 callId) {
if (!m_active || m_callId != callId) return;
call_audio_begin_end(callId); /* старт тона завершения */
QTimer::singleShot(500, this, [this, callId]() {
if (m_callId == callId) stop();
});
}
void CallAudioEngine::stop() {
if (!m_active && !m_device && !m_deviceStarted) return;
m_active = false;
m_muted = false;
quint64 cid = m_callId.load();
teardownDevice();
call_audio_release(cid);
m_callId = 0;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: stopped");
}
void CallAudioEngine::teardownDevice() {
if (m_deviceStarted) {
ma_device_stop(m_device);
ma_device_uninit(m_device);
m_deviceStarted = false;
}
if (m_device) { delete m_device; m_device = nullptr; }
}
void CallAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) {
const int16_t* in16 = (const int16_t*)in;
int16_t* out16 = (int16_t*)out;
/* захват: копим 960 samples → encode+send (сервисный слой). mute → тишина. */
if (in16) {
bool muted = m_muted.load();
for (unsigned int i = 0; i < frames; i++) {
m_captureBuf[m_capturePos++] = muted ? 0 : in16[i];
if (m_capturePos >= (int)m_captureBuf.size()) {
call_audio_feed_pcm(m_callId.load(), m_captureBuf.data(), (int)m_captureBuf.size());
m_capturePos = 0;
}
}
}
/* воспроизведение: финальный PCM из сервисного слоя (jitter+stretch+тоны) */
int n = call_audio_pull_pcm(m_callId.load(), out16, (int)frames);
if (n < (int)frames)
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t));
}

62
tools/chatgui/src/call_audio_engine.h

@ -0,0 +1,62 @@
#pragma once
#include <QObject>
#include <atomic>
#include <cstdint>
#include <vector>
struct ma_device;
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM.
*
* Потоки:
* - аудио-поток (miniaudio callback): capture → call_audio_feed_pcm,
* call_audio_pull_pcm → playback;
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */
class CallAudioEngine : public QObject {
Q_OBJECT
public:
static CallAudioEngine* instance();
/* Запустить полнодуплекс для звонка (после ACCEPTED). 0 = успех. */
int start(quint64 callId);
/* По CALL_ENDED: тон завершения (~450мс), затем авто-teardown. */
void beginEnd(quint64 callId);
/* Немедленный teardown (device + освобождение call_audio). */
void stop();
bool isActive() const { return m_active.load(); }
quint64 callId() const { return m_callId.load(); }
/* Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
void setMuted(bool muted);
bool isMuted() const { return m_muted.load(); }
/* Устройство вывода звонка (-1 = default). Смена на лету пересоздаёт ma_device. */
void setPlaybackDeviceIndex(int index);
int playbackDeviceIndex() const { return m_playbackDeviceIndex; }
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */
void dataCallback(const void* in, void* out, unsigned int frames);
private:
CallAudioEngine() = default;
void teardownDevice();
int initDevice();
std::atomic<bool> m_active{false};
std::atomic<uint64_t> m_callId{0};
std::atomic<bool> m_muted{false};
int m_playbackDeviceIndex = -1; /* только GUI-поток */
ma_device* m_device = nullptr;
bool m_deviceStarted = false;
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
int m_capturePos = 0;
};

179
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 <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QComboBox>
#include <QTimer>
#include <QDateTime>
#include <QCloseEvent>
#include <cstring>
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<int>::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged);
m_durationTimer = new QTimer(this);
m_durationTimer->setInterval(1000);
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration);
setState(Outgoing);
}
CallWindow::~CallWindow() = default;
void CallWindow::setPeerName(const QString& name) {
m_peerLabel->setText(name);
}
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')));
}

60
tools/chatgui/src/callwindow.h

@ -0,0 +1,60 @@
#pragma once
#include <QWidget>
#include <cstdint>
class QLabel;
class QPushButton;
class QComboBox;
class QTimer;
/* Окно звонка: состояния Outgoing (звоним), Incoming (принять/отклонить),
* Active (идёт разговор, таймер), Ended (завершён). */
class CallWindow : public QWidget {
Q_OBJECT
public:
enum State { Outgoing, Incoming, Active, Ended };
explicit CallWindow(QWidget* parent = nullptr);
~CallWindow() override;
void setCallId(quint64 id) { m_callId = id; }
quint64 callId() const { return m_callId; }
void setPeerName(const QString& name);
void setState(State s);
State state() const { return m_state; }
signals:
void closed(quint64 callId);
protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onAccept();
void onDecline();
void onHangup();
void updateDuration();
void onMuteToggled(bool checked);
void onDeviceChanged(int index);
private:
void updateButtons();
void populateDeviceCombo();
void postCallFn(void (*fn)(void*), uint64_t callId);
quint64 m_callId = 0;
State m_state = Outgoing;
qint64 m_startMs = 0;
QLabel* m_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;
};

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

3
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 {

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

5
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();

3
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,

146
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 <cmath>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <csignal>
extern "C" {
#include "chat/chat_core.h"
#include "call/call.h"
#include "dm/dm_core.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
void chat_core_attachment_download_trampoline(void* arg);
@ -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));

18
tools/chatgui/src/mainwindow.h

@ -5,6 +5,7 @@
#include <QMenu>
#include <QQueue>
#include <QSet>
#include <QHash>
#include <QTimer>
class ChannelList;
@ -17,6 +18,8 @@ class AudioRecorder;
class ConnMonitorWindow;
class VideoPlayerWindow;
class ImageViewerWindow;
class CallWindow;
class CallAudioEngine;
struct AutoPlayItem {
QString channelId;
@ -63,6 +66,19 @@ public:
void requestShutdown();
UtunNode* utunNode() const { return m_node; }
/* звонки */
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name);
void onCallIncoming(const QString& chId, quint64 caller, quint64 callId);
void onCallRinging(quint64 callId);
void onCallAccepted(quint64 callId);
void onCallEnded(quint64 callId, uint8_t reason);
void closeCallWindow(quint64 callId);
/* DM (p2p чат) */
void onPmRequested(const QString& chId, quint64 nodeId, const QString& name);
void onDmConvUpdated(const QString& convId);
void onDmMsgReceived(const QString& convId, quint64 author);
private:
void setupUi();
void saveWindowGeometry();
@ -101,4 +117,6 @@ private:
QTimer* m_autoPlayTimer = nullptr;
QTimer* m_shutdownTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
QHash<quint64, CallWindow*> m_activeCalls; /* call_id → окно звонка */
};

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

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

108
tools/chatgui/src/messagelist.cpp

@ -35,6 +35,7 @@ static std::vector<std::unique_ptr<LottieIcon>> 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();
}

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

101
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 <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
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<int16_t>& 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<int16_t> frame(FRAME, 0);
std::vector<int16_t> 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;
}

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

113
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) ── */

Loading…
Cancel
Save