72 KiB
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.
Прямое превью Camera2 передаёт UI, ожидающей команде и каждой camera session отдельный
StreamPreviewTarget на общий Surface/SurfaceTexture. retain создаёт новую ссылку,
release одной ссылки идемпотентен; идентичность источника проверяется через sameSource.
Ошибка listener отменяет предложенную ссылку, queued receiver пропускает released
target. Detach/close продолжают освобождение независимо от ошибки доставки команды;
поздний update не активирует закрытое превью. Последняя ссылка передаёт физическую
очистку process-владельцу: Surface и texture подтверждаются независимо, retry идёт
через 250 мс..30 с. При отказе Handler повторный release завершает её явно.
CallAudioRouter сохраняет device callback и каждый полученный Bluetooth proxy до
успешного unregister/close. stop инвалидирует callbacks и снимки устройств сразу,
очищает независимые ресурсы и бросает ошибку при неподтверждённом результате.
Собственные communication device/SCO-заявки отмечаются до API и снимаются отдельно;
перед передачей в Telecom глобальный маршрут должен быть освобождён. Повтор start
сначала завершает прежнюю очистку, сохраняя заранее настроенную Telecom Connection.
Отказы удерживаются process-ссылкой; Handler повторяет очистку через 250 мс..30 с.
При отказе Handler владелец сохраняется для явного stop/start. Принятый запрос
getProfileProxy ожидает callback; поздний proxy после stop также получает владельца
и повтор close при ошибке. При активном новом маршруте очищаются лишь старые proxy.
Дубликат callback освобождённого/заменённого proxy не делает его текущим снова.
MediaAudioSession.close требует отсутствия потребителей и сразу запрещает новые
подписки, outputs и refresh. Hardware и router очищаются независимо; ошибка оставляет
close повторяемым и сохраняет executor до подтверждения обеих частей. Пока close
не завершён, awaitIdle также отвергает передачу ресурсов звонку.
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, в фоне — отдельным уведомлением 1006, без замены уведомлений сервисов. Отказ уведомления/локализации не мешает удалению карточки.
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.
Фон и диагностика
CallService на main владеет audio/video движками, PARTIAL_WAKE_LOCK, power receiver,
foreground-уведомлением 1002 и собственными PendingIntent. Запуск сначала
подтверждает phoneCall|microphone, CPU lock и receiver, затем сообщает serviceReady.
Ошибка канала в onCreate логируется; onStart повторяет её до захвата ресурсов.
Ошибка диагностического чтения канала не отменяет успешный create. Null от
startForegroundService считается отказом, а отменённый запрос завершает
foreground-handshake без микрофона/CPU lock и останавливается.
Владельцы сохраняются до IPC: частичные acquire/register/promote получают rollback. startAudio проверяет свой ID и CPU lock; повтор не заменяет живые движки. Ошибка audio start/video constructor откатывает сервис. Неудачный camera promote сохраняет последний подтверждённый флаг; caller не меняет capture при false. Notification update/язык и power diagnostics ловят ошибки независимо от media. Токены open/END имеют identity своего звонка, повторно используются при обновлении и отменяются при shutdown. Старый END не завершает другой звонок; после stop уведомление не публикуется. Chronometer использует startedAtElapsedRealtime из controller.
Shutdown независимо пытается остановить video/audio, снять receiver, CPU lock и токены. Успешные операции не повторяются, отказавшие владельцы сохраняются. IllegalArgumentException от unregister подтверждает отсутствие receiver; отказ release после эффекта подтверждается следующим isHeld=false. Foreground и stopSelf(lastStartId) снимаются после успешных stop API обоих движков. Последний owned call ID сохраняется отдельно для serviceLost после локального освобождения. Audio stop снимает active и callbacks, пытается разблокировать capture и ждёт каждый worker до 250 мс за попытку; playback также ждёт свой reporter. Живые потоки сохраняются, устройства освобождаются после завершения обоих workers. Ошибки release оставляют AudioRecord/CallAudioOutput у движка, включая частичный запуск и отказ после эффекта. Остановка router, удаление communication listener и восстановление собственного AudioManager mode/speaker повторяются независимо; после передачи режима в Telecom fallback его не восстанавливает. Незавершённая очистка бросает ошибку сервису и запрещает новый start. Native audio stop вызывается после подтверждения этих операций, его отказ также допускает повтор. Video stop подтверждает RX join/освобождение его decoder, playout handle и Surface, native close и принятие cleanup камерой/encoder worker; окончательная camera cleanup асинхронна. Ошибка RX join или post допускает повтор stop. RX join ограничен 250 мс за попытку; decoder не теряется при отказе release, его Surface остаётся до подтверждённого освобождения codec. Остальные Surface и playout очищаются независимо. Очередь Surface владеет callback release до успешного возврата, включая позднюю передачу после stop; callback должен допускать повтор после исключения. Process-ссылка и Handler сохраняют cleanup после выхода RX, без переноса уже назначенного раннего таймера. Отказ post не теряет владельцев и допускает явный stop.
Shutdown возвращает Deferred, успешный результат означает подтверждение всех владельцев сервиса. При отказе process-ссылка удерживает экземпляр даже после onDestroy, Handler повторяет попытку с задержкой 250 мс..30 с. Отказ post не теряет владельца: shutdown/requireReleased повторяет очистку явно. Отмена Deferred не подтверждает освобождение. CallController снимает карточку независимо от retry, blocksMedia запрещает другой audio, а восстановление рации ждёт Deferred и mediaPreparation. NativeLib перед stop/restart требует подтверждённый сервис и завершённую подготовку media; иначе возвращает ошибку без разрушения ядра.
RadioOverlayService на main обеспечивает foreground-режим микрофона при активной
рации и включённом оверлее либо автоматике. Сам PCM захватывает RadioAudioEngine;
разрешённый экран/фоновое окно по-прежнему проверяются движком. Старт FGS
откладывается до видимой Activity. Разрешения запрашиваются только явной командой
пользователя; отказ launcher снимает ожидание и допускает повтор. Ошибки настроек,
проверки разрешения и обновления языка логируются с причиной.
Starting удерживается от запроса ОС до onCreate; stop в этом промежутке ставит pendingStop. Сервис сначала завершает foreground-handshake, затем запрашивает остановку. Новый запрос во время stop ждёт onDestroy. Отказ stopService/исключение не фиксирует pendingStop навсегда и допускает повтор. Ошибки создания канала, promote и получения WindowManager попадают в общий rollback запуска. Уведомление оверлея имеет отдельный ID 1005, без замены звонкового FGS (1002), трансляции (1004) и результатов вызовов (1003/1006). Обновление языка не публикует уведомление остановленного владельца.
Окно и LayoutParams сохраняются до addView: исключение после эффекта API получает remove. Успешный remove либо IllegalArgumentException подтверждает отсутствие окна; прочие отказы сохраняют ссылку и отключают обработку касаний. IsShown означает добавленное либо ещё не подтверждённо удалённое окно. После onDestroy такой владелец остаётся в currentService, и sync повторяет очистку до замены новым. Повторный/поздний onDestroy не гасит новый сервис. Touch/layout callbacks проверяют identity окна и владельца; старый callback позиционирования не сбрасывает флаг нового.
Обычное отпускание OVERLAY оставляет хвост 250 мс. Два коротких тапа фиксируют PTT; следующее нажатие снимает latch. Drag начинается только в первые 300 мс удержания, снимает PTT с обычным хвостом и сохраняет выбранную позицию до нового окна. ACTION_CANCEL, ошибка перемещения, remove и stop отменяют OVERLAY сразу, сохраняя BUTTON/VOLUME. Ошибка окна и отзыв разрешения также останавливают ненужный FGS, если автоматическая передача его не удерживает. При разрешённой автоматике сервис может продолжать работу без плавающей кнопки. Ошибка observer не уходит в необработанное исключение: свой PTT/окно закрываются, сервис запрашивает stop.
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 и отказы
таймеров ACK/автоматики. Проверяется и сохранение владельцев после частичного
эффекта API, и продолжение независимой очистки перед повторной попыткой.
RadioWorkerLifecycleTest проверяет исключения и неверные результаты pull/write,
целостность частичных PCM-записей и заполнение тишиной, запуск capture с частичным
native-эффектом, отказ feed/PTT/границы, отсутствие детектора, interruption и
отказ main-очереди. Барьеры потоков воспроизводят ошибки старых playback/feed/start
после запроса новой группы. Проверяются освобождение подписок/encoder/track и
сохранение нового PTT. Android/JNI в этих сценариях моделируются на границах;
DSP, звуковое оборудование и сетевой эфир не запускаются.
CallAudioLifecycleTest компилирует настоящие CallAudioEngine/CallAudioOutput и workers.
Проверяются отказы release до/после эффекта, частичная подготовка AudioRecord/AudioTrack,
регистрация/удаление communication listener, native stop, восстановление режима,
поздний Telecom handoff и запрет stop вне main/нулевого ID. Барьер capture доказывает,
что до выхода worker устройства/native сохраняются и новая сессия не запускается.
Android hardware и DSP моделируются; AAudio/OEM поведение на устройстве не проверяется.
:video-tests:test проверяет настоящий CallVideoPlayer, uAV2 KEY framing и смену поколений
на JVM. Управляемые границы MediaCodec/probe/JNI позволяют воспроизвести отказы частичного
configure/start/stop/release, Surface callback, playout destroy, join и retry Handler.
Проверяются независимое освобождение, запрет release Surface до codec, старый detach,
ACK показа/PTS и EOS drain без смены Surface. OEM/реальное декодирование остаются вне модели.
StreamPreviewLifecycleTest проверяет реальные preview/leases: потерю/частичный эффект
callback, отменённые команды, независимые и конкурентные владельцы, физические
отказы release до/после эффекта, retry Handler, detach/visibility и поздние update.
RadioCallLifecycleTest использует настоящие RadioCallController, IncomingCallManager,
CoreActivity и гудки. Управляемые Android/JNI/repository-границы проверяют все
команды сигналинга, pending START, валидацию событий/полной identity, активацию,
старые callbacks/таймеры, PHONE_BUSY/CONTINUE, uint64 ID, отказы активности и
публикации результатов. IncomingCallLifecycleTest также использует настоящий
рационный контроллер при доставке DECLINE. Радиоэфир и C-сигналинг в JVM не
запускаются; native-протокол проверяется отдельным test_radio.
CallServiceLifecycleTest в :call-service-tests использует настоящие CallService,
CallController, CallState, CoreActivity и router. Управляемые OS/DSP/camera/Telecom
границы воспроизводят частичные startup/stop, независимую очистку, повтор после
onDestroy, отказ очереди retry и отмену Deferred. Проверяются запрет другого
media/core teardown, ожидание восстановления рации, старая identity callbacks,
повторное использование notification tokens, camera flag и conversation clock.
Настоящие codec/камеры, Telecom и ограничения FGS/OEM в JVM не запускаются.
:app:testDebugUnitTest включает этот модуль как зависимость задачи, без host-классов в APK.
RadioOverlayLifecycleTest использует настоящие overlay service, RadioAudioEngine,
микрофонные подписки и MediaAudioController. Host-модели service/window IPC
проверяют частичные add/remove, повторную очистку, отказы channel/promote/stop,
старые touch/layout/destroy callbacks, PTT/latch/drag, отзыв разрешений и
изолированные ID уведомлений. Settings ACK позволяет выполнять настоящие PCM
workers через управляемую JNI-границу. WindowManager/foreground restrictions,
отрисовка и OEM-поведение на устройстве в JVM не проверяются.
На 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.