Более широкая архитектура чата, использующая этот транспорт, описана в Chat Architecture. О том, как два устройства получают общий ключ ChaCha20, которым шифруется каждая полезная нагрузка BLE, см. в Pairing Flow.
Содержание
- Зачем вообще нужен BLE-транспорт?
- Структура GATT-сервиса
- Идентификация пиров: shortId, а не MAC
- Двухуровневая схема фрагментации
- RPC-примитив:
BleDeviceApi.requestAsync - Формат конверта на проводе
- Путь отправки сообщения (end-to-end)
- Путь загрузки файла (end-to-end)
- Приоритизация: как чат обгоняет файлы на практике
- Управление параллелизмом и статическая GATT-очередь
- Жизненный цикл соединения и согласование MTU
- Управление потоком для уведомлений
- Обработка ошибок: TransportUnavailable против реального сбоя
- Справочник ключевых констант
- Сводка проектных компромиссов
Зачем вообще нужен BLE-транспорт? {#why-a-ble-transport-at-all}
PlainApp — serverless и offline-first. Транспортный слой представляет собой
упорядоченную цепочку fallback'ов: LAN → Wi-Fi Aware → BLE. LAN — сценарий
без ошибок (HTTPS через Wi-Fi, ~10 мс round trip). Wi-Fi Aware покрывает пиров
в разных подсетях (разные SSID, guest vs IoT VLANs). Оба требуют некоторой
IP-связности. BLE — единственный транспорт, работающий:
- Когда устройства вообще не находятся в одной IP-сети.
- Когда Wi-Fi выключен или включён режим «в самолёте» (BLE-радио отдельно).
- Когда Wi-Fi Aware не поддерживается (Android < 13, все iOS-варианты PlainApp).
BLE медленный — десятки KB/s, секунды задержки на запрос — но он
гарантирован для любого сопряжённого пира, потому что ему нужно только
знать clientId пира, который всегда транслируется в BLE scan response.
Структура GATT-сервиса {#gatt-service-layout}
PlainApp рекламирует один кастомный GATT-сервис с двумя характеристиками.
Зарегистрированного 16-битного UUID нет — сервис использует 128-битный UUID,
чей завершающий байт в ASCII-декодировании даёт plpai\x01:
Почему две характеристики?
У двух протоколов совершенно разные модели доверия и формы полезной нагрузки:
- NEARBY переносит сообщения сопряжения. Они приходят до того, как пир сопряжён (общего ключа ещё нет), поэтому используют собственные Ed25519-подписанные JSON-полезные нагрузки с собственной префиксной маршрутизацией. Тело — обычная строка.
- HTTP переносит весь пост-сопряжённый трафик (чат, файлы, presence).
Всегда шифруется ChaCha20 общим ключом и использует тот же
HttpRouteRegistry, что и LAN Ktor-сервер, поэтому обработчики маршрутов (/peer_graphql,/fs,/peer_status) написаны один раз и переиспользуются для обоих транспортов.
Почему уведомления, а не чтения?
BLE ATT-протокол ограничивает чтение одного атрибута 512 байтами.
GraphQL-ответ или 16 KB файл-чанк может быть значительно больше. PlainApp
обходит это, никогда не используя readCharacteristic для реальных данных —
onCharacteristicReadRequest сервера возвращает пустую полезную нагрузку с
GATT_SUCCESS. Вместо этого клиент пишет свой запрос в характеристику, а
сервер отвечает последовательностью фрагментированных уведомлений, которые
клиент собирает обратно. Это описано в BleDeviceApi.requestAsync,
BleServerProtocol.handleWrite и AndroidBleGattServer.sendChunkedResponse.
Идентификация пиров: shortId, а не MAC {#peer-identification-shortid-not-mac}
BLE-рекламные пакеты малы (31 байт), а BLE MAC-адрес рандомизируется Android
примерно каждые 15 минут — поэтому его нельзя использовать как стабильный
идентификатор. Вместо этого PlainApp транслирует 9-байтовую полезную нагрузку
serviceData в scan response:
Почему усечённый хеш, а не полный clientId?
13-символьный clientId уместился бы в 13 байт, но PlainApp выбирает 8-байтовый усечённый SHA-256 по двум причинам:
- Стабильный байтовый бюджет. 9 байт общего объёма комфортно умещаются в 31-байтовую рекламную полезную нагрузку вместе с service UUID (16 байт), полями длины и типа (~27 байт занято, 4 байта в запасе).
- Конфиденциальность. Пассивный наблюдатель BLE-сканирования не может восстановить clientId из shortId (8-байтовый префикс SHA-256 на практике необратим). Он может лишь узнать пира, который уже рекламировал тот же shortId ранее — перечислить пользователей PlainApp он не может.
Полный clientId раскрывается только пиру, который реально подключился по
GATT и обменялся DDiscoverReply — т. е. пиру, с которым пользователь уже
выбрал взаимодействие.
Двухуровневая схема фрагментации {#two-layer-chunking-design}
Это самая тонкая часть BLE-транспорта, и важно понимать оба уровня, поскольку они имеют совершенно разные размеры и назначение:
Почему 380 символов?
Согласованный ATT MTU — 517 байт на Android (requestMtu(517) — максимум,
допускаемый спецификацией BLE) и ~185+ на iOS (автосогласование CoreBluetooth).
За вычетом ATT-заголовка (~3 байта) и накладных расходов JSON-обёртки
BleSegmentData ({"d":"...","s":N} добавляет ~12 байт), 380 символов
полезной нагрузки с запасом умещаются в один ATT MTU на обеих платформах.
Значение симметрично (и фрагменты запроса клиента, и фрагменты уведомления
сервера используют 380), что упрощает код.
Почему 16 KiB для файловых чанков?
16 KiB файл-чанк в base64 кодируется в ~22 KiB JSON, что фрагментируется в
~58 GATT-сегментов уведомления. Каждый round-trip requestAsync по BLE
занимает секунды, поэтому меньшее число более крупных чанков снижает
накладные расходы на чанк. Дальнейшее увеличение рискует упереться в
BLE RPC-тайм-ауты и ухудшить отзывчивость прогресса (пользователь видит
обновление прогресса только раз за чанк). 16 KiB — эмпирически подобранная
золотая середина: достаточно большой для пропускной способности, достаточно
маленький для отзывчивого UI прогресса.
RPC-примитив: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}
Каждое BLE-сообщение чата и каждый файл-чанк — это один вызов
BleDeviceApi.requestAsync(service, requestData) — suspend-функция,
возвращающая BleResult. С точки зрения вызывающего она синхронна:
один запрос → один полностью собранный ответ, без конвейеризации.
Ключевые инварианты
- Один запрос → один ответ.
requestAsyncсинхронна с точки зрения вызывающего — она возвращается только после того, как полный ответ собран. Конвейеризации нет. - Уведомления включаются на каждый вызов. Клиент пишет CCCD в начале
каждого
requestAsyncи отключает в конце. Это расточительно (два лишних GATT-записа на вызов), но делает протокол stateless — серверу не нужно отслеживать, какие клиенты «слушают». - Нет повторных попыток внутри RPC. Если любой одиночный
writeCharacteristicистекает по тайм-ауту (5 с), весь RPC прерывается. Повторяет толькоensureConnected(3 попытки при сбое подключения). Грубый транспортный backoff обеспечиваетPeerCircuitBreaker, а не RPC-слой.
Формат конверта на проводе {#wire-envelope-format}
Полезная нагрузка внутри сегментов Layer A — это вложенный JSON-конверт. Без учёта фрагментации логическая структура такова:
Форма ответа
Ответ течёт в обратном направлении через ту же фрагментацию Layer A, но
внутренний JSON — это BleHttpResponse с тремя полями: s (HTTP-код
статуса), h (карта заголовков ответа) и b (тело). Тело всегда
base64-кодируется BleHttpCall.encodeResponse(), даже когда пусто — ответ
может быть бинарным (зашифрованные GraphQL-байты, сырые байты файла /fs), а
BLE-транспорт строковый, поэтому один и тот же JSON-конверт переносит как
текстовые, так и бинарные полезные нагрузки.
Путь отправки сообщения (end-to-end) {#message-send-path-end-to-end}
Сводя всё вместе — что происходит при отправке сообщения чата через BLE:
Примечательные проектные решения
- Тот же ключ, что и для LAN. Общий ключ ChaCha20 из сопряжения
переиспользуется для BLE — отдельного BLE-ключа нет. OkHttp
crypto-интерсептор, используемый
LanTransport, и ручныеchaCha20Encrypt/chaCha20DecryptвBleTransport— один и тот же примитив, просто вызывается по-разному. - Те же обработчики маршрутов, что и для LAN.
BleHttpRequestпроходит черезHttpRouteRegistry.matchRoute(path)— тот же реестр, что использует Ktor LAN-сервер. Поэтому/peer_graphql,/fs,/peer_statusи т. д. реализованы ровно один раз и работают одинаково над обоими транспортами. - Без переиспользования соединений. Блок
finally { scanner.teardownConnection(client) }выполняется всегда. Каждое сообщение оплачивает полную стоимость connect→discoverServices→MTU (~секунды). Это намеренный компромисс — см. Design Trade-offs.
Путь загрузки файла (end-to-end) {#file-download-path-end-to-end}
Загрузки через BLE — стриминговые: файл читается 16 KiB чанками и пишется
во временный файл по мере поступления, поэтому файлу 10 MB не нужно 10 MB RAM.
Трюк в том, что RPC каждого чанка — отдельный вызов requestAsync, а чанки
проталкиваются в ByteChannel, который потребитель читает параллельно.
Почему стриминг, а не один большой RPC?
Файл 10 MB, отправленный одним RPC, означал бы ~280 000 сегментов уведомления, все одновременно в памяти на обеих сторонах, прежде чем ответ мог бы даже начаться — и вся передача должна была бы завершиться, прежде чем появится какой-либо прогресс. Хуже того, одна потерянная посередине нотификация испортила бы всё целиком.
Чанковая схема даёт три выигрыша:
- Постоянная память. В полёте одновременно только один чанк 16 KiB.
- Живой прогресс.
DownloadQueue.notifyProgressUpdate()срабатывает каждую секунду, и UI показывает полосу загрузки. - Устойчивость. Сбойный чанк можно повторить независимо
(
DownloadQueueподдерживает pause/resume/retry на уровне задачи; сбой в середине потока оставляет частичный временный файл, хотя сейчас загрузчик удаляет его при сбое — см. компромиссы).
Почему onClose отменяет job загрузки
Колбэк DownloadedResponse.onClose вызывает downloadJob.cancel(). Это
необходимо, потому что цикл загрузки выполняется в дочерней корутине, которая
иначе выполнялась бы вечно, если потребитель бросит канал раньше (например,
пользователь нажал Pause). Контракт AutoCloseable у DownloadedResponse
означает, что блок use { ... } потребителя автоматически вызовет onClose
при выходе, отменяя корутину BLE-загрузки и разрывая GATT-соединение в блоке
finally корутины.
Приоритизация: как чат обгоняет файлы на практике {#prioritization-how-chat-beats-files-in-practice}
Это самый важный вопрос для любого чат-приложения: когда идёт медленная BLE-загрузка файла, может ли новое сообщение чата перепрыгнуть её?
Честный ответ: явной схемы приоритетов нет
В BLE-коде и очереди загрузки нет поля priority, нет priority queue, нет
preemption. Я проверил это исчерпывающим grep — единственные совпадения
priority в shared/src относятся к уровням логирования и EXIF-метаданным,
ничего связанного с упорядочиванием сообщение-против-загрузки.
Вместо этого существует набор архитектурных разделений, которые производят желаемое поведение как эмерджентное свойство:
Почему это работает на практике
Разделение, благодаря которому чат «ощущается приоритизированным», структурное:
- Отправка чата не идёт через
DownloadQueue. Она выполняется напрямуюPeerGraphQLClient→PeerTransportRouter→BleTransport.send. Поэтому сообщение чата никогда не стоит за очередью загрузок файлов. - Каждый вызов
BleTransportоткрывает собственное GATT-соединение. Длительная загрузка, удерживающая одно соединение, не мешает отправке чата открыть второе соединение к тому же пиру. Android поддерживает несколько одновременных GATT-соединений. - Chat-RPC короткие. Одно сообщение чата — один round-trip
requestAsync(~1 с после подключения). Даже если радио занято загрузкой, отправка чата завершается за несколько секунд.
Где дизайн проигрывает
Компромиссы «без явного приоритета»:
- Задержка подключения. И чат, и загрузка оплачивают стоимость connect→discover→MTU (~секунды) каждый раз, поскольку соединения не переиспользуются. Сообщение чата, пришедшее во время загрузки, не может «подсветиться» к существующему соединению загрузки — оно открывает новое.
- Статическая очередь на Android. Процесс-глобальная
operationQueueвAndroidBleGattClientсериализует GATT-операции по всем пирам и всем соединениям. Поэтому, хотя два GATT-соединения могут сосуществовать, их операции write/read/notify перемежаются на уровне очереди. На практике это нормально (каждая операция ~мс), но это тонкое глобальное узкое место при высокой параллельности. - Нет preemption. Идущая загрузка не может быть приостановлена, чтобы пропустить сообщение чата. Отправка чата просто выполняется параллельно и конкурирует за радио-время.
Будущим улучшением мог бы стать per-peer Mutex вокруг BleTransport.send и
downloadFile, а также поле приоритета в очереди — но текущий дизайн
опирается на то, что chat-RPC достаточно коротки, и конкуренция редко видна
пользователю.
Управление параллелизмом и статическая GATT-очередь {#concurrency-control--the-static-gatt-queue}
Это заслуживает отдельного раздела, поскольку это самый тонкий аспект Android BLE-реализации.
Почему статическая (процесс-глобальная)?
Android BLE-стек не допускает параллельных GATT-операций на одном экземпляре
BluetoothGatt — вызов writeCharacteristic во время другого in-flight
записи возвращает false и тихо отбрасывает вторую запись. Стандартный обход
— очередь на каждый BluetoothGatt. PlainApp идёт дальше и использует
процесс-глобальную очередь (в companion object), что избыточно
консервативно, но корректно: гарантируется, что никакие две GATT-операции
во всём приложении не выполняются одновременно.
Цена — операции write/read/notify длительной BLE-загрузки файла стоят за (и стоят за) операциями GATT любого другого пира. Поскольку каждая отдельная операция ~мс, это редко узкое место, видимое пользователю — но при интенсивном параллельном BLE-трафике к нескольким пирам оно может им стать.
Без per-peer блокировки на транспортном слое
BleDeviceApi.requestAsync — обычная suspend fun без мьютекса, без
очереди, без per-peer сериализации. Два параллельных вызова
BleTransport.send для одного пира откроют каждое своё GATT-соединение и
выполнятся независимо. Сериализация происходит неявно на уровне GATT-операций
(через статическую очередь на Android или через последовательный await на
iOS).
Жизненный цикл соединения и согласование MTU {#connection-lifecycle--mtu-negotiation}
Почему requestMtu(517)?
ATT MTU по умолчанию — 23 байта (только 20 байт полезной нагрузки после 3-байтового ATT-заголовка). С MTU по умолчанию каждый 380-символьный сегмент требовал бы ~19 GATT-записей вместо 1 — замедление в 19 раз. Запрос максимального MTU, допускаемого спецификацией BLE (517 байт), позволяет 380-символьным сегментам умещаться в одну ATT-операцию, резко повышая пропускную способность.
iOS не предоставляет явного API запроса MTU — CoreBluetooth согласовывает его автоматически с периферией при подключении. Современные iOS-устройства обычно согласовывают ~185 байт, что по-прежнему с запасом вмещает 380-символьные сегменты (после вычета ATT-заголовка и накладных расходов JSON-обёртки).
Управление потоком для уведомлений {#flow-control-for-notifications}
Сервер отправляет фрагменты ответа как уведомления, но BLE-уведомления не имеют встроенного управления потоком — если сервер отправляет уведомления быстрее, чем контроллер способен их передать, они тихо теряются. PlainApp реализует явное ack-управление потоком:
Без этого управления потоком подряд идущие уведомления тихо отбрасывались бы
BLE-контроллером при заполнении внутренней очереди отправки — известная
проблема Android BLE, задокументированная в комментариях интерфейса
BleGattServer. Правило «одно in-flight на устройство» гарантирует, что
каждое уведомление либо передаётся, либо вызывает тайм-аут (который затем
трактуется как транспортный сбой).
Обработка ошибок: TransportUnavailable против реального сбоя {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable — сигнал, говорящий PeerTransportRouterпровалиться к следующему транспорту. Всё остальное — реальный сбой,
возвращаемый вызывающему.
Тонкость сбоя загрузки
BleTransport.downloadFile возвращает
DownloadedResponse(200, channel, onClose) немедленно — чанковый цикл
загрузки выполняется в фоновой корутине, пишущей в канал. Если RPC чанка
падает в середине потока, цикл вызывает
channel.close(TransportUnavailable(...)), что означает, что потребитель
(PeerFileDownloader.downloadAsync) видит ошибку как выброшенное исключение
из channel.readAvailable(buf).
Это значит, что сам вызов PeerTransportRouter.downloadFile завершился
успешно (вернул DownloadedResponse), поэтому circuit breaker не
записывает сбой для ошибок загрузки в середине потока. Лишь сбои на этапе
подключения и сканирования ловятся маршрутизатором. Это намеренное проектное
решение — сбой в середине потока не должен навсегда отключать BLE для этого
пира (пир мог просто временно выйти из зоны действия).
Справочник ключевых констант {#key-constants-reference}
| Константа | Значение | Где | Назначение |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | Фрагментация GATT-сегмента | Размер каждого BleSegmentData.data (вмещается в ATT MTU после JSON-накладных) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Байтовый диапазон загрузки файла | Размер каждого запроса чанка /fs |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | BLE-сканирование | Тайм-аут для scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | RPC-ответ | Ожидание одного уведомления в requestAsync |
AndroidBleGattClient MTU | 517 | Настройка соединения | requestMtu(517) — максимум по спецификации BLE |
AndroidBleGattClient connect timeout | 10 000 | Настройка соединения | Ожидание STATE_CONNECTED |
AndroidBleGattClient MTU timeout | 5 000 | Настройка соединения | Ожидание onMtuChanged |
AndroidBleGattClient write timeout | 5 000 | GATT-запись | Ожидание onCharacteristicWrite |
AndroidBleGattClient read timeout | 10 000 | GATT-чтение | Ожидание onCharacteristicRead (не используется для реальных данных) |
AndroidBleGattClient notify-state timeout | 5 000 | CCCD-запись | Ожидание записи CCCD-дескриптора |
ensureConnected retries | 3 | Настройка соединения | До 4 попыток всего (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Управление потоком уведомлений | Ожидание onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Фрагментация ответа | То же, что BleDeviceApi.CHUNK_SIZE |
IosBleGattServer retry cap | 10 | Управление потоком уведомлений | Максимальное число повторов updateValue до сдачи |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Circuit breaker транспорта | Длительность открытия после порога |
PeerCircuitBreaker.MAX_FAILURES | 2 | Circuit breaker транспорта | Сбоев в окне для открытия |
DownloadQueue.MAX_CONCURRENT | 3 | Пул воркеров загрузки | Параллельных корутин загрузки |
BleServiceData.SHORT_ID_BYTES | 8 | Идентификация пиров | Байты усечённого префикса SHA256 |
BleServiceData.PAYLOAD_BYTES | 9 | Идентификация пиров | 1 байт флагов + 8 байт shortId |
BleSegmentData.STATE_START_BIT | 1 | Сигнализация EOF Layer A | Первый сегмент многосегментного сообщения |
BleSegmentData.STATE_END_BIT | 2 | Сигнализация EOF Layer A | Последний сегмент (или одиночный сегмент) |
Сводка проектных компромиссов {#design-trade-offs-recap}
Дополнительная литература
- Chat Architecture — как
BleTransportвписывается в цепочку fallback'овLAN → Aware → BLEи более широкий конвейер отправки/получения чата. - Pairing Flow — как устанавливается общий ключ ChaCha20, используемый каждой полезной нагрузкой BLE, и как характеристика NEARBY используется для рукопожатия сопряжения.