# uTun BLE Test Отдельное Android-приложение для проверки BLE discovery и автоматических двусторонних GATT-соединений. Android 8.0+, compile/target SDK 36, JDK 17. `:app` — тестовый экран; `:ble-transport` — переиспользуемая Android-библиотека с сервисом и очередями, без зависимости от экрана. ## Сборка и запуск ```bash cd tools/ble-test-android ./build.sh # unit tests, debug APK, Android lint ./build.sh install DEVICE_SERIAL # то же + установка на выбранное устройство ``` APK: `app/build/outputs/apk/debug/app-debug.apk`, package: `com.utun.bletest`. Gradle wrapper используется из соседнего `chatgui-android`; NDK, Compose и native-ядро не нужны. На двух и более устройствах задайте разные ники и одинаковый период, например 1000 мс. Нажмите «Запустить тест», выдайте разрешения и включите Bluetooth по запросу. Ник занимает 1–13 **байт UTF-8**: латиница — один байт, кириллица обычно два. Период — 250–3600000 мс. Для изменения настроек остановите сервис и запустите снова. На Android 8–11 нужны разрешение геопозиции и включённая системная геопозиция. Android 12+ запрашивает Nearby devices, Android 13+ отдельно запрашивает уведомления; отказ от уведомлений не запрещает тест. GUI отображает успешные публикации TX, уникальные сообщения RX по никам, повторы, номер последнего сообщения, RSSI и давность приёма каждого сеанса. Отдельно видны ошибки, очереди, переполнения и пропуски периодического источника. Одинаковые ники суммируются, идентификаторы сеансов остаются видимыми. Установка получает постоянный ненулевой 64-битный ID, видимый над настройками и в статистике соседей. Флажок автоматического подключения включает discovery → запрос → callback допуска → READY без bonding/диалогов. Направление автоматического запроса выбирается по unsigned ID: меньший подключается к большему. Можно явно задать ID назначения. Поле допуска ограничивает входящие одним ID; пустое разрешает любые ID в тестовом приложении. В библиотеке отсутствие callback означает отказ. Настройки допуска применяются при запуске теста. ## Фон и проверка стабильности Сервис `connectedDevice` работает с постоянным уведомлением и partial wake lock. Закрытие Activity, Home, блокировка экрана и удаление задачи из recent apps его не останавливают. Остановка — кнопкой на экране или в уведомлении. Wake lock удерживается только во время теста: он намеренно расходует батарею ради непрерывной работы таймеров при погашенном экране. После уничтожения процесса система может восстановить START_STICKY-сервис с сохранёнными параметрами; при этом создаётся новый сеанс, а статистика и очереди в памяти начинаются заново. Force stop, перезагрузка телефона и ограничения конкретной прошивки не дают гарантии восстановления. Автозапуска после загрузки нет. При необходимости разрешите фоновую работу в настройках прошивки. Рекомендуемый ручной прогон: 1. Сверить рост TX и RX на двух телефонах при видимых экранах, проверить «Отправить сейчас». 2. Уйти на Home и погасить экраны на 15–30 минут; затем сверить новые сообщения и давность приёма. 3. Выключить и включить Bluetooth на одном телефоне: сервис ждёт адаптер и восстанавливает радио. 4. После ошибки BLE проверить повторный запуск через 30 секунд и причину в GUI/logcat. 5. Проверить stop/start, поворот экрана и повторное открытие приложения. «Отправлено» означает успешный callback Bluetooth-стека о публикации новых данных. Одна публикация многократно объявляется контроллером до следующего обновления; это не число физических радиообъявлений и не подтверждение получения другими устройствами. Нельзя вычислять точный процент радиопотерь только по этим счётчикам: есть повторы, позднее подключение, перезапуски и время без сканирования. Последний `seq` помогает сравнить прогресс. Android BLE не гарантирует доставку, точный период или непрерывную работу на любой прошивке. Ограничения фоновой работы описаны в [Android BLE background guide](https://developer.android.com/develop/connectivity/bluetooth/ble/background). ## Binder API Сервис принадлежит приложению, `exported=false`, использует тот же процесс. Запустите его из видимой Activity после выдачи `requiredPermissions()`, затем привяжитесь через `bindService`. `onServiceConnected` получает `BleBroadcastService.LocalBinder`. ```kotlin // Периодический источник произвольных байтов; null отключает источник, оставляя ручной TX и RX. BleBroadcastService.start(context, periodicPayload = "Phone-1".toByteArray(), periodMs = 1000) context.bindService( Intent(context, BleBroadcastService::class.java), connection, Context.BIND_AUTO_CREATE ) // Из onServiceConnected: val api = binder as BleBroadcastService.LocalBinder val result = api.send(payload) // неблокирующая запись; копирует payload val packet = api.pollReceived() // одно сообщение или null val batch = api.drainReceived(64) // до 64 сообщений; limit > 0 val stats = api.snapshot() // потокобезопасный снимок, не забирает сообщения BleBroadcastService.stop(context) ``` ## API подключения и обмена с узлом При интеграции передавайте ID существующего ядра через `localNodeId`; по умолчанию берётся постоянный ID установки. Для смены собственного ID сначала остановите транспорт. Все 64 бита передаются без усечения: Kotlin `Long` хранит unsigned ID как битовое представление, для текста используйте `BleProtocol.nodeIdText(id)`. ```kotlin BleBroadcastService.start(context, "Phone-1".toByteArray(), 1000, targetNodeId = remoteId, localNodeId = myNodeId) // После привязки. Callback принадлежит приложению/сервису, а не Activity. api.setConnectionValidator { request -> request.localNodeId == myNodeId && allowedNodeIds.contains(request.remoteNodeId) } val requested = api.connect(remoteId) // поиск адреса по broadcasts, connectGatt, HELLO, повтор после обрыва val result = api.sendTo(remoteId, payload) val packet: ByteArray? = api.pollNodeReceived(remoteId) val packets: List = api.drainNodeReceived(remoteId, limit = 64) val links = api.connectionSnapshots() api.disconnect(remoteId) // отменяет повторные попытки и автоматическое подключение к этому ID ``` `start(..., autoConnect=true)` включает автоматический запрос к обнаруженным узлам. Параметры `start` сохраняются для sticky restart, явно добавленные Binder-запросы действуют до stop/смерти процесса. `connect` возвращает `REQUESTED`, `STOPPED`, `INVALID_ID` или `LIMIT_REACHED`; `REQUESTED` означает принятую заявку, готовность видна как `READY` в снимке. Одновременно допускается до 8 транспортов, история очередей ограничена 32 ID до пересоздания сервиса. Для восстановления политики после смерти процесса класс `Application` может реализовать `BleConnectionValidator`; сервис возьмёт этот callback при создании. Пример — `TestApplication`. Callback выполняется синхронно в BLE worker, должен быстро вернуть `Boolean`, исключение означает отказ. Он получает собственный ID, ID отправителя HELLO и имя из discovery, если оно уже получено. Сервис сначала проверяет HELLO `source != 0`, `source != local`, `target == local`; инициатор сверяет ID в ACCEPT с ожидаемым ID назначения. DATA до допуска запрещён. ID здесь заявленный, без криптографического доказательства владения. Для чата аутентификацию выполняет его протокол. `sendTo` возвращает `QUEUED`, `NOT_CONNECTED`, `QUEUE_FULL` или `TOO_LARGE`. Максимум сообщения — 4096 байт. Для каждого ID отдельные FIFO TX и RX по 64 сообщения; входной payload копируется. При `QUEUE_FULL` дождитесь уменьшения `txQueued`. GUI умеет отправлять текст и пачку 64 × 512 байт, показывать MTU, байты, очереди, ожидание ACK и блокировку RX. Флажок чтения RX позволяет остановить consumer и проверить backpressure. Congestion/flow control консервативный: окно — один фрагмент, следующий выпускается по ACK предыдущего. Размер фрагмента следует согласованному MTU (23–517), ATT value ограничен 512 байтами. Сервер передаёт indications, клиент — writes с ответом; один ATT writer сериализует операции. Служебный ACK имеет приоритет над DATA, поэтому одновременный обмен не блокирует подтверждения. При полном RX финальный ACK сообщения удерживается, сохраняется одно дополнительное собираемое сообщение; чтение RX возвращает кредит и возобновляет отправителя. RX соединения ничего не вытесняет. ACK означает помещение сообщения в RX удалённого API, счётчик `sent` растёт только после этого. При обрыве неподтверждённый TX сбрасывается с `discardedTx`, готовый RX сохраняется в пределах жизни сервиса. Приложение само решает, какие сообщения повторять: финальный ACK мог потеряться уже после помещения в RX. Handshake ограничен 30 с, отдельная ATT операция — 20 с; после обрыва заявка повторяется через 10 с. Ожидание свободного RX не имеет короткого таймаута: consumer может быть остановлен намеренно. Wire GATT: HELLO/ACCEPT — `type:u8 | source:u64le | target:u64le` (типы 1/2); DATA — `3 | sequence:u32le | offset:u16le | total:u16le | fragment`; ACK — `4 | sequence:u32le | acceptedOffset:u16le`; REJECT — байт 5. Счётчики последовательности независимы по направлениям, начинаются с 1 на новом соединении. `send` возвращает `QUEUED`, `STOPPED`, `QUEUE_FULL`, `PAYLOAD_TOO_LARGE` или `SEQUENCE_EXHAUSTED`. При `QUEUE_FULL` дождитесь уменьшения `snapshot().txQueued` перед повтором, не накапливайте новые сообщения. Одна FIFO TX на 64 записи; головная запись снимается только после успешного callback. При сбое радио запись сохраняется и повторяется с тем же номером. При явном stop ожидающий TX удаляется и учитывается в `txDiscarded`. Уже опубликованный пакет передаётся минимум 250 мс перед следующим. Периодический источник пропускает тик при занятом TX/отключённом радио, не раздувая очередь. RX — одна FIFO на 256 уникальных сообщений, с одним логическим consumer. При заполнении удаляется старейшее сообщение, растёт `rxDropped`, появляется warning. GUI забирает RX при видимом экране, но сервис ведёт статистику независимо от чтения очереди. После длительной работы в фоне переполнение RX ожидаемо; оно не означает потерю радиопакетов. `BleReceived` содержит `session`, `sequence`, `payload`, `rssi`, `elapsedMs`. Время — монотонное Android elapsed realtime, не UTC. Идентификатор сеанса — случайное ненулевое unsigned 32-bit число, новый при каждом запуске транспорта. Дедупликация по `(session, sequence)`, окно перестановок — 256 сообщений. Повторы и пакеты старше окна учитываются отдельно, в RX не попадают. Статистика ограничена 256 сеансами; при превышении вытесняется дольше всего не наблюдавшийся, растёт `peerEvictions`. Общие счётчики сохраняются. Снимки и входные payload копируются. История и идентичность сеансов не сохраняются в БД. Переполнение unsigned sequence требует нового запуска. ## Подключение к chatgui-android В `tools/chatgui-android/settings.gradle.kts`: ```kotlin include(":ble-transport") project(":ble-transport").projectDir = file("../ble-test-android/ble-transport") ``` В `app/build.gradle.kts` добавить `implementation(project(":ble-transport"))`, в корневой plugins-блок — `id("com.android.library") version "8.13.2" apply false`. Manifest библиотеки добавит BLE-права и сервис. Приложение отвечает за runtime-разрешения, привязку, чтение RX и явный stop. Unbind останавливает только связь GUI с API. Для транспорта, привязанного к жизненному циклу чат-сервиса, запускать/останавливать этот сервис явно; текущий API не добавляет BLE в native-ядро и не требует изменений протокола чата. ## Радиоформат и диагностика Один legacy advertising set, connectable/scannable, LE 1M, интервал объявления 100 мс. Advertising данные меняются через `setAdvertisingData`, без цикла stop/start для каждого сообщения. Сканирование LOW_LATENCY с manufacturer-фильтром и нулевой задержкой отчётов. API Android описан в [AdvertisingSet](https://developer.android.com/reference/android/bluetooth/le/AdvertisingSet). Manufacturer Company ID `0xffff` используется только для локального эксперимента; перед production нужен собственный Company ID или другой AD-формат. Manufacturer data: `75 54 | version:u8=2 | session:u32le | sequence:u32le | payload:0..13 bytes`. Всего до 24 байт, плюс 4 байта manufacturer AD и резерв 3 байта flags: укладывается в 31 байт legacy BLE. Scan response содержит service data UUID `f61a9001-4abc-45ac-98e1-761df4c13811`: `version:u8=2 | nodeId:u64le` (9 байт + 18 байт AD). Android возвращает имя и ID в объединённом ScanRecord. Имя обновляется вместе со счётчиком; ID неизменен во время работы транспорта. Обратной совместимости с v1 нет. Ник — соглашение тестового приложения; транспорт принимает любые байты. Broadcast discovery не имеет ACK. Для данных соединения есть фрагментация/ACK/очереди, шифрование и аутентификация — задача чата. ```bash adb -s DEVICE_SERIAL logcat -v threadtime -s UtunBle adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle DEBUG # дополнительно каждый опубликованный TX adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle INFO ``` INFO: запуск/остановка, состояние адаптера, готовность advertising, сводка каждые 15 с. WARN/ERROR: переполнения, отказ API, некорректные пакеты, код/название ошибки BLE, callback timeout, ошибки очистки и отозванные права. Ошибка радио закрывает обе операции и запускает повтор через 30 с; поздние callbacks старого поколения игнорируются. Таймаут advertising callback — 10 с. Unit tests проверяют wire-формат и лимит размера, unsigned поля, malformed данные, backpressure, дедупликацию/перестановки, переполнение RX, независимость сеансов, ограничение памяти, копирование payload, stop/start и одновременную запись из нескольких потоков. Радиоприём, Doze и поведение конкретного BLE-чипа требуют проверки на физических телефонах. ## Выполненная проверка — 28.10.2026 11 unit tests PASS; lint приложения и библиотеки — без замечаний. Собраны debug APK и `ble-transport/build/outputs/aar/ble-transport-debug.aar`. На Q8 / Android 14 окончательная сборка выполнила 199 публикаций за короткий прогон: проверены работа со свёрнутой Activity и погашенным экраном, ручной TX через Binder, восстановление после off/on Bluetooth и явная остановка; итоговый счётчик ошибок — 0. Подключено одно устройство: физический приём между телефонами и длительный прогон ещё не проверены. Общий `./check.sh` вне песочницы: 223 PASS / 3 FAIL из-за недоступного IPv6 loopback `::1` (`test_ncd_faults`, `test_ipv6_sockets`, `test_route_connectivity`); известная проблема среды уже описана в `doc/tasks.md`. ## Проверка GATT API на двух телефонах ```bash ./gradlew :ble-transport:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest :app:lintDebug :ble-transport:lintDebug python3 tests/run_two_devices.py --install \ --receiver Q8_SERIAL --receiver-id Q8_NODE_ID \ --initiator SAMSUNG_SERIAL --initiator-id SAMSUNG_NODE_ID \ --output /tmp/ble-gatt-duplex # Та же команда с --reject и другой --output проверяет отказ валидации. ``` Runner запускает оба instrumentation процесса одновременно. `BleApiProbe` обращается к Binder API без GUI consumer, отправляет в обе стороны по 70 сообщений (69 × 512 + 4096 байт), проверяет содержимое и порядок, заполняет RX, выдерживает паузу без кредита и затем проверяет продолжение. После прогона сервис останавливается. Instrumentation код находится только в отдельном test APK. Модульных тестов — 18: discovery, все 64 бита ID, ACK window, фрагментация при MTU 23/64/247/517, нулевые/максимальные сообщения, duplex, неправильные ACK/offset/size, TX/RX backpressure и disconnect. На Q8 и Samsung SM-A525F оба прогона PASS: разрешение/отказ callback и duplex по 70 сообщений, 39424 байта в каждом направлении, MTU 517. Обе стороны зафиксировали заполнение RX и возобновление после возврата кредита; содержимое и порядок проверены побайтово. 18 unit tests PASS, lint обоих модулей без замечаний. Повторный общий прогон: 223 PASS / те же 3 FAIL из-за недоступного `::1`.