Preserve documentation audit and Android bridge fixes

This commit is contained in:
evgeny committed 2026-10-27 13:17:22 +01:00
1 parent ae4fb89bef
commit 7eaeb0e2f9
38 files changed
+988 -966

No files matched your search

+15 -3
View File
@@ -1,7 +1,8 @@
# Ссылки в сообщениях
Android и Qt распознают `http://`, `https://` и `utun://` в обычном тексте
личных и групповых сообщений. HTTP/HTTPS нечувствительны к регистру схемы.
личных и групповых сообщений. HTTP/HTTPS нечувствительны к регистру схемы;
`utun://` распознаётся только в нижнем регистре. Утилиты разбора не открывают ссылки.
Ссылки выделяются цветом и подчёркиванием; длинные веб-адреса переносятся по ширине.
Сокращённая строка структурированного PM-приглашения сохраняет полный `utun://` адрес.
@@ -11,7 +12,15 @@ Android и Qt распознают `http://`, `https://` и `utun://` в обы
Адреса без схемы не распознаются. Некорректные веб-адреса остаются обычным текстом.
Исходное сообщение в БД и протоколе не меняется.
Разборщики возвращают диапазоны UTF-16 и полные адреса:
У `utun://` распознаётся кандидат из обычного base64-алфавита с до двух конечных `=`.
На этом этапе не проверяются версия, поля и содержимое приглашения: `utun://abc+/==`
может получить подсветку, но быть отклонённым при открытии. Предпросмотр Qt и Android
использует общий `src/chat/invite_link.c` через Qt-обёртку/JNI; принимается версия 0x03.
Сам декодер не проверяет регистрацию/срок join_key и не даёт членства.
Разборщики возвращают непересекающиеся диапазоны исходного текста в порядке появления
и полные адреса в исходном написании: Qt — `start/length`, Android — `start/end`,
где end исключён. Единицы обоих API — UTF-16:
`tools/chatgui/src/message_links.cpp`, Android `data/MessageLinks.kt`.
Подсветка и нажатие используют эти диапазоны. Android подставляет emoji placeholders
только в обычный текст между ссылками: emoji внутри адреса остаётся частью полного URL.
@@ -19,7 +28,10 @@ Qt использует один QTextLayout для размеров, отрис
Перетаскивание выделяет текст без открытия ссылки.
HTTP/HTTPS открываются из GUI системным обработчиком: Android `ACTION_VIEW`,
Qt `QDesktopServices::openUrl`. `utun://` запускает существующий JOIN.
Qt `QDesktopServices::openUrl`. На нажатии веб-адрес снова проверяется.
В Qt `utun://` сначала декодируется: уже существующая группа, где узел является
участником, открывается без JOIN; иначе показывается JoinDialog с автоматической попыткой входа.
На Android адрес передаётся в существующий invite/JOIN flow.
Ошибки открытия показываются через перевод интерфейса и логируются.
Диагностика открытия содержит схему и host, без полного пути/query.
+8 -1
View File
@@ -426,16 +426,23 @@ test_group_connect_standby_LDFLAGS = $(test_clock_ldflags)
test_android_udp_log_SOURCES = test_android_udp_log.c ../tools/chatgui-android/jni_bridge/android_udp_log.c
test_android_udp_log_LDADD = $(COMMON_LIBS)
test_android_udp_log_LDFLAGS = -Wl,--wrap=uasync_set_timeout -Wl,--wrap=uasync_cancel_timeout
test_android_replies_SOURCES = test_android_replies.c \
android_bridge_test_sources = \
../tools/chatgui-android/jni_bridge/android_jni_bridge.c ../tools/chatgui-android/jni_bridge/android_udp_log.c \
../tools/chatgui-android/libutun_lite/instance_lite.c ../tools/chatgui-android/libutun_lite/utun_config_api.c \
../tools/chatgui-android/libutun_lite/attachment_sender.c ../tools/chatgui-android/libutun_lite/photo_sender.c \
../tools/chatgui-android/libutun_lite/video_sender.c ../tools/chatgui-android/libutun_lite/voice_recorder.c \
../tools/chatgui-android/libutun_lite/standby.c ../tools/chatgui-android/libutun_lite/stream_bridge.c
test_android_replies_SOURCES = test_android_replies.c $(android_bridge_test_sources)
test_android_replies_CPPFLAGS = $(AM_CPPFLAGS) -I$(top_srcdir)/src/chat -I$(top_srcdir)/src/media_async
test_android_replies_CFLAGS = $(AM_CFLAGS) -ffunction-sections -fdata-sections
test_android_replies_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_android_replies_LDFLAGS = -Wl,--gc-sections -Wl,--wrap=instance_lite_get_instance -Wl,--wrap=instance_lite_get_uasync
check_PROGRAMS += test_android_bridge
test_android_bridge_SOURCES = test_android_bridge.c $(android_bridge_test_sources)
test_android_bridge_CPPFLAGS = $(test_android_replies_CPPFLAGS)
test_android_bridge_CFLAGS = $(test_android_replies_CFLAGS)
test_android_bridge_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_android_bridge_LDFLAGS = -Wl,--gc-sections
test_pool_corruption_log_SOURCES = test_pool_corruption_log.c
test_pool_corruption_log_LDADD = $(COMMON_LIBS)
+25 -5
View File
@@ -1,4 +1,9 @@
/* Изоляция регрессий: падение/зависание одного случая не скрывает остальные. */
/* Linux/ELF-стенд регрессий: regression_run запускает каждый случай в fork с лимитом 2000мс реального времени.
* Падение/зависание не скрывает остальные случаи; успешный случай дополнительно проверяется на u_* и fd-утечки.
* Снимки fd читаются из /proc/self/fd; если proc недоступен, этот контроль не работает.
* fault/clock/epoll работают через --wrap из async_regression_ldflags в tests/Makefile.am.
* Состояние подмен глобально, без синхронизации; не менять его параллельно с использующими его вызовами.
* Это отдельный стенд, его clock wrapper нельзя совмещать с wrappers test_clock.c в одном бинарнике. */
#ifndef ASYNC_REGRESSION_H
#define ASYNC_REGRESSION_H
@@ -8,6 +13,8 @@
#include <stdio.h>
#include <sys/epoll.h>
/* Проверки вычисляют аргументы по одному разу; при отказе печатают место/условие и возвращают 1 из run.
* CHECK_EQ сравнивает после преобразования к long long; макросы не выполняют cleanup случая. */
#define CHECK(cond) do { if (!(cond)) { \
fprintf(stderr, "[FAIL] %s:%d: %s\n", __FILE__, __LINE__, #cond); return 1; \
} } while (0)
@@ -18,23 +25,36 @@
} while (0)
struct regression_case {
const char* name;
int (*run)(int param);
int param;
const char* name; // Заимствованное имя для вывода и фильтра по подстроке, живёт до возврата regression_run.
int (*run)(int param); // Выполняется в дочернем процессе: 0 — успех, 77 — skip, прочие коды — ошибка.
int param; // Параметр конкретного случая, передаётся run без преобразования.
};
/* argc/argv — main: необязательный argv[1] фильтрует имена по подстроке; таблица cases заимствуется на вызов.
* Возврат 0 — все выбранные прошли/skip, 1 — ошибка/timeout/signal, нет совпадений или неверные аргументы.
* Вывод ребёнка наследует stdout/stderr; ресурсный контроль выполняется только для run=0. */
int regression_run(int argc, char** argv, const struct regression_case* cases, size_t count);
// CLOCK_MONOTONIC в мс, независимо от regression_clock_shift; ошибка часов завершает процесс через abort.
uint64_t regression_time_ms(void);
// WARN для всех категорий; наличие UTUN_TEST_DEBUG добавляет DEBUG для sys/socket/timers. Вызывает и сам runner в ребёнке.
void regression_log_init(void);
enum regression_alloc { FAULT_MALLOC = 1, FAULT_CALLOC = 2, FAULT_REALLOC = 4, FAULT_ALLOC = 7 };
// nth=0 — устойчивый отказ, nth>0 — отказ только соответствующего выделения.
/* Отказ u_malloc/u_calloc/u_realloc из mask: scope — подстрока allocation location либо NULL для всех мест.
* nth=0 — все совпавшие выделения до reset, nth>0 — только N-е совпавшее. scope заимствуется на время подмены.
* arm сбрасывает признак hit; отказ выдаёт NULL и [INJECT] с местом. Обычный malloc не перехватывается. */
void regression_fault_arm(int mask, const char* scope, int nth);
// Был хотя бы один отказ после последнего arm (0/1), не число отказов.
int regression_fault_hit(void);
// Только отключить allocation faults; hit, часы и прочие подмены не сбрасываются.
void regression_fault_reset(void);
// Заменить поправку gettimeofday в мс (0 отключает), не менять CLOCK_REALTIME/MONOTONIC или часы ОС.
void regression_clock_shift(int64_t milliseconds);
// Скопировать 0..64 события и один раз вернуть их следующим epoll_wait; нужен maxevents>=count. Новый вызов заменяет пакет.
void regression_epoll_events(const struct epoll_event* events, int count);
// Один отказ следующего epoll_ctl с данным EPOLL_CTL_* (ENOMEM); operation=0 снимает эту подмену.
void regression_epoll_ctl_fail(int operation);
// Один отказ следующего eventfd/epoll_create1 с errno=EMFILE.
void regression_eventfd_fail(void);
void regression_epoll_create_fail(void);
+10 -4
View File
@@ -1,6 +1,8 @@
/* Модель истории для тестов с заранее заданными участниками, без запуска chat_sync.
* Общий Merkle настоящий; пустая MEMBERS-модель не реплицирует тестовые удостоверения.
* Если движок создан фикстурой (!chat_sync), перед destroy ядра вызвать merkle_sync_destroy. */
/* Подключение настоящей MESSAGES-модели к общему Merkle для тестов с заранее заданными участниками.
* При отсутствии inst->msync создаёт его с пустой MEMBERS-моделью: она не выдаёт/не принимает удостоверения.
* Существующий Merkle и его MEMBERS-модель не заменяются. READY, допуск пира и подписи истории остаются настоящими.
* Вызовы в uasync-потоке; фикстура не запускает core/chat и не владеет instance.
* Если движок создан вне chat_sync, caller вызывает merkle_sync_destroy до destroy ядра, в том числе после ошибки setup. */
#ifndef TEST_DB_SYNC_FIXTURE_H
#define TEST_DB_SYNC_FIXTURE_H
#include "../src/utun_instance.h"
@@ -9,18 +11,22 @@
#include "../src/chat/merkle_sync.h"
#include <string.h>
// Пустая MEMBERS-модель не дописывает данные в digest бакета.
static int fixture_member_hash(void* ctx, const char* ns, uint8_t level, uint64_t prefix, EVP_MD_CTX* hash) {
(void)ctx; (void)ns; (void)level; (void)prefix; (void)hash; return 0;
}
// Вернуть пустую страницу: len=0, more=0, next обнулён; output-параметры обязательны.
static int fixture_member_page(void* ctx, const char* ns, uint64_t prefix, int av, const merkle_key_t* after,
uint8_t* data, size_t* len, merkle_key_t* next, int* more) {
(void)ctx; (void)ns; (void)prefix; (void)av; (void)after; (void)data;
*len = 0; *more = 0; memset(next, 0, sizeof(*next)); return 0;
}
// Принять только пустой MEMBERS payload (0), непустой отвергнуть (-1), состояние не меняется.
static int fixture_member_apply(void* ctx, const char* ns, uint64_t peer, const uint8_t* data, size_t len) {
(void)ctx; (void)ns; (void)peer; (void)data; return len ? -1 : 0;
}
/* После init ядра и регистрации таблиц; 0/-1. READY/допуск пира/подписи истории не подменяются. */
/* После init ядра/db_sync и регистрации таблиц; inst обязателен, db_sync должен быть enabled.
* 0 — MESSAGES зарегистрирована/уже была, -1 — отказ. Созданный Merkle при отказе MESSAGES не откатывается. */
static int setup_history_model(struct UTUN_INSTANCE* inst) {
static const struct merkle_sync_data_ops ops = {
.update_bucket_hash = fixture_member_hash, .get_page = fixture_member_page, .apply_items = fixture_member_apply
+38 -8
View File
@@ -1,4 +1,9 @@
/* Общий стенд media: настоящие SQLite/router callbacks и Ed25519, управляемая доставка пакетов. */
/* Общий однопоточный стенд media QUERY/BLOCK/репликации: реальные SQLite/router handlers и Ed25519, управляемая доставка.
* Включать в одну единицу трансляции бинарника с --wrap=etcp_route_send/RAND_bytes (цели tests/Makefile.am).
* Caller создаёт ua и временный directory, затем instance/result; уничтожает nodes/metadata до uasync и каталога.
* instance здесь — минимальный UTUN_INSTANCE, без core_start/сети; освобождается только парным destroy этого стенда.
* Пакеты копируются в локальную FIFO и доставляются receive явно: автоматически по p.to ничего не пересылается.
* Любая проверка REQUIRE завершает процесс с кодом 1. Статические globals не поддерживают параллельные/вложенные receive. */
#ifndef TEST_MEDIA_DELIVERY_FIXTURE_H
#define TEST_MEDIA_DELIVERY_FIXTURE_H
/* Регрессии QUERY: реальные обработчики сервиса и SQLite, управляемая доставка/ошибки транспорта.
@@ -29,22 +34,29 @@
#define MAX_WIRE 2048
#define REQUIRE(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "QUERY test line=%d: %s", __LINE__, #x); exit(1); } } while (0)
/* Полная копия после удаления service ID: from/to/group — ID узлов/группы; data[0] — MEDIA_SUBCMD_*.
* len — байты payload без router envelope, не больше MAX_WIRE. */
struct packet { uint64_t from, to, group; size_t len; uint8_t data[MAX_WIRE]; };
static struct packet packets[128];
static struct packet packets[128]; // FIFO захваченных отправок, индекс 0 выдаёт take_packet.
/* Счётчики отправок/завершений и faults; fail_send — subcmd отказа, fail_rng — устойчивый отказ RAND_bytes,
* capture_blocks включает захват прочих subcmd помимо QUERY/QUERY_RESP. */
static int packet_count, query_count, request_count, done_count, done_err, fail_send, fail_rng, capture_blocks;
static struct UASYNC* ua;
static char directory[256];
static void* injected;
static uint8_t* poison_tail;
static uint8_t author_key[32] = {1};
static void (*sent_hook)(struct UTUN_INSTANCE*, uint64_t, uint64_t, int);
static struct UASYNC* ua; // Общий заимствованный loop, который заранее создаёт main теста.
static char directory[256]; // NUL-путь временного каталога main; start пишет directory/result.bin.
static void* injected; // Единственный текущий payload receive, снимается его dgram_free_fn.
static uint8_t* poison_tail; // Начало 64 защитных байтов после логического payload для ASAN.
static uint8_t author_key[32] = {1}; // Фиксированный Ed25519 private key тестового автора SERVER.
static void (*sent_hook)(struct UTUN_INSTANCE*, uint64_t, uint64_t, int); // Синхронный callback успешной отправки (inst/group/dst/subcmd).
int __real_RAND_bytes(unsigned char* out, int count);
// При fail_rng вернуть 0 без заполнения out; иначе вызвать настоящий RAND_bytes.
int __wrap_RAND_bytes(unsigned char* out, int count) {
if (fail_rng) return 0;
return __real_RAND_bytes(out, count);
}
/* Вернуть 0/-1 по fail_send, посчитать QUERY/BLOCK_REQ и при необходимости скопировать payload в FIFO.
* В обоих исходах потребляет entry/dgram, как настоящий router; sent_hook вызывается только при успехе. */
int __wrap_etcp_route_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t dst,
struct ll_entry* entry, int force, int mode) {
(void)force; (void)mode;
@@ -70,6 +82,8 @@ static void release_injected(uint8_t* data) {
injected = NULL; poison_tail = NULL; u_free(data);
}
/* Скопировать p в router envelope с src/group и вызвать настоящий media callback выбранного inst.
* p заимствован на вызов; callback обязан потребить payload синхронно, что проверяется через injected. */
static void receive(struct UTUN_INSTANCE* inst, const struct packet* p) {
struct ll_entry* entry = queue_entry_new(0); REQUIRE(entry);
size_t bytes = ROUTER_SVC_PAYLOAD_OFF + p->len;
@@ -88,6 +102,7 @@ static void receive(struct UTUN_INSTANCE* inst, const struct packet* p) {
REQUIRE(!injected);
}
// Извлечь первую копию FIFO по значению; пустая очередь завершает тест. Данные результата не требуют free.
static struct packet take_packet(void) {
REQUIRE(packet_count > 0);
struct packet result = packets[0];
@@ -95,6 +110,8 @@ static struct packet take_packet(void) {
return result;
}
/* Создать минимальный собственный узел node на общем ua: :memory: SQLite, router и media delivery.
* БД содержит публичный ключ автора SERVER; полноценные core/chat/topology сервисы здесь не запускаются. */
static struct UTUN_INSTANCE* instance(uint64_t node) {
struct UTUN_INSTANCE* inst = u_calloc(1, sizeof(*inst)); REQUIRE(inst);
inst->ua = ua; inst->node_id = node;
@@ -113,13 +130,18 @@ static struct UTUN_INSTANCE* instance(uint64_t node) {
return inst;
}
/* Уничтожить media/router/SQLite/chat_core этого стенда и выполнить poll(0); inst обязателен, повтор не допускается.
* Дополнительными ресурсами, которые тест сам добавил в inst (например topo_groups), владеет caller. */
static void destroy(struct UTUN_INSTANCE* inst) {
media_delivery_destroy(inst); etcp_router_destroy(inst); sqlite3_close(inst->topo_sqlite_db);
u_free(inst->chat_core); u_free(inst); uasync_poll(ua, 0);
}
// Callback загрузки: увеличить done_count и сохранить последнюю ошибку; arg не используется.
static void completed(void* arg, int err) { (void)arg; done_count++; done_err = err; }
/* Собственные метаданные count>0 однобайтовых блоков: payload i-го блока=(uint8_t)i, подписи/общий SHA256 настоящие.
* media_id фиксирован AA; block_ids/block_sigs освобождаются caller через media_index_result_free. */
static struct media_index_result result(int count) {
struct media_index_result r = {.file_size = count, .block_size = 1, .num_blocks = count};
memset(r.media_id, 0xAA, 16);
@@ -134,15 +156,19 @@ static struct media_index_result result(int count) {
return r;
}
// Заимствованная первая загрузка inst либо NULL; не удерживает её после callback/отмены/destroy.
static struct media_download* download(struct UTUN_INSTANCE* inst) {
return inst->md.downloads && inst->md.downloads->head ? (struct media_download*)inst->md.downloads->head->data : NULL;
}
// Запустить загрузку r в directory/result.bin для GROUP/SERVER с callback completed; directory уже должен существовать.
static void start(struct UTUN_INSTANCE* inst, struct media_index_result* r) {
char path[512]; snprintf(path, sizeof(path), "%s/result.bin", directory);
REQUIRE(!media_download_start(inst, GROUP, r, path, directory, SERVER, completed, NULL, NULL, NULL));
}
/* Добавить availability первых count блоков r для GROUP: node_id=1000+h при distinct=0,
* иначе 1000+i%distinct (в этом режиме задавать holders=1). Данные блоков на диск не записываются. */
static void seed(struct UTUN_INSTANCE* inst, const struct media_index_result* r, int count, int holders, int distinct) {
sqlite3_stmt* st = NULL;
REQUIRE(sqlite3_prepare_v2(inst->topo_sqlite_db,
@@ -156,11 +182,13 @@ static void seed(struct UTUN_INSTANCE* inst, const struct media_index_result* r,
REQUIRE(sqlite3_finalize(st) == SQLITE_OK);
}
// Очистить FIFO/счётчики/faults и sent_hook; не освобождает узлы/загрузки/ua и не удаляет файлы.
static void reset(void) {
packet_count = query_count = request_count = done_count = done_err = fail_send = fail_rng = capture_blocks = 0;
sent_hook = NULL;
}
// Доставить первый QUERY серверу и извлечь первый QUERY_RESP; ожидается именно такая последовательность FIFO.
static struct packet answer(struct UTUN_INSTANCE* server) {
struct packet query = take_packet(); REQUIRE(query.data[0] == MEDIA_SUBCMD_QUERY);
receive(server, &query);
@@ -168,6 +196,8 @@ static struct packet answer(struct UTUN_INSTANCE* server) {
return response;
}
/* Для однобайтового блока bi загрузки dl доставить CHUNK и DONE от выбранного holder.
* Нужны уже выбранный holder и активная загрузка CLIENT; последний DONE может завершить/освободить загрузку. */
static void deliver_block(struct UTUN_INSTANCE* c, struct media_download* dl, int bi) {
struct media_download_block* b = &dl->blocks[bi];
struct packet p = {.from = b->holders[b->holder_idx], .to = CLIENT, .group = GROUP, .len = MEDIA_BLOCK_CHUNK_HDR_SIZE + 1};
+19 -6
View File
@@ -1,3 +1,8 @@
/* NAT-стенд с настоящими ядрами/UDP/NCD и одной общей uasync: init → node/connect → cleanup.
* До 8 узлов и 24 NCD handles; фикстура владеет ими, caller получает заимствованные указатели.
* Запускает core и создаёт UTUN topo-группу, но не UTUN/chat сервисы. Управление только в общем uasync-потоке.
* Проверки и обязательные вызовы находятся в assert: собирать без NDEBUG. Отказы завершают тест.
* Cleanup вызывается один раз после завершения внешних операций; раннее уничтожение узлов/handles вручную запрещено. */
#ifndef NAT_FIXTURE_H
#define NAT_FIXTURE_H
@@ -17,18 +22,22 @@
#include "../lib/mem.h"
struct nat_fixture {
struct UASYNC *ua;
struct UTUN_INSTANCE *nodes[8];
struct NODE_CONN_DIRECT *handles[24];
unsigned node_count, handle_count;
size_t allocations;
struct UASYNC *ua; // Собственный event loop для всех узлов.
struct UTUN_INSTANCE *nodes[8]; // Собственные ядра в порядке создания.
struct NODE_CONN_DIRECT *handles[24]; // Собственные NCD handles явных соединений.
unsigned node_count, handle_count; // Число занятых элементов соответствующих массивов.
size_t allocations; // Снимок u_* allocations до init для проверки полного cleanup.
};
// Обнулить свежую фикстуру, сохранить allocation baseline и создать uasync; повторно поверх живого стенда не вызывать.
static inline void nat_fixture_init(struct nat_fixture *fixture) {
memset(fixture, 0, sizeof(*fixture)); fixture->allocations = u_get_allocated_count();
fixture->ua = uasync_create(); assert(fixture->ua);
}
/* Создать/запустить узел: ip — локальный bind IP, порт UDP выбирает ОС; keys=NULL генерирует новую X25519-пару.
* extra — добавляемый INI-текст либо NULL, может задавать дополнительные серверы/настройки. auto_sockets=no, NTP выключен.
* Для NAT разрешаются локальные адреса, создаётся UTUN-группа; возвращённый instance принадлежит fixture. */
static inline struct UTUN_INSTANCE *nat_fixture_node(struct nat_fixture *fixture, const char *ip,
const struct SC_MYKEYS *keys, const char *extra) {
struct SC_MYKEYS generated;
@@ -48,7 +57,9 @@ static inline struct UTUN_INSTANCE *nat_fixture_node(struct nat_fixture *fixture
fixture->nodes[fixture->node_count++] = node; return node;
}
/* Без автосвязности: заданный UDP-транспорт присоединяется к группе; остальные узлы доступны через BGP. */
/* Явно соединить from→to через локальный listener from с именем source (UDP либо TCP).
* Удерживает собственный NCD handle, ждёт links_up до 5с, затем READY UTUN-группы с обеих сторон ещё до 5с.
* Возвращает заимствованный ETCP_CONN; ожидания обслуживают общий uasync и не подменяют часы. */
static inline struct ETCP_CONN *nat_fixture_connect(struct nat_fixture *fixture, struct UTUN_INSTANCE *from,
const char *source, struct UTUN_INSTANCE *to) {
assert(fixture->handle_count < 24);
@@ -76,6 +87,8 @@ static inline struct ETCP_CONN *nat_fixture_connect(struct nat_fixture *fixture,
(unsigned long long)from->node_id, (unsigned long long)to->node_id); abort();
}
/* Force-close handles → destroy всех узлов → poll(0) → проверка timer/socket balance → destroy uasync.
* Сравнить u_* allocations с baseline, при расхождении вывести утечки и завершить assert. Не обнуляет fixture. */
static inline void nat_fixture_cleanup(struct nat_fixture *fixture) {
for (unsigned i = 0; i < fixture->handle_count; i++) node_conn_direct_force_close(fixture->handles[i]);
for (unsigned i = 0; i < fixture->node_count; i++) utun_instance_destroy(fixture->nodes[i]);
+142
View File
@@ -0,0 +1,142 @@
/* Регрессии native bridge: владение config callbacks и границы батчей настоящего Opus-декодера. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "../lib/debug_config.h"
#include "../lib/mem.h"
#include "../src/media_async/voice_file.h"
#include "../tools/chatgui-android/jni_bridge/android_jni_bridge.h"
#include "../tools/chatgui-android/libutun_lite/utun_config_api.h"
#include "test_utils.h"
#define REQUIRE(value) do { if (!(value)) { \
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "Android bridge check failed line=%d: %s", __LINE__, #value); return 1; \
} } while (0)
static const char* config_string(const char* key, const char* def) {
return !strcmp(key, CFG_KEY_NODE_NAME) ? "Android config" : def;
}
static int64_t config_int64(const char* key, int64_t def) {
return !strcmp(key, "wide") ? INT64_C(0x123456789abcdef) : def;
}
static int config_int(const char* key, int def) { return !strcmp(key, CFG_KEY_CONTROL_PORT) ? 4321 : def; }
static int config_bool(const char* key, int def) { return !strcmp(key, "enabled") ? 3 : def; }
/* Таблица caller меняется после регистрации: сохранённые функции не должны зависеть от её памяти. */
static int check_provider_copy(void) {
utun_config_provider_t provider = {config_string, config_int64, config_int, config_bool};
utun_config_set_provider(&provider);
memset(&provider, 0, sizeof(provider));
REQUIRE(!strcmp(cfg_get_string(CFG_KEY_NODE_NAME, "fallback"), "Android config"));
REQUIRE(cfg_get_int64("wide", -1) == INT64_C(0x123456789abcdef));
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, -1) == 4321);
REQUIRE(cfg_get_bool("enabled", -1) == 3);
REQUIRE(!strcmp(cfg_get_string("missing", "fallback"), "fallback"));
REQUIRE(cfg_get_int64("missing", 123) == 123 && cfg_get_int("missing", 456) == 456);
REQUIRE(cfg_get_bool("missing", 789) == 789);
provider.get_int = config_int;
utun_config_set_provider(&provider);
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, -1) == 4321);
REQUIRE(!strcmp(cfg_get_string(CFG_KEY_NODE_NAME, "fallback"), "fallback"));
REQUIRE(cfg_get_int64("wide", 123) == 123 && cfg_get_bool("enabled", 456) == 456);
utun_config_set_provider(NULL);
REQUIRE(cfg_get_string(CFG_KEY_NODE_NAME, NULL) == NULL);
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, 9999) == 9999);
printf("[PASS] config provider: owned table, optional callbacks and reset\n");
return 0;
}
static const char* bridge_string(const char* key) { return config_string(key, NULL); }
static int64_t bridge_int64(const char* key) { return config_int64(key, 0); }
static int bridge_int(const char* key) { return config_int(key, 0); }
/* Рабочая регистрация bridge переживает возврат init, повторная регистрация и destroy снимают прежние callbacks. */
static int check_bridge_config(void) {
struct utun_bridge_config config = {bridge_string, bridge_int64, bridge_int};
REQUIRE(utun_bridge_init(NULL, 0, NULL, NULL, &config));
memset(&config, 0, sizeof(config));
REQUIRE(!strcmp(cfg_get_string(CFG_KEY_NODE_NAME, NULL), "Android config"));
REQUIRE(!strcmp(cfg_get_string("missing", "fallback"), "fallback"));
REQUIRE(cfg_get_int64("wide", -1) == INT64_C(0x123456789abcdef));
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, -1) == 4321 && cfg_get_bool("enabled", 99) == 99);
REQUIRE(utun_bridge_init(NULL, 0, NULL, NULL, NULL));
REQUIRE(!strcmp(cfg_get_string(CFG_KEY_NODE_NAME, "fallback"), "fallback"));
REQUIRE(cfg_get_int64("wide", 123) == 123 && cfg_get_int(CFG_KEY_CONTROL_PORT, 456) == 456);
config.get_int = bridge_int;
REQUIRE(utun_bridge_init(NULL, 0, NULL, NULL, &config));
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, -1) == 4321);
REQUIRE(cfg_get_string(CFG_KEY_NODE_NAME, NULL) == NULL && cfg_get_int64("wide", 789) == 789);
utun_bridge_destroy();
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, 9999) == 9999);
REQUIRE(utun_bridge_init(NULL, 0, NULL, NULL, NULL));
REQUIRE(cfg_get_int(CFG_KEY_CONTROL_PORT, 9998) == 9998);
utun_bridge_destroy();
printf("[PASS] bridge config: init, replacement, destroy and reinit\n");
return 0;
}
/* Один и тот же файл декодируется покадрово и батчами: PCM и количество отсчётов должны совпасть. */
static int check_voice_batches(const char* path, int rate, int channels) {
int frame = rate / 50 * channels, count = frame * 27;
int16_t* input = u_calloc((size_t)count, sizeof(*input));
int16_t* reference = u_calloc((size_t)count, sizeof(*reference));
int16_t* output = u_calloc((size_t)(count + 8 * frame), sizeof(*output));
REQUIRE(input && reference && output);
for (int i = 0; i < count; i++) input[i] = (int16_t)((i % 97 - 48) * 200);
struct attachment_info info = {0};
REQUIRE(!voice_file_encode(input, (size_t)count, rate, channels, 1, path, &info));
int decoded_rate, decoded_channels, total;
void* decoder = utun_bridge_voice_decode_open(path, &decoded_rate, &decoded_channels, &total);
REQUIRE(decoder && decoded_rate == rate && decoded_channels == channels && total == count);
for (int position = 0; position < count; position += frame)
REQUIRE(utun_bridge_voice_decode_read(decoder, reference + position, frame) == frame);
REQUIRE(!utun_bridge_voice_decode_read(decoder, output, frame));
utun_bridge_voice_decode_close(decoder);
decoder = utun_bridge_voice_decode_open(path, &decoded_rate, &decoded_channels, &total); REQUIRE(decoder);
REQUIRE(!utun_bridge_voice_decode_read(decoder, output, frame - 1));
int position = 0;
while (position < count) {
int expected = count - position < 7 * frame ? count - position : 7 * frame;
output[position + expected] = 12345;
int read = utun_bridge_voice_decode_read(decoder, output + position, 7 * frame + frame / 2);
REQUIRE(read == expected && output[position + expected] == 12345); position += read;
}
REQUIRE(position == count && !memcmp(output, reference, (size_t)count * sizeof(*output)));
REQUIRE(!utun_bridge_voice_decode_read(decoder, output, count + frame));
REQUIRE(!utun_bridge_voice_decode_read(decoder, output, count + frame));
utun_bridge_voice_decode_close(decoder);
decoder = utun_bridge_voice_decode_open(path, &decoded_rate, &decoded_channels, &total); REQUIRE(decoder);
output[count] = 12345;
REQUIRE(utun_bridge_voice_decode_read(decoder, output, count + frame) == count);
REQUIRE(output[count] == 12345 && !memcmp(output, reference, (size_t)count * sizeof(*output)));
REQUIRE(!utun_bridge_voice_decode_read(decoder, output, count + frame));
utun_bridge_voice_decode_close(decoder);
REQUIRE(!test_unlink(path));
u_free(input); u_free(reference); u_free(output);
printf("[PASS] voice decode: rate=%d channels=%d, partial batch, oversized read, exact PCM and EOF\n", rate, channels);
return 0;
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_DEBUG);
int failed = check_provider_copy();
/* Проверки bridge требуют исправного владения таблицей провайдера. */
if (!failed) failed += check_bridge_config();
utun_config_set_provider(NULL);
char directory[512] = "/tmp/utun_android_bridge_XXXXXX", path[1024];
REQUIRE(!test_mkdtemp(directory));
snprintf(path, sizeof(path), "%s/voice.opus", directory);
const int rates[] = {8000, 12000, 16000, 24000, 48000};
for (unsigned i = 0; i < sizeof(rates) / sizeof(rates[0]); i++)
for (int channels = 1; channels <= 2; channels++) failed += check_voice_batches(path, rates[i], channels);
if (failed) return 1;
REQUIRE(!test_rmdir(directory));
printf("test_android_bridge: PASS\n");
return 0;
}
+15 -5
View File
@@ -1,19 +1,29 @@
/* Часы сетевых тестов с реальными сокетами: init → test_clock_poll/run_for/mainloop → destroy ресурсов → finish.
* Linux/ELF: нужны test_clock_sources и test_clock_ldflags из tests/Makefile.am; при пустом I/O продвигается offset часов.
* Наличие UTUN_TEST_REAL_TIME с любым значением выключает ускорение; вне Linux функции используют реальные ожидания.
* TB = 0.1мс. Управление только из потока теста, вне callbacks; вложенные интервалы/ускоренные poll запрещены.
* Offset действует на весь бинарник, включая чтение часов другими потоками; отдельные uasync он не изолирует. */
#ifndef TEST_CLOCK_H
#define TEST_CLOCK_H
#include "../lib/u_async.h"
/* Linux: часы продвигаются при пустом I/O до ближайшего таймера, с сохранением реальных сокетов.
* UTUN_TEST_REAL_TIME=1 и остальные платформы используют обычные часы/ожидание.
* init — до создания uasync; finish — после уничтожения. Вызовы только из потока теста, не из callbacks. */
/* До создания uasync: max_step_tb>0 ограничивает один искусственный шаг; неверный аргумент завершает тест через abort.
* Повторный init не обнуляет offset/счётчики. Категория debug включается на INFO для статистики стенда. */
void test_clock_init(int max_step_tb);
// Та же прибавка для календарных часов (TTL и durable retry_at); до создания инстансов.
// В Linux включить тот же offset для CLOCK_REALTIME/gettimeofday (TTL/retry_at); до создания инстансов, обратно не выключается.
void test_clock_sync_wall_time(void);
/* Обычный uasync_poll с ускорением только при timeout_tb>0. Шаг ограничен max_step, ближайшим timer и текущим run_for.
* Сам timeout_tb не ограничивает размер искусственного шага; прямой uasync_poll не включает ускорение ожидания. */
void test_clock_poll(struct UASYNC* ua, int timeout_tb);
// Poll по 100 TB до ua->stop; ua обязателен. Не меняет флаг stop и не уничтожает event loop.
void test_clock_mainloop(struct UASYNC* ua);
// Обслуживать I/O/callbacks до монотонного дедлайна duration_tb>=0; 0 не выполняет poll, вложенный run_for запрещён.
void test_clock_run_for(struct UASYNC* ua, int duration_tb);
// Дождаться ближайшего неотменённого таймера; требуется хотя бы один таймер, callbacks выполняет uasync.
/* Дойти до дедлайна ближайшего неотменённого timer и выполнить poll(0), обслуживая также другие события.
* ua и хотя бы один живой timer обязательны; пустая heap/переполнение интервала завершают тест через abort. */
void test_clock_next_timer(struct UASYNC* ua);
// Только вывести статистику; не сбрасывает offset/режим/счётчики. Вызывать после уничтожения ресурсов стенда.
void test_clock_finish(void);
#endif
+16 -10
View File
@@ -1,3 +1,8 @@
/* Односторонняя потеря прямых UDP-пакетов к выбранному peer для BGP-тестов, включая новые линки.
* Обнулённый context → init → set(blocked, only, spared) → destroy до уничтожения instance.
* Один владелец send_hook для пары (instance, peer); старый hook не сохраняется и не восстанавливается.
* Использовать только прямые UDP-линки: разрешённые пакеты уходят в socket_sendto, STCP/SOCKS/эмуляторы не обслуживаются.
* Все вызовы/счётчики в uasync-потоке. Это потеря исходящих пакетов, а не отключение fd/входящей стороны. */
#ifndef TEST_CONNECTION_LOSS_H
#define TEST_CONNECTION_LOSS_H
@@ -6,17 +11,15 @@
#include "utun_instance.h"
#include "../lib/debug_config.h"
/* Разрыв пары узлов охватывает и новые линки из свежего NODEINFO.
* only/spared позволяют отдельно проверить потерю одного линка и работу
* единственного оставшегося линка. Контекст живёт до loss_destroy(). */
/* Контекст заимствуется подпиской/hook и живёт до test_loss_destroy; init не обнуляет состояние/статистику. */
struct test_connection_loss {
struct UTUN_INSTANCE* instance;
uint64_t peer_id;
struct ETCP_LINK* only;
struct ETCP_LINK* spared;
int blocked;
unsigned links;
unsigned long long dropped;
struct UTUN_INSTANCE* instance; // Заимствованный локальный instance, NULL после destroy.
uint64_t peer_id; // ID удалённого узла, чьи линки перехватываются.
struct ETCP_LINK* only; // NULL — все линки, иначе только этот; указатель не удерживает link.
struct ETCP_LINK* spared; // Этот линк пропускается даже при совпадении only; NULL — без исключения.
int blocked; // Ненулевое значение включает заданную потерю.
unsigned links; // Накопленное число установок hook, не текущее количество живых линков.
unsigned long long dropped; // Число отброшенных отправок, не байты; сохраняется между set.
};
static ssize_t test_loss_send(socket_t fd, const void* buf, size_t len, const struct sockaddr* addr,
@@ -38,6 +41,7 @@ static void test_loss_link(struct ETCP_CONN* conn, struct ETCP_LINK* link, int o
(unsigned long long)loss->peer_id, (void*)link, loss->links, loss->blocked);
}
// Прикрепить hook к существующим линкам и подписаться на новые; instance и loss обязаны жить до destroy.
static void test_loss_init(struct test_connection_loss* loss, struct UTUN_INSTANCE* instance, uint64_t peer_id) {
loss->instance = instance; loss->peer_id = peer_id;
etcp_add_link_status_cbk(instance, test_loss_link, loss);
@@ -46,12 +50,14 @@ static void test_loss_init(struct test_connection_loss* loss, struct UTUN_INSTAN
test_loss_link(conn, link, 0, 0, loss);
}
// Изменить фильтр без сброса статистики. Заимствованные only/spared сбрасывать до освобождения соответствующих линков.
static void test_loss_set(struct test_connection_loss* loss, int blocked, struct ETCP_LINK* only, struct ETCP_LINK* spared) {
loss->blocked = blocked; loss->only = only; loss->spared = spared;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "test partition: peer=%016llx blocked=%d only=%p spared=%p links=%u dropped=%llu",
(unsigned long long)loss->peer_id, blocked, (void*)only, (void*)spared, loss->links, loss->dropped);
}
// Снять свою подписку и свои hooks, instance оставить живым; повторный destroy допустим, loss не освобождается.
static void test_loss_destroy(struct test_connection_loss* loss) {
if (!loss->instance) return;
etcp_remove_link_status_cbk(loss->instance, test_loss_link, loss);
+9 -2
View File
@@ -1,3 +1,7 @@
/* Именованные сетевые endpoints тестов: сначала открыть сервер на порту 0, затем подставить его адрес в CFG_CLIENT.
* Сокет остаётся открыт: helper не резервирует порт через временный fd и не меняет ключи пира.
* Вызовы выполнять в потоке владельца instance, вне параллельных изменений сокетов/конфига.
* Неверная конфигурация завершает тест через abort; возвращённые sockets/instances остаются у ядра. */
#ifndef TEST_NET_H
#define TEST_NET_H
@@ -8,13 +12,14 @@
#include <stdlib.h>
#include <string.h>
// Читаем порт уже открытого сокета; владение fd и резервирование порта сохраняются.
// Порт уже bound fd в host byte order; fd не закрывается. Нулевой/недоступный порт завершает тест через abort.
static inline uint16_t test_bound_port(socket_t fd) {
uint16_t port = socket_get_bound_port(fd);
if (!port) abort();
return port;
}
// Найти listener по NUL-имени: local_addr/interface_addr должны содержать одинаковый ненулевой порт; иначе abort.
static inline struct ETCP_SOCKET* test_etcp_bound_socket(struct UTUN_INSTANCE* inst, const char* name) {
for (struct ETCP_SOCKET* s = inst ? inst->etcp_sockets : NULL; s; s = s->next) {
if (strcmp(s->name, name)) continue;
@@ -24,7 +29,9 @@ static inline struct ETCP_SOCKET* test_etcp_bound_socket(struct UTUN_INSTANCE* i
DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "test fixture: missing listener %s", name); abort();
}
// Связываем явно указанные endpoints до start; никаких замен по номеру порта или обхода эмуляторов.
/* Обновить remote_addr всех links первого CFG_CLIENT с именем client_name до запуска UTUN-сервиса (!utun_started).
* peer/socket_name выбирают уже bound listener с конкретным адресом; family/transport должны совпадать.
* Копируется только endpoint; peer key и настройки транспорта клиентской секции не меняются. */
static inline void test_etcp_set_peer(struct UTUN_INSTANCE* client, const char* client_name,
struct UTUN_INSTANCE* peer, const char* socket_name) {
struct ETCP_SOCKET* target = test_etcp_bound_socket(peer, socket_name);
+15 -10
View File
@@ -1,7 +1,8 @@
/**
* @file test_utils.h
* @brief Utility functions for cross-platform tests
*/
/* Небольшие файловые/env helpers тестов POSIX/Windows; возврат 0 — успех, -1 либо код CRT — ошибка.
* Созданными каталогами/файлами владеет caller: cleanup выполняется явно, автоматического удаления нет.
* POSIX mkdtemp требует writable шаблон с конечными XXXXXX. Windows игнорирует шаблон и возвращает путь в TEMP:
* буфер caller должен вмещать MAX_PATH, для переносимого кода передавать ненулевой буфер не меньше этого размера.
* Windows использует общие rand/srand и статический буфер в каждой единице трансляции; параллельные вызовы не поддерживаются. */
#ifndef TEST_UTILS_H
#define TEST_UTILS_H
@@ -15,10 +16,10 @@
#include <windows.h>
#include <direct.h>
// Windows temp directory buffer
// Временный путь текущей единицы трансляции; успешный test_mkdtemp копирует его в буфер caller.
static char test_temp_dir[MAX_PATH];
// Cross-platform mkdtemp for Windows
// До 100 попыток создать TEMP/utun_test_XXXXXXXX; 0 — создан (путь копируется при template_str!=NULL), -1 — отказ.
static inline int test_mkdtemp(char *template_str) {
char tmp_path[MAX_PATH];
GetTempPathA(MAX_PATH, tmp_path);
@@ -42,38 +43,42 @@ static inline int test_mkdtemp(char *template_str) {
return -1; // Failed after 100 attempts
}
// Cross-platform setenv for Windows (updates _environ, видим через getenv)
// Изменить CRT _environ, видимый getenv; overwrite=0 сохраняет существующую переменную. Возврат — _putenv_s.
static inline int test_setenv(const char *name, const char *value, int overwrite) {
if (!overwrite && getenv(name)) return 0;
return _putenv_s(name, value); /* 0 = success, errno_t != 0 = error */
}
// Cross-platform unlink for Windows
// Удалить один файл (не дерево каталогов), возврат _unlink: 0/-1.
static inline int test_unlink(const char *path) {
return _unlink(path);
}
// Cross-platform rmdir for Windows
// Удалить пустой каталог, возврат _rmdir: 0/-1.
static inline int test_rmdir(const char *path) {
return _rmdir(path);
}
#else
// Linux/macOS - use standard functions
// POSIX: helpers делегируют штатным API; errno остаётся у вызывающего.
#include <unistd.h>
// Создать каталог по mutable шаблону XXXXXX и записать путь в тот же буфер; 0/-1.
static inline int test_mkdtemp(char *template_str) {
return (mkdtemp(template_str) == NULL) ? -1 : 0;
}
// Изменить environment через setenv; overwrite=0 сохраняет существующее значение, возврат 0/-1.
static inline int test_setenv(const char *name, const char *value, int overwrite) {
return setenv(name, value, overwrite);
}
// Удалить один файл/симлинк через unlink, не рекурсивно; 0/-1.
static inline int test_unlink(const char *path) {
return unlink(path);
}
// Удалить пустой каталог через rmdir; 0/-1.
static inline int test_rmdir(const char *path) {
return rmdir(path);
}
+16 -6
View File
@@ -1,19 +1,29 @@
/* Управляемый backend только для lifecycle/PCM теста; не заменяет проверку настоящей модели. */
/* Минимальный собственный stub whisper API для test_chat_whisper.c и тестируемого chat_whisper.c.
* Подключается только через include path тестовой цели; это не ABI заголовок настоящей libwhisper.
* Backend управляется globals теста: отказ загрузки, отказ/ожидание inference, число сегментов, счётчики lifecycle.
* Проверяет подготовленный PCM и порядок освобождения; распознавание речи/настоящую модель не выполняет. */
#ifndef TEST_WHISPER_H
#define TEST_WHISPER_H
struct whisper_context;
struct whisper_context_params { int use_gpu; };
struct whisper_context; // Собственный контекст stub, освобождается whisper_free после завершения inference.
struct whisper_context_params { int use_gpu; }; // Stub требует use_gpu=0, default_params возвращает нули.
struct whisper_full_params {
const char* language;
int n_threads, no_timestamps, single_segment;
int print_progress, print_realtime, print_timestamps, print_special;
const char* language; // Заимствованный язык; тестовый backend проверяет "ru".
int n_threads, no_timestamps, single_segment; // Только поля, используемые chat_whisper.c, не layout настоящей библиотеки.
int print_progress, print_realtime, print_timestamps, print_special; // Флаги вывода, default_params обнуляет их.
};
#define WHISPER_SAMPLING_GREEDY 0
// Вернуть обнулённые параметры; единственный mode теста — CPU.
struct whisper_context_params whisper_context_default_params(void);
// Непустой путь используется как идентификатор, модель с диска не читается; NULL при внедрённом отказе, иначе owned context.
struct whisper_context* whisper_init_from_file_with_params(const char*, struct whisper_context_params);
// Освободить ненулевой контекст и обновить счётчики теста; не вызывать во время whisper_full.
void whisper_free(struct whisper_context*);
// Вернуть нули; strategy принимается для совместимости вызова внутри теста и игнорируется.
struct whisper_full_params whisper_full_default_params(int);
// Заимствованный mono PCM float 16kHz с count>0, нормированный в [-1,1]; 0 — успех, заданный тестом код — отказ.
int whisper_full(struct whisper_context*, struct whisper_full_params, const float*, int);
// Число результатов: 0 либо 2 по настройке теста; context обязателен.
int whisper_full_n_segments(struct whisper_context*);
// Заимствованные литералы "hello" и " world" для индексов 0/1; не освобождать и не использовать вне диапазона сегментов.
const char* whisper_full_get_segment_text(struct whisper_context*, int);
#endif
+165 -257
View File
@@ -1,281 +1,189 @@
# chatgui-android — Архитектура
# chatgui-android — архитектура
## 1. Обзор
## Состав и сборка
Android-версия чатгуи: P2P чат на базе STCP (Secure TCP) вместо ETCP/UDP.
Весь UDP-стек (ETCP, BBR, loadbalancer, pkt_normalizer, NAT, routing, TUN) исключён.
Сетевой транспорт: только STCP (X25519 + AES-CCM поверх TCP).
Android-клиент использует общее ядро из `lib/` и `src/`: ETCP с UDP-линками и
STCP-линками поверх TCP, NCD, группы/BGP, ETCP-router, чат, DM и media-сервисы.
Отдельной Android-реализации chat_sync/merkle_sync и отдельного lite-instance нет.
STCP передаёт ETCP-пакеты; его потоковое шифрование — AES-CTR, пакетное ETCP — AES-CCM.
Контракты транспорта описаны в `../../../src/transport_layer/stcp.h` и `etcp.h`.
Приложение работает в двух режимах:
- **Headless** — без UI, с управляющим TCP-сокетом (для отладки сетевого стека)
- **Full UI** — Android приложение с Jetpack Compose интерфейсом
`libutun_lite/utun_sources.cmake` собирает `lib/*.c`, LMDB и рекурсивно `src/*.c`,
исключая `utun.c`, каталоги lwip_orig/uip/transport_layer/BBR и файлы отдельного
голосового стека. Это источник актуального состава, а не список «только TCP».
Файлы Android-обёртки перечислены в `_cfg_src`; старая копия `invite_link_c.c` туда не входит.
## 2. Уровни приложения
NDK-сборка в `app/src/main/cpp/CMakeLists.txt` создаёт OBJECT-библиотеку `utun_core`
и итоговую `libutun.so`: ядро, JNI, UDP-лог, отдельная статическая `utun_voice`,
OpenSSL Crypto, Opus, SpeexDSP, ONNX Runtime. JNI распределён между
`android_jni_bridge.c`, `android_stream_jni.c`, `android_call_video_jni.c` и
`android_call_output_jni.c`. Голосовой стек собирается по `src/call/voice_sources.cmake`.
```
┌─────────────────────────────────────────────────┐
│ Android App (Kotlin / Jetpack Compose) │
│ ├─ UI Layer (Compose) │
│ │ ├─ ChannelListScreen │
│ │ ├─ ChatScreen (MessageList + InputBar) │
│ │ ├─ CreateChannelDialog │
│ │ └─ JoinChannelDialog │
│ ├─ ViewModel Layer │
│ │ └─ ChatViewModel (StateFlow, Coroutines) │
│ ├─ Repository Layer │
│ │ ├─ ChatRepository (JNI → native) │
│ │ └─ DbRepository (Room, read-only) │
│ └─ NativeLib.kt (JNI declarations) │
├─────────────────────────────────────────────────┤
│ JNI Bridge (C: android_jni_bridge.c) │
│ ├─ Kotlin → C: init/send/create/connect │
│ └─ C → Kotlin: callbacks via JNI CallVoidMethod │
├─────────────────────────────────────────────────┤
│ Headless Control (встроен в libutun_lite) │
│ ├─ TCP control socket (текстовый протокол) │
│ ├─ Команды: connect, send, status, debug, ... │
│ └─ Events: MSG, PEER_ONLINE/OFFLINE, ... │
├─────────────────────────────────────────────────┤
│ libutun_lite.so │
│ ├─ Core: uasync, ll_queue, memory_pool, │
│ │ debug_config, timeout_heap │
│ ├─ Crypto: secure_channel (X25519+AES-CCM), │
│ │ crc32, sha256 │
│ ├─ Transport: STCP (server/client/link) │
│ ├─ Chat: chat_core, chat_event, chat_sync, │
│ │ member_sync, merkle_sync │
│ ├─ DB: sqlite3 (WAL, чтение из GUI, запись │
│ │ из uasync) │
│ └─ Platform: socket_compat, platform_compat │
│ (Linux/Android) │
└─────────────────────────────────────────────────┘
```
Параметры приложения определяет `app/build.gradle.kts`: minSdk 26, compile/targetSdk 36,
NDK 29.0.14206865, JVM 17, ABI arm64-v8a/x86_64. UI — Kotlin, Jetpack Compose и Material3;
CameraX отвечает за захват фото/видео, MediaCodec — за live video, AudioRecord/AudioTrack — за аудио.
## 3. Потоковая модель (Android)
## Владение ядром и потоки
- **Main thread (UI)** — Compose rendering, ViewModel state
- **uasync thread** — event loop (один на процесс, как и в desktop-версии)
- STCP соединения: каждое = одно TCP-соединение в uasync
- chat_core: все DB-записи из этого потока
- chat_sync: P2P синхронизация через STCP
- **DB:** SQLite WAL mode
- uasync thread: INSERT/UPDATE/DELETE (через chat_core)
- Main thread: SELECT (через Room/DbRepository, read-only)
`NativeLib.init(dbPath, controlPort)` сохраняет config/log callbacks и настраивает
voice_recorder. Он не запускает ядро и не открывает control-listener; controlPort
в этой ветке только логируется. Затем `NativeLib.start(configText)` передаёт INI
в `instance_lite_start()`.
## 4. Потоковая модель (Headless, Linux)
Один worker pthread создаёт uasync и `UTUN_INSTANCE`, принудительно задаёт
CLIENT_TYPE_MOBILE и запускает `utun_core_start()` + `chat_service_start()`.
UTUN-сервис включается отдельно через `[gui] utun_enabled=1`, по умолчанию выключен.
Наличие TUN/routing/NAT-кода в сборке само по себе сервис не включает.
Один процесс, один uasync-поток. Всё в одном потоке, включая control socket.
Дополнительный поток для control socket не нужен — uasync обрабатывает и сеть и control-команды.
До `CHAT_EVT_SERVICE_STARTED` worker привязывает stream bridge и call_video_port,
а при UTUN_WITH_VOICE — call_audio. Успех start означает создание потока либо уже
существующий запуск; он не подтверждает готовность сервисов. Готовность проверяется
по событию и `instance_lite_is_running()`.
## 5. STCP вместо ETCP — изменения
Команды UI обычно копируют аргументы и ставят работу в uasync. При этом C JSON-getters
и некоторые настройки читают SQLite/живые структуры синхронно в вызывающем потоке;
C `utun_bridge_set_member_nick()` пишет локальный nickname напрямую. C setter chat_setting
также синхронный, тогда как JNI nativeSetChatSetting использует trampoline.
Нельзя считать весь bridge автоматически выполняющимся в потоке ядра.
Заимствованные inst/ua не удерживают ресурс: frontend должен сериализовать вызовы
по ним с stop/restart; обход живых списков требует исключить их параллельное изменение.
### 5.1. Что уходит
JNI получает JNIEnv для native-потока и вызывает Kotlin `onEvent(type, ByteArray?)`.
Payload копируется в byte array; его формат зависит от CHAT_EVT_* из `src/chat/chat_event.h`.
Это не универсальный JSON. Отдельный C `utun_event_fn` не является обработчиком
бинарных событий instance_lite. ViewModel переносит обновление UI в свой coroutine context.
| Модуль | Причина |
|--------|---------|
| ETCP (etcp.c, etcp_api.c, etcp_connections.c, etcp_router.c, etcp_loadbalancer.c, etcp_bbr.c, etcp_connect.c) | Заменено на STCP link |
| BBR (bbr_v3.c) | Congestion control для ETCP |
| pkt_normalizer.c | Фрагментация/сборка — не нужна для потокового STCP |
| dummynet.c | Эмулятор сети для отладки ETCP |
| tun_if/tun_linux/tun_windows/tun_route | Нет VPN-туннеля |
| routing, route_lib, route6_lib, route_ping, route_connectivity | Нет роутинга трафика |
| eim_nat, nat_transport | Нет NAT-транспорта |
| topo_group, topo_node, topo_node_lmdb | BGP-топология — прямые P2P |
| conn_mgr | Менеджер ETCP-соединений |
| firewall, proxy/*, lwip_tcp/*, uip/* | Не нужны |
| ntp_time, ntp_node_time | Пока не нужны (можно добавить позже) |
| packet_dump, etcp_debug, etcp_dump | Отладочные утилиты ETCP |
Перед stop/restart Kotlin сбрасывает controllers и останавливает внешние media workers.
Worker снимает media-привязки, уничтожает instance, снимает standby/UDP-таймеры и уничтожает uasync.
`instance_lite_stop()` ждёт до двух секунд: при timeout отсоединяет поток, поэтому возврат
не гарантирует завершённый cleanup. Без опубликованного instance он возвращается сразу.
Restart пересоздаёт instance и uasync. Дополнительный опрос is_running в bridge до 5с
может увидеть ещё старое работающее ядро; подтверждение нового запуска — SERVICE_STARTED.
Общие правила освобождения: `../../../doc/service_lifecycle.md`.
### 5.2. chat_sync — адаптация
## Данные и конфигурация
Десктопная версия chat_sync использует ETCP-роутер: множество каналов мультиплексируется
через одно ETCP-соединение. В Android-версии каждое P2P-соединение — отдельный STCP линк.
Ядро владеет общей SQLite `db_path/chats.db`; путь без db_path даёт in-memory БД.
Chat использует тот же handle, что и топология. Схему создают общие модули
`src/routing_layer/topo_node_sqlite.c`, `src/chat/`, `src/dm/` и media-подсистемы.
Room/DbRepository и отдельного Kotlin SQL-соединения в приложении нет:
ChatRepository получает JSON через JNI и разбирает события, ViewModel хранит StateFlow.
Имена/форматы таблиц не следует дублировать в Android-коде.
Замены в chat_sync.c:
- `etcp_send(conn, svc_id, data)` → `stcp_link_send(link, data)`
- `etcp_api_bind(svc_id, callback)` → приём из `stcp_link` rx_queue
- Каждый peer = одно `stcp_link` соединение
- Сервер: `stcp_server_listen()` принимает входящие
### 5.3. merkle_sync — адаптация
merkle_sync — универсальный протокол синхронизации на Merkle-деревьях.
В десктопе работает поверх ETCP-сервиса. В Android-версии:
- Вместо etcp_send в рамках ETCP-сервиса → отправка напрямую через stcp_link
- Формат wire-сообщений не меняется (MSG_HASHES, MSG_REQUEST, MSG_BATCH)
- Изменяется только транспорт: вместо ETCP datagram → STCP stream
## 6. Control Socket (Headless)
Управляющий TCP-сокет для отладки и управления без UI.
Аналог `control_server` из utun, но с текстовым протоколом для простоты ручного тестирования.
Порт: настраивается в конфиге (по умолчанию 9999).
### Команды (клиент → сервер)
```
connect <addr> <port> [pubkey_hex] — STCP-подключение к пиру
disconnect <node_id> — отключиться от пира
connections — список активных STCP-соединений
create_channel <name> — создать канал
invite <channel_id> — invite-ссылка (utun://...)
join <invite_string> — подключиться по invite
send <channel_id> <text> — отправить сообщение
messages <channel_id> [limit] — последние сообщения
channels — список каналов
status — статус (соединения, каналы, ключи)
debug <cat>=<level> — debug level на лету
debug_level — показать уровни отладки
subscribe <events> — подписаться на push-события
help — список команд
quit — отключиться от control (не остановка сервера)
```
### Unsolicited events (сервер → клиент, только после subscribe)
```
EVENT MSG <channel_id> <author_hex> — новое сообщение
EVENT PEER_ONLINE <node_id_hex> — пир подключился
EVENT PEER_OFFLINE <node_id_hex> — пир отключился
EVENT CHANNEL_CREATED <channel_id> — канал создан (локально)
EVENT CHANNEL_JOINED <channel_id> — канал принят (по invite)
EVENT CONN_RESULT <node_id_hex> <ok|err> — результат connect
EVENT DB_READY — БД открыта и готова
EVENT STATUS <text> — периодический статус
```
## 7. База данных (SQLite)
Схема та же, что в десктопной версии chatgui (см. `tools/chatgui/doc/desc.txt`).
БД открывается в WAL-режиме. Записи — из uasync-потока, чтение — из любого.
Таблицы:
- `channels` — каналы (channel_id, name, ключи X25519/Ed25519, подписи)
- `msg_<channel_id>` — сообщения per-channel (id, author, content_type, data, timestamp, chain_hash, signature)
- `peers_<channel_id>` — участники канала (node_id, online, x25519, ed25519, join_sig, адреса)
- `nodes` — узлы (node_id, x25519_pubkey, ed25519_pubkey, name, online)
- `node_addresses` — адреса узлов (node_id, family, address, port)
- `accounts` — локальные аккаунты/контакты
- `ui_state` — key-value для UI состояния
- `merkle_tree_hash` — сохранённые хеши Merkle-дерева
- `local_identity` — локальные ключи узла
## 8. Структура каталогов
```
tools/chatgui-android/
├── doc/
│ ├── ARCHITECTURE.md # этот документ
│ └── IMPL_PLAN.md # план реализации с этапами
├── libutun_lite/ # урезанное C-ядро (статическая библиотека)
│ ├── CMakeLists.txt
│ └── utun_lite.h # публичное API
├── headless/ # CLI для Linux (тестирование без Android)
│ ├── CMakeLists.txt
│ ├── headless_main.c # точка входа
│ └── headless_control.c/h # управляющий TCP-сокет
├── jni_bridge/ # JNI прослойка
│ ├── CMakeLists.txt # (только для Android NDK)
│ └── android_jni_bridge.c # JNI-функции + коллбэки
├── app/ # Android приложение
│ ├── build.gradle.kts
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── cpp/ # NDK: jni_bridge + линковка libutun_lite
│ │ └── CMakeLists.txt
│ ├── java/com/utun/chat/
│ │ ├── ChatApplication.kt
│ │ ├── MainActivity.kt
│ │ ├── ui/screens/ # Compose экраны
│ │ ├── ui/components/# Compose компоненты
│ │ ├── viewmodel/ # ViewModels
│ │ ├── data/ # Repository, NativeLib.kt, Room DAOs
│ │ └── headless/ # HeadlessService.kt (фоновый сервис)
│ └── res/
└── CMakeLists.txt # корневой (для headless + libutun_lite)
```
## 9. Минимальный набор C-файлов (libutun_lite)
### lib/ (infrastructure)
- `u_async.c/h` — event loop (epoll на Linux, poll/select fallback)
- `ll_queue.c/h` — lock-free очередь с хеш-индексом
- `memory_pool.c/h` — пулы объектов
- `debug_config.c/h` — логирование (уровни, категории, файл/консоль)
- `timeout_heap.c/h` — min-heap таймеров
- `sha256.c/h` — SHA256 (fallback; приоритет OpenSSL)
- `mem.c/h` — u_malloc/u_free/u_calloc (wrappers)
- `platform_compat.c/h` — кроссплатформенность (byte order, time, random)
- `socket_compat.c/h` — кроссплатформенные сокеты
- `serialize.c/h` — бинарная сериализация
### src/transport_layer/ (crypto + STCP)
- `secure_channel.c/h` — AES-CCM + X25519 key exchange + pubkey obfuscation
- `crc32.c/h` — CRC32 checksum
- `stcp.c/h` — STCP connection lifecycle (session keys, buffers, state machine)
- `stcp_server.c/h` — STCP server (listen, accept, handshake)
- `stcp_client.c/h` — STCP client (connect, handshake)
- `stcp_link.c/h` — STCP link management (обёртка над stcp_conn для etcp-подобного API)
### src/chat/ (чатовая логика)
- `chat_core.c/h` + `chat_core_priv.h` — центральный API чата (send, create channel, connect)
- `chat_event.c/h` — система нотификаций (MSG_RECEIVED, PEER_ONLINE, ...)
- `chat_channel.c` — операции с каналами (создание, invite, join)
- `chat_msg.c` — приём/отправка/сохранение сообщений
- `chat_status.c` — сбор статуса для control socket / UI
- `chat_profile.c` — профиль узла (имя, ключи)
- `chat_sync.c/h` — P2P синхронизация (адаптирована под STCP)
- `member_sync.c/h` — синхронизация мемберов
- `merkle_sync.c/h` — Merkle-дерево синхронизации
- `db_sync.c/h` — DB синхронизация (вставка/обновление записей синхронизации)
### Упрощённый вариант src/ (без TUN/ETCP/BGP)
- `utun_instance_lite.c/h` — облегчённый instance (только ua, my_keys, db, stcp_server, control_socket)
- `config_parser.c/h` — парсинг конфига (без ETCP/TUN/BGP-секций)
### Внешние
- `lib/sqlite3.c/h` — SQLite amalgamation
- OpenSSL (Crypto) — SHA256, AES-CCM, X25519, Ed25519
## 10. Конфиг (chatgui-android.cfg)
Формат INI, упрощённый относительно десктопной версии:
ConfigProvider хранит настройки в DataStore, читает ключи и формирует общий INI
через buildConfigText/buildConfigFromServers. Callback-ключи вроде `node.name` и `db.path`
не являются именами INI-секций. Native config API копирует таблицу callbacks по значению;
функции и их окружение остаются у провайдера. Init без config и destroy снимают прежнюю
регистрацию; смену провайдера нужно сериализовать с cfg_get_*. Минимальный пример INI:
```ini
[node]
private_key = hex...
public_key = hex...
node_name = My Node
listen_port = 12345 ; STCP server port
[global]
my_node_name=Android
# При отсутствии ключей начальный запуск сгенерирует X25519-пару.
db_path=/data/user/0/com.utun.chat/files
db_sync_enabled=1
chatserver_enabled=1
auto_sockets=android
[control]
port = 9999 ; управляющий сокет
bind_ip = 127.0.0.1
[gui]
utun_enabled=0
[db]
path = chat_data/chats.db
[allowed_keys]
allow_all=yes
[ntp]
enabled=no
[debug]
console_level = info
file_level = debug
log_file = utun.log
debug_categories = stcp=debug,chat_core=info
chat=info
socket=info
```
## 11. Отличие от десктопной версии
Секции `[server:<имя>]` с addr/type/transport/netif строятся из интерфейсов устройства.
NetworkChangeMonitor передаёт новый INI в updateSockets: uasync согласует сокеты через
`auto_socket_reload_config`, сохраняя ядро/чат. В auto_sockets=android общий
`auto_socket_on_network_change()` не запускает scan: нужен именно reload из Kotlin.
Настройки голоса, рации, stream, standby и хранения передаются в `[chat]`,
UDP-лог — в `[log_udp] ip/port`; полный набор задаёт ConfigProvider.
Начальная генерация ключей пытается сохранить их в `db_path/keys.conf`.
`instance_lite_regenerate_keys()` меняет ключи/node_id на месте, без пересоздания соединений.
| Аспект | Desktop (chatgui) | Android (chatgui-android) |
|--------|------------------|--------------------------|
| UI | Qt 6 Widgets (C++) | Jetpack Compose (Kotlin) |
| Bridge | gui_bridge (Qt signals) | JNI callbacks + StateFlow |
| Core сборка | CMake → libutun.a (все src/) | CMake → libutun_lite.a (выборочно) |
| Транспорт | ETCP (UDP) + STCP (TCP) | STCP только (TCP) |
| VPN | TUN + routing + BGP | Нет |
| NAT | EIM NAT engine | Нет |
| Аудио | miniaudio + opus | Нет (пока) |
| Эмодзи/анимации | rlottie + TGS | Нет (пока) |
| QR | zxing-cpp | Нет (пока) |
| FFmpeg | Да | Нет |
| Control | control_server (binary protocol) | headless_control (text protocol) |
| Платформы | Linux, FreeBSD, Windows | Linux (headless), Android (app) |
## Каналы, приглашения и DM
Создание канала ставится в uasync; channel_id, переданный старому bridge API создания,
игнорируется: ядро генерирует ID. `connect_node(address,port,pubkey)` остался заглушкой.
Для подключения канала/участника используются общие chat_core/topo_group API.
`InviteLink.kt` получает предпросмотр через `NativeLib.decodeInvite()` — JNI вызывает
общий `src/chat/invite_link.c`. Ссылка `utun://...` содержит версию 0x03, пароль,
channel_id, join_key, REALITY-параметры и блоки публичного ключа/адресов.
JOIN передаёт исходную ссылку и request ID через потокобезопасный chat_join_port;
принятый запрос завершается CONNECT_RESULT с тем же request. Отрицательный возврат
submit означает отказ без события. Детали: `src/chat/chat_join.h`, `chat_join_port.h`.
Регистрация join_key подтверждается до выдачи ссылки. JOIN добавляет подписанного мембера,
Merkle распространяет запись; доступ к группе определяется локальной таблицей участников.
JOIN_READY добавления и READY групповой сессии — разные состояния.
DM-команды используют conv_id и поколение беседы. История приходит событием
CHAT_EVT_DM_MESSAGES, список — CHAT_EVT_DM_CONVERSATIONS. Frontend отбрасывает
ответы старого поколения/другой беседы. Voice/video принимают target `dm:<conv_id>`;
отдельные dm_* функции принимают conv_id без префикса. Общее устройство DM:
`../../../doc/dm_arch.md`.
## Вложения и media
Канальные text/file/photo используют chat_core_submit_trampoline. reply_to — ровно
32 байта исходного message_id, нули означают отсутствие ответа. request_id связывает
публикацию с CHAT_EVT_MSG_SUBMITTED; успешная постановка не означает доставку пиру.
Обычный файл и готовое фото остаются у caller и должны жить неизменными до завершения
асинхронного копирования/индексации. Фото здесь не декодируется и не масштабируется.
voice_recorder принимает int16 mono 48kHz, отсоединяет PCM/compressor при stop и
передаёт общий attachment_send. Запись короче 500мс по монотонным часам отбрасывается.
Готовый MP4 передаётся без транскода; при принятом video-запросе cache-файл принадлежит
задаче и удаляется также при ошибке/отмене. То же правило у отдельной DM-отправки файла.
При отказе постановки файл остаётся caller. Generation защищает публикацию в DM;
канальный reply_to/request_id фиксируется до начала подготовки.
Звонок и рация используют общий C голосовой стек. Kotlin владеет AudioRecord/AudioTrack,
feed/pull workers и маршрутом Android Telecom; DSP и сетевой сигналинг разделены.
Вызовы lifecycle DSP выполняются после остановки его внешних feed/pull callers.
Контракты PCM/PTS: `src/call/call_audio.h`, `src/radio/radio_audio.h`, `jni_bridge/android_jni_bridge.h`.
Live stream bridge владеет одним publisher и независимыми viewers поверх общих
`stream_publisher`/`stream_viewer`. UI использует session IDs, не inst/ua.
Данные идут через pull, а каталог источников — через CHAT_EVT_STREAM_SOURCES.
Pull возвращает owned буфер записей с 28-байтовым BE-заголовком; освобождение — u_free.
Demand ограничивает батч viewer; GAP/CLOSED требуют сбросить прежний frontend-буфер.
Точный формат, лимиты и срок жизни signal: `libutun_lite/stream_bridge.h`.
## Фон и диагностика
Android HeadlessService — foreground service с уведомлением и общим native-ядром,
а не Linux control-loop. Он запускает NetworkChangeMonitor и NetworkActivityMonitor.
Переход UI в фон передаёт client_activity в uasync. Standby чередует ACTIVE/SLEEP;
сам event loop продолжает работать. Ожидания SLEEP будятся после перехода в ACTIVE,
сетевой activity может разбудить после min_sleep. Контракт отмены: `libutun_lite/standby.h`.
DEBUG_* фильтруются общими уровнями/категориями. Bridge переводит их в logcat и,
при включённой подписке, Kotlin LogManager. Debug hook также пишет в UDP-буфер:
16KiB threshold либо timer 200мс, неблокирующая отправка без повторов. ERROR вызывает flush.
Target — числовой IPv4; attach/detach таймера выполняет uasync worker при каждом запуске/рестарте.
Ошибки самого UDP-логгера идут в отдельный utun-udplog, минуя debug hook.
## Linux headless и проверки
Каталог `headless/` — отдельный каркас: headless_main запускает select-loop и JSON-line
control socket, но не создаёт uasync/instance и не запускает чат. `-p` меняет порт,
`-l` только печатает имя файла. ping/status/connections/debug_level возвращают заглушки,
subscribe/quit управляют клиентом, ряд команд даёт not implemented. Текстовые команды
вида `send <channel> <text>` этот каркас не поддерживает. JOIN/settings требуют уже
запущенное ядро и исполнения в его потоке; каркас не заменяет рабочий headless API.
Для Linux сетевых сценариев используются основной `src/utun`,
`[chatserver] headless_control_bind=127.0.0.1:9999` и `tools/chatcli`.
Для APK: `cd tools/chatgui-android && ./gradlew assembleDebug`.
Проверки native bridge/standby доступны в `tests/Makefile.am`;
проверка независимости media workers на устройстве описана в
`../tests/stream_worker_smoke/README.md` и не проверяет сетевую доставку/UI.
+82 -440
View File
@@ -1,465 +1,107 @@
# chatgui-android — План реализации
# chatgui-android — этапы реализации и проверки
## Порядок этапов
Документ описывает текущие точки входа и критерии проверки. Наличие реализации
не означает, что соответствующий сценарий выполнен на устройстве.
Архитектура и контракты: [ARCHITECTURE.md](ARCHITECTURE.md), заголовки `libutun_lite/` и `jni_bridge/`.
```
Этап 1 ── Структура проекта + CMake
│
▼
Этап 2 ── libutun_lite (C-ядро)
│
▼
Этап 3 ── Headless CLI + control socket ◄── первый работающий артефакт
│ тестируется на Linux
▼
Этап 4 ── JNI bridge (C ↔ Kotlin)
│
▼
Этап 5 ── Android UI (Jetpack Compose) ◄── второй артефакт
│
▼
Этап 6 ── Android Headless Service ◄── третий артефакт
│
▼
Этап 7 ── End-to-end тесты
```
## 1. Сборка общего ядра
Ключевой принцип: **сначала работает headless → потом UI**. Сетевой стек отлаживается
на Linux без Android. Только когда P2P чат работает между двумя headless-экземплярами —
переходим к Android.
Реализовано: `utun_sources.cmake` использует общие `lib/` и `src/`, включая ETCP,
STCP, NCD и группы/BGP. Android не требует отдельной реализации chat_sync и
не исключает UDP/маршрутизатор. Источники голосового стека собираются отдельно
через `src/call/voice_sources.cmake`. Итог NDK — `libutun.so`.
---
Проверять состав и исключения по `app/src/main/cpp/CMakeLists.txt`, а версии SDK,
NDK, JVM и ABI — по `app/build.gradle.kts`. Не создавать второй lite-instance или
локальную схему БД ради Android. UTUN-сервис управляется `[gui] utun_enabled`.
## Этап 1. Структура проекта и сборочная система
**Цель:** Собрать пустой проект, который компилируется (пока без логики).
### 1.1. Создать файлы
```
tools/chatgui-android/
├── CMakeLists.txt # корневой: add_subdirectory(libutun_lite; headless)
├── libutun_lite/
│ ├── CMakeLists.txt # libutun_lite.a из выбранных .c
│ └── utun_lite.h # заглушка API
└── headless/
├── CMakeLists.txt # headless binary
└── headless_main.c # int main() { return 0; }
```
### 1.2. libutun_lite/CMakeLists.txt
Список всех нужных .c файлов с путями к `../../src/` и `../../lib/`.
Использовать `file(GLOB ...)` для категорий (lib/*.c, src/transport_layer/*.c, src/chat/*.c),
затем `list(REMOVE_ITEM ...)` исключить ненужные.
Результат — статическая библиотека `libutun_lite.a`.
### 1.3. headless/CMakeLists.txt
Линкует `headless_main.c` + `libutun_lite.a` + `OpenSSL::Crypto` + `pthread` + `dl`.
### 1.4. Проверка
Сборка из корня репозитория:
```bash
cd tools/chatgui-android && mkdir -p build && cd build
cmake .. && make -j4
./build.sh --no-gui -j4
cd tools/chatgui-android
./gradlew assembleDebug
```
Должно: собраться без ошибок, `./headless/headless` выходит с 0.
Критерий: обе сборки завершились успешно; APK появился в
`app/build/outputs/apk/debug/app-debug.apk`. Сборка APK не подтверждает работу на устройстве.
---
## 2. Lifecycle и JNI
## Этап 2. Минимальное C-ядро (libutun_lite)
Реализовано: NativeLib.init сохраняет callbacks, start передаёт INI и создаёт worker,
SERVICE_STARTED подтверждает готовность. Restart пересоздаёт ядро; updateSockets
согласует `[server:*]` без рестарта. Код stop ждёт worker до двух секунд, после
таймаута возврат не подтверждает освобождение ресурсов.
**Цель:** `libutun_lite.a` собирается из отобранных .c файлов без ошибок компиляции.
Проверять последовательно: init → установка event callback → start → SERVICE_STARTED →
рабочая команда → результат → остановка media workers → stop. Отдельно проверить
restart и смену интерфейсов через updateSockets. Возврат nativeRestart/nativeUpdateSockets
не является подтверждением применения конфигурации.
### 2.1. Выверка списка файлов
Критерий: события приходят в Kotlin, нет обращений по старым inst/ua, stop/restart
не пересекаются с внешними I/O workers. Проверять категории sys/socket/connection/chat
и сообщения lifecycle; при таймауте остановки сначала установить причину по логам.
Пройти по каждому .c из списка в `ARCHITECTURE.md §9`, проверить что:
- Все `#include` разрешаются (пути к lib/ и src/ корректны)
- Нет зависимостей от исключённых модулей (etcp, tun, bgp, ...)
- Если есть зависимости — либо исключить файл, либо застабить
## 3. Каналы, приглашения и личные беседы
### 2.2. Застабить зависимости
Реализовано: Compose UI, ChatRepository с JSON/JNI и ViewModel/StateFlow;
SQLite принадлежит общему ядру. Room/отдельный Kotlin SQL-reader не используются.
InviteLink.kt вызывает общий native-кодек для предпросмотра и передаёт исходную
ссылку JOIN-port с request ID. connectNode остался логирующей заглушкой.
Некоторые файлы могут ссылаться на исключённые модули. Варианты:
- Добавить `#ifndef CHATGUI_ANDROID ... #endif` с заглушками
- Вынести проблемный код в `_lite` версию файла
- Создать `stubs.c` с пустыми реализациями нужных функций
Проверять на двух узлах: создать канал → запросить invite → дождаться готовой ссылки →
JOIN → CONNECT_RESULT → синхронизация участников и сообщений. Для сетевого baseline
на Linux использовать основной `src/utun` и `tools/chatcli`, описание команд —
`../../chatcli_commands.txt`. Android-каталог `headless/` не запускает ядро и не подходит
для такого end-to-end сценария.
### 2.3. utun_instance_lite
Критерий: request результата совпадает, сообщение появляется на другом узле,
готовность JOIN и READY групповой сессии различается в диагностике.
Для DM отдельно проверить поколения беседы: поздняя история/подготовленное вложение
после удаления беседы не должны появиться в новом поколении.
Создать `src/utun_instance_lite.c/h` — облегчённый instance:
- Поля: `uasync* ua`, `SC_MYKEYS my_keys`, `sqlite3* db`, `stcp_server* srv`, `void* control_srv`
- Нет: tun, etcp_connections, routing, bgp, nat, firewall, proxy
## 4. Вложения, звонки, рация и трансляции
### 2.4. config_parser — упрощение
Реализовано: file/photo для каналов, общий attachment_send для voice/video каналов и DM,
DM files, native голосовой стек звонка/рации, MediaCodec и независимые stream workers.
Убрать секции, не нужные в Android-версии (tun, etcp, routing, bgp, nat, firewall, proxy).
Оставить: node, control, db, debug.
При проверке файлов учитывать владение: file/photo caller сохраняет до завершения
копирования; принятые video/DM-file запросы забирают временный cache-файл и удаляют
также при ошибке/отмене. Отказ постановки оставляет источник caller. Успешный return
не подтверждает публикацию или доставку; смотреть события подготовки и отправки.
### 2.5. Проверка
Для звонка/рации проверять capture/playback, PTT/auto, смену маршрута и остановку I/O
до освобождения DSP. Для stream — KEY/revision, отдельные viewers, demand/pull,
GAP/CLOSED, завершение аудио/видео и wake/освобождение signal.
Существующие native-проверки из корня репозитория:
```bash
cd tools/chatgui-android/build && make -j4
make -C tests test_android_bridge test_android_replies test_android_udp_log test_android_stream test_standby test_standby_transport test_group_connect_standby
(cd tests && ./test_android_bridge && ./test_android_replies && ./test_android_udp_log && ./test_android_stream && ./test_standby && ./test_standby_transport && ./test_group_connect_standby)
```
Все .c компилируются, линкуется libutun_lite.a.
---
## Этап 3. Headless CLI + управляющий сокет
**Цель:** Запустить headless-экземпляр, подключиться control-клиентом, выполнить базовые команды.
### 3.1. headless_main.c
```c
int main(int argc, char** argv) {
// 1. Парсинг аргументов: -c config.cfg [-p port] [-l logfile]
// 2. Чтение конфига (config_parser)
// 3. Инициализация ключей (генерация если нет)
// 4. Открытие БД (sqlite3_open, WAL, создание таблиц)
// 5. Создание uasync
// 6. Запуск STCP сервера (stcp_server_listen)
// 7. Запуск control сокета (headless_control_init)
// 8. uasync event loop (uasync_run)
// 9. cleanup
}
```
### 3.2. headless_control.c
Управляющий TCP-сокет с текстовым протоколом (см. ARCHITECTURE.md §6).
Реализация:
- `headless_control_init(ua, port)` — создать слушающий сокет
- При новом подключении: зарегистрировать fd в uasync, выделить буфер
- При данных: парсинг строк, выполнение команд, отправка ответа
- Поддержка subscribe: сохранить список подписанных клиентов, рассылать события
- Команды первого этапа (минимальный набор):
- `status` — показать состояние (node_id, port, uptime)
- `connections` — список STCP соединений (заглушка: "none")
- `debug` / `debug_level` — управление отладкой
- `help` — список команд
- `quit` — отключиться
### 3.3. Тестирование этапа 3
```bash
# Терминал 1: запуск сервера
./headless -c test_node1.cfg -p 9999 -l node1.log
# Терминал 2: подключение control-клиентом
nc localhost 9999
> status
node_id: 0xABCD...
uptime: 5s
connections: 0
> help
...
> quit
bye
```
**Критерий:** headless запускается, слушает control-порт, отвечает на status/help/debug/quit.
### 3.4. Тестирование — два экземпляра, STCP connect
Добавить команды:
- `connect <addr> <port> [pubkey_hex]` — STCP клиент к другому headless
- `connections` — показывает peer после connect
```bash
# Терминал 1: node1 (сервер, порт 9999 control, порт 12345 stcp)
./headless -c node1.cfg -p 9999
# Терминал 2: node2 (сервер, порт 9998 control, порт 12346 stcp)
./headless -c node2.cfg -p 9998
# Терминал 3: управляем node2
nc localhost 9998
> connect 127.0.0.1 12345 <node1_pubkey_hex>
connected to <node1_id>
> connections
1: <node1_id> 127.0.0.1:12345 ready
```
**Критерий:** Два headless устанавливают STCP handshake, соединение активно.
### 3.5. Тестирование — сообщение между двумя узлами
Добавить команды:
- `create_channel <name>` — создаёт канал
- `send <channel_id> <text>` — отправляет сообщение подключённому пиру
- `messages <channel_id> [limit]` — показывает сообщения из локальной БД
```bash
# node1 (control port 9999)
> create_channel general
created channel general
> send general hello from node1 # сообщение сохраняется в БД
msg_id=1
> messages general
[1] 12:00:00 (me) hello from node1
```
После того как node2 подключён и канал синхронизирован:
```bash
# node2 (control port 9998)
> messages general
[1] 12:00:00 <node1_id> hello from node1
```
**Критерий:** Сообщение, отправленное на node1, появляется в БД node2 через STCP.
---
## Этап 4. JNI-прослойка (C → Kotlin)
**Цель:** Kotlin-код вызывает функции C-ядра через JNI, C-ядро шлёт коллбэки в Kotlin.
### 4.1. android_jni_bridge.c
JNI-функции (вызываются из Kotlin):
```c
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit(
JNIEnv* env, jobject thiz, jstring configPath, jstring dbPath, jint controlPort);
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy(
JNIEnv* env, jobject thiz);
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage(
JNIEnv* env, jobject thiz, jstring channelId, jstring text);
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCreateChannel(
JNIEnv* env, jobject thiz, jstring name, jstring channelId);
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeConnectNode(
JNIEnv* env, jobject thiz, jlong nodeId, jstring addr, jint port, jbyteArray pubkey);
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback(
JNIEnv* env, jobject thiz, jobject callback);
```
Все действия (send, create, connect) отправляются в uasync-поток через `uasync_post`.
### 4.2. Коллбэки (C → Kotlin)
При `nativeInit()`:
1. JNI сохраняет `g_callback = env->NewGlobalRef(callback)`
2. `chat_event_set_handler(jni_event_handler)` — регистрирует C-обработчик
3. `jni_event_handler(int type, uint8_t* data, int len)`:
- Получает JNIEnv (через `g_jvm->AttachCurrentThread`)
- Вызывает `env->CallVoidMethod(g_callback, onEvent, type, byteArray)`
### 4.3. NativeLib.kt
```kotlin
object NativeLib {
init { System.loadLibrary("utun") }
external fun nativeInit(configPath: String, dbPath: String, controlPort: Int): Boolean
external fun nativeDestroy()
external fun nativeSendMessage(channelId: String, text: String)
external fun nativeCreateChannel(name: String, channelId: String)
external fun nativeConnectNode(nodeId: Long, addr: String, port: Int, pubkey: ByteArray)
external fun nativeSetEventCallback(callback: ChatEventCallback)
}
interface ChatEventCallback {
fun onEvent(type: Int, data: ByteArray?)
}
```
### 4.4. Тестирование (пока без Android)
Собрать `libutun.so` под Linux (не под Android NDK) и написать маленькую Java-программу,
которая грузит библиотеку и вызывает `nativeInit()`. Или написать C-тест, который
имитирует JNI-вызовы напрямую.
**Критерий:** `nativeInit()` создаёт uasync, запускает STCP сервер, открывает БД.
`nativeSendMessage()` пишет в БД. Коллбэк вызывается при событиях.
---
## Этап 5. Android UI (Jetpack Compose)
**Цель:** Минимальное Android-приложение: список каналов, чат, отправка сообщений.
### 5.1. Gradle проект
- `app/build.gradle.kts`: compileSdk 34, minSdk 26, NDK 26+
- Зависимости: Compose BOM, Material3, Navigation Compose, Room, Coroutines
- NDK: `externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } }`
### 5.2. app/src/main/cpp/CMakeLists.txt
NDK-сборка: компилирует `android_jni_bridge.c` + линкует `libutun_lite`
(сборка libutun_lite отдельно или inline в том же CMakeLists.txt).
Добавляет OpenSSL из NDK (или prebuilt .so).
### 5.3. Экраны (Compose)
**ChannelListScreen:**
- LazyColumn каналов (ChannelItem: иконка, имя, последнее сообщение, unread badge)
- FAB "+" для создания канала
- Pull-to-refresh
**ChatScreen:**
- TopAppBar с именем канала
- LazyColumn сообщений (MessageBubble: аватар, имя, текст, время)
- InputBar (TextField + IconButton send) внизу
**CreateChannelDialog:**
- AlertDialog с TextField имени
- Кнопки Create/Cancel
### 5.4. ViewModel
```kotlin
class ChatViewModel : ViewModel() {
private val _channels = MutableStateFlow<List<Channel>>(emptyList())
val channels: StateFlow<List<Channel>> = _channels
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
fun init(configPath: String, dbPath: String) { ... }
fun selectChannel(channelId: String) { ... }
fun sendMessage(text: String) { ... }
fun createChannel(name: String) { ... }
// Вызывается из JNI callback потока:
fun onNativeEvent(type: Int, data: ByteArray?) { ... }
}
```
### 5.5. Room DB (read-only)
DAO для чтения channels и messages. Использует ту же БД что и C-ядро (WAL mode).
Room открывает БД в read-only, C-ядро пишет. Конфликтов нет.
### 5.6. Тестирование
Запустить на эмуляторе или реальном устройстве. Проверить:
- Открывается список каналов (пустой)
- Создание канала (появляется в списке)
- Открытие канала (пустой чат)
- Отправка сообщения (появляется в чате)
**Критерий:** Приложение запускается, можно создать канал, написать сообщение, оно сохраняется в БД.
---
## Этап 6. Android Headless Service
**Цель:** Фоновый сервис без UI — для отладки сетевого стека на реальном устройстве.
### 6.1. HeadlessService.kt
```kotlin
class HeadlessService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Запуск foreground notification
// NativeLib.init(configPath, dbPath, controlPort)
// Уведомление: "uTun Chat running"
return START_STICKY
}
override fun onDestroy() {
NativeLib.destroy()
}
}
```
### 6.2. Отладка через adb
```bash
# На устройстве
adb shell am startservice com.utun.chat/.headless.HeadlessService
# Логи
adb logcat -s utun:*
# Control socket (проброс порта)
adb forward tcp:9999 tcp:9999
nc localhost 9999
> status
> debug etcp=trace
> connect <addr> <port> <pubkey>
```
**Критерий:** Сервис запускается, control-сокет доступен через adb forward, логи видны в logcat.
---
## Этап 7. End-to-end тесты
### 7.1. Headless тесты (Linux)
Скрипт `test_headless.sh`:
1. Запускает node1 на порту 9999 (control) и 12345 (stcp)
2. Запускает node2 на порту 9998 (control) и 12346 (stcp)
3. Через control-сокет node1: создаёт канал "general"
4. Через control-сокет node1: получает invite-ссылку
5. Через control-сокет node2: подключается к node1
6. Через control-сокет node2: join по invite
7. Через control-сокет node1: send "test message"
8. Через control-сокет node2: messages (проверка что сообщение пришло)
9. Остановка обоих экземпляров
```bash
cd tools/chatgui-android && ./test_headless.sh
```
### 7.2. Android Instrumentation тесты
```kotlin
@Test
fun testNativeInitAndSend() {
NativeLib.init(configPath, dbPath, controlPort)
// Проверить что БД создалась
val db = Room.databaseBuilder(...)
NativeLib.sendMessage("test_ch", "hello")
// Подождать и проверить что сообщение в БД
}
```
### 7.3. UI тесты (Compose)
```kotlin
@Test
fun testCreateChannelAndSend() {
composeTestRule.setContent { ChatApp() }
// Нажать FAB "+"
// Ввести имя канала
// Нажать Create
// Проверить что канал появился
// Открыть канал
// Ввести текст
// Нажать Send
// Проверить что сообщение отобразилось
}
```
---
## Контрольные точки
| Этап | Что проверяется | Где тестируется |
|------|----------------|-----------------|
| 1 | Проект собирается | Linux |
| 2 | libutun_lite.a компилируется без ошибок | Linux |
| 3 | Headless запускается, control socket отвечает | Linux |
| 3.4 | Два headless соединяются по STCP | Linux |
| 3.5 | Сообщение доставляется от node1 к node2 | Linux |
| 4 | JNI-функции вызываются, коллбэки приходят | Linux / эмулятор |
| 5 | Android приложение запускается, UI работает | Эмулятор / устройство |
| 6 | Headless Service работает, control через adb | Устройство |
| 7 | End-to-end тесты проходят | Linux + эмулятор |
## Правила разработки
- **Все C-файлы** — C99, стиль как в AGENTS.md
- **Логирование:** каждый модуль логирует инициализацию/очистку и ошибки
- **Memory:** u_malloc/u_free из lib/mem.h
- **Headless control:** все команды логируются входящие/исходящие
- **JNI:** каждая функция проверяет аргументы на NULL, логирует ошибки
- **Android UI:** ViewModel использует StateFlow, не держит ссылки на Context
- **Тесты:** каждая команда control-сокета проверяется хотя бы одним вызовом
test_android_bridge проверяет владение config callbacks, повторный init/destroy и
покадровое/батчевое декодирование настоящих Opus-файлов (пять частот, mono/stereo, EOF).
Эти тесты проверяют отдельные native-контракты, не весь Android lifecycle.
Устройство с собранным APK: сценарий задержки video worker на 500мс, EOF/reset и
независимых аппаратных аудиочасов — [stream_worker_smoke](../tests/stream_worker_smoke/README.md).
Критерии и зависимости заданы там; сетевой путь этим тестом не проверяется.
## 5. Фоновый сервис и диагностика устройства
Реализовано: HeadlessService как foreground service, сеть через NetworkChangeMonitor,
уведомления activity через NetworkActivityMonitor и native standby ACTIVE/SLEEP.
Отдельный Linux headless control socket не встроен в этот Android-сервис.
Общий headless API ядра требует явного `[chatserver] headless_control_bind`; параметр
controlPort у NativeLib.init listener не открывает.
Проверять: foreground → background → SLEEP → timer/ранний wake → foreground;
затем смену сети и restart. Уровни категорий задать до повторного прогона, чтобы
логи показывали причину перехода и согласованный набор сокетов.
UDP-лог настроить через `[log_udp]`; приёмник запускать по инструкции `tools/logreceiver/`.
После shutdown/restart не должно оставаться таймера старого uasync.
Критерий: ядро продолжает обслуживать события, standby-ожидания корректно будятся
или отменяются, UI получает новое состояние; при ошибке причина видна в логах.
Проверки на физическом устройстве выполняются отдельно от сборки и Linux-тестов.
@@ -14,13 +14,19 @@ struct UASYNC;
/* ── Lifecycle ── */
/* Открыть один IPv4 listener: bind_ip=NULL → 127.0.0.1, port=1..65535; ua игнорируется.
* 0 — слушает, -1 — ошибка. Перед повторным init вызвать destroy. */
int headless_control_init(struct UASYNC* ua, const char* bind_ip, int port);
/* Обработать подключения/строки; select может ждать до 100мс. Все API модуля — из одного потока. */
void headless_control_poll(void);
/* Закрыть listener и всех клиентов, повторный вызов допустим. */
void headless_control_destroy(void);
/* ── Send event to all subscribed clients ── */
/* json_event — готовая NUL-терминированная JSON-строка; заимствуется на вызов, добавляется LF. */
void headless_control_event(const char* json_event);
/* Формирует событие log; строки должны быть уже безопасны для включения внутрь JSON-кавычек. */
void headless_control_broadcast_log(const char* level, const char* category, const char* msg);
#endif /* HEADLESS_CONTROL_H */
@@ -148,18 +148,16 @@ bool utun_bridge_init(const char* db_path, int control_port,
g_on_event = on_event;
debug_set_log_hook(bridge_debug_hook);
if (config) {
g_cfg_string = config->get_string;
g_cfg_int64 = config->get_int64;
g_cfg_int = config->get_int;
utun_config_provider_t provider = {
.get_string = cfg_wrapper_string,
.get_int64 = cfg_wrapper_int64,
.get_int = cfg_wrapper_int,
.get_bool = NULL,
};
utun_config_set_provider(&provider);
}
g_cfg_string = config ? config->get_string : NULL;
g_cfg_int64 = config ? config->get_int64 : NULL;
g_cfg_int = config ? config->get_int : NULL;
utun_config_provider_t provider = {
.get_string = cfg_wrapper_string,
.get_int64 = cfg_wrapper_int64,
.get_int = cfg_wrapper_int,
.get_bool = NULL,
};
utun_config_set_provider(config ? &provider : NULL);
bridge_log(BLEV_INFO, "bridge init: db=%s port=%d (stub, use nativeStart for real init)",
db_path ? db_path : "(null)", control_port);
@@ -170,6 +168,7 @@ bool utun_bridge_init(const char* db_path, int control_port,
void utun_bridge_destroy(void) {
voice_recorder_deinit();
instance_lite_stop();
utun_config_set_provider(NULL);
g_on_log = NULL;
g_cfg_string = NULL;
g_cfg_int64 = NULL;
@@ -1557,13 +1556,26 @@ invalid_stream:
int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples) {
struct voice_decode_handle* h = (struct voice_decode_handle*)handle;
if (!h || !buf || max_samples <= 0) return -1;
if (!h || !buf || max_samples <= 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: invalid read handle=%p buffer=%p samples=%d", handle, (void*)buf, max_samples);
return -1;
}
if (h->pcm_consumed >= h->total_samples) return 0;
int frame = h->frame_samples * h->channels;
int remaining = h->total_samples - h->pcm_consumed;
int capacity = max_samples < remaining ? max_samples : remaining;
DEBUG_TRACE(DEBUG_CATEGORY_MEDIA, "voice decode: requested=%d remaining=%d capacity=%d frame=%d",
max_samples, remaining, capacity, frame);
if (capacity < frame) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "voice decode: read buffer too small samples=%d frame=%d", max_samples, frame);
return 0;
}
uint8_t packet[4096];
int written = 0;
while (written + h->frame_samples * h->channels <= max_samples) {
/* capacity ограничена остатком записи; вычитание не переполняется даже при max_samples=INT_MAX. */
while (capacity - written >= frame) {
uint16_t plen;
if (fread(&plen, 2, 1, h->file) != 1 || plen == 0 || plen > 4000 || fread(packet, 1, plen, h->file) != plen) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: stream changed after validation"); return -1;
@@ -1571,9 +1583,12 @@ int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples) {
int decoded = opus_codec_decode(h->dec, packet, plen, buf + written, h->frame_samples);
if (decoded != h->frame_samples) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: invalid packet result=%d", decoded); return -1; }
written += decoded * h->channels;
written += frame;
}
h->pcm_consumed += written;
if (h->pcm_consumed == h->total_samples)
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "voice decode: complete samples=%d rate=%d channels=%d",
h->pcm_consumed, h->sample_rate, h->channels);
return written;
}
@@ -1,9 +1,13 @@
/*
* android_jni_bridge.h — JNI bridge API (C side)
*
* Provides lifecycle management and callbacks between C core and Kotlin.
* NDK собирает bridge вместе с ядром в libutun.so. Действия передаются в uasync;
* callbacks логов/событий вызываются из native-потоков, UI переносит их в свой поток.
* C API Android bridge; JNI-точки входа Kotlin находятся в .c под __ANDROID__ и не все входят в этот заголовок.
* NDK собирает bridge с общим ядром в libutun.so. Команды обычно ставятся в uasync, готовность/результат приходят событиями.
* Синхронные getters читают SQLite/живые структуры в вызывающем потоке; set_member_nick также пишет SQLite напрямую.
* Внешний владелец сериализует такие вызовы и команды по сырым handles с stop/restart; getter не удерживает instance.
* Все char* JSON-результаты принадлежат caller (u_free); при ошибке возможны []/{}, при OOM также NULL.
* channel_id — десятичная NUL-строка; conv_id — ID DM без dm:. Префикс dm: нужен только voice/video target.
* reply_to в send/voice/photo/video для канала — обязательный указатель на 32 байта (нули = без ответа).
* request_id коррелирует канальную публикацию с CHAT_EVT_MSG_SUBMITTED; постановка в очередь не означает доставку.
* Аргументы строк/PCM заимствованы на вызов, асинхронные команды копируют их; сроки жизни файлов описаны ниже.
*/
#ifndef ANDROID_JNI_BRIDGE_H
#define ANDROID_JNI_BRIDGE_H
@@ -18,65 +22,89 @@ extern "C" {
/* ── Callback types (C → Kotlin) ── */
/* Native-поток; строки заимствованы на вызов. level: TRACE=0, DEBUG=1, INFO=2, WARN=3, ERROR=4. */
typedef void (*utun_log_fn)(int level, const char* category, const char* message);
/* Отдельный C JSON-callback, вызываемый utun_bridge_event; это не бинарный CHAT_EVT_* callback JNI.
* Рабочие события instance_lite передаются instance_lite_event_fn / Kotlin onEvent(type, ByteArray?). */
typedef void (*utun_event_fn)(int event_type, const char* json_data);
/* Дополнительный callback-конфиг обёртки; основной конфиг ядра передаётся INI в start. */
typedef const char* (*utun_config_string_fn)(const char* key);
typedef int64_t (*utun_config_int64_fn)(const char* key);
typedef int (*utun_config_int_fn)(const char* key);
struct utun_bridge_config {
utun_config_string_fn get_string;
utun_config_int64_fn get_int64;
utun_config_int_fn get_int;
utun_config_string_fn get_string; /* NUL-строка, NULL → default; результат не копируется wrapper. */
utun_config_int64_fn get_int64; /* NULL → default, установленный callback сам определяет отсутствие ключа. */
utun_config_int_fn get_int; /* То же для int. Функции и их окружение живут до destroy. */
};
/* ── Initialize uTun core (legacy stub — use start for real init) ── */
/* Сохранить callbacks, установить debug hook, инициализировать voice_recorder(db_path).
* Таблицы callbacks копируются, config=NULL отключает прежний config-provider; функции/окружение остаются у caller.
* control_port только логируется: listener и ядро здесь не запускаются. Текущая реализация возвращает true
* независимо от результата voice_recorder_init; start нужен отдельно. */
bool utun_bridge_init(const char* db_path, int control_port,
utun_log_fn on_log,
utun_event_fn on_event,
const struct utun_bridge_config* config);
/* ── Start full uTun stack from INI config text ── */
/* INI копируется в instance_lite: 0 — поток создан/уже существует, -1 — отказ.
* Готовность сервисов — CHAT_EVT_SERVICE_STARTED, а не возврат start. */
int utun_bridge_start(const char* config_text);
/* ── Shutdown uTun core ── */
/* Освободить recorder, вызвать instance_lite_stop и снять C log/config callbacks.
* Config-provider сбрасывается к defaults; повторный init начинает новую регистрацию.
* Перед вызовом остановить внешние media workers/DSP; ограничения ожидания — instance_lite.h.
* JNI nativeStop дополнительно останавливает UDP-лог, nativeDestroy освобождает JNI global refs. */
void utun_bridge_destroy(void);
/* ── Actions (Kotlin → C) ── */
/* text — NUL-строка UTF-8; 0 — запрос поставлен в uasync, -1 — отказ подготовки. */
int utun_bridge_send_message(const char* channel_id, const char* text, const uint8_t reply_to[32], uint64_t request_id);
/* Создать канал по name; переданный channel_id игнорируется, ID генерирует ядро. Результат — CHAT_EVT_CHANNEL_UPDATED. */
void utun_bridge_create_channel(const char* name, const char* channel_id);
/* Заглушка: только пишет address/port/pubkey_hex в лог, соединение не создаёт. */
void utun_bridge_connect_node(const char* address, int port, const char* pubkey_hex);
/* Connect to all peers of a channel (auto-connect on select) */
/* Поставить запрос автоподключения канала; выбор пиров выполняет общий topo_group_connect. */
void utun_bridge_connect_channel(const char* channel_id);
/* Однократная попытка подключения к конкретному узлу канала (ручной "Connect"). */
void utun_bridge_connect_member_node(const char* channel_id, uint64_t node_id);
/* ── Queries ── */
/* Синхронные JSON-снимки; память освобождать u_free. */
char* utun_bridge_get_channels_json(void);
/* limit<=0 → 50; anchor=NULL — последние сообщения, иначе окно вокруг 32-байтового message_id.
* exact!=0 с anchor выбирает только этот ID; порядок результата timestamp/message_id по возрастанию.
* Зашифрованный файл доступен UI только после decrypt с валидными метаданными. */
char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uint8_t* anchor, int exact);
/* Текущий node_id либо 0, если чат не инициализирован. */
uint64_t utun_bridge_get_my_node_id(void);
/* ── Restart (config update, key regen, DB clear) ── */
/* Передать INI в instance_lite_restart, затем до 5с опрашивать is_running.
* У живого ядра старое is_running может сразу дать 1: возврат не гарантирует завершение рестарта.
* Событие CHAT_EVT_SERVICE_STARTED подтверждает запуск нового ядра; перед вызовом остановить media workers/DSP. */
void utun_bridge_restart(const char* config_text);
/* ── Лёгкое обновление сокетов (auto_sockets=android) ── */
/* Копирует INI, ставит reload [server:*] в uasync (auto_sockets=android); без готового ядра запрос отбрасывается. */
void utun_bridge_update_sockets(const char* config_text);
/* ── Health check ── */
/* Локальный ping uasync; is_responsive=1 после обработки последнего ping, 0 до первого/после stop.
* Ожидания и таймаута здесь нет, доступность пиров не проверяется. */
void utun_bridge_ping(void);
int utun_bridge_is_responsive(void);
/* ── Voice recording ── */
/* Voice recorder: start лениво делает init, фиксирует target канала/dm:conv_id, reply/request/generation.
* Формат int16 mono 48kHz; feed count — число отсчётов. 0 — успех, -1 — отказ.
* stop(out_duration_ms nullable) передаёт PCM/compressor фоновой подготовке, запись <500мс отбрасывает с 0.
* is_active=0/1; preset=0..2; peak=(2*RMS+peak)/2 последнего raw feed. Подробности — voice_recorder.h. */
int utun_bridge_voice_start(const char* channel_id, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation);
int utun_bridge_voice_feed(const int16_t* samples, int count);
@@ -91,29 +119,41 @@ void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, i
float rise_rate_per_sec, float target_level_db);
float utun_bridge_voice_get_peak_level(void);
/* ── Attachment ── */
/* Обычный файл в канал: 0 — поставлено, -1 — отказ. db_path — каталог БД;
* исходный файл остаётся у caller и нужен неизменным до завершения media-копирования. */
int utun_bridge_attachment_send(const char* channel_id, const char* file_path, const char* db_path,
const uint8_t reply_to[32], uint64_t request_id);
/* Скачать вложение: msg_id>0 — локальный SQLite row ID; 0 — запрос поставлен, -1 — отказ. */
int utun_bridge_attachment_download(const char* channel_id, int64_t msg_id);
/* Отправка видеосообщения (src — готовый mp4, без транскода) */
/* Готовый MP4 в канал/dm:conv_id, без транскода; db_path игнорируется.
* 0 передаёт владение временным файлом задаче (удаление также при ошибке/отмене), -1 оставляет файл caller.
* duration_ms=1..86400000, размеры=1..16384; generation применяется к DM. См. video_sender.h. */
int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path,
int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation);
/* Отправка фото (src — готовый jpeg, без транскода) */
/* Готовое фото в канал, без декодирования; JPEG/PNG/GIF/BMP/WebP по расширению, caller задаёт размеры.
* db_path — каталог БД; источник остаётся у caller до завершения копирования. 0/-1, см. photo_sender.h. */
int utun_bridge_photo_send(const char* channel_id, const char* file_path, const char* db_path,
int width, int height, const uint8_t reply_to[32], uint64_t request_id);
/* ── Voice playback (Opus decode) ── */
/* Декодирование собственного OPUS-файла (не Ogg): open возвращает owned handle либо NULL.
* Все три out обязательны: частота, число каналов, общее число int16 (включая interleaved каналы).
* Один последовательный reader на handle; close освобождает decoder/FILE, NULL допустим. */
void* utun_bridge_voice_decode_open(const char* file_path,
int* out_sample_rate, int* out_channels,
int* out_total_samples);
/* buf вмещает max_samples int16, выдаются только целые 20мс кадры (rate/50*channels).
* Возврат: число int16, 0 — EOF либо ёмкость меньше кадра, -1 — аргументы/ошибка чтения или decode.
* Последний батч ограничивается оставшимися отсчётами: EOF возвращает 0 при следующих чтениях.
* После ошибки чтения/decode закрыть handle; продолжение этого потока не поддерживается. */
int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples);
void utun_bridge_voice_decode_close(void* handle);
/* ── P2P аудио-звонок (сигналинг + медиа) ── */
/* Сигналинг ставится в uasync: ch_id — десятичный ID группы либо dm:conv_id, peer_node_id и call_id ненулевые.
* Результат — CHAT_EVT_CALL_*; accept/decline/hangup применяются к указанному call_id. */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id);
void utun_bridge_call_accept(uint64_t call_id);
@@ -124,20 +164,28 @@ void utun_bridge_call_hangup(uint64_t call_id);
* Opus → peer (feed); принятый Opus → джиттер+декод+stretch → PCM (pull). */
/* GUI-поток: начать DSP после готовности ядра; call_audio уже привязан его worker. 0 = успех, -1 = ошибка. */
int utun_bridge_call_audio_start(uint64_t call_id);
/* Capture worker: настоящий raw PCM, ровно960 mono-сэмплов @48кГц; muted!=0 применяется после AEC/AGC.
/* Capture worker: настоящий raw PCM, ровно 960 mono-сэмплов @48кГц; muted!=0 применяется после AEC/AGC.
* capture_pts_us — монотонная метка первого сэмпла либо INT64_MIN. 0 = поставлено в uasync, -1 = отказ/ошибка. */
int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count, int muted, int64_t capture_pts_us);
/* Playback worker: int16 mono 48kHz, активный DSP выдаёт max_samples; 0 — нет движка/неверные аргументы. */
int utun_bridge_call_audio_pull(uint64_t call_id, int16_t* out, int max_samples);
/* После остановки внешних feed/pull workers освободить DSP; привязку к instance снимает worker ядра. */
void utun_bridge_call_audio_stop(void);
/* ── Рация (PTT walkie-talkie): подписка + аудио-движок ── */
/* Подписка — posted-команда; subscribers читает текущий счётчик синхронно (0 без instance). */
void utun_bridge_radio_set_active(uint64_t group_id, int on);
int utun_bridge_radio_subscribers(uint64_t group_id);
/* Создать/привязать DSP и начать рацию: capture_channels=0 только RX, 1/2 RX+TX. 0/-1.
* Один TX writer для feed/talk_begin/end и отдельный RX writer для pull; lifecycle сериализует frontend. */
int utun_bridge_radio_audio_start(uint64_t group_id, int capture_channels);
/* PCM 48kHz int16 interleaved, count — число int16, кратное числу каналов; длина произвольная. См. radio_audio.h. */
int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count);
/* PCM 48kHz stereo, max_samples/возврат — число int16, а не кадров; см. radio_audio.h. */
int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_samples);
/* Остановить после завершения внешних feed/pull callers. */
void utun_bridge_radio_audio_stop(void);
/* RADIO_AUTO_OFF/VAD/LEVEL; ручные talk_begin/end управляют PTT текущей группы. */
int utun_bridge_radio_auto_mode(void);
void utun_bridge_radio_talk_begin(uint64_t group_id);
void utun_bridge_radio_talk_end(uint64_t group_id);
@@ -147,68 +195,81 @@ void utun_bridge_radio_talk_end(uint64_t group_id);
void utun_bridge_request_invite_candidates(const char* channel_id);
void utun_bridge_request_invite_link(const char* channel_id, uint64_t target_node_id, uint64_t request_id);
/* ── Debug ── */
/* Применить уровень синхронно: пустая/NULL category → общий уровень, иначе имя из debug_config. */
void utun_bridge_set_debug_level(const char* category, const char* level);
/* ── Chat settings ── */
/* C API: set синхронно вызывает chat_core_set_setting, только из uasync; get читает с fallback def.
* JNI nativeSetChatSetting отдельно копирует запрос и ставит trampoline в uasync. */
void utun_bridge_chat_setting_set(const char* name, const char* value);
int utun_bridge_chat_setting_get_int(const char* name, int def);
/* ── Channel members ── */
/* Снимок БД, поле on здесь всегда 0; актуальное состояние — отдельное событие CHAT_EVT_MEMBER_LIST. */
char* utun_bridge_get_channel_members_json(const char* channel_id);
/* Данные БД (адреса/инвайтер); live-достижимость получать отдельным CHAT_EVT_MEMBER_LIST. */
char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id);
/* Синхронная локальная правка SQLite: пустой/NULL nick сбрасывает псевдоним. В сеть не распространяется. */
void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick);
/* Асинхронный toggle локального mute участника рации: 0 — поставлено, -1 — отказ. */
int utun_bridge_toggle_member_radio_mute(const char* channel_id, uint64_t node_id);
/* 1 — в локальной БД есть приватный ключ канала, 0 — нет/ошибка; не общий тест всех административных прав. */
int utun_bridge_is_channel_owner(const char* channel_id);
/* Поставить подписанную правку всех шести тегов; проверку полномочий выполняет chat_member_tags_commit. */
void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id,
int admin, int supernode, int moder, int storage,
int verified, int deleted);
/* Синхронный обход живых ETCP links, {} без соединения. Не вызывать параллельно изменениям списка. */
char* utun_bridge_get_member_links_json(uint64_t node_id);
/* Передать канальный приватный ключ (права админа) другому узлу. */
/* Асинхронно передать канальный приватный ключ (права админа) другому узлу. */
void utun_bridge_transfer_admin(const char* channel_id, uint64_t node_id);
/* Локально удалить канал (drop таблиц, соединений, медиа, db_sync). */
/* Поставить локальное удаление канала (таблицы, соединения, медиа, db_sync); удаление не распространяется по сети. */
void utun_bridge_delete_channel(const char* channel_id);
/* ── Per-channel «play sound on message» (троттлинг db_sync на сервере) ── */
/* Политика звука канала: get синхронный (fallback 1), set ставится в uasync и нормализует on к 0/1. */
int utun_bridge_get_sound_on_message(const char* channel_id);
void utun_bridge_set_sound_on_message(const char* channel_id, int on);
/* ── Local sockets state (for auto-mode display) ── */
/* Синхронный обход живых сокетов/links для UI auto-mode; probe_only скрыты, [] без готового чата. */
char* utun_bridge_get_local_sockets_json(void);
/* ── Network change notification (ConnectivityManager → auto_socket) ── */
/* Делегировать auto_socket_on_network_change: режим android игнорирует вызов (нужен update_sockets),
* в других режимах auto_socket ставит scan в uasync. */
void utun_bridge_on_network_change(void);
/* ── App foreground/background (Android lifecycle) → client_activity ── */
/* Поставить client_activity в uasync: active!=0 — foreground, 0 — standby. */
void utun_bridge_set_active(int active);
/* ── External network activity (ConnectivityManager) → standby early wake ── */
/* Поставить уведомление standby в uasync; досрочный wake только в SLEEP после min_sleep. */
void utun_bridge_notify_network_active(void);
/* ── Connection monitor (post collect functions to uasync thread) ── */
/* Асинхронные снимки через CHAT_EVT_CONN_LIST/CHAT_EVT_CONN_METRICS. */
void utun_bridge_collect_conn_list(void);
void utun_bridge_collect_conn_metrics(uint64_t peer_node_id);
/* ── DM (личные сообщения) ──
* dm_start/dm_send выполняются асинхронно через uasync (трамплины dm_core).
* Результат dm_start — событие CHAT_EVT_DM_CONV_UPDATED. */
* Результат dm_start — CHAT_EVT_DM_CONV_UPDATED; source_ch_id/peer_name могут быть NULL.
* generation в send/history/file относится к поколению беседы, не к instance. */
void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name);
void utun_bridge_dm_send(const char* conv_id, const char* text, uint64_t generation);
/* Запросить CHAT_EVT_DM_CONVERSATIONS. */
void utun_bridge_dm_request_conversations(void);
/* Снимок видимой истории указанного поколения; запоздалый запрос отбрасывается в uasync. */
void utun_bridge_dm_request_messages(const char* conv_id, uint64_t generation);
/* 0 — принято и передано владение временным path (удаляется задачей), -1 — отказ, файл остаётся caller.
* name — отображаемое имя, строки копируются. Поколение беседы проверяется в uasync. */
int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* name, uint64_t generation);
#ifdef __cplusplus
@@ -1,8 +1,9 @@
/*
* android_udp_log.h — non-blocking UDP log sender for Android
*
* Thread-safe buffer with non-blocking send.
* Flushes at 16KB threshold or 200ms timer.
* Android UDP-лог: set_target → attach к uasync → write → detach → stop.
* Общий буфер защищён mutex; send неблокирующий, доставка без повторов.
* Flush при 16KiB, переполнении либо каждые 200мс при привязанном uasync.
* Один IPv4 datagram содержит до 65507 байтов, строки длиннее отбрасываются целиком.
* Ошибки идут в utun-udplog/stderr без debug hook, чтобы не создавать рекурсию.
*/
#ifndef ANDROID_UDP_LOG_H
#define ANDROID_UDP_LOG_H
@@ -17,17 +18,17 @@ struct UASYNC;
void udp_log_attach(struct UASYNC* ua);
void udp_log_detach(struct UASYNC* ua);
/* Любой поток. Смена адреса передаёт управление таймером в поток ядра. */
/* Любой поток: ip — числовой IPv4, port=1..65535; неверный адрес/порт выключает отправку.
* Старый буфер отправляется прежнему адресату; таймер обновляется posted-командой в uasync. */
void udp_log_set_target(const char* ip, int port);
/* Write log line to buffer. Non-blocking send if threshold reached.
* Thread-safe — call from any thread (bridge_debug_hook). */
/* Любой поток: копирует len байтов (включая нужные caller разделители); без target ничего не сохраняет. */
void udp_log_write(const char* data, int len);
/* Любой поток: ошибки отправляем немедленно, до возможной остановки ядра. */
/* Любой поток: отправить накопленное немедленно; ошибка отправки также очищает буфер. */
void udp_log_flush(void);
/* Flush buffer, close socket. Call after instance shutdown. */
/* После shutdown/detach: flush, закрыть сокет и выключить target; stop сам не снимает uasync-таймер. */
void udp_log_stop(void);
#ifdef __cplusplus
@@ -1,6 +1,7 @@
/* Android-обёртка отправки обычного файла в канал через chat_core_submit_trampoline.
* Строит имя назначения в db_path/media/channel_id, копирование/индексацию выполняет media_index.
* 0 = запрос поставлен в uasync, -1 = отказ подготовки. Исходный файл нужен до завершения media-задачи. */
* 0 = запрос поставлен в uasync, -1 = отказ подготовки, результат публикации — CHAT_EVT_MSG_SUBMITTED.
* Вызовы сериализовать со stop/restart ядра; строки копируются, исходный файл нужен неизменным до завершения media-задачи. */
#ifndef ATTACHMENT_SENDER_H
#define ATTACHMENT_SENDER_H
#include <stdint.h>
@@ -9,6 +10,9 @@
extern "C" {
#endif
/* channel_id — десятичный ID канала, db_path — каталог БД; пути/строки NUL-терминированы.
* reply_to обязательно указывает на 32 байта (нули = без ответа), request_id коррелирует результат с UI.
* Источник остаётся у caller и здесь не удаляется; MIME application/octet-stream, имя — basename источника. */
int attachment_send(const char* channel_id, const char* src_file_path, const char* db_path,
const uint8_t reply_to[32], uint64_t request_id);
@@ -3,6 +3,8 @@
* UTUN выключен по умолчанию; [gui] utun_enabled=yes включает его явно. Поток владеет instance/ua; UI передаёт работу через uasync.
* С UTUN_WITH_VOICE worker регистрирует call_audio до SERVICE_STARTED и снимает привязку перед уничтожением ядра.
* Полученные handles заимствованы и могут смениться при restart; сохранять их между рестартами нельзя.
* Управляющие start/stop/restart и прочие вызовы по сырым handles сериализует frontend:
* атомарный getter не удерживает ресурс от уничтожения. JOIN использует отдельный защищённый port.
*/
#ifndef INSTANCE_LITE_H
#define INSTANCE_LITE_H
@@ -21,28 +23,33 @@ struct UTUN_INSTANCE;
* Готовность самого ядра появляется позже: проверять is_running и события инициализации. */
int instance_lite_start(const char* config_text);
/* Сигнализировать stop и дождаться потока; поток снимает media-привязки, уничтожает instance, затем uasync.
/* При существующем instance сигнализировать stop и ждать поток до 2с; обычно затем pthread_join.
* При timeout поток отсоединяется, завершение cleanup не гарантировано к возврату; без instance вызов сразу возвращается.
* Worker снимает media-привязки, уничтожает instance, затем uasync.
* Управляющий frontend предварительно останавливает аудио I/O и освобождает DSP звонка.
* Вызывать с управляющего потока при существующем instance, не из callback ядра. */
void instance_lite_stop(void);
/* Returns non-zero if instance is running. */
/* 1 — instance опубликован и завершил запуск сервисов, 0 — ещё не готов либо остановлен; это не проверка сети. */
int instance_lite_is_running(void);
/* Get current uasync handle (NULL if not running). */
/* Текущий заимствованный uasync, NULL до create/после destroy; ненулевой handle ещё не означает готовность ядра. */
struct UASYNC* instance_lite_get_uasync(void);
/* Get current UTUN_INSTANCE handle (NULL if not running). */
/* Текущий заимствованный instance, NULL до create/после снятия; может быть ненулевым во время инициализации. */
struct UTUN_INSTANCE* instance_lite_get_instance(void);
/* Потокобезопасный JOIN: 0 — принят с request, <0 — отказ без события. */
/* Потокобезопасный JOIN через удержанный port: копирует len байтов ссылки, request ненулевой.
* 0 — принят, результат CHAT_EVT_CONNECT_RESULT с тем же request; <0 — отказ без события.
* Не требует от caller удерживать inst/ua; при stop/restart port закрывается. */
int instance_lite_join_link(uint64_t request, const char* link, size_t len);
/* Set Kotlin callback for chat events (CONNECT_RESULT, MSG_RECEIVED, ...) */
/* События CHAT_EVT_* из native-потока; data заимствован только на вызов, формат зависит от type (не всегда JSON).
* Установить до start; callback и его окружение должны жить до завершения worker. NULL отключает доставку. */
typedef void (*instance_lite_event_fn)(int type, const uint8_t* data, int len);
void instance_lite_set_event_handler(instance_lite_event_fn handler);
/* Regenerate X25519 keys in the worker thread. Saves to keys.conf file.
* Call from any thread. Keys are regenerated asynchronously. */
/* Поставить генерацию X25519-ключей в uasync; обновляет config, my_keys, node_id и пытается сохранить db_path/keys.conf.
* CHAT_EVT_KEYS_GENERATED несёт 64 HEX-байта публичного ключа. Само ядро/соединения здесь не пересоздаются. */
void instance_lite_regenerate_keys(void);
/* Копирует INI, будит poll-loop и пересоздаёт instance и uasync внутри worker.
@@ -54,11 +61,12 @@ void instance_lite_restart(const char* new_config_text);
* Если ядро не запущено — только логирует ошибку и дропает вызов. */
void instance_lite_update_sockets(const char* config_text);
/* Health check: posts a ping to uasync, returns responsiveness after delay */
/* Поставить локальный ping в uasync (не сетевой). is_responsive=1 после исполнения последнего ping;
* до первого ping/при остановке возвращает 0. Встроенного ожидания и проверки таймаута нет. */
void instance_lite_ping(void);
int instance_lite_is_responsive(void);
/* Connection monitor: post collect_conn_list/conn_metrics to uasync thread */
/* Асинхронные снимки: CHAT_EVT_CONN_LIST и CHAT_EVT_CONN_METRICS; peer_node_id — ID интересующего пира. */
void instance_lite_collect_conn_list(void);
void instance_lite_collect_conn_metrics(uint64_t peer_node_id);
@@ -1,6 +1,7 @@
/* Старая отдельная копия C-кодека utun://; invite_link_c.c исключён из текущего UTUN_SOURCES.
* Рабочее общее API — src/chat/invite_link.h, Qt/Kotlin используют тот же формат версии 0x03.
* Эти структуры не следует смешивать с InviteData общего API. */
* Эти структуры не следует смешивать с InviteData общего API; символы функций совпадают, вместе линковать нельзя.
* Копия декодирует версии 1/2/3, кодирует только 3; это описание оставшейся реализации, не API приложения. */
#ifndef INVITE_LINK_C_H
#define INVITE_LINK_C_H
@@ -22,46 +23,47 @@ extern "C" {
#endif
struct InviteAddrC {
int family;
uint8_t address[16];
uint8_t socketId;
uint8_t proto;
uint16_t port;
int family; /* 4 — IPv4, 6 — IPv6; не AF_INET/AF_INET6. */
uint8_t address[16]; /* Сетевые байты; для IPv4 используются первые 4. */
uint8_t socketId; /* ID сокета в записи узла. */
uint8_t proto; /* Биты INVITE_PROTO_*; REALITY поверх TCP. */
uint16_t port; /* Host byte order; wire использует BE. */
};
/* Reality-параметры узла (node-global: одинаковы для всех reality-сокетов). */
struct InviteRealityC {
uint8_t has;
uint8_t short_id[8];
uint8_t server_pubkey[32];
uint8_t version[3];
char server_name[INVITE_SNAME_MAX];
uint8_t has; /* 0 — блок отсутствует, иначе поля ниже заполнены. */
uint8_t short_id[8]; /* REALITY short ID. */
uint8_t server_pubkey[32]; /* X25519 ключ REALITY-сервера. */
uint8_t version[3]; /* Тройка байтов версии. */
char server_name[INVITE_SNAME_MAX]; /* NUL-строка SNI. */
};
struct InviteDataC {
uint64_t channelId;
uint64_t channelId; /* ID канала в host byte order. */
uint64_t join_key; /* 64-битный ключ входа (генерируется инвайтером) */
uint8_t pubkey[INVITE_PUBKEY_SIZE];
uint64_t nodeId;
uint8_t addrCount;
struct InviteAddrC addrs[INVITE_ADDR_MAX];
char password[INVITE_PASS_MAX]; /* empty = no password (v1 compat) */
uint8_t password_len;
struct InviteRealityC reality;
uint8_t pubkey[INVITE_PUBKEY_SIZE]; /* Ключ первого блока при decode, всех блоков при encode. */
uint64_t nodeId; /* Вычисляется из первого pubkey; encode не использует. */
uint8_t addrCount; /* Число элементов addrs, максимум INVITE_ADDR_MAX. */
struct InviteAddrC addrs[INVITE_ADDR_MAX]; /* Адреса всех прочитанных блоков без разделения по ключу. */
char password[INVITE_PASS_MAX]; /* Декодированный пароль, NUL; пусто = без пароля. */
uint8_t password_len; /* Длина декодированного пароля в байтах. */
struct InviteRealityC reality; /* Общие REALITY-параметры. */
};
/* decode utun://base64 link string, returns 0 on success, -1 on error.
error_buf необязателен, текст ошибки ограничивается error_buf_size. */
/* link — len байтов с префиксом utun://; out предварительно обнулить. 0 — разобрано, -1 — ошибка.
* error_buf необязателен; частично изменённый out при ошибке не использовать. */
int invite_link_decode(const char* link, size_t link_len, struct InviteDataC* out,
char* error_buf, size_t error_buf_size);
/* encode invite data to utun://base64 string.
password=NULL означает пустой пароль в версии 0x03.
out_size включает utun://, base64 и NUL; недостаточная ёмкость даёт -1.
data.password/password_len/nodeId игнорируются; addrCount=1..32, строки должны быть NUL-терминированы.
Реализация также отвергает pubkey с первым нулевым байтом. Буфер брать с запасом: проверка ёмкости консервативна.
returns base64 length (excluding prefix and null), or -1 on error */
int invite_link_encode(const struct InviteDataC* data, const char* password, char* out, size_t out_size);
/* serialize for chat_sync_connect_from_invite(): Reality-префикс + адреса:
/* Сериализовать в caller-буфер Reality-префикс (has:1 + параметры при has) и адреса:
family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE)
returns total bytes written, or -1 if buf too small */
int invite_serialize_addrs(const struct InviteDataC* data, uint8_t* buf, size_t buf_size);
@@ -1,6 +1,7 @@
/* Отправка уже подготовленного изображения в канал: MIME выбирается по расширению, width/height даёт caller.
* Не декодирует и не масштабирует изображение; ставит копирование/индексацию в chat_core/media_index.
* 0 = запрос поставлен в uasync, -1 = отказ. src_file_path должен существовать до завершения задачи. */
* 0 = запрос поставлен в uasync, -1 = отказ; результат — CHAT_EVT_MSG_SUBMITTED.
* Вызовы сериализовать со stop/restart; неизменный src_file_path нужен до завершения задачи, здесь не удаляется. */
#ifndef PHOTO_SENDER_H
#define PHOTO_SENDER_H
#include <stdint.h>
@@ -9,6 +10,9 @@
extern "C" {
#endif
/* channel_id — десятичный ID канала, db_path — каталог БД, width/height — размеры подготовленного файла.
* PNG/GIF/BMP/WebP определяются по расширению без учёта регистра, остальное считается JPEG.
* reply_to — обязательные 32 байта (нули = без ответа), request_id — корреляция результата с UI. */
int photo_send(const char* channel_id, const char* src_file_path, const char* db_path,
int width, int height, const uint8_t reply_to[32], uint64_t request_id);
+15 -9
View File
@@ -5,8 +5,10 @@
* узел периодически просыпается: ACTIVE (X сек) → SLEEP (Y сек) → ACTIVE ...
*
* Потребители периодической работы используют standby_wait(): колбэк
* срабатывает на старте следующего активного интервала, а при выходе из
* standby (приложение вернулось в форграунд) — немедленно.
* в SLEEP срабатывает на старте следующего ACTIVE; в ACTIVE ставится отложенно.
* Выход из standby (приложение вернулось в форграунд) будит ожидающих немедленно.
* Singleton: все API, включая getters/cancel, выполняются в одном uasync-потоке.
* TB = 0.1мс; SLEEP управляет сетевым duty-cycle, не останавливает event loop.
*/
#ifndef STANDBY_H
#define STANDBY_H
@@ -19,7 +21,7 @@ extern "C" {
struct UTUN_INSTANCE;
/* Привязать модуль к uasync. Вызывается после каждого uasync_create(). */
/* Привязать к живому uasync. Вызывать после create; перед повторной привязкой обязательно deinit. */
void standby_init(struct UASYNC* ua);
/* Отмена таймеров, освобождение пула и очистка подписок (без вызова колбэков). */
@@ -34,29 +36,32 @@ int standby_is_enabled(void);
/* Колбэк, вызываемый при каждом переключении standby-режима. */
void standby_set_switch_callback(void (*cb)(int enabled, void* arg), void* arg);
/* Сколько TB спать сейчас (0 = активный интервал или standby выключен). */
/* Остаток текущего SLEEP в TB (0 = ACTIVE/standby выключен/дедлайн истёк). */
int standby_get_sleep_tb(void);
/* Сколько TB осталось активного режима (0 = спим или standby выключен). */
int standby_get_active_remaining_tb(void);
/* Полная длительность SLEEP-фазы duty-cycle (timebase 0.1мс). */
/* Полная настроенная длительность SLEEP в TB, в том числе при выключенном standby. */
int standby_get_sleep_duration_tb(void);
/* Время до гарантированного пробуждения (timebase 0.1мс): остаток SLEEP, либо
* остаток ACTIVE + полная длительность SLEEP. 0 = standby выключен. */
int standby_get_wake_deadline_tb(void);
/* Период keepalive в спячке (мс), из настройки standby_keepalive_ms. */
/* Период keepalive в спячке (мс), из standby_keepalive_ms, минимум 100мс. */
int standby_get_keepalive_ms(void);
/* Колбэк смены фазы duty-cycle: sleeping=1 — вошли в SLEEP, 0 — вышли в ACTIVE. */
void standby_set_phase_callback(void (*cb)(int sleeping, void* arg), void* arg);
/* Ждёт до начала следующего активного интервала (как uasync timeout).
/* При SLEEP ждёт следующего ACTIVE, в ACTIVE вызывает cb отложенно через таймер 0.
* В SLEEP ждёт фазового события после анонса ACTIVE; при выходе cb вызывается после восстановления линков.
* Возвращает handle для standby_wait_cancel(). */
* При выключенном standby — call_soon без отменяемого handle; при OOM cb может выполниться синхронно.
* NULL означает отсутствие handle, а не обязательно ошибку. arg заимствуется до cb/cancel/deinit.
* Handle действителен до первого cb/cancel/deinit, после этого использовать его нельзя. cb не должен быть NULL. */
void* standby_wait(void* arg, void (*cb)(void* arg));
/* Отменить ещё ожидающий callback, не вызывая его; NULL допустим. */
void standby_wait_cancel(void* handle);
/* Уведомление о внешней сетевой активности (Android ConnectivityManager).
@@ -70,7 +75,8 @@ void standby_log_rx(const char* kind, const char* who);
/* Переопределить интервалы (мс), минуя chat_setting. Для тестов.
* active_ms/sleep_ms <= 0 → вернуться к chat_setting.
* min_sleep_ms может быть 0 (реагировать на сетевую активность сразу). */
* min_sleep_ms ограничивается 0..sleep_ms. Текущий фазовый таймер не переустанавливается;
* override сохраняется между deinit/init, сбрасывать явно. */
void standby_set_intervals_ms(int active_ms, int sleep_ms, int min_sleep_ms);
#ifdef __cplusplus
@@ -1,12 +1,14 @@
/* Android-очереди live media: attach/detach только в uasync, вне callbacks.
* UI/кодировщики используют только session_id, никогда не сохраняют inst/ua.
* Один publisher и независимые viewers, TX <=2MB/128 records, RX — ограниченный батч на viewer.
* Один publisher и независимые viewers, TX <=2MiB/128 records/250мс, RX — ограниченный батч на viewer.
* Data принимает полные access units пяти кодеков uAV2 (KEY: config_length:4/config/frame) и Opus 48k mono/stereo/20ms.
* pull передаёт u_malloc-буфер последовательных записей:
* kind:1,flags:1,width:2,height:2,rotation:2,PTS_us:8,length:4,revision:4,codec:1,reserved:3,data.
* kinds: video=1,audio=2,gap=3,opened=4,ended=5,error=6,video_end=7,audio_end=8; error.flags = stream_close_reason.
* Конец трека следует за его media; GAP/error отменяют старые записи. Числа big-endian.
* Медиаданные передаются через pull, не через общий чатовый event bus. */
* Медиаданные передаются через pull, не через общий чатовый event bus; полученный буфер освобождает caller через u_free.
* Команды/снимки защищены bridge mutex, signal имеет свой mutex; callers используют API из frontend/media workers.
* После detach команды получают отказ/пустой результат, read_request с допустимыми параметрами возвращает 0. */
#ifndef ANDROID_STREAM_BRIDGE_H
#define ANDROID_STREAM_BRIDGE_H
@@ -21,36 +23,57 @@ struct stream_viewer_telemetry;
struct android_stream_signal;
#define ANDROID_STREAM_HEADER_SIZE 28
struct android_stream_tx_stats {
/* ID сеанса, время снимка в мкс; video/audio/stream — очереди publisher, transport/inflight/acked_1s — всего instance.
* total_bytes — сумма video/audio/stream/transport/inflight; байты ACK за 1с в неё не входят. */
uint64_t id, sample_us, video_bytes, audio_bytes, stream_bytes, transport_bytes, inflight_bytes, acked_1s, total_bytes;
/* Число соединений в общем ETCP-снимке. */
unsigned connections;
/* Позиции получателей в кольце, обновляются раз в 500мс. */
struct stream_buffer_positions buffer;
};
/* Снимок обновляется в uasync каждые 100ms; getter копирует его под bridge mutex. */
/* Снимок publisher обновляется в uasync каждые 100мс; getter копирует под mutex. 0/-1, ошибка не меняет stats. */
int android_stream_tx_snapshot(uint64_t id, struct android_stream_tx_stats *stats);
/* 0 attached, -1 invalid/busy: нельзя передать singleton другому ядру до detach старого. */
int android_stream_attach(struct UTUN_INSTANCE *inst);
/* Тот же inst: отменить watch, закрыть managers/сеансы и снять привязку; повтор допустим. */
void android_stream_detach(struct UTUN_INSTANCE *inst);
/* source=0 — publisher; source!=0 — viewer с обязательным expected_epoch. Команда принята=0, результат через pull. */
/* id/group ненулевые. source=0 — publisher: width/height=1..4096, rotation=0/90/180/270, codec=H264..H266.
* source!=0 — viewer с обязательным expected_epoch; размеры/rotation игнорируются. codec проверяется в обоих режимах.
* audio включает Opus stereo у publisher либо получение аудио viewer. 0 — команда принята, -1 — отказ; результат через pull.
* Publisher ID занят до получения ended/error через pull; viewer ID — до обработки stop в uasync. */
int android_stream_open(uint64_t id, uint64_t group, uint64_t source, unsigned width, unsigned height,
unsigned rotation, int audio, uint64_t expected_epoch, unsigned codec);
/* Наблюдение выбранного канала через CHAT_EVT_STREAM_SOURCES; group=0 снимает его. */
/* Наблюдение канала через CHAT_EVT_STREAM_SOURCES; group=0 снимает его. 0 — поставлено, -1 — отказ;
* несколько команд до исполнения сливаются в последнюю. */
int android_stream_watch(uint64_t group);
/* Publisher: 0 — команда mute/unmute принята, -1 — нет сеанса/аудиотрека; codec/эпоха не меняются.
* Producer сам заглушает PCM; эта команда только публикует состояние. */
int android_stream_set_audio_enabled(uint64_t id, int enabled);
/* Видео содержит revision/размер/поворот; новая revision принимается атомарно с KEY. Для аудио format=NULL. */
/* Publisher: pts — мкс <=INT64_MAX, строго растёт отдельно по трекам; data копируется на вызове.
* Видео требует format с revision/размером/поворотом; новая revision принимается атомарно с KEY.
* Для аудио format/key игнорируются. 1 — принято в очередь, 0 — drop (video: запросить KEY), -1 — отказ. */
int android_stream_feed(uint64_t id, int video, uint64_t pts, int key, const uint8_t *data, size_t length,
const struct stream_media_format *format);
/* feed: 1 accepted, 0 dropped (video: запросить KEY), -1 detached/stopped/invalid. */
/* Асинхронный stop: publisher дренирует очередь и держит FIN 2с; viewer снимает reader и notify.
* Уже выданный pull-буфер остаётся у caller. Старый/нулевой ID допустим. */
void android_stream_stop(uint64_t id);
/* Передать owned батч; length обязателен, NULL/length=0 — сейчас нет данных либо ошибка.
* Publisher выдаёт opened/ended/error без demand; viewer — также media по read_request. */
uint8_t *android_stream_pull(uint64_t id, size_t *length);
/* Один батч на запрос: до video кадров и records записей, звук до position_us+audio_ahead_us.
* Повторные запросы до получения ответа не создают дополнительные записи. Указатели кольца остаются в uasync. */
* video=0..records, records=1..16; audio_ahead_us=-1 выключает звук, иначе 0..60000000; position_us<0 — прогрев до KEY.
* Повторный demand заменяет прежний. 0 — параметры допустимы (даже для неизвестного ID/после detach), -1 — неверные параметры.
* CLOSED/END доступны pull после закрытия cursor; указатели кольца остаются в uasync. */
int android_stream_read_request(uint64_t id, int64_t position_us, unsigned video, int64_t audio_ahead_us, unsigned records);
/* Viewer-снимки: 0 — скопировано, -1 — неверные аргументы/ID; ошибка не меняет выход. */
int android_stream_get_status(uint64_t id, struct stream_media_status *status);
int android_stream_get_telemetry(uint64_t id, struct stream_viewer_telemetry *telemetry);
/* Viewer: очистить старый батч и искать ближайший KEY; 0 — принято, -1 — отказ. */
int android_stream_resync(uint64_t id);
/* Worker владеет signal; create привязывает его к открытому viewer. Готовность/stop/detach будят wait.
* signal также будит worker после расходования PCM. Уведомления сливаются, но не теряются до wait.
* destroy — только после stream_stop и остановки всех внешних signal/wait callers. */
* create возвращает NULL при ошибке. На viewer допускается один signal/notify.
* destroy — только после stream_stop или detach и остановки всех внешних signal/wait callers. */
struct android_stream_signal *android_stream_signal_create(uint64_t id);
void android_stream_signal_notify(struct android_stream_signal *signal);
/* 1 — уведомление, 0 — deadline, -1 — ошибка; timeout_ms 0..50, ожидание не держит mutex ядра/bridge. */
@@ -2,34 +2,36 @@
* utun_config_api.c — configuration provider implementation
*/
#include "utun_config_api.h"
#include <string.h>
#include "../../../lib/debug_config.h"
static const utun_config_provider_t* g_provider = NULL;
static utun_config_provider_t g_provider;
void utun_config_set_provider(const utun_config_provider_t* provider) {
g_provider = provider;
g_provider = provider ? *provider : (utun_config_provider_t){0};
DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "config provider: callbacks string=%d int64=%d int=%d bool=%d",
!!g_provider.get_string, !!g_provider.get_int64, !!g_provider.get_int, !!g_provider.get_bool);
}
const char* cfg_get_string(const char* key, const char* def) {
if (g_provider && g_provider->get_string)
return g_provider->get_string(key, def);
if (g_provider.get_string)
return g_provider.get_string(key, def);
return def;
}
int64_t cfg_get_int64(const char* key, int64_t def) {
if (g_provider && g_provider->get_int64)
return g_provider->get_int64(key, def);
if (g_provider.get_int64)
return g_provider.get_int64(key, def);
return def;
}
int cfg_get_int(const char* key, int def) {
if (g_provider && g_provider->get_int)
return g_provider->get_int(key, def);
if (g_provider.get_int)
return g_provider.get_int(key, def);
return def;
}
int cfg_get_bool(const char* key, int def) {
if (g_provider && g_provider->get_bool)
return g_provider->get_bool(key, def);
if (g_provider.get_bool)
return g_provider.get_bool(key, def);
return def;
}
@@ -1,7 +1,7 @@
/*
* Callback-провайдер отдельных настроек native-обёртки. Без провайдера/accessor возвращает default.
* Основной конфиг ядра передаётся INI-текстом в instance_lite_start, а не через этот интерфейс.
* set_provider заимствует таблицу callbacks; она и возвращаемые строки должны жить во время вызовов.
* set_provider копирует таблицу callbacks; функции, их окружение и возвращаемые строки принадлежат провайдеру.
* Провайдер установить до запуска потребителей: синхронизации внутри модуля нет.
*/
#ifndef UTUN_CONFIG_API_H
@@ -17,15 +17,19 @@ extern "C" {
/* ── Config provider interface ── */
typedef struct {
/* Заимствованная NUL-строка; accessor возвращает результат callback без копирования и проверки на NULL. */
const char* (*get_string)(const char* key, const char* default_val);
/* Числовые callbacks сами применяют default_val при отсутствии ключа. */
int64_t (*get_int64)(const char* key, int64_t default_val);
int (*get_int)(const char* key, int default_val);
int (*get_bool)(const char* key, int default_val);
} utun_config_provider_t;
/* Скопировать таблицу по значению, caller может сразу освободить/изменить её; NULL отключает провайдер.
* Функции и их окружение должны жить до отключения. Смена — без параллельных cfg_get_* вызовов. */
void utun_config_set_provider(const utun_config_provider_t* provider);
/* ── Key constants (used by C code to request values) ── */
/* Имена callback-ключей; это не имена секций/полей общего INI-парсера. */
#define CFG_KEY_NODE_PRIVATE_KEY "node.private_key"
#define CFG_KEY_NODE_PUBLIC_KEY "node.public_key"
@@ -42,7 +46,8 @@ void utun_config_set_provider(const utun_config_provider_t* provider);
#define CFG_KEY_DEBUG_LOG_FILE "debug.log_file"
#define CFG_KEY_DEBUG_CATEGORIES "debug.categories"
/* ── Convenience accessors ── */
/* key/def передаются соответствующему callback как есть; без callback возвращается def.
* cfg_get_bool не нормализует число к 0/1; семантику задаёт провайдер. */
const char* cfg_get_string(const char* key, const char* def);
int64_t cfg_get_int64(const char* key, int64_t def);
@@ -1,7 +1,8 @@
/* Отправка подготовленного MP4 в канал или dm:conv_id через общий attachment_send.
* Android передаёт duration_ms/width/height; повторного транскодирования здесь нет. db_path не используется.
* 0 = запрос поставлен в uasync, -1 = отказ. Передаётся владение временным cache-файлом:
* задача удаляет src_file_path после подготовки. Результат публикации приходит событием ядра. */
* При принятом запросе задача удаляет src_file_path также при ошибке/отмене; при -1 файл остаётся у caller.
* Вызовы сериализовать со stop/restart ядра; строки копируются. Результат приходит событиями ядра. */
#ifndef VIDEO_SENDER_H
#define VIDEO_SENDER_H
#include <stdint.h>
@@ -10,6 +11,10 @@
extern "C" {
#endif
/* target — десятичный ID канала либо dm:conv_id; source — NUL-путь короче 1024 байтов.
* duration_ms=1..86400000, width/height=1..16384. Для канала reply_to — обязательные 32 байта
* (нули = без ответа), request_id → CHAT_EVT_MSG_SUBMITTED. В DM эти поля игнорируются;
* generation фиксирует поколение беседы, 0 захватывает его в trampoline. */
int video_send(const char* channel_id, const char* src_file_path, const char* db_path,
int duration_ms, int width, int height, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation);
@@ -1,5 +1,6 @@
/* Android-накопитель голосового: init → start(target,48000,1) → feed PCM int16 → stop либо cancel.
* target — channel_id либо dm:conv_id. feed копирует count mono-сэмплов; управление защищено mutex.
* target — channel_id либо dm:conv_id. feed принимает count mono-сэмплов; PCM копируется либо передаётся компрессору.
* Все операции защищены mutex; stop, использующий handles ядра, сериализовать с его stop/restart.
* stop отсоединяет PCM/компрессор и ставит attachment_send в uasync: encode/file работа идёт в worker.
* stop=0 означает постановку подготовки либо отбрасывание записи короче 500мс, не доставку сообщения.
* deinit освобождает текущую запись; завершение сетевой отправки отслеживается событиями ядра. */
@@ -15,21 +16,37 @@ extern "C" {
struct voice_recorder;
/* Создать singleton: 0 — готов/уже создан, -1 — OOM. db_path копируется (NULL → пусто);
* текущая публикация использует путь instance, а не эту сохранённую строку. Повторный init настройки не сбрасывает. */
int voice_recorder_init(const char* db_path);
/* Удалить singleton и незавершённую запись; уже переданная attachment-задача живёт отдельно. */
void voice_recorder_deinit(void);
/* Непустой target короче 64 байтов; только 48000 Hz/1 канал, reply_to — обязательные 32 байта (нули = нет ответа).
* request_id и reply_to относятся к каналу; generation относится к DM (0 захватывает поколение в trampoline).
* 0 — запись начата, -1 — неверные аргументы/нет init/уже active. */
int voice_recorder_start(const char* channel_id, int sample_rate, int channels, const uint8_t reply_to[32], uint64_t request_id, uint64_t generation);
/* samples заимствован на вызов, count>0 — число int16; 0 — принят, -1 — нет записи/аргументы/OOM. */
int voice_recorder_feed(const int16_t* samples, int count);
/* Остановить и передать PCM/compressor attachment-задаче; out_duration_ms необязателен, получает монотонное
* время захвата по часам (не по числу PCM). 0 — поставлено либо длительность <500мс и запись отброшена;
* -1 — нет активной записи/ядра/ресурсов. Результат подготовки и публикации асинхронный. */
int voice_recorder_stop(int* out_duration_ms);
/* Отбросить активную запись без публикации; повтор допустим. is_active возвращает 0/1. */
void voice_recorder_cancel(void);
int voice_recorder_is_active(void);
/* Preset ограничивается 0..2; getter без init возвращает 1. Применяется при stop. */
void voice_recorder_set_preset(int preset);
int voice_recorder_get_preset(void);
/* Нормализует enabled к 0/1, getter без init возвращает 1. Менять между записями. */
void voice_recorder_set_compressor_enabled(int enabled);
int voice_recorder_is_compressor_enabled(void);
/* Настройки компрессора для 48k mono/20мс; gain/target в dB/dBFS, окна в мс, rise в dB/с.
* Настраивать между записями; допустимые значения и сброс состояния — audio_compressor_configure(). */
void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
float rise_rate_per_sec, float target_level_db);
/* Индикатор последнего feed: (2*RMS+peak)/2 по raw PCM, без ограничения сверху к 1; без init — 0. */
float voice_recorder_get_peak_level(void);
#ifdef __cplusplus
+5 -3
View File
@@ -1,4 +1,6 @@
/* Диалог ввода имени нового канала. groupName возвращает имя для создания канала вызывающим кодом. */
/* Модальный диалог ввода имени канала, только GUI-поток: exec() → Accepted → groupName().
* Поле ограничено 128 единицами UTF-16, пустое после trim имя не принимается.
* Создание канала/выдача ID остаются вызывающему коду; reject не запускает никаких действий ядра. */
#pragma once
#include <QDialog>
@@ -9,8 +11,8 @@ class CreateGroupDialog : public QDialog {
public:
explicit CreateGroupDialog(QWidget* parent = nullptr);
QString groupName() const;
QString groupName() const; // Текущее имя с trim по краям; проверять результат диалога перед использованием.
private:
QLineEdit* m_nameEdit;
QLineEdit* m_nameEdit; // Дочернее поле имени, освобождается Qt вместе с диалогом.
};
+14 -7
View File
@@ -1,5 +1,9 @@
/* Выбор группы для приглашения фиксированного собеседника в PM.
* Закрытие отменяет ожидающую регистрацию; результат сопоставляется по request_id. */
* Показывает только группы, где локальный узел — участник; отправка выполняется dm_invite_trampoline.
* Только GUI-поток: использовать exec(), обработчик результата выбирает activeModalWidget.
* Занимает единственный dm_invite_result callback bridge; не совмещать с другим его владельцем.
* Деструктор снимает callback и, если pending/core доступны, ставит отмену регистрации по request_id.
* Уже отправленное PM-сообщение закрытием не отзывается. */
#pragma once
#include <QDialog>
@@ -14,17 +18,20 @@ class QPushButton;
class DmInviteDialog : public QDialog {
Q_OBJECT
public:
// db обязателен и используется при создании; peer — ненулевой чужой node_id, name — имя для UI/PM.
// source — контекстный ID группы/маршрута для DM. Аргументы строк копируются, DB не переходит во владение диалога.
DmInviteDialog(DbManager* db, quint64 peer, const QString& name, const QString& source, QWidget* parent = nullptr);
~DmInviteDialog() override;
// Читать после Accepted: conv_id без префикса dm:, непустой только после успешного результата отправки.
QString conversationId() const { return m_convId; }
private:
void sendInvite();
void onResult(const uint8_t* data, int len);
quint64 m_peer, m_requestId = 0;
QString m_name, m_source, m_convId;
QComboBox* m_groups;
QLabel* m_status;
QPushButton* m_send;
bool m_pending = false;
quint64 m_peer, m_requestId = 0; // Фиксированный адресат и ID текущего запроса; оба сверяются в ответе.
QString m_name, m_source, m_convId; // Имя/контекст адресата и conv_id успешной личной беседы.
QComboBox* m_groups; // Дочерний список доступных групп, блокируется на время регистрации/отправки.
QLabel* m_status; // Дочерний статус/ошибка без изменения выбранного адресата.
QPushButton* m_send; // Дочерняя кнопка, недоступна до выбора группы и пока pending.
bool m_pending = false; // Есть принятый запрос; снимается только совпадающим результатом.
};
+15 -12
View File
@@ -1,5 +1,7 @@
/* Qt-кодек utun://: channelId/joinKey, пароль, Reality и адреса connection-узла.
* decodeInviteLink возвращает ошибку в InviteData.error; подключения выполняет UtunNode/chat_sync. */
/* Qt-обёртка общего C-кодека src/chat/invite_link.h: decodeInviteLink переводит QString в UTF-8 и копирует результат.
* Принимается только версия 0x03; ошибка возвращается в InviteData.error. Собственного wire-парсера здесь нет.
* Успешный разбор не проверяет срок регистрации join_key/членство и не запускает подключение.
* Структуры Qt и C имеют одинаковые имена, но различное представление: не передавать их между API. */
#pragma once
#include <QString>
@@ -18,29 +20,30 @@
struct InviteAddr {
int family; // 4=IPv4, 6=IPv6
QByteArray address;
quint16 port;
QByteArray address; // 4/16 сетевых байтов в зависимости от family.
quint16 port; // Порт в host byte order.
uint8_t socketId; // socket_id из ETCP
uint8_t proto; // INVITE_PROTO_*
};
/* Reality-параметры узла (node-global: одинаковы для всех reality-сокетов). */
struct InviteReality {
bool has = false;
bool has = false; // При false остальные поля не используются.
QByteArray shortId; // 8 байт
QByteArray serverPubkey; // 32 байта
QByteArray version; // 3 байта
QString serverName;
QString serverName; // UTF-8 SNI из общего кодека, преобразованный в QString.
};
struct InviteData {
uint64_t channelId = 0;
uint64_t channelId = 0; // Числовой ID канала, не десятичная строка GUI.
uint64_t joinKey = 0; // 64-битный ключ входа (генерируется инвайтером)
QByteArray pubkey;
QList<InviteAddr> addrs;
QString password;
QString error;
InviteReality reality;
QByteArray pubkey; // 32 байта X25519 connection-узла при успешном разборе.
QList<InviteAddr> addrs; // Проверенные общим кодеком адреса того же connection-узла.
QString password; // Декодированный UTF-8 пароль либо пустая строка.
QString error; // Пусто при успехе; иначе текст ошибки, остальные поля не использовать.
InviteReality reality; // Общие REALITY-параметры connection-узла.
};
// link передаётся целиком, без trim/исправления регистра; возвращённые Qt-поля владеют своими данными.
InviteData decodeInviteLink(const QString& link);
+13 -9
View File
@@ -1,4 +1,7 @@
/* Диалог приглашения узла по его utun:// ссылке в текущий канал; ждёт результата ядра через bridge. */
/* Предложить владельцу utun:// ссылки вступить в текущий канал: передаёт исходную ссылку в gui_bridge_offer_link.
* channelId задаёт наш канал, channelId/joinKey самой ссылки не заменяют его. Успех — вступление адресата.
* Только GUI-поток; модальный диалог занимает единственный connect_result callback, доставка идёт activeModalWidget.
* При закрытии callback снимается, уже принятый OFFER/JOIN в ядре не отменяется. */
#pragma once
#include <QDialog>
@@ -12,6 +15,7 @@ class UtunNode;
class InviteByLinkDialog : public QDialog {
Q_OBJECT
public:
// channelId — десятичный ID нашего канала; node заимствован и нужен для проверки ссылки на самого себя (может быть nullptr).
explicit InviteByLinkDialog(const QString& channelId, UtunNode* node, QWidget* parent = nullptr);
~InviteByLinkDialog() override;
@@ -23,12 +27,12 @@ private slots:
private:
void setInviteStatus(const QString& text, bool isError = false);
QString m_channelId;
UtunNode* m_node;
QLineEdit* m_inviteLinkEdit = nullptr;
QLabel* m_inviteStatusLabel = nullptr;
QPushButton* m_inviteSendBtn = nullptr;
bool m_inviteSending = false;
uint64_t m_request = 0;
uint64_t m_inviteTargetNodeId = 0;
QString m_channelId; // Неизменный канал назначения приглашения.
UtunNode* m_node; // Заимствованный узел GUI; не освобождается диалогом.
QLineEdit* m_inviteLinkEdit = nullptr; // Дочерние widgets принадлежат диалогу; поле исходной ссылки адресата.
QLabel* m_inviteStatusLabel = nullptr; // Предпросмотр и результат последней попытки.
QPushButton* m_inviteSendBtn = nullptr; // Заблокирована до результата; успех закрывает диалог через 400мс.
bool m_inviteSending = false; // Ожидание ответа; текст поля не меняет уже отправленный target.
uint64_t m_request = 0; // Корреляция bridge, проверяется вместе с target node_id.
uint64_t m_inviteTargetNodeId = 0; // Зафиксированный X25519-derived ID адресата.
};
+15 -10
View File
@@ -1,5 +1,9 @@
/* Диалог выдачи invite-ссылки и QR: запрашивает кандидатов и зарегистрированную ссылку у ядра.
* requestId связывает асинхронный ответ с текущим запросом; URL отображается после LINK_READY. */
* requestId/channel связывают асинхронный ответ с текущим запросом; URL отображается после LINK_READY.
* Только GUI-поток, при готовом bridge/core; одновременно один ShareInviteDialog.
* Занимает callbacks candidates/link_ready/local_devices/local_delivery и снимает их в деструкторе.
* Закрытие не отменяет уже отправленные запросы и не отзывает созданный join_key.
* Локальная доставка подтверждает получение ссылки устройством, а не вступление в канал. */
#pragma once
#include <QDialog>
@@ -15,6 +19,7 @@ class QPushButton;
class ShareInviteDialog : public QDialog {
Q_OBJECT
public:
// channelId — десятичный ID; db заимствован для имён узлов (nullptr допускается, тогда показываются ID).
explicit ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent = nullptr);
~ShareInviteDialog();
@@ -31,15 +36,15 @@ private:
void sendLocalInvite();
void updateLocalSend();
uint64_t m_requestId = 0;
QString m_channelId;
DbManager* m_db;
QComboBox* m_nodeCombo;
QLineEdit* m_linkEdit;
QLabel* m_qrLabel;
uint64_t m_requestId = 0; // Только последний запрос построения ссылки; запоздалые LINK_READY игнорируются.
QString m_channelId; // Канал, для которого строится ссылка.
DbManager* m_db; // Заимствованная БД, не закрывается диалогом.
QComboBox* m_nodeCombo; // Дочерний список connection-узлов, первым идёт auto-кандидат.
QLineEdit* m_linkEdit; // Готовая ссылка, только чтение; очищается при новом запросе.
QLabel* m_qrLabel; // Дочерний QR готовой ссылки.
QWidget* m_localRow; // выбор устройства; скрыт, пока устройств нет
QComboBox* m_devices;
QPushButton* m_sendLocal;
QLabel* m_delivery;
QComboBox* m_devices; // Дочерний список устройств LAN, данные пункта — discovery peer_id.
QPushButton* m_sendLocal; // Активна при готовой ссылке/устройстве и отсутствии ожидающей доставки.
QLabel* m_delivery; // Дочерний статус доставки ссылки в LAN.
uint64_t m_localRequest = 0; // текущая доставка; 0 — нет ожидания ACK
};
+17 -13
View File
@@ -1,5 +1,8 @@
/* Диалог входа в канал по utun://: проверяет ссылку, отправляет запрос ядру и ждёт CONNECT_RESULT.
* joined сообщает об успешном входе, а не только о создании транспорта к connection-узлу. */
* joined сообщает об успешном входе, а не только о создании транспорта к connection-узлу.
* Только GUI-поток; диалог занимает единственный connect_result callback bridge и снимает его в деструкторе.
* Работает как activeModalWidget: не совмещать с другим владельцем этого callback.
* Закрытие снимает получение результатов, но не отменяет уже принятый JOIN в ядре. */
#pragma once
#include <QDialog>
@@ -15,12 +18,13 @@ class DbManager;
class JoinDialog : public QDialog {
Q_OBJECT
public:
// node/db — заимствованный контекст; autoJoinLink заполняет поле и ставит попытку JOIN на следующий оборот Qt-loop.
explicit JoinDialog(UtunNode* node, DbManager* db, QWidget* parent = nullptr,
const QString& autoJoinLink = QString());
~JoinDialog() override;
signals:
void joined(const QString& channelId);
void joined(const QString& channelId); // Десятичный ID успешно принятого канала; испускается перед accept().
private slots:
void onConnectClicked();
@@ -30,15 +34,15 @@ private:
void setStatus(const QString& text, bool isError = false);
void onConnectResult(uint64_t nodeId, uint64_t channelId, int result, uint64_t request);
UtunNode* m_node;
DbManager* m_db;
QLineEdit* m_linkEdit;
QLabel* m_statusLabel;
QPushButton* m_connectBtn;
QPushButton* m_cancelBtn;
QTimer* m_decodeTimer;
bool m_connecting = false;
uint64_t m_request = 0;
uint64_t m_pendingNodeId = 0;
uint64_t m_pendingChannelId = 0;
UtunNode* m_node; // Контекст вызывающего кода, диалог им не владеет.
DbManager* m_db; // Контекст БД без передачи владения.
QLineEdit* m_linkEdit; // Дочернее поле исходной ссылки; read-only во время JOIN.
QLabel* m_statusLabel; // Дочерний виджет предпросмотра/состояния/ошибки.
QPushButton* m_connectBtn; // Повторная отправка запрещена до результата.
QPushButton* m_cancelBtn; // Закрывает диалог, не отменяет запрос ядра.
QTimer* m_decodeTimer; // Дочерний single-shot 400мс для предпросмотра после изменения текста.
bool m_connecting = false; // Ожидается результат текущей попытки.
uint64_t m_request = 0; // Корреляция bridge; принимается только совпадающий request/node/channel.
uint64_t m_pendingNodeId = 0; // Connection-узел из отправленной ссылки.
uint64_t m_pendingChannelId = 0; // Канал из отправленной ссылки, не из изменённого поля UI.
};
+9 -6
View File
@@ -1,14 +1,17 @@
/* Ссылки в обычном тексте сообщения: границы UTF-16 и полный адрес для подсветки и нажатия.
* Поддерживает utun:// и HTTP/HTTPS; внешняя пунктуация не входит в адрес.
* Разбор не меняет сообщение и не открывает адреса. Некорректные URL остаются обычным текстом. */
/* Ссылки в обычном тексте сообщения: messageLinks(text) → диапазоны для подсветки и нажатия.
* HTTP/HTTPS без учёта регистра; utun:// только в нижнем регистре, с base64-алфавитом и до двух '='.
* Для web удаляются конечная пунктуация и лишние закрывающие скобки, проверяются QUrl::StrictMode и host.
* Для utun проверяется только шаблон текста, не содержимое invite: decodeInviteLink вызывается отдельно.
* Разбор не меняет сообщение, не открывает адреса и не обращается к ядру. */
#pragma once
#include <QString>
#include <QVector>
struct MessageLink {
int start;
int length;
QString url;
int start; // Смещение начала в исходном QString, в кодовых единицах UTF-16.
int length; // Длина адреса в тех же единицах, без отделённой внешней пунктуации.
QString url; // Полный адрес в исходном написании; равен text.mid(start, length).
};
// Возвращает непересекающиеся диапазоны в порядке текста; пустой список, если подходящих ссылок нет.
QVector<MessageLink> messageLinks(const QString& text);
+13 -5
View File
@@ -1,5 +1,8 @@
/* Одно уведомление о сообщениях: не берёт фокус, ждёт активности и скрывается через 10 секунд.
* Новое сообщение заменяет содержимое и отменяет прежний отсчёт. activated передаёт ключ канала/DM владельцу. */
* До первой активности автоматического закрытия нет; дальнейшая активность не продлевает начатый отсчёт.
* Новое сообщение заменяет содержимое и заново ждёт активности. Только GUI-поток, один внешний владелец QWidget.
* Окно верхнего уровня без parent, не удаляет себя при close; все дочерние widgets/monitor принадлежат ему.
* На Wayland размещение/поверхность контролирует compositor, показ на текущем desktop — best effort. */
#pragma once
#include <QWidget>
@@ -14,11 +17,16 @@ class MessageNotification : public QWidget {
public:
MessageNotification();
~MessageNotification() override;
// target — ключ владельца (десятичный channel_id либо dm:conv_id), sender/channel — только отображаемые строки.
// Экран: trayGeometry.center() → preferredScreen → primaryScreen; без пригодного экрана окно скрывается.
// preferredScreen заимствован на вызов; содержимое/target копируются, старый countdown отменяется.
void showMessage(const QString& target, const QString& sender, const QString& channel,
const QRect& trayGeometry, QScreen* preferredScreen = nullptr);
// Скрыть без activated, остановить timer/monitor; reason — строка причины для лога, например "close-button".
void dismiss(const char* reason);
signals:
// Только левый клик по телу: передаёт последний target после скрытия; кнопка закрытия сигнал не испускает.
void activated(const QString& target);
protected:
@@ -27,8 +35,8 @@ protected:
void closeEvent(QCloseEvent* event) override;
private:
QString m_target;
QLabel* m_body = nullptr;
UserActivityMonitor* m_activity = nullptr;
QTimer m_hideTimer;
QString m_target; // Последний target для activated, прежние сообщения не накапливаются.
QLabel* m_body = nullptr; // Дочерний QLabel с plain text отправителя и канала, без тела сообщения.
UserActivityMonitor* m_activity = nullptr; // Дочернее одноразовое наблюдение первой активности после showMessage.
QTimer m_hideTimer; // Single-shot 10с от первой активности; hide/dismiss/showMessage отменяют его.
};
+4 -1
View File
@@ -1,10 +1,13 @@
/* Генерация QR и распознавание штрихкодов из QImage через ZXingQt. Ошибка генерации — пустой QImage. */
/* QR для произвольного текста и чтение штрихкодов из QImage через ZXingQt.
* Результаты принадлежат caller; модуль не проверяет utun-ссылки и ничего не открывает. */
#pragma once
#include <QImage>
#include <QString>
#include <QStringList>
// Сгенерировать QR: moduleSize>0 задаёт scale ZXing, иначе остаётся значение библиотеки; при ошибке пустой QImage.
QImage generateQRCode(const QString& text, int moduleSize = 8);
// Тексты всех valid результатов ReadBarcodes с настройками по умолчанию, не только QR; без результатов пустой список.
QStringList readQRCodes(const QImage& image);
+21 -15
View File
@@ -1,5 +1,8 @@
/* Первая активность после start(): системный idle time или события приложения при ограничениях ОС.
* stop() отменяет наблюдение и запоздалые D-Bus ответы. Все вызовы и сигнал — в GUI-потоке. */
* Опрос системного idle time каждые 250мс; ввод до start не считается новой активностью.
* На Wayland пробует GNOME D-Bus, затем KIdleTime при доступной сборке/backend; иначе только ввод ChatGUI.
* Фильтр событий приложения работает также при системном наблюдении, принимает только spontaneous события и не поглощает их.
* Перед activity наблюдение останавливается: на один start не больше одного сигнала. Все вызовы и сигнал — GUI-поток. */
#pragma once
#include <QObject>
@@ -11,15 +14,18 @@ class UserActivityMonitor : public QObject {
public:
explicit UserActivityMonitor(QObject* parent = nullptr);
~UserActivityMonitor() override;
// Начать новое наблюдение с нуля, отменив прежнее; нужен существующий QApplication и его event loop.
void start();
// Остановить без activity; запоздалые D-Bus ответы игнорируются по generation. Повтор допустим.
void stop();
signals:
// Первый обнаруженный после start ввод; для следующего сигнала требуется новый start().
void activity();
protected:
bool eventFilter(QObject* object, QEvent* event) override;
virtual qint64 systemIdleTime();
virtual qint64 systemIdleTime(); // Системное время без ввода в мс, -1 при недоступности; override используется тестами.
private:
void pollIdleTime();
@@ -27,17 +33,17 @@ private:
void useLocalActivity();
void startWaylandFallback();
QTimer m_pollTimer;
QElapsedTimer m_elapsed;
bool m_watching = false;
bool m_localWarningLogged = false;
bool m_dbusPending = false;
bool m_useGnome = false;
bool m_gnomeUnavailable = false;
bool m_gnomeReadyLogged = false;
bool m_useKIdleTime = false;
QMetaObject::Connection m_kIdleConnection;
quint64 m_generation = 0;
void* m_display = nullptr;
bool m_x11Checked = false;
QTimer m_pollTimer; // Интервал системного опроса 250мс; GNOME не запускает второй запрос до первого ответа.
QElapsedTimer m_elapsed; // Время от последнего start, сравнивается с idle time.
bool m_watching = false; // Разрешены наблюдение и один activity для текущего запуска.
bool m_localWarningLogged = false; // Предупреждение о fallback только на ввод приложения уже выведено.
bool m_dbusPending = false; // В текущем generation есть ожидающий GNOME GetIdletime.
bool m_useGnome = false; // Текущий способ системного наблюдения — GNOME D-Bus.
bool m_gnomeUnavailable = false; // Ошибка GNOME запоминается до уничтожения объекта, следующие start идут в fallback.
bool m_gnomeReadyLogged = false; // Успешная готовность GNOME уже залогирована.
bool m_useKIdleTime = false; // Ожидается событие resumingFromIdle compositor backend.
QMetaObject::Connection m_kIdleConnection; // Подписка текущего наблюдения, снимается в stop.
quint64 m_generation = 0; // Инвалидирует ответы прежнего GNOME-запроса после stop/restart.
void* m_display = nullptr; // Собственное X11 Display*, закрывается деструктором; в других сборках nullptr.
bool m_x11Checked = false; // X11 display/extension проверены; не открывать повторно после отказа.
};