Files
utun2/tools/chatgui-android/doc/ARCHITECTURE.md
T

565 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# chatgui-android — архитектура
## Состав и сборка
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`.
`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` туда не входит.
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`.
Параметры приложения определяет `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 — за аудио.
Сборка использует Gradle 8.13, AGP 8.13.2, Kotlin/Compose compiler 2.2.20 и CameraX 1.7.0-rc01;
минимальный Android приложения остаётся API 26.
`CameraRecordScreen` объединяет режимы «Фото / Видео» для канала и PM. Адресат,
поколение PM и канальный ответ фиксируются до открытия камеры. Фото запрашивает
только CAMERA; RECORD_AUDIO требуется в режиме видео. Привязывается Preview и
один выбранный use case (ImageCapture либо VideoCapture). После снимка доступны
просмотр и пересъёмка; переключение режима разрешено в состоянии готовности.
Экран удаляет отменённые cache-файлы и игнорирует запоздавшие callbacks. При отправке
владение файлом переходит ViewModel, а после принятия JPEG/PNG — `attachment_send`.
Общие с трансляцией иконки открывают меню камеры, разрешения, кодека и битрейта.
Видеозапись использует H.264/H.265 и AAC в MP4; доступные кодеки и разрешения
проверяются через `Recorder.getVideoCapabilities(cameraInfo, mime)` для выбранной камеры.
Разрешение и битрейт независимы. Перед готовностью/отправкой `MediaExtractor`
проверяет, что фактический MIME видеодорожки совпадает с выбранным кодеком.
В фото отдельное меню JPEG quality (10..100); `ImageCapture.setJpegQuality()`
задаёт сжатие при съёмке. Предпросмотр читает полученный JPEG с EXIF и сохраняет
артефакты сжатия; масштабирование позволяет рассмотреть их. Декодирование ограничено
4096 по большей стороне, на SM-A525F штатные снимки читаются без уменьшения.
JPG/JPEG/PNG из выбора файла и камеры проходят общий `sendPhotoMessage`:
фактический MIME и размеры с учётом EXIF читаются в IO, затем передаются через JNI.
В PM метаданные JPEG/PNG шифруются вместе с остальными метаданными вложений.
Превью декодируется с ограничением размера; подпись содержит разрешение и размер
файла. Каналы и PM используют `PhotoViewerScreen` с масштабированием и перемещением.
## Владение ядром и потоки
`NativeLib.init(dbPath, controlPort)` сохраняет config/log callbacks и настраивает
voice_recorder. Он не запускает ядро и не открывает control-listener; controlPort
в этой ветке только логируется. Затем `NativeLib.start(configText)` передаёт INI
в `instance_lite_start()`.
Один worker pthread создаёт uasync и `UTUN_INSTANCE`, принудительно задаёт
CLIENT_TYPE_MOBILE и запускает `utun_core_start()` + `chat_service_start()`.
UTUN-сервис включается отдельно через `[gui] utun_enabled=1`, по умолчанию выключен.
Наличие TUN/routing/NAT-кода в сборке само по себе сервис не включает.
До `CHAT_EVT_SERVICE_STARTED` worker привязывает stream bridge и call_video_port,
а при UTUN_WITH_VOICE — call_audio. Успех start означает создание потока либо уже
существующий запуск; он не подтверждает готовность сервисов. Готовность проверяется
по событию и `instance_lite_is_running()`.
Команды 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; обход живых списков требует исключить их параллельное изменение.
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.
Перед 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`.
## Данные и конфигурация
Ядро владеет общей 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-коде.
ConfigProvider хранит настройки в DataStore, читает ключи и формирует общий INI
через buildConfigText/buildConfigFromServers. Callback-ключи вроде `node.name` и `db.path`
не являются именами INI-секций. Native config API копирует таблицу callbacks по значению;
функции и их окружение остаются у провайдера. Init без config и destroy снимают прежнюю
регистрацию; смену провайдера нужно сериализовать с cfg_get_*. Минимальный пример INI:
```ini
[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
[gui]
utun_enabled=0
[allowed_keys]
allow_all=yes
[ntp]
enabled=no
[debug]
chat=info
socket=info
```
Секции `[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 на месте, без пересоздания соединений.
## Язык интерфейса
`AppLanguage.supportedTags` читает общий `res/xml/locales_config.xml`: имена
нормализуются в BCP47, пустые/повторные/некорректные записи отклоняются атомарно.
Parser закрывается также при ошибке. Успешный каталог кэшируется; провал lazy
логируется и не запрещает следующую попытку чтения.
`selectedTag()` возвращает requested список Android/AppCompat через запятую,
пустая строка означает системный выбор. Effective ресурсы получают отдельно
через `context()`/`text()`, в том числе вне Activity: для этого используется
[ContextCompat.getContextForLanguage](https://developer.android.com/reference/androidx/core/content/ContextCompat#getContextForLanguage(android.content.Context)).
`nativeName()` учитывает приоритетный список и именует каждый тег на его языке.
`select()` вызывается на main после создания Activity, принимает один тег
каталога либо пустую строку. true означает уже выбранный/принятый запрос,
false — отказ, а не подтверждение отката или завершения смены ресурсов.
Выбор хранит Android/AppCompat с `autoStoreLocales`, без дублирования в DataStore;
механизм хранения/пересоздания описан в
[AppCompatDelegate.setApplicationLocales](https://developer.android.com/reference/androidx/appcompat/app/AppCompatDelegate#setApplicationLocales(androidx.core.os.LocaleListCompat)).
UI перечитывает requested состояние после попытки, включая частичный отказ;
ошибки чтения и выбора показываются отдельно с возможностью повторить действие.
`applied()` на main получает effective locale и при каждом сигнале конфигурации/
создании Activity заново запрашивает обновление четырёх владельцев уведомлений:
сеть, входящий звонок/рация, текущий звонок и radio overlay. Кэша «этот locale
уже применён» нет: владелец может обработать отказ внутри себя или стать активным
после прежнего сигнала. Обновляются каналы/тексты; ядро, media и сигналы вызова
не перезапускаются. Ошибка одного владельца не прерывает остальные. При отказе
чтения конфигурации сигнал логируется и пропускается до следующего вызова.
## Каналы, приглашения и 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://...` содержит версию 0x04 и kind:
CHANNEL несёт пароль/channel_id/join_key, SERVER — 32-байтовый add_key без полей канала.
Далее идут REALITY-параметры и блоки публичного ключа/адресов. В Kotlin-preview
SERVER имеет channelId/joinKey=0 и пустой password; add_key остаётся в исходной ссылке.
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 групповой сессии — разные состояния.
LAN-приглашения хранит app-scoped `LocalDiscovery`; AppEventHandler передаёт ему
события напрямую на main, без replay ViewModel. Список устройств 61 заменяется
целиком; входящая канальная ссылка 62 только ставится в очередь показа, без JOIN.
Кадры 61..63 имеют BE-поля ID/длин, 64 содержит байт активности. Принимаются
не более 128 устройств, имена 1..127 UTF-8 байтов без ASCII control, ссылка до
1100 байтов; входящая UI-очередь ограничена 8 приглашениями. Повреждённые кадры,
невалидный UTF-8, нулевые/повторные ID устройств не заменяют прежнее состояние.
Одна исходящая LAN-отправка на приложение хранит request и снимок адресата.
Событие 63 применяется только к pending с совпавшими request и peer; статусы
0/1/2 означают failed/received/forbidden. ACK подтверждает приём ссылки, не JOIN.
Отказ/исключение JNI завершает pending ошибкой. Stop очищает устройства и завершает pending ошибкой,
сохраняет уже полученные приглашения; поздний ACK не возобновляет завершённый запрос.
Android MulticastLock следует эффективной активности native discovery, а не
видимости Activity. Владелец сохраняется до configure/acquire и при ошибке
query/release: следующий ACTIVE/STOP повторяет операцию с тем же lock.
В логе указаны точный этап и сохранён ли владелец; на повторном ACTIVE удерживаемый
lock не захватывается ещё раз. Постоянный таймер повторов здесь не запускается.
`InviteLinkDialog` получает candidates/link/pending от владельца генерации.
Он показывает QR или текст, запрашивает LAN-снимок и отображает результат
последней отправки вместе с её адресатом. Закрытие не отменяет pending-доставку.
Ошибка QR логируется без ссылки, текст остаётся доступен; ошибка clipboard
показывается в диалоге. Смена ссылки сбрасывает подтверждение копирования.
DM-команды используют conv_id и поколение беседы. История приходит событием
CHAT_EVT_DM_MESSAGES, список — CHAT_EVT_DM_CONVERSATIONS. Frontend отбрасывает
ответы старого поколения/другой беседы. Voice/video принимают target `dm:<conv_id>`;
отдельные dm_* функции принимают conv_id без префикса. Общее устройство DM:
`../../../doc/dm_arch.md`.
## Вложения и media
Канальные text/file/photo используют chat_core_submit_trampoline. reply_to — ровно
32 байта исходного message_id, нули означают отсутствие ответа. request_id связывает
публикацию с CHAT_EVT_MSG_SUBMITTED; успешная постановка не означает доставку пиру.
Обычный файл и готовое фото остаются у caller и должны жить неизменными до завершения
асинхронного копирования/индексации. Фото здесь не декодируется и не масштабируется.
voice_recorder принимает int16 mono 48kHz, отсоединяет PCM/compressor при stop и
передаёт общий attachment_send. Запись короче 500мс по монотонным часам отбрасывается.
Готовый MP4 передаётся без транскода; при принятом video-запросе cache-файл принадлежит
задаче и удаляется также при ошибке/отмене. То же правило у отдельной DM-отправки файла.
При отказе постановки файл остаётся caller. Generation защищает публикацию в DM;
канальный reply_to/request_id фиксируется до начала подготовки.
Звонок и рация используют общий C голосовой стек. Kotlin владеет AudioRecord/AudioTrack,
feed/pull workers и маршрутом Android Telecom; DSP и сетевой сигналинг разделены.
Вызовы lifecycle DSP выполняются после остановки его внешних feed/pull callers.
Контракты PCM/PTS: `src/call/call_audio.h`, `src/radio/radio_audio.h`, `jni_bridge/android_jni_bridge.h`.
`RadioCallController` на main управляет адресным приглашением участника в обычную
рацию канала. Это отдельный сигналинг `src/radio/radio_call.h`, без отдельного
аудиозвонка. START резервирует busy до первого события ядра: быстрое повторное
нажатие не создаёт второй запрос, а CoreActivity удерживает активность также
на этом промежутке. Отказ очереди освобождает резерв, reset отменяет pending.
Неготовое ядро сообщает недоступность; занятая попытка или P2P-звонок — занятость.
Событие 65 содержит ровно 27 little-endian байтов: id/group/peer по 8, затем
phase/reason/incoming по 1. ID ненулевые; Long хранит все биты uint64, строка
канала — десятичный uint64. Проверяются диапазоны и сочетания роли/фазы/причины,
а у своей попытки — вся тройка и incoming. Посторонние/устаревшие события не
меняют живую попытку и не публикуют результат от старой. При недоступных именах
repository UI использует ID, READY по-прежнему отправляется.
Входящий сначала публикует оповещение и отправляет READY. Ответ из уведомления
ждёт onStart Activity, разрешение микрофона перепроверяется; callbacks старой
попытки не принимают новую. Accept публикует PREPARING/activation, Activity
включает обычную рацию и возвращает activated(id, success). Только тогда входящий
отправляет ACCEPT либо FAIL и ждёт FINISHED. У исходящего ACCEPTED запрашивает
локальную активацию; её успешное завершение сразу снимает карточку. Поздние
RINGING/WAIT_DECISION/повторный FINISHED не откатывают исходящий PREPARING.
CONTINUE разрешён только своей WAIT_DECISION-попытке и сохраняет id.
Отказ/исключение JNI логируется с action/id/group/peer. Неотправленный READY,
ACCEPT/FAIL или CONTINUE завершает локальную попытку; DECLINE/CANCEL очищает UI
независимо от очереди. Блокирующая захват трансляция отправляет FAIL и закрывает
оповещение. Ошибка CoreActivity не прерывает сигналинг и независимую очистку:
CoreActivity сохраняет намерение для следующего sync. Deadline приглашения
остаётся в ядре; приложение владеет только отменяемой задержкой 2,5 с удаления
ENDED-карточки. Новая попытка/reset отменяет её, callback проверяет сам снимок.
Завершение приглашения не выключает обычную рацию. StateFlow повторяет текущие
state/activation для пересозданной Activity; история завершённых вызовов не
восстанавливается. RadioCallDialog отображает этот снимок и передаёт действия
контроллеру, не управляя deadline/аудио. Результат видимой Activity показывается
Toast, в фоне — отдельным уведомлением 1004, без замены уведомлений сервисов.
Отказ уведомления/локализации не мешает удалению карточки.
`RadioPttHolds` на main хранит по одному ручному удержанию BUTTON/OVERLAY/VOLUME.
Повтор press того же источника идемпотентен; несколько volume-клавиш агрегирует
Activity и отпускает общий VOLUME после последней клавиши. Release возвращает
true только при удалении последнего владельца; неизвестное отпускание не гасит
чужую передачу. Cancel возвращает отсутствие владельцев также для уже пустого
набора: это позволяет отменить хвост передачи после release. Null снимает всех.
Агрегатор не управляет VAD, аудио или таймерами; их владелец — RadioAudioEngine.
Ручные talkBegin/talkEnd/cancelTalk в RadioAudioEngine проверяют main-поток до
изменения владельцев/таймера. Неактивная рация отказывает с WARN; отсутствие
RECORD_AUDIO запрашивает разрешение без сохранения удержания. После выдачи
пользователь нажимает снова. Принятый PTT — асинхронный запрос, не подтверждение
первого PCM/TX: эти этапы видны отдельно в логах capture/native и transmittingGroup.
Последний talkEnd оставляет 250 мс хвоста; новое нажатие снимает его таймер,
cancel последнего владельца снимает хвост сразу. Если postDelayed отказал,
ERROR содержит source/group/generation; движок немедленно снимает ручной запрос
и ставит согласование capture в io. Сообщение scheduled появляется только при
успешной постановке. Когда захват больше не нужен, подписка закрывается и native
capture останавливается. При работающем измерителе/автоматике захват сохраняется,
а отпускание PTT проходит общей очередью управления/PCM до native radioTalkEnd.
Снятие ручного PTT само по себе не выключает автоматическую передачу.
Stop/смена группы сбрасывает удержания и прежний хвост; io проверяет generation.
Start/stop рации также вызываются на main. Start отвергает нулевую группу,
проверяет размер буфера AudioTrack и выполняет на io сначала очистку прежних
ресурсов, затем setup. Даже отказ первоначальной очистки завершает onReady(false)
на main. Отложенный результат перепроверяет generation: он не подтверждает и
не очищает новый запрос. Ошибка текущего запуска снимает activeGroup после
callback, если callback не начал новый start/stop. Это позволяет restoreAfterCall
сначала выключить native-членство неудачно восстановленной группы. Исключение
пользовательского callback логируется и передаётся main-caller. Если main-очередь
отвергла доставку, callback на io не вызывается, новые аудиоресурсы очищаются с ERROR.
Stop снимает логическое участие сразу; его Future завершается после io-очистки
либо возвращает первый критический отказ. Ждать Future нужно вне main. Receiver,
capture, output lease/AudioTrack и native DSP очищаются независимо. Перед остановкой
DSP движок дожидается своих capture/play workers; interruption не пропускает join,
флаг восстанавливается после очистки. Ссылка на отказавший receiver/lease/track
сохраняется для следующего stop/start. При ошибке detach lease трек ещё принадлежит
MediaAudioController и не получает release. Успешно очищенные ссылки сбрасываются;
native audio stop остаётся идемпотентной завершающей попыткой после join.
Новая группа не заменяет владельцев при неподтверждённой очистке.
Capture сохраняет подписку до native start и отдельно помнит возможное владение
encoder. Даже исключение после частичного эффекта API получает rollback; неудачный
native capture stop сохраняется для повторной попытки независимо от закрытой
PCM-подписки. Подтверждённый capture/audio stop сбрасывает наблюдаемый TX и режим
детектора. Отказ автоматического детектора закрывает автоматический захват;
ручной PTT или измеритель могут продолжить работу в режиме OFF.
Оба worker сохраняются до Thread.start. Capture принимает ровно 960 PCM frames
на канал; старые кадры отбрасываются с границей перед следующим свежим кадром.
Ошибка poll, PTT-команды, границы или feed логируется с этапом/группой/кадром,
после чего io закрывает захват, сохраняя приём. False от feed означает отказ
native-захвата; это не потеря сетевого пакета. Новый пользовательский запрос
может повторить захват после устранения причины. Очистка проверяет подписку и
generation; rollback старого запуска не снимает PTT нового канала.
Playback использует переданные при запуске track/группу/поколение. Pull допускает
0..1920 стереосэмплов с выравниванием по двум каналам, дополняя короткий результат
тишиной. Положительные частичные write передают остаток того же кадра; ноль,
отрицательный, слишком большой либо нечётный результат работающего write — ошибка.
Ошибка head/pull/write/sleep освобождает свою сессию на io до уведомления main;
отказ main-очереди не удерживает ресурсы. Identity трека защищает новую сессию
от запоздалой очистки. Interruption при штатном stop и результат разблокированного
им write считаются отменой; interruption работающего worker логируется как ошибка.
Screen receiver удерживается до вызова registerReceiver, включая частичный отказ
IPC. IllegalArgumentException от unregister подтверждает отсутствие регистрации;
прочие ошибки сохраняют владельца. Доставленный после замены receiver проверяет
identity и исходное поколение до изменения состояния экрана. Проверка settings ACK
имеет собственный отменяемый Runnable: ACK, новая настройка и stop снимают его.
Отказ постановки ACK-timeout логируется, захват по-прежнему ждёт подтверждения.
Отказ таймера фоновой автоматики закрывает её окно; ручной PTT/измеритель могут
сохранить захват в режиме OFF. Логи содержат группу, поколение, этап отказа и join.
Reconfigure инвалидирует прежний settings request до чтения новых настроек.
Ошибка чтения/применения/обновления capture логируется с этапом; приём остаётся
включённым, capture ждёт повторной перенастройки и нового ACK. Прежний ACK не
возобновляет захват после отказа. Частично созданный capture получает rollback.
`RadioTalkState` хранит отдельный снимок говорящих для активной группы. Включение/
смена канала создаёт новый снимок, выключение устанавливает groupId=0. Событие 43
разбирается строго как 19 little-endian байтов: group/node/stream/on; ID ненулевые,
stream=0..65535, on=0/1. Неверное событие возвращает null и логируется ViewModel.
START/STOP обновляет только набор node ID своей группы; чужая группа и повтор
состояния сохраняют прежний объект. В C radio_talker_on заменяет stream одного
узла без STOP старого потока, поэтому stream не является ключом UI-набора.
Индикатор речи не зависит от обновления таблицы участников или локального mute;
выключенная рация игнорирует события. Эти снимки не управляют ручным PTT.
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`.
## Фон и диагностика
`IncomingCallManager` на main владеет одним входящим оповещением P2P/рации.
Повтор `show` с теми же ID/каналом/пиром/видом обновляет только уведомление.
Замена сначала снимает прежнее оповещение. Ошибки wake lock и уведомления
не мешают запуску `RingtonePlayer`; обновление языка не повторяет звук,
пробуждение или full-screen intent.
`stopRinging` сразу снимает `active`, затем вызывает остановку `RingtonePlayer`
и независимо очищает wake lock, кнопки и уведомление. Receiver проверяет полный
64-битный call ID и вид P2P/рация: уже доставленный DECLINE после stop/замены
игнорируется. PendingIntent различаются URI с видом/ID, а не усечённым requestCode.
Wake lock один, без подсчёта ссылок, с таймаутом 30 секунд. Менеджер хранит его
до успешного `isHeld`/`release`; если ОС уже сняла lock, второй release не нужен.
При отказе старый lock не перезаписывается новым: следующий звонок получает
звук/уведомление, но пропускает новое пробуждение до очистки прежнего lock.
Каждый PendingIntent хранится с владельцем до успешного `cancel`. Необходимость
отмены уведомления отмечается до вызова `notify`: исключение может прийти после
эффекта в ОС, поэтому ошибка публикации не освобождает от последующей отмены.
Повтор `stopRinging`, `init` или `refreshLanguage` без `active` повторяет
незавершённую очистку, не трогая уже успешно очищенные ресурсы. Пока канал
не подготовлен, `init`/`refreshLanguage` также отменяет возможное уведомление
прежнего процесса; отказ создания канала не блокирует очистку. Отдельного
таймера повторных попыток нет. При активном оповещении обновление языка
сохраняет его кнопки, звук и lock. Логи `InCall` содержат ID/вид вызова для
кнопок, фазу/идентификатор lock и признак оставшейся очистки после stop.
`RingtonePlayer` читает системный режим звонка при `play`: NORMAL даёт звук и
вибрацию, VIBRATE — вибрацию, SILENT — ни одного эффекта. API 28+ использует
зацикленный Ringtone, API 26–27 — MediaPlayer с профилем звонка после выдачи
audio focus. Отказ одного эффекта не запрещает другой. Потеря focus запрашивает
остановку звука, сохраняя вибрацию; последующий GAIN не перезапускает мелодию.
`stop` сначала запрещает callbacks звука и снимает таймер вибрации. Владельцы
Ringtone/MediaPlayer/Vibrator остаются до успешного stop/release/cancel,
а исходная пара AudioManager/AudioFocusRequest — до GRANTED от
[abandonAudioFocusRequest](https://developer.android.com/reference/android/media/AudioManager#abandonAudioFocusRequest(android.media.AudioFocusRequest)).
Новый звук не запускается, пока остаётся старый плеер или focus; новый импульс
не запускается при незавершённой отмене вибрации. Повтор `stop`/начало `play`
повторяет только незавершённую очистку. IncomingCallManager вызывает её также
при повторных stop/init/обновлении языка без активного оповещения.
Вибрация состоит из конечных импульсов 1000 мс с повторным запуском через 2000 мс.
Отказ импульса или постановки таймера отключает повторение и запрашивает cancel.
Даже при ошибке cancel новые импульсы не ставятся в очередь. Ошибка API после
возможного эффекта сохраняет владельца для следующей попытки; отдельного таймера
очистки нет. Логи `Ringtone` показывают идентификатор ресурса, причину отказа,
успешное освобождение и оставшиеся sound/focus/vibration после запроса stop.
`CallRingback` на main владеет одним зацикленным MediaPlayer с профилем MEDIA:
connecting и ringing выбирают разные ресурсы. Повтор активной фазы сохраняет
плеер. Смена фазы, stop, error и неожиданный completion сначала запрещают
callbacks, затем запрашивают release. При исключении ссылка и прежний resource ID
остаются для следующего stop/start. Пока release не подтверждён, новая фаза
пропускается с WARN; запоздалые callbacks не повторяют очистку и не трогают
замену. После восстановления start может заново запустить ту же фазу.
Пропущенный start автоматически не воспроизводится; таймера повторов нет.
`NotificationSound` на main запускает отдельный однократный плеер с профилем
уведомлений; сигналы разных сообщений могут перекрываться. Объект приложения
сильно удерживает каждый плеер от получения из create до успешного release,
включая настройку listeners и проигрывание: это предотвращает преждевременное
завершение звука сборщиком мусора, описанное в
[контракте MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer).
ID звука — канал либо `dm:<ID>`; enabled/mutedChannels — снимки настроек для
новых сигналов. Смена настроек не останавливает уже играющие сигналы.
Completion/error/ошибка настройки или запуска запрашивает release своего
плеера. Исключение сохраняет владельца с признаком незавершённой очистки;
следующий callback либо play повторяет попытку. В начале play повторяются
только такие release, включая случаи disabled/mute и последующий отказ create.
Неудача одного плеера не мешает очистить остальные или запустить новый сигнал;
ещё играющие плееры остаются нетронутыми. После успешного release владелец
удаляется, поздние callbacks игнорируются. Автоматического таймера очистки нет.
Логи Ringback/Sound отмечают успех только после возврата release без исключения;
при отказе показывают ресурс/канал, идентификатор плеера и причину попытки.
`BatteryOptimizationPermission` читает power allowlist Android для текущего пакета:
true/false — ответ ОС, null — ошибка чтения сервиса/статуса с ERROR-диагностикой.
Флаг в DataStore не сохраняется и результат не кэшируется. Это исключение
из оптимизации, а не снятие всех фоновых ограничений; контракт ОС описан в
[PowerManager](https://developer.android.com/reference/android/os/PowerManager#isIgnoringBatteryOptimizations(java.lang.String)).
Компонент `BatteryOptimizationSetting` используется в первоначальной настройке
и настройках приложения. Он перечитывает статус на каждом RESUME. При null
показывает ошибку и «Повторить»: эта кнопка только повторяет чтение. При false
кнопка «Разрешить» сначала перечитывает ОС, затем открывает пакетный запрос,
а при отказе запуска — общий список исключений. Успешный startActivity означает
лишь открытие системного экрана; выдача исключения определяется после возврата.
Неизвестный результат не считается отказом пользователя. Pending запроса
переживает пересоздание UI, оба неудачных запуска снимают его с видимой ошибкой.
Настройка не перезапускает ядро и не блокирует завершение первоначальной настройки.
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.
LogManager хранит последние 100000 строк в памяти процесса, в порядке приёма.
Прямой Kotlin addLog сохраняет важность logcat, но не проходит native-фильтр
и не доставляет строку в native-файл/UDP. videoDebug/videoTrace проходят общий
video-фильтр через JNI. JNI callback получает уровни bridge 0=TRACE..4=ERROR,
а не числовые DEBUG_LEVEL_*; неизвестный уровень фиксируется предупреждением.
Строки логов передаются через `onNativeLog(int, byte[], byte[])` как обычный UTF-8:
это сохраняет кириллицу/emoji без ограничений Modified UTF-8 `NewStringUTF`.
Повреждённые UTF-8-байты Kotlin отображает как U+FFFD. На каждый вызов JNI берёт
свой local ref callback под mutex; замена/очистка global ref не инвалидирует
уже начатую доставку. Java-код вызывается после освобождения mutex. Указатель
C log callback читается/меняется атомарно; отключение не ждёт начатых вызовов.
init включает пересылку, Activity подписывается на экране логов и отключает её
при выходе; stop снимает C callback, destroy дополнительно освобождает global ref.
Ошибки JNI-доставки и повторный вход в callback того же потока фиксируются
напрямую в logcat, минуя debug hook, чтобы исключить рекурсию. Проверяются
attach, ссылки, method lookup, выделение/копирование массивов и Java callback.
Все свои local refs освобождаются; detach выполняется только после своего attach.
Исключение от собственной операции/callback описывается и снимается; исключение,
уже существовавшее при входе в logger, сохраняется, доставка этой строки пропускается.
Неудачная доставка не повторяется. Native logcat/файл/UDP работают независимо.
Экран логов показывает последние 10000 строк истории, обновляя снимок не чаще
кадра. StateFlow передаёт объединяемую ревизию, строки читаются из общей истории:
задержка UI и открытие экрана после последнего сообщения не теряют строки
в отдельной очереди событий. Лимиты истории/экрана всё равно вытесняют старые строки.
Фильтр выбирает точный уровень; пауза отключает только прокрутку, сбор продолжается.
Автопрокрутка следует последней видимой строке после фильтрации и возобновления.
## Linux headless и проверки
Каталог `headless/` — отдельный каркас: headless_main запускает select-loop и JSON-line
control socket, но не создаёт uasync/instance и не запускает чат. `-p` меняет порт,
`-l` только печатает имя файла. ping/status/connections/debug_level возвращают заглушки,
subscribe/quit управляют клиентом, ряд команд даёт not implemented. Текстовые команды
вида `send <channel> <text>` этот каркас не поддерживает. JOIN/settings требуют уже
запущенное ядро и исполнения в его потоке; каркас не заменяет рабочий headless API.
Для Linux сетевых сценариев используются основной `src/utun`,
`[chatserver] headless_control_bind=127.0.0.1:9999` и `tools/chatcli`.
Для APK: `cd tools/chatgui-android && ./gradlew assembleDebug`.
`:app:testDebugUnitTest` включает `:router-tests:test`: настоящие
IncomingCallManager/receiver/RingtonePlayer проверяются на JVM с моделями
Android-служб. Сценарии включают отказы до/после эффекта acquire/release,
notify/cancel и отмены PendingIntent, stop/release проигрывателя, request/abandon
audio focus, импульса/cancel вибрации и постановки таймера. Проверяются независимая
повторная очистка, замена вызова и устаревшие callbacks. Эти модели не проверяют
поведение системных служб/OEM на устройстве.
Для CallRingback/NotificationSound проверяются также запрет смены фазы при
неосвобождённом loop, независимость перекрывающихся сигналов, очистка без
установленных callbacks и повтор release при выключенном звуке/ошибке create.
RadioPttLifecycleTest использует настоящие RadioAudioEngine, MicrophoneCapture и
MediaAudioController с host-границами Android/JNI. Проверяется не только UI-флаг,
но и команда native capture stop либо упорядоченный radioTalkEnd при сохранённом
измерителе: отказ/обычный хвост таймера, повторное нажатие, независимые владельцы,
отмена, смена группы, RECORD_AUDIO и запрет фоновых PTT-вызовов. PCM-подписки и
workers настоящие; звукового оборудования, DSP и сетевой передачи в модели нет.
RadioAudioLifecycleTest проверяет отказы регистрации/очистки receiver и AudioTrack,
зависимость release от detach lease, interruption, ошибки native/start/settings,
отложенные и повторно входящие callbacks, восстановление после звонка, отказ
перенастройки с последующим старым ACK и отказы
таймеров ACK/автоматики. Проверяется и сохранение владельцев после частичного
эффекта API, и продолжение независимой очистки перед повторной попыткой.
RadioWorkerLifecycleTest проверяет исключения и неверные результаты pull/write,
целостность частичных PCM-записей и заполнение тишиной, запуск capture с частичным
native-эффектом, отказ feed/PTT/границы, отсутствие детектора, interruption и
отказ main-очереди. Барьеры потоков воспроизводят ошибки старых playback/feed/start
после запроса новой группы. Проверяются освобождение подписок/encoder/track и
сохранение нового PTT. Android/JNI в этих сценариях моделируются на границах;
DSP, звуковое оборудование и сетевой эфир не запускаются.
RadioCallLifecycleTest использует настоящие RadioCallController, IncomingCallManager,
CoreActivity и гудки. Управляемые Android/JNI/repository-границы проверяют все
команды сигналинга, pending START, валидацию событий/полной identity, активацию,
старые callbacks/таймеры, PHONE_BUSY/CONTINUE, uint64 ID, отказы активности и
публикации результатов. IncomingCallLifecycleTest также использует настоящий
рационный контроллер при доставке DECLINE. Радиоэфир и C-сигналинг в JVM не
запускаются; native-протокол проверяется отдельным test_radio.
На Linux `:app:testDebugUnitTest` также запускает 25 fault-сценариев настоящего
JNI logger через `:app:testJniLog`; окружение и границы модели: `tests/jni_log/README.md`.
Проверки native bridge/standby доступны в `tests/Makefile.am`;
проверка независимости media workers на устройстве описана в
`../tests/stream_worker_smoke/README.md` и не проверяет сетевую доставку/UI.