Compare commits

...

5 Commits

  1. 24
      doc/service_lifecycle.md
  2. 50
      src/call/call_audio.c
  3. 20
      src/transport_layer/etcp_connections.c
  4. 54
      src/transport_layer/etcp_keepalive.c
  5. 9
      src/transport_layer/etcp_keepalive.h
  6. 57
      src/utun_instance.c
  7. 6
      src/utun_instance.h
  8. 17
      tests/Makefile.am
  9. 14
      tests/test_services.c
  10. 180
      tests/test_standby_transport.c
  11. 6
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  12. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  13. 28
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  14. 330
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  15. 358
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
  16. 229
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt
  17. 24
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
  18. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt
  19. 75
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
  20. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  21. 27
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt
  22. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt
  23. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt
  24. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
  25. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  26. 27
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  27. 335
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  28. 6
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  29. 121
      tools/chatgui-android/libutun_lite/standby.c
  30. 11
      tools/chatgui-android/libutun_lite/standby.h
  31. 26
      tools/chatgui-android/libutun_lite/tests/test_standby.c
  32. 52
      tools/chatgui/tests/test_call_jitter.cpp

24
doc/service_lifecycle.md

@ -57,3 +57,27 @@ SQLite открывается ядром один раз: `db_path/chats.db`, л
сохранение идентичности/сокетов/БД, повторную загрузку каналов, отмену workers, сохранение идентичности/сокетов/БД, повторную загрузку каналов, отмену workers,
заблокированного медиа-потока, транзита и ожиданий заменённой очереди. `test_chat_join_e2e` заблокированного медиа-потока, транзита и ожиданий заменённой очереди. `test_chat_join_e2e`
проверяет join и распространение membership между узлами без UTUN-сервиса. проверяет join и распространение membership между узлами без UTUN-сервиса.
В Android звонковой сессией владеет `CallController` уровня приложения; события
ядра и команды Telecom обрабатываются один раз в main thread, без повторного
проигрывания событий при создании ViewModel. UI получает `StateFlow` и передаёт
пользовательские команды. `CallService` владеет `CallAudioEngine` и частичным
wake lock: перед исходящим вызовом или принятием входящего запускается foreground
режим `phoneCall|microphone`, затем сигналинг и аудио. Завершение сначала
останавливает I/O и дожидается аудиопотоков, затем освобождает native-аудио,
wake lock и сервис. Перед `NativeLib.stop/restart/destroy` выполняется тот же
порядок; новый звонок разрешается после `SERVICE_STARTED` нового ядра.
Активность ядра задаётся явно: видимая Activity или незавершённый звонок удерживают
ACTIVE и выключают Android standby. Состояние не истекает по таймеру. После
завершения звонка в фоне возвращается standby; экран может гаснуть во время
разговора независимо от частичного wake lock сервиса.
`utun_set_client_activity()` в сборке с `UTUN_HAVE_STANDBY` применяет фактический
режим duty-cycle до рассылки событий активности; JNI не переключает его отдельно.
Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает
standby даже при начальном `client_activity=STANDBY`. Keepalive подписан на режим
и фазы самого standby; новые UDP/TCP-линки сразу анонсируют текущую фазу после
handshake. Пробуждение по таймеру и внешней сети сначала сообщает ACTIVE-окно
пирам, затем запускает ожидающие задачи. При выходе сначала восстанавливаются
линки; deinit отменяет ожидающие задачи и очищает подписки старого ядра.

50
src/call/call_audio.c

@ -25,7 +25,7 @@
#define CALL_AUDIO_ID "call_audio" #define CALL_AUDIO_ID "call_audio"
#define CALL_AUDIO_BITRATE 32000 #define CALL_AUDIO_BITRATE 32000
#define CALL_STALL_TB 5000 /* 500мс без медиа и недобор PCM → глитч-тон */ #define CALL_STALL_SAMPLES (CALL_AUDIO_SAMPLE_RATE / 2) /* 500мс без PCM из jitter → глитч-тон */
#define CALL_AEC_FILTER_SAMPLES 14400 /* хвост эха 300мс @48кГц */ #define CALL_AEC_FILTER_SAMPLES 14400 /* хвост эха 300мс @48кГц */
#define CALL_AEC_DELAY_FRAMES 2 /* desktop: минимальная задержка duplex */ #define CALL_AEC_DELAY_FRAMES 2 /* desktop: минимальная задержка duplex */
@ -36,7 +36,7 @@ static int g_ending = 0;
static int g_glitch_started = 0; /* только аудиопоток */ static int g_glitch_started = 0; /* только аудиопоток */
static int g_ended_started = 0; /* только аудио-поток: тон завершения запущен */ static int g_ended_started = 0; /* только аудио-поток: тон завершения запущен */
static uint64_t g_call_id = 0; static uint64_t g_call_id = 0;
static uint64_t g_last_media_tb = 0; static int g_gap_samples = 0; /* только аудиопоток; насыщение на пороге */
static opus_codec_encoder_t* g_encoder = NULL; static opus_codec_encoder_t* g_encoder = NULL;
static opus_codec_decoder_t* g_decoder = NULL; static opus_codec_decoder_t* g_decoder = NULL;
static struct call_jitter* g_vj = NULL; static struct call_jitter* g_vj = NULL;
@ -85,7 +85,6 @@ void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id,
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id && g_vj && opus && len > 0) { if (g_active && call_id == g_call_id && g_vj && opus && len > 0) {
call_jitter_push(g_vj, opus, len); call_jitter_push(g_vj, opus, len);
g_last_media_tb = get_time_tb();
} }
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
} }
@ -185,7 +184,7 @@ int call_audio_start(uint64_t call_id) {
g_ending = 0; g_ending = 0;
g_ended_started = 0; g_ended_started = 0;
g_glitch_started = 0; g_glitch_started = 0;
g_last_media_tb = 0; g_gap_samples = 0;
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID, DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID,
@ -220,7 +219,7 @@ void call_audio_release(uint64_t call_id) {
g_ending = 0; g_ending = 0;
g_ended_started = 0; g_ended_started = 0;
g_call_id = 0; g_call_id = 0;
g_last_media_tb = 0; g_gap_samples = 0;
enc = g_encoder; g_encoder = NULL; enc = g_encoder; g_encoder = NULL;
dec = g_decoder; g_decoder = NULL; dec = g_decoder; g_decoder = NULL;
j = g_vj; g_vj = NULL; j = g_vj; g_vj = NULL;
@ -347,7 +346,6 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) {
int active = 0, ending = 0; int active = 0, ending = 0;
struct call_jitter* j = NULL; struct call_jitter* j = NULL;
struct call_tones* tones = NULL; struct call_tones* tones = NULL;
uint64_t last_media = 0;
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id) { if (g_active && call_id == g_call_id) {
@ -355,7 +353,6 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) {
ending = g_ending; ending = g_ending;
j = g_vj; j = g_vj;
tones = g_tones; tones = g_tones;
last_media = g_last_media_tb;
} }
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
@ -375,21 +372,36 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) {
n = call_tones_render(tones, out, max_samples); n = call_tones_render(tones, out, max_samples);
} else { } else {
n = call_jitter_pull(j, out, max_samples); n = call_jitter_pull(j, out, max_samples);
uint64_t now = get_time_tb(); int previous_gap = g_gap_samples;
int stalled = n < max_samples && last_media != 0 && now - last_media >= CALL_STALL_TB; if (n > 0) g_gap_samples = 0;
if (stalled != g_glitch_started) { int missing = max_samples - n;
g_glitch_started = stalled; int quiet = CALL_STALL_SAMPLES - g_gap_samples;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: gap tone %s call=%016llx media_gap=%llums pcm=%d/%d", if (quiet > missing) quiet = missing;
CALL_AUDIO_ID, stalled ? "started" : "stopped", (unsigned long long)call_id, int tone_samples = missing - quiet;
(unsigned long long)((now - last_media) / 10), n, max_samples); if (n > 0 && previous_gap > 0)
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: PCM resumed call=%016llx gap=%dms pcm=%d/%d", CALL_AUDIO_ID,
(unsigned long long)call_id, previous_gap * 1000 / CALL_AUDIO_SAMPLE_RATE, n, max_samples);
if (missing > 0 && g_gap_samples == 0)
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: PCM gap started call=%016llx pcm=%d/%d", CALL_AUDIO_ID,
(unsigned long long)call_id, n, max_samples);
g_gap_samples += quiet;
/* Новый PCM прерывает предыдущий цикл тона, даже если хвост блока снова пуст. */
if (g_glitch_started && (n > 0 || tone_samples == 0)) {
g_glitch_started = 0;
call_tones_glitch_stop(tones);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: gap tone stopped call=%016llx pcm=%d/%d", CALL_AUDIO_ID,
(unsigned long long)call_id, n, max_samples);
} }
if (stalled) { memset(out + n, 0, (size_t)quiet * sizeof(*out));
if (tone_samples > 0) {
if (!g_glitch_started) {
g_glitch_started = 1;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: gap tone started call=%016llx pcm_gap=500ms pcm=%d/%d", CALL_AUDIO_ID,
(unsigned long long)call_id, n, max_samples);
}
call_tones_glitch_start(tones); call_tones_glitch_start(tones);
n += call_tones_render(tones, out + n, max_samples - n); call_tones_render(tones, out + n + quiet, tone_samples);
} else {
call_tones_glitch_stop(tones);
} }
for (int i = n; i < max_samples; i++) out[i] = 0;
n = max_samples; n = max_samples;
} }

20
src/transport_layer/etcp_connections.c

@ -1256,11 +1256,7 @@ void etcp_link_enter_ready_tcp(struct ETCP_LINK *link) {
if (!etcp->peer_reset_id) etcp_session_start(etcp); // сервер создаёт ETCP_CONN после STCP handshake if (!etcp->peer_reset_id) etcp_session_start(etcp); // сервер создаёт ETCP_CONN после STCP handshake
if (etcp->initialized == 0) etcp_conn_ready(etcp); if (etcp->initialized == 0) etcp_conn_ready(etcp);
if (etcp->state == 2) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "enter_ready_tcp: conn DELETED after conn_ready, link=%p", (void*)link); return; } if (etcp->state == 2) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "enter_ready_tcp: conn DELETED after conn_ready, link=%p", (void*)link); return; }
/* в standby анонс спячки вместо обычного flagless-keepalive */ etcp_keepalive_link_ready(link);
if (!etcp_keepalive_announce_mode(link)) {
etcp_link_send_keepalive(link);
start_keepalive_timer(link);
}
loadbalancer_link_ready(link); loadbalancer_link_ready(link);
etcp_fire_link_status_cbk(link, old_state, link->link_status); etcp_fire_link_status_cbk(link, old_state, link->link_status);
DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] TCP link %d UP (mtu=%d init=%d up=%d tcp_links=%d)", DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] TCP link %d UP (mtu=%d init=%d up=%d tcp_links=%d)",
@ -1955,7 +1951,7 @@ static void send_init_response(struct ETCP_SOCKET* e_sock, struct ETCP_DGRAM* pk
DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] Connection established (socket=%s, link=%d, addr=%s)", link->etcp->log_name, e_sock->name, link->local_link_id, sockaddr_storage_to_str(&link->remote_addr).str); DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] Connection established (socket=%s, link=%d, addr=%s)", link->etcp->log_name, e_sock->name, link->local_link_id, sockaddr_storage_to_str(&link->remote_addr).str);
} }
DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] Link %d UP (server, mtu=%d, addr=%s)", link->etcp->log_name, link->local_link_id, link->mtu_local, sockaddr_storage_to_str(&link->remote_addr).str); DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] Link %d UP (server, mtu=%d, addr=%s)", link->etcp->log_name, link->local_link_id, link->mtu_local, sockaddr_storage_to_str(&link->remote_addr).str);
start_keepalive_timer(link); etcp_keepalive_link_ready(link);
loadbalancer_link_ready(link); loadbalancer_link_ready(link);
// Restart NAT check after link is up (e.g. after address change or reinit) // Restart NAT check after link is up (e.g. after address change or reinit)
if (link->etcp->instance->nat_det) nat_detection_link_ready(link->etcp->instance->nat_det, link); if (link->etcp->instance->nat_det) nat_detection_link_ready(link->etcp->instance->nat_det, link);
@ -2061,11 +2057,7 @@ static int handle_init_response_client(struct ETCP_SOCKET* e_sock, struct ETCP_D
loadbalancer_link_ready(link); loadbalancer_link_ready(link);
DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] Link %d UP (client, mtu=%d, addr=%s)", link->etcp->log_name, link->local_link_id, link->mtu, sockaddr_storage_to_str(&link->remote_addr).str); DEBUG_INFO(DEBUG_CATEGORY_ETCP, "[%s] Link %d UP (client, mtu=%d, addr=%s)", link->etcp->log_name, link->local_link_id, link->mtu, sockaddr_storage_to_str(&link->remote_addr).str);
/* в standby анонс спячки вместо обычного flagless-keepalive */ etcp_keepalive_link_ready(link);
if (!etcp_keepalive_announce_mode(link)) {
etcp_link_send_keepalive(link);
start_keepalive_timer(link);
}
loadbalancer_link_ready(link); loadbalancer_link_ready(link);
@ -2601,11 +2593,7 @@ int etcp_packet_decrypted(struct ETCP_SOCKET* e_sock, struct ETCP_DGRAM* pkt,
return 0; return 0;
} }
if (link->link_state == LINK_STATE_TRY_RECONNECT) {// 0 - just init, 1 - handshake, 2 - try reconnect, 3 - connected if (link->link_state == LINK_STATE_TRY_RECONNECT) {// 0 - just init, 1 - handshake, 2 - try reconnect, 3 - connected
/* в standby анонс спячки вместо обычного flagless-keepalive */ etcp_keepalive_link_ready(link);
if (!etcp_keepalive_announce_mode(link)) {
start_keepalive_timer(link);
etcp_link_send_keepalive(link);
}
int old_state = link->link_state; link->link_state = LINK_STATE_CONNECTED; int old_state = link->link_state; link->link_state = LINK_STATE_CONNECTED;
etcp_fire_link_status_cbk(link, old_state, link->link_status); etcp_fire_link_status_cbk(link, old_state, link->link_status);
} }

54
src/transport_layer/etcp_keepalive.c

@ -386,13 +386,14 @@ static void ka_wake(struct ETCP_LINK* link) {
typedef void (*ka_link_fn)(struct ETCP_LINK* link); typedef void (*ka_link_fn)(struct ETCP_LINK* link);
static void ka_for_each_link(struct UTUN_INSTANCE* inst, ka_link_fn fn) { static int ka_for_each_link(struct UTUN_INSTANCE* inst, ka_link_fn fn) {
int count = 0;
if (inst->connections) { if (inst->connections) {
for (struct ll_entry* e = inst->connections->head; e; e = e->next) { for (struct ll_entry* e = inst->connections->head; e; e = e->next) {
struct conn_queue_entry* ce = (struct conn_queue_entry*)e->data; struct conn_queue_entry* ce = (struct conn_queue_entry*)e->data;
if (!ce || !ce->conn) continue; if (!ce || !ce->conn) continue;
for (struct ETCP_LINK* l = ce->conn->links; l; l = l->next) for (struct ETCP_LINK* l = ce->conn->links; l; l = l->next)
if (l->initialized) fn(l); if (l->initialized) { fn(l); count++; }
} }
} }
if (inst->tcp_connections) { if (inst->tcp_connections) {
@ -400,12 +401,22 @@ static void ka_for_each_link(struct UTUN_INSTANCE* inst, ka_link_fn fn) {
struct tcp_conn_entry* te = (struct tcp_conn_entry*)e->data; struct tcp_conn_entry* te = (struct tcp_conn_entry*)e->data;
if (!te || !te->etcp_conn) continue; if (!te || !te->etcp_conn) continue;
for (struct ETCP_LINK* l = te->etcp_conn->links; l; l = l->next) for (struct ETCP_LINK* l = te->etcp_conn->links; l; l = l->next)
if (l->initialized) fn(l); if (l->initialized) { fn(l); count++; }
} }
} }
return count;
} }
#ifdef UTUN_HAVE_STANDBY #ifdef UTUN_HAVE_STANDBY
// Фактический режим standby меняется после подготовки его фазы и таймера.
static void ka_standby_switch(int enabled, void* arg) {
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg;
if (!inst || inst->client_type != CLIENT_TYPE_MOBILE) return;
int links = ka_for_each_link(inst, enabled ? ka_enter_sleep : ka_wake);
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "standby mode=%s applied to %d links node=%016llx",
enabled ? "sleep" : "normal", links, (unsigned long long)inst->node_id);
}
// Вход в ACTIVE-окно — анонс-серия с ретраями до ACK (active_remaining>0). // Вход в ACTIVE-окно — анонс-серия с ретраями до ACK (active_remaining>0).
static void ka_active_announce(struct ETCP_LINK* link) { static void ka_active_announce(struct ETCP_LINK* link) {
if (!link->ka_my_sleeping) return; if (!link->ka_my_sleeping) return;
@ -540,6 +551,7 @@ int etcp_keepalive_on_recv(struct ETCP_SOCKET* e_sock, struct ETCP_DGRAM* pkt,
return 0; return 0;
} }
#ifndef UTUN_HAVE_STANDBY
static void etcp_keepalive_on_activity(struct UTUN_INSTANCE* inst, int active, void* arg) { static void etcp_keepalive_on_activity(struct UTUN_INSTANCE* inst, int active, void* arg) {
(void)arg; (void)arg;
if (!inst || inst->client_type != CLIENT_TYPE_MOBILE) return; if (!inst || inst->client_type != CLIENT_TYPE_MOBILE) return;
@ -547,23 +559,37 @@ static void etcp_keepalive_on_activity(struct UTUN_INSTANCE* inst, int active, v
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "keepalive activity change: %s -> applied to links (node=%016llx)", DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "keepalive activity change: %s -> applied to links (node=%016llx)",
active ? "ACTIVE(wake)" : "STANDBY(sleep)", (unsigned long long)inst->node_id); active ? "ACTIVE(wake)" : "STANDBY(sleep)", (unsigned long long)inst->node_id);
} }
#endif
// На только что (пере)инициализированном линке: если я в standby — анонсировать спячку // Сразу сообщает текущий режим/фазу нового или восстановленного линка и запускает нужный механизм keepalive.
// (возвращает 1, обычный flagless в этом случае слать НЕ надо); иначе 0. void etcp_keepalive_link_ready(struct ETCP_LINK* link) {
int etcp_keepalive_announce_mode(struct ETCP_LINK* link) { if (!link || !link->etcp || !link->etcp->instance) {
if (!link || !link->etcp || !link->etcp->instance) return 0; DEBUG_ERROR(DEBUG_CATEGORY_KEEPALIVE, "link_ready: missing link/connection/instance link=%p", (void*)link);
struct UTUN_INSTANCE* inst = link->etcp->instance; return;
if (inst->client_type != CLIENT_TYPE_MOBILE) return 0; }
if (!inst->standby_enabled) return 0; /* не в standby — обычный режим */ int sleeping = 0;
ka_enter_sleep(link); #ifdef UTUN_HAVE_STANDBY
return 1; sleeping = link->etcp->instance->client_type == CLIENT_TYPE_MOBILE && standby_is_enabled();
#endif
DEBUG_DEBUG(DEBUG_CATEGORY_KEEPALIVE, "[%s] link ready id=%u tcp=%u server=%u mode=%s",
link->etcp->log_name, link->local_link_id, link->is_tcp, link->is_server, sleeping ? "standby" : "normal");
if (sleeping) {
ka_enter_sleep(link);
} else if (link->ka_my_sleeping) {
ka_wake(link);
} else {
etcp_link_send_keepalive(link);
if (!link->ka_peer_sleeping) start_keepalive_timer(link);
}
} }
void etcp_keepalive_register(struct UTUN_INSTANCE* inst) { void etcp_keepalive_register(struct UTUN_INSTANCE* inst) {
if (!inst) return; if (!inst) return;
utun_add_activity_cbk(inst, etcp_keepalive_on_activity, NULL);
#ifdef UTUN_HAVE_STANDBY #ifdef UTUN_HAVE_STANDBY
standby_set_switch_callback(ka_standby_switch, inst);
standby_set_phase_callback(ka_phase_cb, inst); standby_set_phase_callback(ka_phase_cb, inst);
#else
utun_add_activity_cbk(inst, etcp_keepalive_on_activity, NULL);
#endif #endif
DEBUG_DEBUG(DEBUG_CATEGORY_KEEPALIVE, "keepalive activity + phase callbacks registered"); DEBUG_DEBUG(DEBUG_CATEGORY_KEEPALIVE, "keepalive mode/phase callbacks registered node=%016llx", (unsigned long long)inst->node_id);
} }

9
src/transport_layer/etcp_keepalive.h

@ -64,14 +64,13 @@ void start_keepalive_timer(struct ETCP_LINK* link);
int etcp_keepalive_on_recv(struct ETCP_SOCKET* e_sock, struct ETCP_DGRAM* pkt, int etcp_keepalive_on_recv(struct ETCP_SOCKET* e_sock, struct ETCP_DGRAM* pkt,
struct ETCP_LINK* link, size_t pkt_len); struct ETCP_LINK* link, size_t pkt_len);
// Подписка на смену active mode (foreground/background) и фазы спячки duty-cycle. // Подписка на фактический режим/фазу standby; без duty-cycle — на активность клиента.
// Вызывается из init_connections. // Вызывается из init_connections.
void etcp_keepalive_register(struct UTUN_INSTANCE* inst); void etcp_keepalive_register(struct UTUN_INSTANCE* inst);
// На только что (пере)инициализированном линке: если я в standby — анонсировать // После INIT/INIT_RESPONSE, TCP handshake и reconnect сразу анонсирует текущий режим/фазу.
// спячку (возвращает 1, обычный flagless в этом случае слать НЕ надо); иначе 0. // В standby запускает sleep-анонс с ретраями UDP; в normal — обычный keepalive.
// Вызывается из etcp_link_enter_ready_tcp / handle_init_response_client / TRY_RECONNECT. void etcp_keepalive_link_ready(struct ETCP_LINK* link);
int etcp_keepalive_announce_mode(struct ETCP_LINK* link);
#ifdef __cplusplus #ifdef __cplusplus
} }

57
src/utun_instance.c

@ -31,6 +31,9 @@
#include "transport_layer/node_conn_direct.h" #include "transport_layer/node_conn_direct.h"
#include "transport_layer/socket_monitor.h" #include "transport_layer/socket_monitor.h"
#include "transport_layer/auto_socket.h" #include "transport_layer/auto_socket.h"
#ifdef UTUN_HAVE_STANDBY
#include "standby.h"
#endif
#include "../lib/u_async.h" #include "../lib/u_async.h"
#include "../lib/debug_config.h" #include "../lib/debug_config.h"
@ -113,7 +116,6 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u
instance->client_type = config->global.client_type; instance->client_type = config->global.client_type;
instance->keepalive_interval = (uint16_t)(config->global.keepalive_interval > 0 ? config->global.keepalive_interval : 200); instance->keepalive_interval = (uint16_t)(config->global.keepalive_interval > 0 ? config->global.keepalive_interval : 200);
instance->client_activity = (instance->client_type == CLIENT_TYPE_MOBILE) ? CLIENT_ACTIVITY_STANDBY : CLIENT_ACTIVITY_ACTIVE; instance->client_activity = (instance->client_type == CLIENT_TYPE_MOBILE) ? CLIENT_ACTIVITY_STANDBY : CLIENT_ACTIVITY_ACTIVE;
instance->client_activity_timer = NULL;
// Set my keys // Set my keys
if (sc_init_local_keys(&instance->my_keys, config->global.my_public_key_hex, config->global.my_private_key_hex) != SC_OK) { if (sc_init_local_keys(&instance->my_keys, config->global.my_public_key_hex, config->global.my_private_key_hex) != SC_OK) {
@ -397,10 +399,6 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
if (instance->ua) uasync_print_resources(instance->ua, "INSTANCE_DESTROY_BEFORE"); if (instance->ua) uasync_print_resources(instance->ua, "INSTANCE_DESTROY_BEFORE");
instance->running = 0; instance->running = 0;
if (instance->client_activity_timer) {
uasync_cancel_timeout(instance->ua, instance->client_activity_timer);
instance->client_activity_timer = NULL;
}
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] A done — diagnose complete"); DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] A done — diagnose complete");
/* Phase B: NTP */ /* Phase B: NTP */
@ -1133,32 +1131,27 @@ void utun_fire_peer_sleep_cbk(struct UTUN_INSTANCE* instance, uint64_t peer_node
} }
} }
static void client_activity_timeout_cb(void* arg) { /* Явная активность UI/звонка не истекает по таймеру: владелец сообщает завершение сам. */
struct UTUN_INSTANCE* instance = (struct UTUN_INSTANCE*)arg; int utun_set_client_activity(struct UTUN_INSTANCE* instance, int active) {
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity_timer fired: node=%016llx type=%u -> standby", if (!instance) {
(unsigned long long)instance->node_id, instance->client_type); DEBUG_ERROR(DEBUG_CATEGORY_BGP, "client_activity: missing instance active=%d", active);
instance->client_activity_timer = NULL; return -1;
if (instance->client_type == CLIENT_TYPE_SERVER) return; }
instance->client_activity = CLIENT_ACTIVITY_STANDBY; if (instance->client_type == CLIENT_TYPE_SERVER) return 0;
utun_fire_activity_cbk(instance, CLIENT_ACTIVITY_STANDBY); int state = active ? CLIENT_ACTIVITY_ACTIVE : CLIENT_ACTIVITY_STANDBY;
} int previous = instance->client_activity;
instance->client_activity = state;
void utun_set_client_activity(struct UTUN_INSTANCE* instance, int active) { #ifdef UTUN_HAVE_STANDBY
if (!instance) return; if (instance->client_type == CLIENT_TYPE_MOBILE && standby_set_enabled(instance, !active) < 0) {
if (instance->client_type == CLIENT_TYPE_SERVER) return; instance->client_activity = previous;
if (instance->client_activity_timer) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "client_activity: standby transition failed node=%016llx requested=%d previous=%d",
uasync_cancel_timeout(instance->ua, instance->client_activity_timer); (unsigned long long)instance->node_id, state, previous);
instance->client_activity_timer = NULL; return -1;
}
if (active) {
instance->client_activity = CLIENT_ACTIVITY_ACTIVE;
instance->client_activity_timer = uasync_set_timeout(instance->ua, 600000, instance, client_activity_timeout_cb, "activity_timeout");
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set ACTIVE, timer restarted 60s node=%016llx type=%u",
(unsigned long long)instance->node_id, instance->client_type);
} else {
instance->client_activity = CLIENT_ACTIVITY_STANDBY;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set STANDBY node=%016llx type=%u",
(unsigned long long)instance->node_id, instance->client_type);
} }
utun_fire_activity_cbk(instance, active); #endif
if (previous == state) return 0;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set %s until explicit change node=%016llx type=%u",
active ? "ACTIVE" : "STANDBY", (unsigned long long)instance->node_id, instance->client_type);
utun_fire_activity_cbk(instance, state);
return 0;
} }

6
src/utun_instance.h

@ -241,10 +241,8 @@ struct UTUN_INSTANCE {
uint8_t client_type; // CLIENT_TYPE_SERVER/DESKTOP/MOBILE (from config) uint8_t client_type; // CLIENT_TYPE_SERVER/DESKTOP/MOBILE (from config)
uint16_t keepalive_interval; // желаемый keepalive (ms), из конфига. для handshake uint16_t keepalive_interval; // желаемый keepalive (ms), из конфига. для handshake
uint8_t client_activity; // CLIENT_ACTIVITY_STANDBY/ACTIVE uint8_t client_activity; // CLIENT_ACTIVITY_STANDBY/ACTIVE
void* client_activity_timer; // uasync timer handle for inactivity timeout
struct utun_activity_cbk_entry* activity_cbks; // подписки на смену client_activity struct utun_activity_cbk_entry* activity_cbks; // подписки на смену client_activity
struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive) struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive)
uint8_t standby_enabled; // 1 = standby duty-cycle активен (chatgui-android)
// TCP proxy server (exit node) // TCP proxy server (exit node)
struct tcp_proxy_server tcp_proxy_server; struct tcp_proxy_server tcp_proxy_server;
@ -295,7 +293,9 @@ void utun_instance_stop(struct UTUN_INSTANCE *instance);
/* Глобальные переключатели создания TUN/топологии для тестов и встраивания. */ /* Глобальные переключатели создания TUN/топологии для тестов и встраивания. */
void utun_instance_set_tun_init_enabled(int enabled); void utun_instance_set_tun_init_enabled(int enabled);
void utun_instance_set_topo_group_enabled(int enabled); void utun_instance_set_topo_group_enabled(int enabled);
void utun_set_client_activity(struct UTUN_INSTANCE* instance, int active); /* Применяет активность и мобильный standby до уведомления подписчиков; выполнять в uasync-потоке.
* Повторная команда согласует фактический режим без повторного BGP-события. 0 — успех, <0 — ошибка. */
int utun_set_client_activity(struct UTUN_INSTANCE* instance, int active);
void utun_add_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg); void utun_add_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg);
void utun_remove_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg); void utun_remove_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg);
void utun_add_peer_sleep_cbk(struct UTUN_INSTANCE* instance, peer_sleep_cbk_fn fn, void* arg); void utun_add_peer_sleep_cbk(struct UTUN_INSTANCE* instance, peer_sleep_cbk_fn fn, void* arg);

17
tests/Makefile.am

@ -112,9 +112,9 @@ check_PROGRAMS = \
bench_timeout_heap \ bench_timeout_heap \
bench_uasync_timeouts bench_uasync_timeouts
# Linux-only тест: требует root + dummy-интерфейсы + iproute2/netlink # Linux-only: test_auto_socket_dynamic требует root + dummy-интерфейсы + iproute2/netlink.
if OS_LINUX if OS_LINUX
check_PROGRAMS += test_auto_socket_dynamic check_PROGRAMS += test_auto_socket_dynamic test_standby test_standby_transport
endif endif
# Silero VAD: только при включённом ONNX Runtime (--with-silero-vad) # Silero VAD: только при включённом ONNX Runtime (--with-silero-vad)
@ -146,6 +146,19 @@ CRYPTO_LIBS = -lcrypto
# Common libraries (lib/dns.c собирается с -DDNS_RANDOM=RAND_bytes → нужен -lcrypto) # Common libraries (lib/dns.c собирается с -DDNS_RANDOM=RAND_bytes → нужен -lcrypto)
COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@ $(top_builddir)/lib/libopus/libopus_internal.a -lm COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@ $(top_builddir)/lib/libopus/libopus_internal.a -lm
# Вариант общего ядра с Android duty-cycle, без JNI и устройства.
if OS_LINUX
standby_test_sources = ../tools/chatgui-android/libutun_lite/standby.c ../src/utun_instance.c ../src/transport_layer/etcp_keepalive.c
standby_test_cppflags = $(AM_CPPFLAGS) -I$(top_srcdir)/src/chat -I$(top_srcdir)/tools/chatgui-android/libutun_lite -DUTUN_HAVE_STANDBY
test_standby_SOURCES = ../tools/chatgui-android/libutun_lite/tests/test_standby.c $(standby_test_sources)
test_standby_CPPFLAGS = $(standby_test_cppflags)
test_standby_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_standby_transport_SOURCES = test_standby_transport.c $(standby_test_sources)
test_standby_transport_CPPFLAGS = $(standby_test_cppflags)
test_standby_transport_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_standby_transport_LDFLAGS = -Wl,--wrap=etcp_encrypt_send -Wl,--wrap=uasync_set_timeout
endif
# Test definitions # Test definitions
test_etcp_bbr_SOURCES = test_etcp_bbr.c test_etcp_bbr_SOURCES = test_etcp_bbr.c
test_etcp_bbr_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib test_etcp_bbr_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib

14
tests/test_services.c

@ -22,6 +22,19 @@ static void work(void* arg) { ((struct job*)arg)->worked = 1; }
static void done(void* arg, int err) { struct job* job = arg; job->done++; job->error = err; } static void done(void* arg, int err) { struct job* job = arg; job->done++; job->error = err; }
static void ready(struct ll_queue* queue, void* arg) { (void)queue; (*(int*)arg)++; } static void ready(struct ll_queue* queue, void* arg) { (void)queue; (*(int*)arg)++; }
/* Активность звонка не создаёт таймер, который сам переведёт мобильный узел в standby. */
static void check_client_activity(struct UASYNC* ua) {
struct UTUN_INSTANCE mobile = { .ua = ua, .client_type = CLIENT_TYPE_MOBILE, .client_activity = CLIENT_ACTIVITY_STANDBY };
uint64_t timers = ua->timer_alloc_count;
utun_set_client_activity(&mobile, 1);
utun_set_client_activity(&mobile, 1);
uasync_poll(ua, 0);
assert(mobile.client_activity == CLIENT_ACTIVITY_ACTIVE && ua->timer_alloc_count == timers);
utun_set_client_activity(&mobile, 0);
assert(mobile.client_activity == CLIENT_ACTIVITY_STANDBY);
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] explicit mobile activity persists without timeout and returns to standby");
}
/* Pending transit shares the physical connection but belongs to its group. */ /* Pending transit shares the physical connection but belongs to its group. */
static void pending_transit(struct ETCP_CONN* conn, uint64_t group_id, int* calls) { static void pending_transit(struct ETCP_CONN* conn, uint64_t group_id, int* calls) {
if (!conn->transit_queues) conn->transit_queues = queue_new(conn->instance->ua, 16, 0, 24, "test_transit"); if (!conn->transit_queues) conn->transit_queues = queue_new(conn->instance->ua, 16, 0, 24, "test_transit");
@ -47,6 +60,7 @@ static struct TOPO_GROUP* chat_group(struct UTUN_INSTANCE* inst) {
int main(void) { int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO);
struct UASYNC* ua = uasync_create(); assert(ua); struct UASYNC* ua = uasync_create(); assert(ua);
check_client_activity(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua,
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"

180
tests/test_standby_transport.c

@ -0,0 +1,180 @@
/* Проверка режима и фаз standby на реальных keepalive-пакетах до шифрования.
* Управляемая отправка исключает сеть; отказ таймера проверяет сохранение рабочего режима. */
#include <assert.h>
#include <string.h>
#include "utun_instance.h"
#include "etcp.h"
#include "etcp_keepalive.h"
#include "standby.h"
#include "debug_config.h"
static struct UASYNC* ua;
static struct UTUN_INSTANCE inst;
static struct ETCP_CONN udp, tcp;
static struct ETCP_LINK links[3];
static struct ll_queue udp_queue, tcp_queue;
static struct ll_entry *udp_entry, *tcp_entry;
static struct { unsigned count; uint8_t flags; uint16_t active, sleep; } sent[3];
static int fail_phase_timer, activity_events, wait_calls;
static unsigned wait_after_packets;
/* Перехватывает сформированный keepalive, сохраняя его реальные флаги и тайминг. */
int __wrap_etcp_encrypt_send(struct ETCP_DGRAM* pkt) {
assert(pkt && pkt->data[0] == ETCP_KEEPALIVE && pkt->data_len >= 4);
int i;
for (i = 0; i < 3 && pkt->link != &links[i]; i++);
assert(i < 3);
sent[i].count++;
sent[i].flags = pkt->data[1];
sent[i].active = pkt->data_len >= 6 ? pkt->data[4] | ((uint16_t)pkt->data[5] << 8) : 0;
sent[i].sleep = pkt->data_len >= 8 ? pkt->data[6] | ((uint16_t)pkt->data[7] << 8) : 0;
return pkt->data_len;
}
void* __real_uasync_set_timeout(struct UASYNC* ua, int tb, void* arg, timeout_callback_t cb, const char* name);
/* Отказывает только фазовому таймеру, остальные таймеры выполняются настоящим uasync. */
void* __wrap_uasync_set_timeout(struct UASYNC* loop, int tb, void* arg, timeout_callback_t cb, const char* name) {
if (fail_phase_timer && name && !strcmp(name, "standby_phase")) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "[TEST] injected standby phase timer failure");
return NULL;
}
return __real_uasync_set_timeout(loop, tb, arg, cb, name);
}
/* Подписчики активности видят уже согласованные режимы, без промежуточного состояния. */
static void activity_changed(struct UTUN_INSTANCE* owner, int active, void* arg) {
(void)arg;
assert(owner == &inst && inst.client_activity == active);
assert(standby_is_enabled() == !active);
for (int i = 0; i < 3; i++) assert(links[i].ka_my_sleeping == !active);
activity_events++;
}
/* Работа возобновляется после отправки новой фазы пирам или выхода из standby. */
static void waited(void* arg) {
(void)arg;
assert(sent[0].count > wait_after_packets);
if (standby_is_enabled()) {
assert(standby_get_active_remaining_tb() > 0 && sent[0].flags == KA_FLAG_SLEEP && sent[0].active > 0);
} else {
assert(inst.client_activity == CLIENT_ACTIVITY_ACTIVE && !links[0].ka_my_sleeping && sent[0].flags == 0);
}
wait_calls++;
}
/* Мобильный узел с исходящим UDP, входящим UDP и TCP линками. */
static void setup(void) {
ua = uasync_create(); assert(ua);
memset(&inst, 0, sizeof(inst)); memset(&udp, 0, sizeof(udp)); memset(&tcp, 0, sizeof(tcp));
memset(links, 0, sizeof(links)); memset(sent, 0, sizeof(sent));
activity_events = wait_calls = fail_phase_timer = 0;
inst.ua = ua; inst.client_type = CLIENT_TYPE_MOBILE; inst.client_activity = CLIENT_ACTIVITY_STANDBY;
udp.instance = tcp.instance = &inst;
strcpy(udp.log_name, "standby-test-udp"); strcpy(tcp.log_name, "standby-test-tcp");
for (int i = 0; i < 3; i++) {
links[i].etcp = i == 2 ? &tcp : &udp;
links[i].local_link_id = i + 1; links[i].initialized = links[i].link_status = 1;
links[i].keepalive_interval = links[i].ka_period_ms = 2000;
links[i].keepalive_timeout = 20000; links[i].last_recv_local_time = get_time_tb();
}
links[0].next = &links[1]; links[1].is_server = 1; links[2].is_tcp = 1;
udp.links = &links[0]; tcp.links = &links[2];
udp_entry = queue_entry_new(sizeof(struct conn_queue_entry)); assert(udp_entry);
tcp_entry = queue_entry_new(sizeof(struct tcp_conn_entry)); assert(tcp_entry);
((struct conn_queue_entry*)udp_entry->data)->conn = &udp;
((struct tcp_conn_entry*)tcp_entry->data)->etcp_conn = &tcp;
memset(&udp_queue, 0, sizeof(udp_queue)); memset(&tcp_queue, 0, sizeof(tcp_queue));
udp_queue.head = udp_entry; tcp_queue.head = tcp_entry;
inst.connections = &udp_queue; inst.tcp_connections = &tcp_queue;
standby_set_intervals_ms(200, 300, 0);
standby_init(ua); etcp_keepalive_register(&inst);
utun_add_activity_cbk(&inst, activity_changed, NULL);
for (int i = 0; i < 3; i++) etcp_keepalive_link_ready(&links[i]);
}
/* Отменяет таймеры и подписки перед повторной инициализацией ядра. */
static void teardown(void) {
standby_deinit();
for (int i = 0; i < 3; i++) {
if (links[i].keepalive_timer) uasync_cancel_timeout(ua, links[i].keepalive_timer);
if (links[i].ka_sleep_announce_timer) uasync_cancel_timeout(ua, links[i].ka_sleep_announce_timer);
}
while (inst.activity_cbks) utun_remove_activity_cbk(&inst, inst.activity_cbks->fn, inst.activity_cbks->arg);
queue_entry_free(udp_entry); queue_entry_free(tcp_entry);
uasync_poll(ua, 0);
assert(ua->timer_alloc_count == ua->timer_free_count);
uasync_destroy(ua, 0);
}
/* Первый фон при начальном STANDBY, повторы, новые линки, обе причины wake и выход в ACTIVE. */
static void check_transitions(void) {
setup();
assert(!standby_is_enabled());
for (int i = 0; i < 3; i++) assert(sent[i].count == 1 && sent[i].flags == 0 && links[i].keepalive_timer);
assert(utun_set_client_activity(&inst, 0) == 0 && standby_is_enabled() && activity_events == 0);
for (int i = 0; i < 3; i++) {
assert(links[i].ka_my_sleeping && !links[i].keepalive_timer);
assert(sent[i].count == 2 && sent[i].flags == KA_FLAG_SLEEP && sent[i].active > 0 && sent[i].sleep == 3);
}
uint64_t timers = ua->timer_alloc_count;
assert(utun_set_client_activity(&inst, 0) == 0 && ua->timer_alloc_count == timers);
assert(sent[0].count == 2 && sent[1].count == 2 && sent[2].count == 2);
uasync_poll(ua, -1); assert(standby_get_sleep_tb() > 0);
for (int i = 0; i < 3; i++) {
etcp_keepalive_link_ready(&links[i]);
assert(sent[i].flags == KA_FLAG_SLEEP && sent[i].active == 0 && sent[i].sleep > 0 && !links[i].keepalive_timer);
}
assert(links[0].ka_sleep_announce_timer && links[1].ka_sleep_announce_timer && !links[2].ka_sleep_announce_timer);
timers = ua->timer_alloc_count; wait_after_packets = sent[0].count;
assert(standby_wait(NULL, waited)); assert(ua->timer_alloc_count == timers);
fail_phase_timer = 1;
standby_notify_network_activity();
assert(standby_get_sleep_tb() > 0 && wait_calls == 0 && sent[0].count == wait_after_packets);
fail_phase_timer = 0;
standby_notify_network_activity(); assert(wait_calls == 1);
uasync_poll(ua, -1); assert(standby_get_sleep_tb() > 0);
wait_after_packets = sent[0].count;
void* cancelled = standby_wait(NULL, waited); assert(cancelled);
standby_wait_cancel(cancelled);
assert(standby_wait(NULL, waited));
uasync_poll(ua, -1); assert(wait_calls == 2 && standby_get_active_remaining_tb() > 0);
uasync_poll(ua, -1); assert(standby_get_sleep_tb() > 0);
wait_after_packets = sent[0].count; assert(standby_wait(NULL, waited));
links[0].ka_peer_sleeping = 1;
assert(utun_set_client_activity(&inst, 1) == 0 && activity_events == 1 && wait_calls == 3);
for (int i = 0; i < 3; i++) {
assert(!links[i].ka_my_sleeping && sent[i].flags == 0 && !links[i].ka_sleep_announce_timer);
}
assert(!links[0].keepalive_timer && links[1].keepalive_timer && links[2].keepalive_timer);
timers = ua->timer_alloc_count; unsigned packets = sent[0].count;
assert(utun_set_client_activity(&inst, 1) == 0 && ua->timer_alloc_count == timers);
assert(activity_events == 1 && sent[0].count == packets);
assert(utun_set_client_activity(&inst, 0) == 0 && activity_events == 2);
teardown();
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "[PASS] first background, repeated commands, UDP/TCP ready, phase-before-work, peer sleep and cleanup");
}
/* Ошибка ENTER сохраняет normal; потеря фазового таймера возвращает normal, без зависания сна. */
static void check_timer_failures(void) {
setup(); assert(utun_set_client_activity(&inst, 1) == 0);
fail_phase_timer = 1;
assert(utun_set_client_activity(&inst, 0) < 0);
assert(inst.client_activity == CLIENT_ACTIVITY_ACTIVE && !standby_is_enabled() && activity_events == 1);
for (int i = 0; i < 3; i++) assert(!links[i].ka_my_sleeping && links[i].keepalive_timer);
fail_phase_timer = 0;
assert(utun_set_client_activity(&inst, 0) == 0);
fail_phase_timer = 1;
uasync_poll(ua, -1);
assert(!standby_is_enabled() && inst.client_activity == CLIENT_ACTIVITY_ACTIVE && activity_events == 3);
for (int i = 0; i < 3; i++) assert(!links[i].ka_my_sleeping && links[i].keepalive_timer);
fail_phase_timer = 0; teardown();
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "[PASS] timer failure rollback and safe normal fallback");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO);
check_transitions(); check_transitions(); check_timer_failures();
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "PASS: standby transport regression tests");
return 0;
}

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

@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
@ -61,6 +62,11 @@
android:value="ptt_overlay_service" /> android:value="ptt_overlay_service" />
</service> </service>
<service
android:name=".data.CallService"
android:exported="false"
android:foregroundServiceType="phoneCall|microphone" />
<service <service
android:name=".data.UtunConnectionService" android:name=".data.UtunConnectionService"
android:exported="true" android:exported="true"

8
tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt

@ -2,6 +2,8 @@ package com.utun.chat
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import android.os.Handler
import android.os.Looper
import com.utun.chat.data.LogManager import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
@ -9,14 +11,19 @@ import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.TelecomCallManager import com.utun.chat.data.TelecomCallManager
import com.utun.chat.data.ChatEventCallback import com.utun.chat.data.ChatEventCallback
import com.utun.chat.data.CallController
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
object AppEventHandler : ChatEventCallback { object AppEventHandler : ChatEventCallback {
private val mainHandler = Handler(Looper.getMainLooper())
private val _events = MutableSharedFlow<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64) private val _events = MutableSharedFlow<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64)
val events: SharedFlow<Pair<Int, ByteArray?>> = _events val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) { override fun onEvent(type: Int, data: ByteArray?) {
if (type == 12 || type == 13 || type in 34..40 || type == 42) {
mainHandler.post { CallController.onEvent(type, data) }
}
Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}") Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}")
_events.tryEmit(type to data) _events.tryEmit(type to data)
} }
@ -44,6 +51,7 @@ class ChatApplication : Application() {
/* Telecom self-managed call integration (system audio routing for calls) */ /* Telecom self-managed call integration (system audio routing for calls) */
TelecomCallManager.init() TelecomCallManager.init()
CallController.init()
/* First launch config */ /* First launch config */
if (configProvider.isFirstLaunch()) { if (configProvider.isFirstLaunch()) {

28
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -7,6 +7,8 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.WindowManager import android.view.WindowManager
import com.utun.chat.data.CallController
import com.utun.chat.data.CallPhase
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
@ -96,17 +98,15 @@ class MainActivity : ComponentActivity() {
val callState by vm.callState.collectAsState() val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем /* После звонка снимаем showWhenLocked входящего. Экран может гаснуть:
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */ аудио и CPU удерживает CallService независимо от Activity. */
LaunchedEffect(callState) { LaunchedEffect(callState) {
val st = callState val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE && val inCall = st != null && st.phase != CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED st.phase != CallPhase.ENDED
if (inCall) { if (inCall) {
hadCall = true hadCall = true
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else { } else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (hadCall) releaseLockScreen() if (hadCall) releaseLockScreen()
} }
} }
@ -116,8 +116,8 @@ class MainActivity : ComponentActivity() {
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */ показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) { LaunchedEffect(callState?.phase) {
when (callState?.phase) { when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING, CallPhase.OUTGOING, CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE -> { CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call screen = Screen.Call
} }
@ -177,9 +177,9 @@ class MainActivity : ComponentActivity() {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
val callPhase = callState?.phase val callPhase = callState?.phase
val showCallBanner = screen != Screen.Call && val showCallBanner = screen != Screen.Call &&
(callPhase == ChatViewModel.CallPhase.OUTGOING || (callPhase == CallPhase.OUTGOING ||
callPhase == ChatViewModel.CallPhase.RINGING || callPhase == CallPhase.RINGING ||
callPhase == ChatViewModel.CallPhase.ACTIVE) callPhase == CallPhase.ACTIVE)
if (showCallBanner) { if (showCallBanner) {
ActiveCallBanner( ActiveCallBanner(
viewModel = vm, viewModel = vm,
@ -313,7 +313,7 @@ class MainActivity : ComponentActivity() {
} }
} }
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) { if (callState?.phase == CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm) IncomingCallDialog(viewModel = vm)
} }
} }
@ -362,7 +362,7 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
if (NativeLib.isReady()) NativeLib.setActive(true) CallController.setActivityVisible(true)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@ -421,7 +421,7 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false) CallController.setActivityVisible(false)
} }
/** Volume-кнопки (удержание) → PTT рации, при активной рации и включённой опции. */ /** Volume-кнопки (удержание) → PTT рации, при активной рации и включённой опции. */

330
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt

@ -11,6 +11,8 @@ import android.media.AudioTrack
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock
import java.util.concurrent.atomic.AtomicBoolean
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
/** /**
@ -37,6 +39,7 @@ class CallAudioEngine {
const val AEC_ACOUSTIC_SAMPLES = 150L const val AEC_ACOUSTIC_SAMPLES = 150L
} }
private val recordLock = Any()
private var audioRecord: AudioRecord? = null private var audioRecord: AudioRecord? = null
private var captureThread: Thread? = null private var captureThread: Thread? = null
private var audioTrack: AudioTrack? = null private var audioTrack: AudioTrack? = null
@ -62,9 +65,17 @@ class CallAudioEngine {
@Volatile private var capFramesRead = 0L @Volatile private var capFramesRead = 0L
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val aecDelayRunnable = Runnable { measureAecDelay() } private val aecDelayRunnable = Runnable { measureAecDelay() }
private val failed = AtomicBoolean(false)
var onFailure: ((Long, String) -> Unit)? = null
fun isActive(): Boolean = active fun isActive(): Boolean = active
val diagnostics: String get() = synchronized(recordLock) {
"active=$active capture=$capFramesRead play=$playFramesWritten " +
"workers=${captureThread?.isAlive}/${playThread?.isAlive} " +
"record=${audioRecord?.recordingState} track=${audioTrack?.playState} route=${router.route}"
}
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */ /** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
fun setMuted(m: Boolean) { fun setMuted(m: Boolean) {
if (muted == m) return if (muted == m) return
@ -85,107 +96,154 @@ class CallAudioEngine {
callId = id callId = id
muted = false muted = false
failed.set(false)
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn
try { try {
am.mode = AudioManager.MODE_IN_COMMUNICATION val ctx = ChatApplication.instance
} catch (e: Exception) { val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") prevMode = am.mode
} prevSpeaker = am.isSpeakerphoneOn
try {
am.mode = AudioManager.MODE_IN_COMMUNICATION
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
}
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET,
* вынули → сохранённый маршрут, по умолчанию тихая связь). */ * вынули → сохранённый маршрут, по умолчанию тихая связь). */
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE
router.setAutoHeadset(true) router.setAutoHeadset(true)
router.start() router.start()
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном /* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
* динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */ * динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */
playFramesWritten = 0L playFramesWritten = 0L
capFramesRead = 0L capFramesRead = 0L
syncAecForRoute(router.route) syncAecForRoute(router.route)
// ── AudioRecord (захват) ── // ── AudioRecord (захват) ──
wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
wantedWired = wiredActive wantedWired = wiredActive
audioRecord = createAudioRecord(wiredActive) audioRecord = createAudioRecord(wiredActive)
if (audioRecord == null) { if (audioRecord == null) {
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false stop(); return false
} }
// ── AudioTrack (воспроизведение) ── // ── AudioTrack (воспроизведение) ──
val trackMin = AudioTrack.getMinBufferSize( val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) if (trackMin <= 0) {
audioTrack = try { LogManager.addLog("ERROR", "CallAudio", "AudioTrack.getMinBufferSize=$trackMin id=$id")
AudioTrack( stop(); return false
AudioAttributes.Builder() }
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), audioTrack = try {
AudioFormat.Builder().setSampleRate(SAMPLE_RATE) AudioTrack(
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) AudioAttributes.Builder()
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(), .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
} catch (e: Exception) { AudioFormat.Builder().setSampleRate(SAMPLE_RATE)
LogManager.addLog("ERROR", "CallAudio", "AudioTrack create failed: ${e.message}") .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
audioRecord?.release(); audioRecord = null .setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
audioTrack = null trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false } catch (e: Exception) {
} LogManager.addLog("ERROR", "CallAudio", "AudioTrack create failed: ${e.message}")
stop(); return false
}
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) {
глобальной маршрутизации на ряде устройств). */ LogManager.addLog("ERROR", "CallAudio", "AudioTrack not initialized id=$id")
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } stop(); return false
applyTrackOutputDevice(router.currentOutputDevice) }
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */ /* Прямое роутирование трека на выбранное устройство вывода (обход глючной
router.onInputDevice = { _ -> глобальной маршрутизации на ряде устройств). */
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
} applyTrackOutputDevice(router.currentOutputDevice)
active = true /* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */
audioRecord?.startRecording() router.onInputDevice = { _ ->
audioTrack?.play() wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
}
captureThread = Thread { captureLoop() }.apply { active = true
priority = Thread.MAX_PRIORITY; start() audioRecord?.startRecording()
} audioTrack?.play()
playThread = Thread { playLoop() }.apply { if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING ||
priority = Thread.MAX_PRIORITY; start() audioTrack?.playState != AudioTrack.PLAYSTATE_PLAYING) {
LogManager.addLog("ERROR", "CallAudio",
"I/O not running id=$id record=${audioRecord?.recordingState} track=${audioTrack?.playState}")
stop(); return false
}
captureThread = Thread({ runWorker(id, "capture") { captureLoop() } }, "utun-call-capture").apply { start() }
playThread = Thread({ runWorker(id, "play") { playLoop() } }, "utun-call-play").apply { start() }
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
mainHandler.postDelayed(aecDelayRunnable, 300)
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
return true
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "start failed id=$id: ${e.message}")
stop()
return false
} }
}
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */ /** Перехватываем сбой worker и передаём владельцу на main; старый callback не завершит новый звонок. */
mainHandler.postDelayed(aecDelayRunnable, 300) private fun runWorker(id: Long, name: String, work: () -> Unit) {
try {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
work()
if (active) reportFailure(id, "$name worker stopped unexpectedly")
} catch (e: Exception) {
if (active) reportFailure(id, "$name worker failed: ${e.message}")
} finally {
LogManager.addLog("INFO", "CallAudio", "$name worker exited id=$id active=$active")
}
}
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf") private fun reportFailure(id: Long, detail: String) {
return true if (!active || !failed.compareAndSet(false, true)) return
LogManager.addLog("ERROR", "CallAudio", "$detail id=$id route=${router.route} capture=$capFramesRead play=$playFramesWritten")
mainHandler.post { if (callId == id && active) onFailure?.invoke(id, detail) }
} }
/** Сначала разблокируем I/O и дождёмся workers, затем освобождаем Android/native-объекты. */
fun stop() { fun stop() {
if (!active) return if (callId == 0L) return
val id = callId
active = false active = false
mainHandler.removeCallbacks(aecDelayRunnable) mainHandler.removeCallbacks(aecDelayRunnable)
synchronized(recordLock) {
captureThread?.join(1000); captureThread = null audioRecord?.let { rec ->
playThread?.join(1000); playThread = null try { if (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) rec.stop() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record stop failed id=$id: ${e.message}") }
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() } }
audioRecord = null }
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } audioTrack?.let { track ->
try { if (track.playState == AudioTrack.PLAYSTATE_PLAYING) track.pause() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track pause failed id=$id: ${e.message}") }
}
playThread?.interrupt()
captureThread?.join(); captureThread = null
playThread?.join(); playThread = null
synchronized(recordLock) {
try { audioRecord?.release() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record release failed id=$id: ${e.message}") }
audioRecord = null
}
try { audioTrack?.release() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track release failed id=$id: ${e.message}") }
audioTrack = null audioTrack = null
NativeLib.callAudioStop() NativeLib.callAudioStop()
router.onOutputDevice = null router.onOutputDevice = null
router.onInputDevice = null router.onInputDevice = null
router.stop() router.stop()
restoreAudioMode() restoreAudioMode()
callId = 0L callId = 0L
muted = false muted = false
LogManager.addLog("INFO", "CallAudio", "stopped") LogManager.addLog("INFO", "CallAudio", "stopped id=$id capture=$capFramesRead play=$playFramesWritten; workers joined")
} }
private fun captureLoop() { private fun captureLoop() {
@ -197,27 +255,40 @@ class CallAudioEngine {
while (active) { while (active) {
/* проводная гарнитура появилась/исчезла — меняем источник захвата */ /* проводная гарнитура появилась/исчезла — меняем источник захвата */
if (wantedWired != wiredActive) { if (wantedWired != wiredActive) {
audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */ synchronized(recordLock) {
rec?.apply { try { stop() } catch (_: Exception) {}; release() } if (!active) return
wiredActive = wantedWired rec?.apply {
rec = createAudioRecord(wiredActive) stop()
if (rec == null) return release()
audioRecord = rec }
rec.startRecording() audioRecord = null
wiredActive = wantedWired
rec = createAudioRecord(wiredActive)
audioRecord = rec
if (rec == null) return
rec?.startRecording()
}
sinceSwap = 0 sinceSwap = 0
} }
val r = rec ?: return val r = rec ?: return
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
reportFailure(callId, "capture not recording state=${r.recordingState}")
return
}
var read = 0 var read = 0
while (read < FRAME_SAMPLES) { while (read < FRAME_SAMPLES) {
if (!active) return if (!active) return
val n = r.read(buf, read, FRAME_SAMPLES - read) // stop() разблокирует read до join; обычный захват не требует polling.
if (n <= 0) return val n = r.read(buf, read, FRAME_SAMPLES - read, AudioRecord.READ_BLOCKING)
if (n <= 0) {
reportFailure(callId, "AudioRecord.read=$n offset=$read state=${r.recordingState}")
return
}
read += n read += n
} }
/* диагностика: уровень захваченного сигнала (тишина vs звук) */ /* диагностика: уровень захваченного сигнала (тишина vs звук) */
if (sinceSwap < 10 || frame % 100L == 0L) { if (sinceSwap < 10 || frame % 100L == 0L) {
LogManager.addLog("WARN", "CallAudio", LogManager.addLog("DEBUG", "CallAudio",
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}") "cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}")
} }
sinceSwap++ sinceSwap++
@ -228,7 +299,6 @@ class CallAudioEngine {
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false") LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
} }
} }
LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
} }
/** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */ /** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */
@ -245,6 +315,10 @@ class CallAudioEngine {
private fun createAudioRecord(wired: Boolean): AudioRecord? { private fun createAudioRecord(wired: Boolean): AudioRecord? {
val recMin = AudioRecord.getMinBufferSize( val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (recMin <= 0) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord.getMinBufferSize=$recMin wired=$wired")
return null
}
val rec = try { val rec = try {
AudioRecord( AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION, MediaRecorder.AudioSource.VOICE_COMMUNICATION,
@ -283,9 +357,34 @@ class CallAudioEngine {
while (active) { while (active) {
val track = audioTrack ?: return val track = audioTrack ?: return
val n = NativeLib.callAudioPull(callId, buf) val n = NativeLib.callAudioPull(callId, buf)
if (n > 0) track.write(buf, 0, n) if (n !in 0..FRAME_SAMPLES) {
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) reportFailure(callId, "callAudioPull=$n expected=0..$FRAME_SAMPLES")
playFramesWritten += FRAME_SAMPLES return
}
if (n > 0) buf.copyInto(silence, 0, 0, n)
if (n < FRAME_SAMPLES) silence.fill(0, n, FRAME_SAMPLES)
var offset = 0
var blockedAt = 0L
while (active && offset < FRAME_SAMPLES) {
val written = track.write(silence, offset, FRAME_SAMPLES - offset, AudioTrack.WRITE_NON_BLOCKING)
if (written < 0) {
reportFailure(callId, "AudioTrack.write=$written offset=$offset state=${track.playState}")
return
}
if (written == 0) {
val now = SystemClock.elapsedRealtime()
if (blockedAt == 0L) blockedAt = now
if (now - blockedAt > 2000) {
reportFailure(callId, "AudioTrack stalled for ${now - blockedAt}ms offset=$offset state=${track.playState}")
return
}
Thread.sleep(2)
} else {
blockedAt = 0L
offset += written
playFramesWritten += written
}
}
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф /* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф
* (важно для стабильного выравнивания рендер↔захват в AEC). */ * (важно для стабильного выравнивания рендер↔захват в AEC). */
@ -298,7 +397,6 @@ class CallAudioEngine {
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */ nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */
} }
} }
LogManager.addLog("DEBUG", "CallAudio", "play loop done")
} }
/** AEC включается только на громкой связи (спикерфон). */ /** AEC включается только на громкой связи (спикерфон). */
@ -310,29 +408,31 @@ class CallAudioEngine {
/** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */ /** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */
private fun measureAecDelay() { private fun measureAecDelay() {
if (!active) return synchronized(recordLock) {
val track = audioTrack ?: return if (!active) return
val rec = audioRecord ?: return val track = audioTrack ?: return
try { val rec = audioRecord ?: return
val tTs = AudioTimestamp() try {
val rTs = AudioTimestamp() val tTs = AudioTimestamp()
val trackOk = track.getTimestamp(tTs) val rTs = AudioTimestamp()
val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS val trackOk = track.getTimestamp(tTs)
if (!trackOk || !recOk) { val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS
LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames") if (!trackOk || !recOk) {
LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames")
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
return
}
val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L)
val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L)
val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES
val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20)
LogManager.addLog("INFO", "CallAudio",
"AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames")
NativeLib.callAudioSetAecDelay(delayFrames)
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}")
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES) NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
return
} }
val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L)
val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L)
val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES
val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20)
LogManager.addLog("INFO", "CallAudio",
"AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames")
NativeLib.callAudioSetAecDelay(delayFrames)
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}")
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
} }
} }

358
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt

@ -0,0 +1,358 @@
package com.utun.chat.data
import android.content.Intent
import android.os.Looper
import android.os.SystemClock
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.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
* События поступают напрямую из AppEventHandler без replay старых звонков новым подписчикам.
*/
object CallController : TelecomCallManager.Listener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<CallState?>(null)
val state: StateFlow<CallState?> = _state
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
private val _route = MutableStateFlow(AudioRoute.SPEAKER)
val route: StateFlow<AudioRoute> = _route
private val _headset = MutableStateFlow(false)
val headset: StateFlow<Boolean> = _headset
private val _earpiece = MutableStateFlow(true)
val earpiece: StateFlow<Boolean> = _earpiece
private val proximity = ProximityManager(ChatApplication.instance)
val proximityNear: StateFlow<Boolean> = proximity.near
private var service: CallService? = null
private var serviceRequested = false
private var accepting = false
private var acceptSent = false
private var answerWhenVisible = 0L
private var activityVisible = false
private var coreReady = false
private var incomingAt = 0L
private var initialized = false
fun init() {
checkMain()
if (initialized) return
initialized = true
TelecomCallManager.setListener(this)
scope.launch {
while (true) {
delay(500)
val st = _state.value ?: continue
val now = SystemClock.elapsedRealtime()
if (st.phase == CallPhase.ACTIVE) {
_state.value = st.copy(durationSeconds = ((now - st.startedAtElapsedRealtime) / 1000).toInt())
} else if (st.phase == CallPhase.INCOMING && now - incomingAt > 50_000) {
LogManager.addLog("WARN", "Call", "incoming timeout id=${st.callId}; declining")
end(st.callId)
}
}
}
LogManager.addLog("INFO", "Call", "controller initialized; session independent of UI")
}
fun setActivityVisible(visible: Boolean) {
checkMain()
activityVisible = visible
syncCoreActivity()
if (visible && answerWhenVisible != 0L) {
val id = answerWhenVisible
answerWhenVisible = 0L
if (_state.value?.callId == id && _state.value?.phase == CallPhase.INCOMING) accept()
}
}
private fun syncCoreActivity() {
val ongoing = _state.value?.ongoing == true
val active = activityVisible || ongoing
LogManager.addLog("INFO", "Call", "core activity=$active visible=$activityVisible call=$ongoing")
if (coreReady && NativeLib.isReady()) NativeLib.setActive(active)
}
fun start(channelId: String, peerNodeId: Long, peerName: String) {
checkMain()
if (channelId.isEmpty() || peerNodeId == 0L) {
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
return
}
if (_state.value?.ongoing == true || !coreReady || !NativeLib.isReady()) {
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
return
}
var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING)
syncCoreActivity()
requestService(id)
}
fun accept() {
checkMain()
val st = _state.value ?: return
if (st.phase != CallPhase.INCOMING || accepting) {
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
return
}
accepting = true
requestService(st.callId)
}
private fun requestService(id: Long) {
serviceRequested = true
try {
CallService.start(ChatApplication.instance, id)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Call", "service request failed id=$id: ${e.message}")
end(id, 7)
}
}
/** Вызывается сервисом после startForeground и приобретения wake lock, до запуска сигналинга/микрофона. */
fun serviceReady(id: Long, owner: CallService): Boolean {
checkMain()
val st = _state.value
if (st == null || st.callId != id || !st.ongoing || !serviceRequested) {
LogManager.addLog("WARN", "Call", "discard stale service start id=$id current=${st?.callId}")
return false
}
if (service != null) {
if (service !== owner) LogManager.addLog("ERROR", "Call", "service owner conflict id=$id")
return service === owner
}
service = owner
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}")
if (st.phase == CallPhase.OUTGOING) {
NativeLib.callStart(st.channelId, st.peerNodeId, id)
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
CallRingback.startConnecting()
} else if (st.phase == CallPhase.INCOMING && accepting) {
IncomingCallManager.stopRinging()
acceptSent = true
NativeLib.callAccept(id)
}
return true
}
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true
fun serviceLost(id: Long, owner: CallService) {
checkMain()
if (service !== owner) return
service = null
LogManager.addLog("ERROR", "Call", "service destroyed during call id=$id")
end(id, 7)
}
fun audioFailed(id: Long, detail: String) {
checkMain()
if (_state.value?.callId != id || _state.value?.ongoing != true) return
LogManager.addLog("ERROR", "Call", "audio failed id=$id: $detail")
end(id, 7)
}
/** Завершение из UI, Telecom и уведомления; идентификатор защищает от действий старого звонка. */
fun end(id: Long = _state.value?.callId ?: 0L, reason: Int = 0) {
checkMain()
val st = _state.value ?: return
if (st.callId != id || !st.ongoing) {
LogManager.addLog("WARN", "Call", "end ignored id=$id current=${st.callId} phase=${st.phase}")
return
}
if (NativeLib.isReady()) {
if (st.phase == CallPhase.INCOMING && !acceptSent) NativeLib.callDecline(id) else NativeLib.callHangup(id)
}
finish(id, reason)
}
private fun finish(id: Long, reason: Int) {
val st = _state.value ?: return
if (st.callId != id || !st.ongoing) return
val owner = service
service = null
serviceRequested = false
accepting = false
acceptSent = false
answerWhenVisible = 0L
owner?.shutdown()
proximity.stop()
TelecomCallManager.setDisconnected(id, reason)
TelecomCallManager.removeConnection(id)
CallRingback.stop()
IncomingCallManager.stopRinging()
_muted.value = false
_route.value = AudioRoute.SPEAKER
_headset.value = false
_earpiece.value = true
_state.value = st.copy(phase = CallPhase.ENDED, reason = reason)
syncCoreActivity()
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released")
scope.launch {
delay(2500)
if (_state.value?.callId == id && _state.value?.phase == CallPhase.ENDED) _state.value = null
}
}
fun dismiss() {
checkMain()
if (_state.value?.ongoing == true) end()
_state.value = null
}
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
fun reset() {
checkMain()
coreReady = false
end()
_state.value = null
}
fun toggleMute() {
checkMain()
val audio = service?.audio ?: return
if (!audio.isActive()) return
audio.setMuted(!audio.isMuted())
_muted.value = audio.isMuted()
}
fun setRoute(route: AudioRoute) {
checkMain()
service?.audio?.router?.setRoute(route)
_state.value?.let { TelecomCallManager.setRoute(it.callId, route) }
scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) }
}
private fun routeChanged(route: AudioRoute, headset: Boolean) {
_route.value = route
_headset.value = headset
if (route == AudioRoute.EARPIECE && _state.value?.phase == CallPhase.ACTIVE) proximity.start() else proximity.stop()
service?.audio?.syncAecForRoute(route)
}
/** Только живые события ядра, сериализованные AppEventHandler на main thread. */
fun onEvent(type: Int, data: ByteArray?) {
checkMain()
if (type == 12) {
coreReady = NativeLib.isReady()
syncCoreActivity()
LogManager.addLog("INFO", "Call", "core started ready=$coreReady")
return
}
if (type == 13) { reset(); return }
if (type !in 34..40 && type != 42) return
if (!coreReady || !NativeLib.isReady()) {
LogManager.addLog("WARN", "Call", "ignore event=$type while core stopped/restarting")
return
}
if (type == 34) {
if (data == null || data.size < 17) { badEvent(type, data); return }
val len = data[0].toInt() and 0xFF
if (len == 0 || data.size < 17 + len) { badEvent(type, data); return }
val channel = String(data, 1, len)
val buf = ByteBuffer.wrap(data, 1 + len, 16).order(ByteOrder.LITTLE_ENDIAN)
val peer = buf.long
val id = buf.long
if (peer == 0L || id == 0L) { badEvent(type, data); return }
val current = _state.value
if (current?.callId == id) return
if (current?.ongoing == true) {
LogManager.addLog("WARN", "Call", "busy; decline incoming id=$id current=${current.callId}")
NativeLib.callDecline(id)
return
}
incomingAt = SystemClock.elapsedRealtime()
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
_state.value = CallState(id, channel, peer, name, CallPhase.INCOMING)
syncCoreActivity()
IncomingCallManager.show(id, channel, peer)
TelecomCallManager.addIncomingCall(id, peer, name)
return
}
if (data == null || data.size < 8 || ((type == 37 || type == 38 || type == 40 || type == 42) && data.size < 9) ||
(type == 39 && data.size != 24)) { badEvent(type, data); return }
val id = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
val st = _state.value
if (st == null || st.callId != id || !st.ongoing) {
LogManager.addLog("DEBUG", "Call", "ignore event=$type id=$id current=${st?.callId} phase=${st?.phase}")
return
}
when (type) {
35 -> if (st.phase == CallPhase.OUTGOING) {
_state.value = st.copy(phase = CallPhase.RINGING)
CallRingback.startRinging()
service?.updateNotification()
}
36 -> {
if (st.phase == CallPhase.ACTIVE) return
val owner = service
if (owner == null || !owner.startAudio(id)) {
LogManager.addLog("ERROR", "Call", "accepted without working audio/service id=$id")
end(id, 7)
return
}
accepting = false
CallRingback.stop()
IncomingCallManager.stopRinging()
TelecomCallManager.setActive(id)
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0, startedAtElapsedRealtime = SystemClock.elapsedRealtime())
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) }
_earpiece.value = owner.audio.router.hasEarpiece
routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable)
owner.updateNotification()
LogManager.addLog("INFO", "Call", "active id=$id; service owns audio and CPU wake lock")
}
37, 38 -> finish(id, data[8].toInt() and 0xFF)
40 -> {
LogManager.addLog("ERROR", "Call", "native error id=$id code=${data[8]} ${String(data, 9, data.size - 9)}")
finish(id, 7)
}
39 -> if (st.phase == CallPhase.ACTIVE) {
val buf = ByteBuffer.wrap(data, 8, 16).order(ByteOrder.BIG_ENDIAN)
fun metric() = buf.short.toInt() and 0xFFFF
_state.value = st.copy(stats = CallStats(metric(), metric(), metric(), metric(), metric(), metric(), metric(), metric()))
}
42 -> _state.value = st.copy(bgpPath = String(data, 8, data.size - 8))
}
}
private fun badEvent(type: Int, data: ByteArray?) {
LogManager.addLog("WARN", "Call", "invalid event=$type size=${data?.size ?: 0}")
}
override fun onAnswer(callId: Long) {
if (_state.value?.callId != callId || _state.value?.phase != CallPhase.INCOMING) return
if (activityVisible) { accept(); return }
// Ответ гарнитурой в фоне: сначала видимый UI, затем разрешённый запуск microphone FGS.
answerWhenVisible = callId
try {
ChatApplication.instance.startActivity(Intent(ChatApplication.instance, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(IncomingCallManager.EXTRA_CALL_ID, callId)
})
LogManager.addLog("INFO", "Call", "Telecom answer id=$callId; waiting for visible Activity before microphone service")
} catch (e: Exception) {
answerWhenVisible = 0L
LogManager.addLog("ERROR", "Call", "cannot show incoming UI for Telecom answer id=$callId: ${e.message}")
}
}
override fun onReject(callId: Long) { end(callId, 2) }
override fun onDisconnect(callId: Long) { end(callId) }
private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "CallController requires main thread" }
}

229
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt

@ -0,0 +1,229 @@
package com.utun.chat.data
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
/**
* Владелец аудиодвижка и PARTIAL_WAKE_LOCK на время исходящего/принятого звонка.
* Сервис запускается действием пользователя до сигналинга и захвата микрофона.
* Не восстанавливает звонок после смерти процесса: native-сессия тогда тоже утрачена.
*/
class CallService : Service() {
companion object {
// У старого low-канала Android не позволяет повысить importance после создания.
private const val CHANNEL = "ongoing_call_lockscreen"
private const val NOTIFICATION_ID = 1002
private const val EXTRA_ID = "call_id"
private const val ACTION_END = "com.utun.chat.action.END_CALL"
fun start(context: Context, callId: Long) {
ContextCompat.startForegroundService(context,
Intent(context, CallService::class.java).putExtra(EXTRA_ID, callId))
}
}
val audio = CallAudioEngine()
private var callId = 0L
private var wakeLock: PowerManager.WakeLock? = null
private var powerEventsRegistered = false
private val powerEvents = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
LogManager.addLog("INFO", "CallService",
"power event=${intent.action} id=$callId interactive=${pm.isInteractive} " +
"idle=${pm.isDeviceIdleMode} save=${pm.isPowerSaveMode} lock=${wakeLock?.isHeld} ${audio.diagnostics}")
}
}
override fun onCreate() {
super.onCreate()
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Собеседник, длительность и завершение звонка на экране блокировки"
setSound(null, null)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
})
val channel = manager.getNotificationChannel(CHANNEL)
LogManager.addLog("INFO", "CallService",
"notification channel=$CHANNEL enabled=${manager.areNotificationsEnabled()} " +
"importance=${channel.importance} lockscreenVisibility=${channel.lockscreenVisibility}")
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
LogManager.addLog("INFO", "CallService", "created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val id = intent?.getLongExtra(EXTRA_ID, 0L) ?: 0L
if (intent?.action == ACTION_END) {
CallController.end(id)
return START_NOT_STICKY
}
if (!CallController.wantsService(id)) {
LogManager.addLog("WARN", "CallService", "stale start id=$id owned=$callId")
if (callId == 0L) {
// Даже отменённый startForegroundService должен завершить foreground-handshake.
try {
promote(false)
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallService", "cancelled foreground handshake failed id=$id: ${e.message}")
} finally {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf(startId)
}
}
return START_NOT_STICKY
}
if (callId == id) return START_NOT_STICKY
if (callId != 0L) {
LogManager.addLog("ERROR", "CallService", "start conflicts id=$id owned=$callId")
CallController.audioFailed(id, "service already owns another call")
return START_NOT_STICKY
}
callId = id
try {
promote(true)
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:ongoing_call").apply {
setReferenceCounted(false)
}
wakeLock!!.acquire()
LogManager.addLog("INFO", "CallService",
"foreground phoneCall|microphone; CPU lock acquired id=$id held=${wakeLock?.isHeld}")
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
}
ContextCompat.registerReceiver(this, powerEvents, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
powerEventsRegistered = true
if (!CallController.serviceReady(id, this)) shutdown()
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallService", "start failed id=$id: ${e.message}")
CallController.audioFailed(id, "foreground/wake lock failed: ${e.message}")
shutdown()
}
return START_NOT_STICKY
}
private fun promote(microphone: Boolean) {
val micType = if (microphone && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0
val types = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or micType
} else 0
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types)
LogManager.addLog("INFO", "CallService", "foreground notification id=$callId phase=${CallController.state.value?.phase}")
}
fun startAudio(id: Long): Boolean {
if (id != callId || wakeLock?.isHeld != true) {
LogManager.addLog("ERROR", "CallService", "audio start rejected id=$id owned=$callId lock=${wakeLock?.isHeld}")
return false
}
audio.router.setPreferredRoute(ChatApplication.instance.configProvider.getCallAudioRoute())
return audio.start(id)
}
fun updateNotification() {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification())
LogManager.addLog("INFO", "CallService", "notification updated id=$callId phase=${CallController.state.value?.phase}")
}
private fun notification(): Notification {
val st = CallController.state.value?.takeIf { it.callId == callId }
val active = st?.phase == CallPhase.ACTIVE
val person = Person.Builder().setName(st?.peerName?.takeIf { it.isNotBlank() } ?: "vibechat")
.setKey("node:${st?.peerNodeId}").build()
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val end = PendingIntent.getService(this, 0,
Intent(this, CallService::class.java).setAction(ACTION_END)
.setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_call)
.setContentTitle(person.name)
.setContentText(when (st?.phase) {
CallPhase.ACTIVE -> "Разговор"
CallPhase.RINGING -> "Звоним…"
else -> "Установка звонка"
})
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentIntent(open)
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, end))
.addPerson(person)
.setShowWhen(active)
.setUsesChronometer(active)
if (active && st != null) {
// Один источник начала разговора для UI и системного таймера; уведомление не обновляется каждую секунду.
builder.setWhen(System.currentTimeMillis() - (SystemClock.elapsedRealtime() - st.startedAtElapsedRealtime))
}
return builder.build()
}
/** Идемпотентное освобождение до остановки сервиса; выполняется в main thread. */
fun shutdown() {
releaseResources()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun releaseResources() {
val id = callId
if (powerEventsRegistered) {
try { unregisterReceiver(powerEvents) }
catch (e: Exception) { LogManager.addLog("WARN", "CallService", "power receiver cleanup failed id=$id: ${e.message}") }
powerEventsRegistered = false
}
audio.router.onChanged = null
try {
audio.stop()
} finally {
wakeLock?.let { lock ->
try {
if (lock.isHeld) lock.release()
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallService", "wake lock release failed id=$id: ${e.message}")
}
}
wakeLock = null
callId = 0L
if (id != 0L) LogManager.addLog("INFO", "CallService", "resources released id=$id; CPU lock cleared")
}
}
override fun onDestroy() {
val id = callId
releaseResources()
CallController.serviceLost(id, this)
LogManager.addLog("INFO", "CallService", "destroyed id=$id")
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}

24
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt

@ -0,0 +1,24 @@
package com.utun.chat.data
/** Состояние единственной звонковой сессии; не зависит от Activity или ViewModel. */
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallStats(
val rttMs: Int, val bufferMs: Int, val tempoX100: Int, val dropped: Int,
val underruns: Int, val minMs: Int, val maxMs: Int, val reserveMs: Int
)
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 durationSeconds: Int = 0,
val bgpPath: String = "",
val stats: CallStats? = null,
val startedAtElapsedRealtime: Long = 0
) {
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
}

3
tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt

@ -19,7 +19,6 @@ class IncomingCallActionReceiver : BroadcastReceiver() {
return return
} }
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId)) LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId))
NativeLib.callDecline(callId) CallController.end(callId, 2)
IncomingCallManager.stopRinging()
} }
} }

75
tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt

@ -9,26 +9,10 @@ import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.utun.chat.AppEventHandler
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity import com.utun.chat.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers /** Уведомление, пробуждение экрана и мелодия входящего звонка. Жизненным циклом управляет CallController. */
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 { object IncomingCallManager {
const val CHANNEL_CALL = "incoming_call" const val CHANNEL_CALL = "incoming_call"
@ -38,28 +22,19 @@ object IncomingCallManager {
const val EXTRA_CHANNEL_ID = "channel_id" const val EXTRA_CHANNEL_ID = "channel_id"
const val EXTRA_PEER_NODE_ID = "peer_node_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() private val ringtone = RingtonePlayer()
@Volatile private var initialized = false @Volatile private var initialized = false
@Volatile private var activeCallId = 0L @Volatile private var activeCallId = 0L
@Volatile private var pendingCall: PendingCall? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
fun init() { fun init() {
if (initialized) return if (initialized) return
initialized = true initialized = true
createChannel(ChatApplication.instance) createChannel(ChatApplication.instance)
scope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
LogManager.addLog("INFO", "InCall", "manager initialized") LogManager.addLog("INFO", "InCall", "manager initialized")
} }
fun currentIncomingCallId(): Long = activeCallId
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */ /** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */
fun canFullScreenIntent(): Boolean { fun canFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
@ -67,14 +42,6 @@ object IncomingCallManager {
return nm.canUseFullScreenIntent() return nm.canUseFullScreenIntent()
} }
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */
@Synchronized
fun consumePendingIncoming(): PendingCall? {
val p = pendingCall
pendingCall = null
return p
}
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */ /** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */
fun stopRinging() { fun stopRinging() {
val id = activeCallId val id = activeCallId
@ -83,42 +50,16 @@ object IncomingCallManager {
ringtone.stop() ringtone.stop()
releaseWakeLock() releaseWakeLock()
cancelNotification() cancelNotification()
pendingCall = null
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id)) LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id))
} }
private fun handleEvent(type: Int, data: ByteArray?) { fun show(callId: Long, channelId: String, peerNodeId: Long) {
when (type) { stopRinging()
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 activeCallId = callId
pendingCall = PendingCall(callId, chId, caller) LogManager.addLog("INFO", "InCall", "incoming call id=$callId peer=$peerNodeId ch=$channelId")
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
acquireWakeLock() acquireWakeLock()
ringtone.play() ringtone.play()
showNotification(callId, chId, caller) showNotification(callId, channelId, peerNodeId)
}
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()
} }
/* ── пробуждение устройства ── */ /* ── пробуждение устройства ── */
@ -139,7 +80,9 @@ object IncomingCallManager {
private fun releaseWakeLock() { private fun releaseWakeLock() {
wakeLock?.let { wakeLock?.let {
try { if (it.isHeld) it.release() } catch (_: Exception) {} try { if (it.isHeld) it.release() } catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "wake lock release failed: ${e.message}")
}
} }
wakeLock = null wakeLock = null
} }

9
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -1,6 +1,8 @@
package com.utun.chat.data package com.utun.chat.data
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
interface ChatEventCallback { interface ChatEventCallback {
fun onEvent(type: Int, data: ByteArray?) fun onEvent(type: Int, data: ByteArray?)
@ -28,6 +30,7 @@ object NativeLib {
} }
fun stop() { fun stop() {
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
if (!isReady()) return if (!isReady()) return
nativeStop() nativeStop()
} }
@ -38,9 +41,13 @@ object NativeLib {
}) })
} }
fun destroy() { nativeDestroy() } fun destroy() {
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
nativeDestroy()
}
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
return nativeRestart(configText) return nativeRestart(configText)
} }

27
tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt

@ -13,15 +13,15 @@ import com.utun.chat.ChatApplication
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
* Мост между жизненным циклом P2P-звонка (ChatViewModel) и Telecom. * Мост между жизненным циклом P2P-звонка (CallController) и Telecom.
* *
* - Регистрирует self-managed PhoneAccount (делает вызовы uTun «настоящими» для системы). * - Регистрирует self-managed PhoneAccount (делает вызовы uTun «настоящими» для системы).
* - placeCall/addIncomingCall создают Connection через UtunConnectionService. * - placeCall/addIncomingCall создают Connection через UtunConnectionService.
* - Хранит Connection по callId и прокидывает state/route из VM в Connection. * - Хранит Connection по callId и прокидывает state/route из контроллера в Connection.
* - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в VM. * - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в контроллер.
* *
* Всё «best effort»: если Telecom недоступен (нет прав/стека), методы возвращают false, * Всё «best effort»: если Telecom недоступен (нет прав/стека), методы возвращают false,
* и VM продолжает звонок прежним путём без системной маршрутизации. * и контроллер продолжает звонок без системной маршрутизации.
*/ */
object TelecomCallManager { object TelecomCallManager {
@ -120,12 +120,25 @@ object TelecomCallManager {
/* ── реестр Connection (вызывается из UtunConnectionService) ── */ /* ── реестр Connection (вызывается из UtunConnectionService) ── */
fun registerConnection(callId: Long, conn: UtunConnection) { fun registerConnection(callId: Long, conn: UtunConnection) {
val st = CallController.state.value
if (st == null || st.callId != callId || !st.ongoing) {
LogManager.addLog("WARN", TAG, "late connection for ended/stale call id=$callId current=${st?.callId}")
conn.markDisconnected(0)
conn.destroy()
return
}
connections[callId] = conn connections[callId] = conn
when (st.phase) {
CallPhase.ACTIVE -> conn.markActive()
CallPhase.INCOMING -> conn.markRinging()
else -> conn.setDialing()
}
LogManager.addLog("INFO", TAG, "connection registered id=$callId phase=${st.phase}")
} }
fun connectionFor(callId: Long): UtunConnection? = connections[callId] fun connectionFor(callId: Long): UtunConnection? = connections[callId]
/* ── события из Connection (системный ввод) → VM ── */ /* ── события из Connection (системный ввод) → контроллер ── */
fun notifyAnswer(callId: Long) = post { listener?.onAnswer(callId) } fun notifyAnswer(callId: Long) = post { listener?.onAnswer(callId) }
@ -135,7 +148,7 @@ object TelecomCallManager {
fun notifyAbort(callId: Long) = post { listener?.onDisconnect(callId) } fun notifyAbort(callId: Long) = post { listener?.onDisconnect(callId) }
/* ── управление состоянием из VM ── */ /* ── управление состоянием из контроллера ── */
fun setRinging(callId: Long) = connections[callId]?.markRinging() fun setRinging(callId: Long) = connections[callId]?.markRinging()
@ -146,7 +159,7 @@ object TelecomCallManager {
fun setRoute(callId: Long, route: AudioRoute) = connections[callId]?.routeTo(route) fun setRoute(callId: Long, route: AudioRoute) = connections[callId]?.routeTo(route)
fun removeConnection(callId: Long) { fun removeConnection(callId: Long) {
connections.remove(callId) connections.remove(callId)?.destroy()
LogManager.addLog("DEBUG", TAG, "connection removed id=0x%016x".format(callId)) LogManager.addLog("DEBUG", TAG, "connection removed id=0x%016x".format(callId))
} }

2
tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt

@ -23,7 +23,7 @@ import java.util.concurrent.CopyOnWriteArrayList
* *
* Владелец — TelecomCallManager (создаётся в UtunConnectionService, хранится в * Владелец — TelecomCallManager (создаётся в UtunConnectionService, хранится в
* реестре по callId). Ответ/отбой/разрыв приходят сюда из системного ввода * реестре по callId). Ответ/отбой/разрыв приходят сюда из системного ввода
* (например, кнопка BT-гарнитуры) и делегируются в VM через TelecomCallManager.Listener. * (например, кнопка BT-гарнитуры) и делегируются в CallController через TelecomCallManager.Listener.
*/ */
class UtunConnection( class UtunConnection(
private val callId: Long, private val callId: Long,

4
tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt

@ -24,7 +24,7 @@ class UtunConnectionService : ConnectionService() {
} }
LogManager.addLog("INFO", "UtunConnSvc", LogManager.addLog("INFO", "UtunConnSvc",
"onCreateIncomingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId)) "onCreateIncomingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId))
return createConnection(callId, peerNodeId, name).also { it.markRinging() } return createConnection(callId, peerNodeId, name)
} }
override fun onCreateOutgoingConnection( override fun onCreateOutgoingConnection(
@ -37,7 +37,7 @@ class UtunConnectionService : ConnectionService() {
} }
LogManager.addLog("INFO", "UtunConnSvc", LogManager.addLog("INFO", "UtunConnSvc",
"onCreateOutgoingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId)) "onCreateOutgoingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId))
return createConnection(callId, peerNodeId, name).also { it.setDialing() } return createConnection(callId, peerNodeId, name)
} }
private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection { private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection {

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt

@ -21,7 +21,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase import com.utun.chat.data.CallPhase
private fun fmtDuration(sec: Int): String { private fun fmtDuration(sec: Int): String {
val m = sec / 60 val m = sec / 60
@ -43,7 +43,7 @@ fun ActiveCallBanner(
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) } val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
val status = when (phase) { val status = when (phase) {
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationMs) CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationSeconds)
CallPhase.RINGING -> "Звоним…" CallPhase.RINGING -> "Звоним…"
else -> "Пытаемся связаться…" else -> "Пытаемся связаться…"
} }

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt

@ -45,7 +45,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import com.utun.chat.data.AudioRoute import com.utun.chat.data.AudioRoute
import com.utun.chat.data.LogManager import com.utun.chat.data.LogManager
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase import com.utun.chat.data.CallPhase
private tailrec fun Context.findActivity(): Activity? = when (this) { private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this is Activity -> this
@ -105,7 +105,7 @@ fun CallScreen(
val proximityNear by viewModel.proximityNear.collectAsState() val proximityNear by viewModel.proximityNear.collectAsState()
val phase = st?.phase ?: CallPhase.NONE val phase = st?.phase ?: CallPhase.NONE
val duration = st?.durationMs ?: 0 val duration = st?.durationSeconds ?: 0
val peerNodeId = st?.peerNodeId ?: 0L val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: "" val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""

27
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -256,6 +256,10 @@ private fun MemberDetailCard(
.padding(8.dp) .padding(8.dp)
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp / 2), .heightIn(max = LocalConfiguration.current.screenHeightDp.dp / 2),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) { Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) {
@ -299,7 +303,7 @@ private fun MemberDetailCard(
text = headerParts.joinToString(" · "), text = headerParts.joinToString(" · "),
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 11.sp, fontSize = 11.sp,
color = Color(0xFFAEBCC8) color = MaterialTheme.colorScheme.onSurfaceVariant
) )
if (isSelf) { if (isSelf) {
@ -315,7 +319,7 @@ private fun MemberDetailCard(
text = "[${sk.linksUp}/${sk.linksDown}] ${sk.protocol} ${sk.ifName} ${sk.bindIp}:${sk.port}", text = "[${sk.linksUp}/${sk.linksDown}] ${sk.protocol} ${sk.ifName} ${sk.bindIp}:${sk.port}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
color = Color.DarkGray color = MaterialTheme.colorScheme.onSurface
) )
} }
} }
@ -329,7 +333,7 @@ private fun MemberDetailCard(
text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}", text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
color = Color.DarkGray color = MaterialTheme.colorScheme.onSurface
) )
} }
} }
@ -345,25 +349,26 @@ private fun MemberDetailCard(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(dcol)) Box(Modifier.size(8.dp).clip(CircleShape).background(dcol))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = Color(0xFFAEBCC8)) Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("${formatAddr(lk.localAddr)}:${lk.localPort}", Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.DarkGray) fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp) Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}", Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.DarkGray) fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
} }
val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName) val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName)
if (cleanSn.isNotEmpty()) { if (cleanSn.isNotEmpty()) {
Row { Row {
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Text(cleanSn, fontSize = 12.sp, color = Color(0xFFAEBCC8)) Text(cleanSn, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }
@ -380,7 +385,7 @@ private fun MemberDetailCard(
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" + Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""), (if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, color = Color.DarkGray) fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
} }
} }
if (detail.indirectPresence) { if (detail.indirectPresence) {
@ -389,7 +394,7 @@ private fun MemberDetailCard(
.background(if (detail.indirectUp) Color(0xFF81C784) else Color(0xFFFFB4AB))) .background(if (detail.indirectUp) Color(0xFF81C784) else Color(0xFFFFB4AB)))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}", Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
fontSize = 13.sp, color = Color.DarkGray) fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
} }
} }
if (detail.bgpPresence && detail.bgpNodes > 0) { if (detail.bgpPresence && detail.bgpNodes > 0) {
@ -398,11 +403,11 @@ private fun MemberDetailCard(
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("BGP: ${detail.bgpNodes} nodes" + Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""), (if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, color = Color.DarkGray) fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
} }
} }
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) { if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.sp, color = Color(0xFFAEBCC8)) Text("offline", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }

335
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -22,8 +22,6 @@ import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
class ChatViewModel : ViewModel() { class ChatViewModel : ViewModel() {
data class PlaybackState( data class PlaybackState(
@ -35,40 +33,10 @@ class ChatViewModel : ViewModel() {
val durMs: Int = 0 val durMs: Int = 0
) )
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallStats(
val rttMs: Int,
val bufferMs: Int,
val tempoX100: Int,
val dropped: Int,
val underruns: Int,
val minMs: Int,
val maxMs: Int,
val reserveMs: Int
)
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,
val bgpPath: String = "",
val stats: CallStats? = null
)
private var repo: ChatRepository? = null private var repo: ChatRepository? = null
private var dbReady = false private var dbReady = false
private val _callState = MutableStateFlow<CallState?>(null) val callState = CallController.state
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
val proximity = ProximityManager(ChatApplication.instance)
val radioAudio = RadioAudioEngine.instance() val radioAudio = RadioAudioEngine.instance()
@ -102,23 +70,11 @@ class ChatViewModel : ViewModel() {
/* рация: активный канал (null = выключена). Одна рация — один канал. */ /* рация: активный канал (null = выключена). Одна рация — один канал. */
private var radioChannelId: String? = null private var radioChannelId: String? = null
private var callStartedMs = 0L val muted = CallController.muted
private var callIncomingAtMs = 0L val audioRoute = CallController.route
val headsetAvailable = CallController.headset
private val _muted = MutableStateFlow(false) val earpieceAvailable = CallController.earpiece
val muted: StateFlow<Boolean> = _muted val proximityNear = CallController.proximityNear
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 _proximityNear = MutableStateFlow(false)
val proximityNear: StateFlow<Boolean> = _proximityNear
private val _playbackState = MutableStateFlow(PlaybackState()) private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState val playbackState: StateFlow<PlaybackState> = _playbackState
@ -230,18 +186,6 @@ class ChatViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
} }
/* Системный ввод по вызову (кнопка BT-гарнитуры, разрыв из Telecom) → те же действия, что и UI. */
TelecomCallManager.setListener(object : TelecomCallManager.Listener {
override fun onAnswer(callId: Long) { acceptCall() }
override fun onReject(callId: Long) { declineCall() }
override fun onDisconnect(callId: Long) {
val st = _callState.value
if (st != null && st.callId == callId && st.phase != CallPhase.ENDED) {
LogManager.addLog("INFO", "VM", "telecom disconnect -> hangup 0x%016x".format(callId))
hangupCall()
}
}
})
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val key = ChatApplication.instance.configProvider.getString("node.public_key") val key = ChatApplication.instance.configProvider.getString("node.public_key")
if (key != null && key.length == 64) _pubKey.value = key if (key != null && key.length == 64) _pubKey.value = key
@ -255,17 +199,6 @@ class ChatViewModel : ViewModel() {
refreshChannels() refreshChannels()
} }
} }
viewModelScope.launch {
delay(300)
val p = IncomingCallManager.consumePendingIncoming() ?: return@launch
val cur = _callState.value
if (cur != null && cur.callId == p.callId) return@launch
LogManager.addLog("INFO", "VM", "restore pending incoming call 0x%016x ch=%s".format(p.callId, p.channelId))
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
resolveCallerName(p.channelId, p.peerNodeId)
}
viewModelScope.launch { viewModelScope.launch {
_foreground.collectLatest { fg -> _foreground.collectLatest { fg ->
while (fg && isActive) { while (fg && isActive) {
@ -308,44 +241,6 @@ class ChatViewModel : ViewModel() {
} }
} }
} }
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.ACTIVE) {
val dur = ((System.currentTimeMillis() - callStartedMs) / 1000).toInt()
_callState.value = st.copy(durationMs = dur)
}
delay(500)
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.INCOMING &&
callIncomingAtMs > 0 &&
System.currentTimeMillis() - callIncomingAtMs > CALL_INCOMING_TIMEOUT_MS) {
LogManager.addLog("WARN", "VM", "INCOMING watchdog: no resolution, clearing call 0x%016x".format(st.callId))
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_callState.value = null
}
delay(1000)
}
}
viewModelScope.launch {
combine(_callState, _audioRoute) { st, route -> st to route }.collect { (st, route) ->
val monitor = st != null && st.phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE
if (monitor) {
proximity.start()
} else {
proximity.stop()
_proximityNear.value = false
}
}
}
viewModelScope.launch {
proximity.near.collect { _proximityNear.value = it }
}
} }
private fun handleEvent(type: Int, data: ByteArray?) { private fun handleEvent(type: Int, data: ByteArray?) {
@ -616,114 +511,6 @@ class ChatViewModel : ViewModel() {
if (cur != null && cur.convId == convId) refreshDmMessages() if (cur != null && cur.convId == convId) refreshDmMessages()
refreshDmConversations() refreshDmConversations()
} }
34 -> { /* CALL_INCOMING: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 8 + 8) return
val chId = String(data, 1, cl)
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val caller = buf.getLong(); val callId = buf.getLong()
LogManager.addLog("INFO", "VM", "CALL_INCOMING ch=$chId caller=0x%016x id=0x%016x".format(caller, callId))
val cur = _callState.value
if (cur != null && cur.callId == callId) return // уже обрабатываем этот звонок (replay/restore)
if (cur != null && cur.callId != callId && cur.phase != CallPhase.ENDED) {
// заняты другим звонком — авто-отклоняем
LogManager.addLog("WARN", "VM", "busy with another call, auto-decline id=0x%016x".format(callId))
NativeLib.callDecline(callId)
return
}
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
TelecomCallManager.addIncomingCall(callId, caller, "0x%016x".format(caller))
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)
CallRingback.startRinging()
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))
CallRingback.stop()
TelecomCallManager.setActive(callId)
callAudio.router.setPreferredRoute(
ChatApplication.instance.configProvider.getCallAudioRoute())
if (!callAudio.start(callId)) {
LogManager.addLog("ERROR", "VM", "call audio engine start failed")
NativeLib.callHangup(callId)
_callState.value = st.copy(phase = CallPhase.ENDED, reason = 7)
} else {
callStartedMs = System.currentTimeMillis()
_muted.value = false
callAudio.router.onChanged = { r, headset ->
_audioRoute.value = r
_headsetAvailable.value = headset
callAudio.syncAecForRoute(r)
}
_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")
}
39 -> { /* CALL_STATS: call_id LE64, восемь BE16 метрик */
if (data == null || data.size != 24) {
LogManager.addLog("WARN", "call", "CALL_STATS invalid size=${data?.size ?: 0}")
return
}
val callId = java.nio.ByteBuffer.wrap(data, 0, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
val st = _callState.value
if (st == null || st.callId != callId || st.phase != CallPhase.ACTIVE) return
val buf = java.nio.ByteBuffer.wrap(data, 8, 16).order(java.nio.ByteOrder.BIG_ENDIAN)
fun readMetric() = buf.short.toInt() and 0xFFFF
val stats = CallStats(readMetric(), readMetric(), readMetric(), readMetric(),
readMetric(), readMetric(), readMetric(), readMetric())
if (st.stats == null)
LogManager.addLog("DEBUG", "call", "CALL_STATS started id=0x%016x reserve=${stats.reserveMs}ms".format(callId))
_callState.value = st.copy(stats = stats)
}
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")
}
42 -> { /* CALL_PATH: [call_id:8][text:var] — BGP-маршрут до пира */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val text = String(data, 8, data.size - 8)
val st = _callState.value
if (st != null && st.callId == callId) {
_callState.value = st.copy(bgpPath = text)
LogManager.addLog("INFO", "VM", "CALL_PATH id=0x%016x %s".format(callId, text))
}
}
43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */ 43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */
if (data == null || data.size < 19) return if (data == null || data.size < 19) return
val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN) val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN)
@ -740,93 +527,12 @@ class ChatViewModel : ViewModel() {
} }
} }
private fun resolveCallerName(chId: String, caller: Long) { /* Звонком владеет CallController; ViewModel передаёт только пользовательские команды. */
val r = repo ?: return fun startCall(channelId: String, peerNodeId: Long, peerName: String) = CallController.start(channelId, peerNodeId, peerName)
viewModelScope.launch { fun acceptCall() = CallController.accept()
val name = withContext(Dispatchers.IO) { r.getMembers(chId) } fun declineCall() = CallController.end(reason = 2)
.firstOrNull { it.nodeId == caller }?.name?.takeIf { it.isNotEmpty() } fun hangupCall() = CallController.end()
val st = _callState.value fun dismissCall() = CallController.dismiss()
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()
TelecomCallManager.setDisconnected(callId, reason)
TelecomCallManager.removeConnection(callId)
CallRingback.stop()
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_muted.value = false
_audioRoute.value = AudioRoute.SPEAKER
_headsetAvailable.value = false
_earpieceAvailable.value = true
_callState.value = st.copy(phase = CallPhase.ENDED, reason = reason)
viewModelScope.launch {
delay(2500)
if (_callState.value?.callId == callId) _callState.value = null
}
}
fun startCall(channelId: String, peerNodeId: Long, peerName: String) {
val existing = _callState.value
if (existing != null && existing.phase != CallPhase.ENDED) {
LogManager.addLog("WARN", "VM", "startCall: call already active")
return
}
var callId = java.security.SecureRandom().nextLong()
if (callId == 0L) callId = 1
val name = peerName.ifEmpty { "0x%016x".format(peerNodeId) }
_callState.value = CallState(callId = callId, channelId = channelId, peerNodeId = peerNodeId,
peerName = name, phase = CallPhase.OUTGOING)
NativeLib.callStart(channelId, peerNodeId, callId)
TelecomCallManager.placeCall(callId, peerNodeId, name)
CallRingback.startConnecting()
LogManager.addLog("INFO", "VM", "startCall ch=$channelId peer=0x%016x id=0x%016x".format(peerNodeId, callId))
}
fun acceptCall() {
val st = _callState.value ?: return
if (st.phase != CallPhase.INCOMING) {
LogManager.addLog("WARN", "VM", "acceptCall ignored: phase=${st.phase} id=0x%016x".format(st.callId))
return
}
callIncomingAtMs = 0L
IncomingCallManager.stopRinging()
NativeLib.callAccept(st.callId)
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
}
fun declineCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.INCOMING) {
NativeLib.callDecline(st.callId)
finishCall(st.callId, 2, "DECLINE")
} else {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun hangupCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.OUTGOING || st.phase == CallPhase.RINGING || st.phase == CallPhase.ACTIVE) {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun dismissCall() {
callAudio.stop()
CallRingback.stop()
callIncomingAtMs = 0L
_callState.value = null
}
/* ── рация ── */ /* ── рация ── */
@ -985,19 +691,8 @@ class ChatViewModel : ViewModel() {
members.value.firstOrNull { it.nodeId == nodeId }?.name members.value.firstOrNull { it.nodeId == nodeId }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId) ?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId)
fun toggleMute() { fun toggleMute() = CallController.toggleMute()
val m = !callAudio.isMuted() fun setAudioRoute(r: AudioRoute) = CallController.setRoute(r)
callAudio.setMuted(m)
_muted.value = m
LogManager.addLog("INFO", "VM", "toggleMute -> ${if (m) "on" else "off"}")
}
fun setAudioRoute(r: AudioRoute) {
callAudio.router.setRoute(r)
_callState.value?.let { TelecomCallManager.setRoute(it.callId, r) }
viewModelScope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(r) }
LogManager.addLog("INFO", "VM", "setAudioRoute -> $r")
}
fun requestRegenerateKeys() { fun requestRegenerateKeys() {
_generatingKeys.value = true _generatingKeys.value = true
@ -1462,8 +1157,6 @@ class ChatViewModel : ViewModel() {
} }
fun clearState() { fun clearState() {
callAudio.stop()
_callState.value = null
repo?.close() repo?.close()
repo = null repo = null
dbReady = false dbReady = false

6
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -1182,8 +1182,10 @@ static void bridge_set_active_trampoline(void* arg) {
int active = (int)(intptr_t)arg; int active = (int)(intptr_t)arg;
struct UTUN_INSTANCE* inst = instance_lite_get_instance(); struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst) return; if (!inst) return;
utun_set_client_activity(inst, active); if (utun_set_client_activity(inst, active) < 0) {
standby_set_enabled(inst, !active); bridge_log(BLEV_ERROR, "client_activity transition failed requested=%s", active ? "ACTIVE" : "STANDBY");
return;
}
bridge_log(BLEV_DEBUG, "client_activity set to %s", active ? "ACTIVE" : "STANDBY"); bridge_log(BLEV_DEBUG, "client_activity set to %s", active ? "ACTIVE" : "STANDBY");
} }

121
tools/chatgui-android/libutun_lite/standby.c

@ -15,8 +15,8 @@
* пробуждает досрочно — следующий цикл как обычно. * пробуждает досрочно — следующий цикл как обычно.
* *
* Список ожидающих (standby_wait) — двусвязный список, элементы из memory_pool. * Список ожидающих (standby_wait) — двусвязный список, элементы из memory_pool.
* При выходе из standby (или досрочном пробуждении) все wait-таймеры * В SLEEP ожидающие будятся фазовым таймером после анонса ACTIVE пирам.
* отменяются, колбэки вызываются сразу. * При выходе из standby они будятся после восстановления режима линков.
*/ */
#include "standby.h" #include "standby.h"
#include "chat_setting.h" #include "chat_setting.h"
@ -35,6 +35,7 @@ struct standby_wait_entry {
void* timer_id; /* handle uasync_set_timeout */ void* timer_id; /* handle uasync_set_timeout */
void (*cb)(void* arg); void (*cb)(void* arg);
void* arg; void* arg;
int dispatching; /* отцеплен для пробуждения; cancel только снимает cb */
}; };
static struct UASYNC* g_ua = NULL; static struct UASYNC* g_ua = NULL;
@ -103,28 +104,38 @@ static int standby_min_sleep_tb(void) {
static void standby_phase_timer_cb(void* arg); static void standby_phase_timer_cb(void* arg);
static void standby_arm_phase(void) { /* Сначала создаёт новый таймер; при ошибке сохраняет прежнюю фазу и её таймер. */
int dur_tb = (g_phase == PHASE_ACTIVE) ? standby_active_tb() : standby_sleep_tb(); static int standby_arm_phase(int phase) {
g_phase_start_tb = get_time_tb(); int dur_tb = (phase == PHASE_ACTIVE) ? standby_active_tb() : standby_sleep_tb();
g_phase_end_tb = g_phase_start_tb + (uint64_t)dur_tb; uint64_t now = get_time_tb();
g_min_sleep_end_tb = (g_phase == PHASE_SLEEP) void* timer = uasync_set_timeout(g_ua, dur_tb, NULL, standby_phase_timer_cb, "standby_phase");
? g_phase_start_tb + (uint64_t)standby_min_sleep_tb() if (!timer) {
: 0; DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: failed to arm phase=%d duration=%dms", phase, dur_tb / 10);
g_phase_timer = uasync_set_timeout(g_ua, dur_tb, NULL, standby_phase_timer_cb, "standby_phase"); return -1;
if (!g_phase_timer) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: failed to arm phase timer");
} }
if (g_phase_timer) uasync_cancel_timeout(g_ua, g_phase_timer);
g_phase_timer = timer;
g_phase = phase;
g_phase_start_tb = now;
g_phase_end_tb = now + (uint64_t)dur_tb;
g_min_sleep_end_tb = phase == PHASE_SLEEP ? now + (uint64_t)standby_min_sleep_tb() : 0;
return 0;
} }
static void standby_wake_all_waiters(void);
static void standby_phase_timer_cb(void* arg) { static void standby_phase_timer_cb(void* arg) {
(void)arg; (void)arg;
g_phase_timer = NULL; g_phase_timer = NULL;
if (!g_enabled) return; if (!g_enabled) return;
uint64_t elapsed = get_time_tb() - g_phase_start_tb; uint64_t elapsed = get_time_tb() - g_phase_start_tb;
g_phase = (g_phase == PHASE_ACTIVE) ? PHASE_SLEEP : PHASE_ACTIVE;
/* переармить фазу ДО фазового колбэка: колбэк читает g_phase_end_tb /* переармить фазу ДО фазового колбэка: колбэк читает g_phase_end_tb
(ka_active_remaining_01s), иначе он увидит старый (уже истёкший) дедлайн. */ (ka_active_remaining_01s), иначе он увидит старый (уже истёкший) дедлайн. */
standby_arm_phase(); if (standby_arm_phase(g_phase == PHASE_ACTIVE ? PHASE_SLEEP : PHASE_ACTIVE) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: phase timer lost; returning to normal mode");
if (g_inst) utun_set_client_activity(g_inst, 1); else standby_set_enabled(NULL, 0);
return;
}
if (g_phase == PHASE_SLEEP) { if (g_phase == PHASE_SLEEP) {
g_cnt_sleeps++; g_cnt_sleeps++;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, DEBUG_INFO(DEBUG_CATEGORY_GENERAL,
@ -137,6 +148,7 @@ static void standby_phase_timer_cb(void* arg) {
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: WAKE (timer), slept %d.%ds", DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: WAKE (timer), slept %d.%ds",
(int)(elapsed / STANDBY_TB_PER_SEC), (int)((elapsed % STANDBY_TB_PER_SEC) / 1000)); (int)(elapsed / STANDBY_TB_PER_SEC), (int)((elapsed % STANDBY_TB_PER_SEC) / 1000));
if (g_phase_cb) g_phase_cb(0, g_phase_cb_arg); if (g_phase_cb) g_phase_cb(0, g_phase_cb_arg);
standby_wake_all_waiters();
} }
} }
@ -173,18 +185,20 @@ static void wait_list_push(struct standby_wait_entry* e) {
static void standby_wake_all_waiters(void) { static void standby_wake_all_waiters(void) {
struct standby_wait_entry* list = g_wait_head; struct standby_wait_entry* list = g_wait_head;
g_wait_head = g_wait_tail = NULL; g_wait_head = g_wait_tail = NULL;
while (list) { for (struct standby_wait_entry* e = list; e; e = e->next) {
struct standby_wait_entry* e = list; e->dispatching = 1;
list = e->next;
e->prev = e->next = NULL;
if (e->timer_id) { if (e->timer_id) {
uasync_cancel_timeout(g_ua, e->timer_id); uasync_cancel_timeout(g_ua, e->timer_id);
e->timer_id = NULL; e->timer_id = NULL;
} }
}
while (list) {
struct standby_wait_entry* e = list;
list = e->next;
void (*cb)(void*) = e->cb; void (*cb)(void*) = e->cb;
void* arg = e->arg; void* arg = e->arg;
wait_entry_free(e); wait_entry_free(e);
cb(arg); if (cb) cb(arg);
} }
} }
@ -213,10 +227,6 @@ void standby_init(struct UASYNC* ua) {
} }
void standby_deinit(void) { void standby_deinit(void) {
if (!g_enabled && !g_phase_timer && !g_wait_head && !g_wait_pool) {
g_ua = NULL;
return;
}
if (g_phase_timer && g_ua) { if (g_phase_timer && g_ua) {
uasync_cancel_timeout(g_ua, g_phase_timer); uasync_cancel_timeout(g_ua, g_phase_timer);
g_phase_timer = NULL; g_phase_timer = NULL;
@ -232,39 +242,46 @@ void standby_deinit(void) {
g_wait_pool = NULL; g_wait_pool = NULL;
} }
g_enabled = 0; g_enabled = 0;
g_phase = PHASE_ACTIVE;
g_phase_start_tb = g_phase_end_tb = g_min_sleep_end_tb = 0;
g_ua = NULL; g_ua = NULL;
g_inst = NULL; g_inst = NULL;
g_switch_cb = NULL;
g_switch_arg = NULL;
g_phase_cb = NULL;
g_phase_cb_arg = NULL;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: deinit (wakes_timer=%u wakes_early=%u sleeps=%u net=%u ignored=%u)", DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: deinit (wakes_timer=%u wakes_early=%u sleeps=%u net=%u ignored=%u)",
g_cnt_wakes_timer, g_cnt_wakes_early, g_cnt_sleeps, g_cnt_net_events, g_cnt_net_ignored); g_cnt_wakes_timer, g_cnt_wakes_early, g_cnt_sleeps, g_cnt_net_events, g_cnt_net_ignored);
} }
void standby_set_enabled(struct UTUN_INSTANCE* inst, int enabled) { int standby_set_enabled(struct UTUN_INSTANCE* inst, int enabled) {
if (!g_ua) { if (!g_ua || (inst && inst->ua != g_ua)) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: set_enabled(%d) but not initialized", enabled); DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: set_enabled(%d) ua=%p instance_ua=%p", enabled,
return; (void*)g_ua, (void*)(inst ? inst->ua : NULL));
return -1;
} }
enabled = !!enabled;
if (inst) g_inst = inst; if (inst) g_inst = inst;
if (g_enabled == enabled) return; if (g_enabled == enabled) return 0;
if (enabled) { if (enabled) {
if (standby_arm_phase(PHASE_ACTIVE) < 0) return -1;
g_enabled = 1; g_enabled = 1;
g_phase = PHASE_ACTIVE; DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: ENTER (active=%dms sleep=%dms min_sleep=%dms)",
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: ENTER (app background, active=%dms sleep=%dms min_sleep=%dms)",
standby_active_tb() / 10, standby_sleep_tb() / 10, standby_min_sleep_tb() / 10); standby_active_tb() / 10, standby_sleep_tb() / 10, standby_min_sleep_tb() / 10);
standby_arm_phase();
} else { } else {
g_enabled = 0; g_enabled = 0;
if (g_phase_timer) { if (g_phase_timer) {
uasync_cancel_timeout(g_ua, g_phase_timer); uasync_cancel_timeout(g_ua, g_phase_timer);
g_phase_timer = NULL; g_phase_timer = NULL;
} }
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: EXIT (app foreground, wakes_timer=%u wakes_early=%u sleeps=%u net=%u ignored=%u)", DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "standby: EXIT (wakes_timer=%u wakes_early=%u sleeps=%u net=%u ignored=%u)",
g_cnt_wakes_timer, g_cnt_wakes_early, g_cnt_sleeps, g_cnt_net_events, g_cnt_net_ignored); g_cnt_wakes_timer, g_cnt_wakes_early, g_cnt_sleeps, g_cnt_net_events, g_cnt_net_ignored);
standby_wake_all_waiters();
} }
if (inst) inst->standby_enabled = (uint8_t)enabled;
if (g_switch_cb) g_switch_cb(enabled, g_switch_arg); if (g_switch_cb) g_switch_cb(enabled, g_switch_arg);
if (!enabled) standby_wake_all_waiters();
return 0;
} }
int standby_is_enabled(void) { int standby_is_enabled(void) {
@ -334,23 +351,18 @@ void* standby_wait(void* arg, void (*cb)(void* arg)) {
} }
e->cb = cb; e->cb = cb;
e->arg = arg; e->arg = arg;
e->dispatching = 0;
/* время до начала следующего активного интервала: /* SLEEP ждёт фазового события; ACTIVE вызывает cb отложенно, без рекурсии. */
* SLEEP — ждём до конца фазы; ACTIVE — уже бодрствуем, будим сразу. */ e->timer_id = NULL;
uint64_t now = get_time_tb(); if (g_phase == PHASE_ACTIVE) {
uint64_t tb; e->timer_id = uasync_set_timeout(g_ua, 0, e, standby_wait_timer_cb, "standby_wait");
if (g_phase == PHASE_SLEEP) { if (!e->timer_id) {
tb = (now < g_phase_end_tb) ? (g_phase_end_tb - now) : 0; DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: uasync_set_timeout failed");
} else { wait_entry_free(e);
tb = 0; cb(arg);
} return NULL;
}
e->timer_id = uasync_set_timeout(g_ua, (int)tb, e, standby_wait_timer_cb, "standby_wait");
if (!e->timer_id) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "standby: uasync_set_timeout failed");
wait_entry_free(e);
cb(arg);
return NULL;
} }
wait_list_push(e); wait_list_push(e);
return e; return e;
@ -359,6 +371,7 @@ void* standby_wait(void* arg, void (*cb)(void* arg)) {
void standby_wait_cancel(void* handle) { void standby_wait_cancel(void* handle) {
if (!handle) return; if (!handle) return;
struct standby_wait_entry* e = (struct standby_wait_entry*)handle; struct standby_wait_entry* e = (struct standby_wait_entry*)handle;
if (e->dispatching) { e->cb = NULL; return; }
if (e->timer_id && g_ua) { if (e->timer_id && g_ua) {
uasync_cancel_timeout(g_ua, e->timer_id); uasync_cancel_timeout(g_ua, e->timer_id);
e->timer_id = NULL; e->timer_id = NULL;
@ -391,18 +404,14 @@ void standby_notify_network_activity(void) {
return; return;
} }
if (standby_arm_phase(PHASE_ACTIVE) < 0) return;
g_cnt_wakes_early++; g_cnt_wakes_early++;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, DEBUG_INFO(DEBUG_CATEGORY_GENERAL,
"standby: WAKE early (network activity), slept %d.%ds min_sleep=%dms", "standby: WAKE early (network activity), slept %d.%ds min_sleep=%dms",
(int)(slept / STANDBY_TB_PER_SEC), (int)((slept % STANDBY_TB_PER_SEC) / 1000), (int)(slept / STANDBY_TB_PER_SEC), (int)((slept % STANDBY_TB_PER_SEC) / 1000),
standby_min_sleep_tb() / 10); standby_min_sleep_tb() / 10);
if (g_phase_timer) { if (g_phase_cb) g_phase_cb(0, g_phase_cb_arg);
uasync_cancel_timeout(g_ua, g_phase_timer);
g_phase_timer = NULL;
}
g_phase = PHASE_ACTIVE;
standby_arm_phase();
standby_wake_all_waiters(); standby_wake_all_waiters();
} }

11
tools/chatgui-android/libutun_lite/standby.h

@ -22,12 +22,13 @@ struct UTUN_INSTANCE;
/* Привязать модуль к uasync. Вызывается после каждого uasync_create(). */ /* Привязать модуль к uasync. Вызывается после каждого uasync_create(). */
void standby_init(struct UASYNC* ua); void standby_init(struct UASYNC* ua);
/* Отмена таймеров и освобождение пула (без вызова колбэков). */ /* Отмена таймеров, освобождение пула и очистка подписок (без вызова колбэков). */
void standby_deinit(void); void standby_deinit(void);
/* Включить/выключить standby-режим. Логирует вход/выход (GENERAL info) /* Применяет режим после подготовки фазового таймера, затем вызывает switch-callback.
* и вызывает switch-callback. Флаг дублируется в inst->standby_enabled. */ * При выключении будит ожидающих после switch-callback. Повторная команда ничего не перезапускает.
void standby_set_enabled(struct UTUN_INSTANCE* inst, int enabled); * Вызывается из utun_set_client_activity в uasync-потоке; 0 — успех, <0 — режим не изменён. */
int standby_set_enabled(struct UTUN_INSTANCE* inst, int enabled);
int standby_is_enabled(void); int standby_is_enabled(void);
/* Колбэк, вызываемый при каждом переключении standby-режима. */ /* Колбэк, вызываемый при каждом переключении standby-режима. */
@ -53,7 +54,7 @@ int standby_get_keepalive_ms(void);
void standby_set_phase_callback(void (*cb)(int sleeping, void* arg), void* arg); void standby_set_phase_callback(void (*cb)(int sleeping, void* arg), void* arg);
/* Ждёт до начала следующего активного интервала (как uasync timeout). /* Ждёт до начала следующего активного интервала (как uasync timeout).
* При выходе из standby таймер отменяется и cb вызывается немедленно. * В SLEEP ждёт фазового события после анонса ACTIVE; при выходе cb вызывается после восстановления линков.
* Возвращает handle для standby_wait_cancel(). */ * Возвращает handle для standby_wait_cancel(). */
void* standby_wait(void* arg, void (*cb)(void* arg)); void* standby_wait(void* arg, void (*cb)(void* arg));
void standby_wait_cancel(void* handle); void standby_wait_cancel(void* handle);

26
tools/chatgui-android/libutun_lite/tests/test_standby.c

@ -45,6 +45,12 @@ static void switch_cb(int enabled, void* arg) {
/* re-entry waiter: в колбэке снова регистрирует себя (ровно один раз) */ /* re-entry waiter: в колбэке снова регистрирует себя (ровно один раз) */
static int g_reentry_calls = 0; static int g_reentry_calls = 0;
static void* g_cancel_other = NULL;
static void cancel_other_cb(void* arg) {
(void)arg;
standby_wait_cancel(g_cancel_other);
g_cancel_other = NULL;
}
static void reentry_cb(void* arg) { static void reentry_cb(void* arg) {
(void)arg; (void)arg;
g_reentry_calls++; g_reentry_calls++;
@ -205,8 +211,28 @@ int main(void) {
standby_set_enabled(NULL, 0); /* EXIT → будит перерегистрированного ещё раз */ standby_set_enabled(NULL, 0); /* EXIT → будит перерегистрированного ещё раз */
CHECK(g_reentry_calls == 2, "re-entry: exit fired re-registered waiter once more"); CHECK(g_reentry_calls == 2, "re-entry: exit fired re-registered waiter once more");
/* ── 17. колбэк отменяет другого waiter из уже отцепленного списка ── */
reset_standby();
standby_set_enabled(NULL, 1);
run_to_next_phase();
CHECK(standby_wait(NULL, cancel_other_cb) != NULL, "cross-cancel: first waiter registered");
g_cancel_other = standby_wait(NULL, wait_cb);
CHECK(g_cancel_other != NULL, "cross-cancel: second waiter registered");
standby_set_enabled(NULL, 0);
CHECK(g_wait_calls == 0 && g_cancel_other == NULL, "cross-cancel: cancelled callback not dispatched");
/* ── 18. даже неиспользованный standby очищает подписки при deinit ── */
reset_standby();
standby_deinit();
standby_init(g_ua);
standby_set_enabled(NULL, 1);
CHECK(g_switch_calls == 0, "deinit: old switch callback cleared");
standby_set_enabled(NULL, 0);
/* ── итог ── */ /* ── итог ── */
standby_deinit(); standby_deinit();
uasync_poll(g_ua, 0);
CHECK(g_ua->timer_alloc_count == g_ua->timer_free_count, "cleanup: all timers freed before loop destruction");
uasync_destroy(g_ua, 0); uasync_destroy(g_ua, 0);
printf("=== standby unit test: %d checks, %d failures ===\n", g_checks, g_failures); printf("=== standby unit test: %d checks, %d failures ===\n", g_checks, g_failures);

52
tools/chatgui/tests/test_call_jitter.cpp

@ -8,8 +8,6 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <vector> #include <vector>
#include <chrono>
#include <thread>
static int failures; static int failures;
#define CHECK(c) do { if (!(c)) { std::printf("FAIL line %d: %s\n", __LINE__, #c); failures++; } } while (0) #define CHECK(c) do { if (!(c)) { std::printf("FAIL line %d: %s\n", __LINE__, #c); failures++; } } while (0)
@ -126,26 +124,48 @@ int main() {
uint8_t encoded[512]; uint8_t encoded[512];
int len = opus_codec_encode(encoder, voice, 960, encoded, sizeof(encoded)); int len = opus_codec_encode(encoder, voice, 960, encoded, sizeof(encoded));
CHECK(len > 0); CHECK(len > 0);
for (int i = 0; i < 3; i++) call_audio_on_media(nullptr, id, i, 0, encoded, len); // Без входных пакетов первые 500мс выходного аудио — строго тишина.
for (int i = 0; i < 30; i++) call_audio_pull_pcm(id, pcm, 480); for (int tick = 0; tick < 50; tick++) {
// Аудиоустройства и uasync в тесте нет: проверяем реальный срок включения сигнала. CHECK(call_audio_pull_pcm(id, pcm, 480) == 480);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); for (auto value : pcm) CHECK(value == 0);
call_audio_pull_pcm(id, pcm, 480); }
bool silent = true; CHECK(call_audio_pull_pcm(id, pcm, 480) == 480);
for (auto value : pcm) if (value) silent = false; bool audible = false;
CHECK(silent); for (auto value : pcm) if (value) audible = true;
std::this_thread::sleep_for(std::chrono::milliseconds(500)); CHECK(audible);
call_audio_pull_pcm(id, pcm, 480); // Один пакет не возобновляет PCM: писк продолжается при ожидании запаса.
silent = true; call_audio_on_media(nullptr, id, 0, 0, encoded, len);
for (auto value : pcm) if (value) silent = false; CHECK(call_audio_pull_pcm(id, pcm, 480) == 480);
CHECK(!silent); audible = false;
for (int i = 0; i < 6; i++) call_audio_on_media(nullptr, id, i+3, 0, encoded, len); for (auto value : pcm) if (value) audible = true;
CHECK(audible);
for (int i = 1; i < 6; i++) call_audio_on_media(nullptr, id, i, 0, encoded, len);
CHECK(call_audio_pull_pcm(id, pcm, 480) == 480); CHECK(call_audio_pull_pcm(id, pcm, 480) == 480);
call_audio_begin_end(id); // явное завершение прекращает догон и включает финальный тон call_audio_begin_end(id); // явное завершение прекращает догон и включает финальный тон
CHECK(call_audio_pull_pcm(id, pcm, 480) > 0); CHECK(call_audio_pull_pcm(id, pcm, 480) > 0);
call_audio_release(id); call_audio_release(id);
opus_codec_encoder_destroy(encoder); opus_codec_encoder_destroy(encoder);
} }
{
// Порог проходит внутри блока: сначала ровно 500мс тишины, затем тон.
CHECK(call_audio_start(124) == 0);
std::vector<int16_t> output(777);
for (int tick = 0; tick < 30; tick++) {
CHECK(call_audio_pull_pcm(124, output.data(), 777) == 777);
for (auto value : output) CHECK(value == 0);
}
CHECK(call_audio_pull_pcm(124, output.data(), 777) == 777);
for (int i = 0; i < 690; i++) CHECK(output[i] == 0);
bool audible = false;
for (int i = 690; i < 777; i++) if (output[i]) audible = true;
CHECK(audible);
call_audio_release(124);
// Новый звонок не наследует включённый тон и отсчёт предыдущего.
CHECK(call_audio_start(125) == 0);
CHECK(call_audio_pull_pcm(125, output.data(), 777) == 777);
for (auto value : output) CHECK(value == 0);
call_audio_release(125);
}
std::printf("call_jitter: %s\n", failures ? "FAILED" : "PASSED"); std::printf("call_jitter: %s\n", failures ? "FAILED" : "PASSED");
return failures ? 1 : 0; return failures ? 1 : 0;
} }

Loading…
Cancel
Save