diff --git a/doc/message_links.md b/doc/message_links.md index 08c77f1a..624a0e78 100644 --- a/doc/message_links.md +++ b/doc/message_links.md @@ -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. diff --git a/tests/Makefile.am b/tests/Makefile.am index 8dccffdf..cbe02f96 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -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) diff --git a/tests/async_regression.h b/tests/async_regression.h index c8ed0d8f..ff9280be 100644 --- a/tests/async_regression.h +++ b/tests/async_regression.h @@ -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 #include +/* Проверки вычисляют аргументы по одному разу; при отказе печатают место/условие и возвращают 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); diff --git a/tests/db_sync_fixture.h b/tests/db_sync_fixture.h index 928a5b58..19fd9808 100644 --- a/tests/db_sync_fixture.h +++ b/tests/db_sync_fixture.h @@ -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 +// Пустая 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 diff --git a/tests/media_delivery_fixture.h b/tests/media_delivery_fixture.h index 480917a3..32a142b8 100644 --- a/tests/media_delivery_fixture.h +++ b/tests/media_delivery_fixture.h @@ -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}; diff --git a/tests/nat_fixture.h b/tests/nat_fixture.h index aee97fa3..abb20034 100644 --- a/tests/nat_fixture.h +++ b/tests/nat_fixture.h @@ -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]); diff --git a/tests/test_android_bridge.c b/tests/test_android_bridge.c new file mode 100644 index 00000000..855f0d4b --- /dev/null +++ b/tests/test_android_bridge.c @@ -0,0 +1,142 @@ +/* Регрессии native bridge: владение config callbacks и границы батчей настоящего Opus-декодера. */ +#include +#include +#include + +#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; +} diff --git a/tests/test_clock.h b/tests/test_clock.h index 55079faa..d2bbdce4 100644 --- a/tests/test_clock.h +++ b/tests/test_clock.h @@ -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 diff --git a/tests/test_connection_loss.h b/tests/test_connection_loss.h index 8eaa8fe0..13d980f1 100644 --- a/tests/test_connection_loss.h +++ b/tests/test_connection_loss.h @@ -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); diff --git a/tests/test_net.h b/tests/test_net.h index f66ab2fc..edd14e96 100644 --- a/tests/test_net.h +++ b/tests/test_net.h @@ -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 #include -// Читаем порт уже открытого сокета; владение 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); diff --git a/tests/test_utils.h b/tests/test_utils.h index 2b21a314..d0e948a3 100644 --- a/tests/test_utils.h +++ b/tests/test_utils.h @@ -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 #include -// 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 +// Создать каталог по 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); } diff --git a/tests/whisper_stub/whisper.h b/tests/whisper_stub/whisper.h index b9b0c521..82f93474 100644 --- a/tests/whisper_stub/whisper.h +++ b/tests/whisper_stub/whisper.h @@ -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 diff --git a/tools/chatgui-android/doc/ARCHITECTURE.md b/tools/chatgui-android/doc/ARCHITECTURE.md index 1c555a05..098f3c78 100644 --- a/tools/chatgui-android/doc/ARCHITECTURE.md +++ b/tools/chatgui-android/doc/ARCHITECTURE.md @@ -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 [pubkey_hex] — STCP-подключение к пиру -disconnect — отключиться от пира -connections — список активных STCP-соединений -create_channel — создать канал -invite — invite-ссылка (utun://...) -join — подключиться по invite -send — отправить сообщение -messages [limit] — последние сообщения -channels — список каналов -status — статус (соединения, каналы, ключи) -debug = — debug level на лету -debug_level — показать уровни отладки -subscribe — подписаться на push-события -help — список команд -quit — отключиться от control (не остановка сервера) -``` - -### Unsolicited events (сервер → клиент, только после subscribe) - -``` -EVENT MSG — новое сообщение -EVENT PEER_ONLINE — пир подключился -EVENT PEER_OFFLINE — пир отключился -EVENT CHANNEL_CREATED — канал создан (локально) -EVENT CHANNEL_JOINED — канал принят (по invite) -EVENT CONN_RESULT — результат connect -EVENT DB_READY — БД открыта и готова -EVENT STATUS — периодический статус -``` - -## 7. База данных (SQLite) - -Схема та же, что в десктопной версии chatgui (см. `tools/chatgui/doc/desc.txt`). -БД открывается в WAL-режиме. Записи — из uasync-потока, чтение — из любого. - -Таблицы: -- `channels` — каналы (channel_id, name, ключи X25519/Ed25519, подписи) -- `msg_` — сообщения per-channel (id, author, content_type, data, timestamp, chain_hash, signature) -- `peers_` — участники канала (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:`; +отдельные 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 ` этот каркас не поддерживает. 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. diff --git a/tools/chatgui-android/doc/IMPL_PLAN.md b/tools/chatgui-android/doc/IMPL_PLAN.md index 76bd3201..11c73350 100644 --- a/tools/chatgui-android/doc/IMPL_PLAN.md +++ b/tools/chatgui-android/doc/IMPL_PLAN.md @@ -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 [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 - connected to -> connections - 1: 127.0.0.1:12345 ready -``` - -**Критерий:** Два headless устанавливают STCP handshake, соединение активно. - -### 3.5. Тестирование — сообщение между двумя узлами - -Добавить команды: -- `create_channel ` — создаёт канал -- `send ` — отправляет сообщение подключённому пиру -- `messages [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 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>(emptyList()) - val channels: StateFlow> = _channels - - private val _messages = MutableStateFlow>(emptyList()) - val messages: StateFlow> = _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 -``` - -**Критерий:** Сервис запускается, 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-тестов. diff --git a/tools/chatgui-android/headless/headless_control.h b/tools/chatgui-android/headless/headless_control.h index d9ef0d96..db12fd2b 100644 --- a/tools/chatgui-android/headless/headless_control.h +++ b/tools/chatgui-android/headless/headless_control.h @@ -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 */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index a2848c1d..cbbd4feb 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -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; } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 594cfc06..e1b7a0ef 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -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 diff --git a/tools/chatgui-android/jni_bridge/android_udp_log.h b/tools/chatgui-android/jni_bridge/android_udp_log.h index 4f90e5e7..c1efb27f 100644 --- a/tools/chatgui-android/jni_bridge/android_udp_log.h +++ b/tools/chatgui-android/jni_bridge/android_udp_log.h @@ -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 diff --git a/tools/chatgui-android/libutun_lite/attachment_sender.h b/tools/chatgui-android/libutun_lite/attachment_sender.h index 5ad36020..6943865b 100644 --- a/tools/chatgui-android/libutun_lite/attachment_sender.h +++ b/tools/chatgui-android/libutun_lite/attachment_sender.h @@ -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 @@ -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); diff --git a/tools/chatgui-android/libutun_lite/instance_lite.h b/tools/chatgui-android/libutun_lite/instance_lite.h index 0c19622e..0c68e67a 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.h +++ b/tools/chatgui-android/libutun_lite/instance_lite.h @@ -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); diff --git a/tools/chatgui-android/libutun_lite/invite_link_c.h b/tools/chatgui-android/libutun_lite/invite_link_c.h index 2e58b0ba..3143fba6 100644 --- a/tools/chatgui-android/libutun_lite/invite_link_c.h +++ b/tools/chatgui-android/libutun_lite/invite_link_c.h @@ -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); diff --git a/tools/chatgui-android/libutun_lite/photo_sender.h b/tools/chatgui-android/libutun_lite/photo_sender.h index 69288eb7..54905c3b 100644 --- a/tools/chatgui-android/libutun_lite/photo_sender.h +++ b/tools/chatgui-android/libutun_lite/photo_sender.h @@ -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 @@ -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); diff --git a/tools/chatgui-android/libutun_lite/standby.h b/tools/chatgui-android/libutun_lite/standby.h index 8631e40a..d0c56f90 100644 --- a/tools/chatgui-android/libutun_lite/standby.h +++ b/tools/chatgui-android/libutun_lite/standby.h @@ -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 diff --git a/tools/chatgui-android/libutun_lite/stream_bridge.h b/tools/chatgui-android/libutun_lite/stream_bridge.h index b4f4c019..2d564b39 100644 --- a/tools/chatgui-android/libutun_lite/stream_bridge.h +++ b/tools/chatgui-android/libutun_lite/stream_bridge.h @@ -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. */ diff --git a/tools/chatgui-android/libutun_lite/utun_config_api.c b/tools/chatgui-android/libutun_lite/utun_config_api.c index 91221658..db52faf0 100644 --- a/tools/chatgui-android/libutun_lite/utun_config_api.c +++ b/tools/chatgui-android/libutun_lite/utun_config_api.c @@ -2,34 +2,36 @@ * utun_config_api.c — configuration provider implementation */ #include "utun_config_api.h" -#include +#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; } diff --git a/tools/chatgui-android/libutun_lite/utun_config_api.h b/tools/chatgui-android/libutun_lite/utun_config_api.h index 2d9f8f9e..4d79a7cd 100644 --- a/tools/chatgui-android/libutun_lite/utun_config_api.h +++ b/tools/chatgui-android/libutun_lite/utun_config_api.h @@ -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); diff --git a/tools/chatgui-android/libutun_lite/video_sender.h b/tools/chatgui-android/libutun_lite/video_sender.h index 4f474ff8..fb09140e 100644 --- a/tools/chatgui-android/libutun_lite/video_sender.h +++ b/tools/chatgui-android/libutun_lite/video_sender.h @@ -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 @@ -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); diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.h b/tools/chatgui-android/libutun_lite/voice_recorder.h index 2291dd7f..99eaa7c1 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.h +++ b/tools/chatgui-android/libutun_lite/voice_recorder.h @@ -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 diff --git a/tools/chatgui/src/creategroupdialog.h b/tools/chatgui/src/creategroupdialog.h index 1f112763..416cbb1b 100644 --- a/tools/chatgui/src/creategroupdialog.h +++ b/tools/chatgui/src/creategroupdialog.h @@ -1,4 +1,6 @@ -/* Диалог ввода имени нового канала. groupName возвращает имя для создания канала вызывающим кодом. */ +/* Модальный диалог ввода имени канала, только GUI-поток: exec() → Accepted → groupName(). + * Поле ограничено 128 единицами UTF-16, пустое после trim имя не принимается. + * Создание канала/выдача ID остаются вызывающему коду; reject не запускает никаких действий ядра. */ #pragma once #include @@ -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 вместе с диалогом. }; diff --git a/tools/chatgui/src/dminvitedialog.h b/tools/chatgui/src/dminvitedialog.h index 02023bc2..5f2db637 100644 --- a/tools/chatgui/src/dminvitedialog.h +++ b/tools/chatgui/src/dminvitedialog.h @@ -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 @@ -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; // Есть принятый запрос; снимается только совпадающим результатом. }; diff --git a/tools/chatgui/src/invite_link.h b/tools/chatgui/src/invite_link.h index 2f2475a4..19b4c554 100644 --- a/tools/chatgui/src/invite_link.h +++ b/tools/chatgui/src/invite_link.h @@ -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 @@ -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 addrs; - QString password; - QString error; - InviteReality reality; + QByteArray pubkey; // 32 байта X25519 connection-узла при успешном разборе. + QList addrs; // Проверенные общим кодеком адреса того же connection-узла. + QString password; // Декодированный UTF-8 пароль либо пустая строка. + QString error; // Пусто при успехе; иначе текст ошибки, остальные поля не использовать. + InviteReality reality; // Общие REALITY-параметры connection-узла. }; +// link передаётся целиком, без trim/исправления регистра; возвращённые Qt-поля владеют своими данными. InviteData decodeInviteLink(const QString& link); diff --git a/tools/chatgui/src/inviteby.h b/tools/chatgui/src/inviteby.h index c016c244..ff447341 100644 --- a/tools/chatgui/src/inviteby.h +++ b/tools/chatgui/src/inviteby.h @@ -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 @@ -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 адресата. }; diff --git a/tools/chatgui/src/invitedialog.h b/tools/chatgui/src/invitedialog.h index 17e32c7d..b0853aa7 100644 --- a/tools/chatgui/src/invitedialog.h +++ b/tools/chatgui/src/invitedialog.h @@ -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 @@ -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 }; diff --git a/tools/chatgui/src/joindialog.h b/tools/chatgui/src/joindialog.h index eeebaba8..dc223af9 100644 --- a/tools/chatgui/src/joindialog.h +++ b/tools/chatgui/src/joindialog.h @@ -1,5 +1,8 @@ /* Диалог входа в канал по utun://: проверяет ссылку, отправляет запрос ядру и ждёт CONNECT_RESULT. - * joined сообщает об успешном входе, а не только о создании транспорта к connection-узлу. */ + * joined сообщает об успешном входе, а не только о создании транспорта к connection-узлу. + * Только GUI-поток; диалог занимает единственный connect_result callback bridge и снимает его в деструкторе. + * Работает как activeModalWidget: не совмещать с другим владельцем этого callback. + * Закрытие снимает получение результатов, но не отменяет уже принятый JOIN в ядре. */ #pragma once #include @@ -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. }; diff --git a/tools/chatgui/src/message_links.h b/tools/chatgui/src/message_links.h index d3fee140..15882682 100644 --- a/tools/chatgui/src/message_links.h +++ b/tools/chatgui/src/message_links.h @@ -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 #include struct MessageLink { - int start; - int length; - QString url; + int start; // Смещение начала в исходном QString, в кодовых единицах UTF-16. + int length; // Длина адреса в тех же единицах, без отделённой внешней пунктуации. + QString url; // Полный адрес в исходном написании; равен text.mid(start, length). }; +// Возвращает непересекающиеся диапазоны в порядке текста; пустой список, если подходящих ссылок нет. QVector messageLinks(const QString& text); diff --git a/tools/chatgui/src/message_notification.h b/tools/chatgui/src/message_notification.h index 4ad98154..3e417a5a 100644 --- a/tools/chatgui/src/message_notification.h +++ b/tools/chatgui/src/message_notification.h @@ -1,5 +1,8 @@ /* Одно уведомление о сообщениях: не берёт фокус, ждёт активности и скрывается через 10 секунд. - * Новое сообщение заменяет содержимое и отменяет прежний отсчёт. activated передаёт ключ канала/DM владельцу. */ + * До первой активности автоматического закрытия нет; дальнейшая активность не продлевает начатый отсчёт. + * Новое сообщение заменяет содержимое и заново ждёт активности. Только GUI-поток, один внешний владелец QWidget. + * Окно верхнего уровня без parent, не удаляет себя при close; все дочерние widgets/monitor принадлежат ему. + * На Wayland размещение/поверхность контролирует compositor, показ на текущем desktop — best effort. */ #pragma once #include @@ -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 отменяют его. }; diff --git a/tools/chatgui/src/qrcode_utils.h b/tools/chatgui/src/qrcode_utils.h index 2d70db87..5d898c55 100644 --- a/tools/chatgui/src/qrcode_utils.h +++ b/tools/chatgui/src/qrcode_utils.h @@ -1,10 +1,13 @@ -/* Генерация QR и распознавание штрихкодов из QImage через ZXingQt. Ошибка генерации — пустой QImage. */ +/* QR для произвольного текста и чтение штрихкодов из QImage через ZXingQt. + * Результаты принадлежат caller; модуль не проверяет utun-ссылки и ничего не открывает. */ #pragma once #include #include #include +// Сгенерировать QR: moduleSize>0 задаёт scale ZXing, иначе остаётся значение библиотеки; при ошибке пустой QImage. QImage generateQRCode(const QString& text, int moduleSize = 8); +// Тексты всех valid результатов ReadBarcodes с настройками по умолчанию, не только QR; без результатов пустой список. QStringList readQRCodes(const QImage& image); diff --git a/tools/chatgui/src/user_activity_monitor.h b/tools/chatgui/src/user_activity_monitor.h index 9b1951c7..34906bc6 100644 --- a/tools/chatgui/src/user_activity_monitor.h +++ b/tools/chatgui/src/user_activity_monitor.h @@ -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 @@ -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 проверены; не открывать повторно после отказа. };