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

48 KiB
Raw Blame History

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:

[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. nativeName() учитывает приоритетный список и именует каждый тег на его языке.

select() вызывается на main после создания Activity, принимает один тег каталога либо пустую строку. true означает уже выбранный/принятый запрос, false — отказ, а не подтверждение отката или завершения смены ресурсов. Выбор хранит Android/AppCompat с autoStoreLocales, без дублирования в DataStore; механизм хранения/пересоздания описан в AppCompatDelegate.setApplicationLocales. 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.

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. Новая группа не заменяет владельцев при неподтверждённой очистке.

Screen receiver удерживается до вызова registerReceiver, включая частичный отказ IPC. IllegalArgumentException от unregister подтверждает отсутствие регистрации; прочие ошибки сохраняют владельца. Доставленный после замены receiver проверяет identity и исходное поколение до изменения состояния экрана. Проверка settings ACK имеет собственный отменяемый Runnable: ACK, новая настройка и stop снимают его. Отказ постановки ACK-timeout логируется, захват по-прежнему ждёт подтверждения. Отказ таймера фоновой автоматики закрывает её окно; ручной PTT/измеритель могут сохранить захват в режиме OFF. Логи содержат группу, поколение, этап отказа и join.

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. Новый звук не запускается, пока остаётся старый плеер или 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. 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.

Компонент 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/автоматики. Проверяется и сохранение владельцев после частичного эффекта API, и продолжение независимой очистки перед повторной попыткой. На 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.