Содержание
- Высокоуровневая архитектура
- Модель данных
- Поверхность GraphQL API
- Peer-чат: отправка сообщения
- Peer-чат: получение сообщения
- Channel-чат: выбор лидера и fan-out
- Системные сообщения канала
- Жизненный цикл канала
- Слой peer-транспорта (LAN → Wi-Fi Aware → BLE)
- Статус и presence пира
- Слой кэширования
- Загрузка файлов
- Сводка паттернов проектирования
Высокоуровневая архитектура {#high-level-architecture}
Чат PlainApp — serverless. На каждом устройстве работает встроенный Ktor HTTP-сервер, и устройства общаются напрямую через локальную сеть, Wi-Fi Aware (NAN) или Bluetooth Low Energy. Ретрансляционного сервера нет, облачного inbox нет, идентификации по номеру телефона нет. Устройства идентифицируются по самостоятельно сгенерированному clientId и аутентифицируются через рукопожатие Ed25519 + ECDH, выполняемое во время сопряжения.
Существуют два вида разговоров:
| Тип | Константа | Описание |
|---|---|---|
PEER | ChatTargetType.PEER | Прямой чат 1-к-1 между двумя сопряжёнными устройствами. |
CHANNEL | ChatTargetType.CHANNEL | Многопартийный групповой чат, владельцем которого является одно устройство; участники распределяют сообщения друг другу. |
Специальный "local" target — это блокнот самого устройства (заметки самому
себе) — отправка в него является no-op на проводе.
Карта компонентов
Архитектура намеренно многоуровневая:
- Точки входа UI / GraphQL никогда не касаются транспортов или БД напрямую.
ChatManager— фасад; каждый вызывающий (UI, GraphQL-резолвер, peer-приёмник) проходит через него.ChatSender— диспетчер, ветвящийся поChatTargetTypeи делегирующий отправителю пира или канала.- Транспортный слой — это подключаемая цепочка стратегий с circuit breaking, поэтому нестабильный канал Wi-Fi Aware никогда не блокирует сообщение, которое могло бы пойти через BLE.
Модель данных {#data-model}
ChatTarget
Наименьшая единица маршрутизации — ChatTarget, пара (toId, type), где
type — PEER или CHANNEL. Он предоставляет encodedToId (peer:<id> или
channel:<id>), который UI использует как стабильный ключ маршрутизации
(например, TempData.activeToId, чтобы приёмник знал, нужно ли эмитить
уведомление), проверку isLocal() (toId == "local") и компаньон parseId,
восстанавливающий target из сохранённой строки.
Таблицы базы данных
Вся персистентность использует Room. Для чата важны три таблицы:
| Таблица | Сущность | Назначение |
|---|---|---|
chats | DChat | Одна строка на сообщение (текст / изображение / файл). |
chat_channels | DChatChannel | Одна строка на групповой канал. |
peers | DPeer | Одна строка на известное устройство (сопряжённое или только channel). |
Стоит отметить несколько моментов:
- Идентификация —
clientId, никогда MAC. Android рандомизирует BLE MAC при каждом соединении, поэтому база использует стабильный 13-символьный самосгенерированный id. Только 8-байтовый префикс SHA-256 (shortId) транслируется через BLE для обеспечения обнаружения. - Пиры со
status="channel"— это участники канала, с которыми это устройство никогда напрямую не сопрягалось. Их полеkeyпусто — они аутентифицируются ключом канала вместо парного общего ключа. owner="me"— сигнальное значение, позволяющее свежеустановленному устройству выступать владельцем до того, как егоclientIdстанет стабильным;isOwnedByMe()принимает и"me", иTempData.clientId.
Поверхность GraphQL API {#graphql-api-surface}
PlainApp предоставляет две GraphQL-схемы:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — обслуживается локальным Ktor-сервером для браузерного UI и для харнессаapitest/. Аутентифицируется ChaCha20-зашифрованным токеном. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — предоставляется на/peer_graphqlдля других устройств через зашифрованный peer-транспорт. Аутентифицируется подписью Ed25519 + ChaCha20-шифрованием тела.
Обе схемы используют одни и те же синглтоны бизнес-логики (ChannelManager,
ChatMessageReceiver, …), но предоставляют разные поверхности, поскольку модель
доверия различается: web GraphQL доверяет локальному UI, тогда как peer
GraphQL доверяет только криптографически аутентифицированным пирам.
Поверхность Web GraphQL (чат)
Запросы: chatChannels (список всех каналов), chatItems(id) (сообщения для
target — id это "local", peer:<id> или channel:<id>), и
latestChatItems (предпросмотр по всем чатам).
Мутации чата: sendChatItem(toId, content), deleteChatItem(id),
deleteChatItems(query) и retryChatItem(id).
Мутации канала: createChatChannel(name), updateChatChannel(id, name),
deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id)
и declineChatChannelInvite(id).
Поверхность Peer GraphQL (транспорт)
Предоставляется на /peer_graphql и аутентифицируется подписью Ed25519 + шифрованием
тела ChaCha20. Границу транспорта пересекают только три мутации:
createChatItem(content) (входящее peer-сообщение), channelSystemMessage(type, payload) (события жизненного цикла канала вроде invite/leave) и startAware
(nudge, просящий пир запустить свой Wi-Fi Aware-сервис, чтобы более быстрый
транспорт мог вступить).
HTTP-заголовок c-id несёт clientId отправителя; заголовок c-cid несёт
id канала, когда запрос ограничен каналом (чтобы приёмник взял ключ
канала, а не парный peer-ключ для расшифровки).
Peer-чат: отправка сообщения {#peer-chat-sending-a-message}
Когда пользователь нажимает Send в peer-разговоре, цепочка вызовов:
Ключевые инварианты, обеспечиваемые на каждом шаге:
ChatManager.createChatItemвсегда сначала вставляет строку, затем отправляет. Это значит, что UI сразу видит пузырь «pending», и сообщение переживёт краш приложения, даже если доставка ещё не произошла.PeerGraphQLClient.buildSignedRequestстроит конверт видаsignature|timestamp|requestJson. Подпись Ed25519 поверх"$timestamp$requestJson", привязывая метку времени к телу, поэтому её нельзя переиграть с новой меткой.PeerTransportRouter.sendперебирает транспорты в порядкеLan → WifiAware → Ble. Каждый транспорт может выброситьTransportUnavailable, чтобы маршрутизатор попробовал следующий.- На принимающей стороне
PeerChatParser.decryptпроверяет, что метка времени в±5 min, и проверяет подпись Ed25519 до того, как мутация GraphQL вообще выполнится. ChatMessageReceiver.receiveдержит множествоseenSignaturesс ключом"$fromPeerId|$signature|$timestamp"и выбрасываетReplayedMessageExceptionпри дубликатах — это существенно, поскольку транспорт может доставить одну и ту же полезную нагрузку дважды (LAN + BLE).
Если PeerChatSender.send возвращает непустую строку ошибки, ChatSender
вызывает triggerPeerRediscovery(peerId), который запускает направленный,
зашифрованный широковещательный DISCOVER, чтобы пир мог повторно анонсировать
свой текущий IP/порт.
Peer-чат: получение сообщения {#peer-chat-receiving-a-message}
Входящие запросы попадают на маршрут /peer_graphql локального Ktor-сервера,
обрабатываемый PeerGraphQLService:
Уведомления
emitNotificationIfNeeded — финальный шаг. Он подавляет уведомление, когда
TempData.activeToId == targetId (т. е. пользователь сейчас смотрит этот
разговор), или когда canShowNotifications() равно false. Уведомления канала
префиксируются именем отправителя.
Channel-чат: выбор лидера и fan-out {#channel-chat-leader-election--fan-out}
Каналы многопартийны, но serverless. Чтобы каждый участник не рассылал одно и то же сообщение N раз, отправляющая сторона выбирает одного лидера, чья задача — широковещательно разослать всем вступившим участникам.
Алгоритм выбора лидера (DChatChannel.electLeader)
- Фильтр до вступивших участников, которые сейчас онлайн (локальное устройство всегда считается онлайн).
- Если владелец среди онлайн-вступивших участников → владелец — лидер.
- Иначе лидер — онлайн-вступивший участник с наименьшим
clientId(детерминированный тай-брейк, без координации). - Возвращает
null, если онлайн-вступивших участников нет.
Поток отправки
Зачем вообще лидер?
Представьте 5-участниковый канал, где каждый вещает каждому: одно сообщение породило бы 20 сетевых round-trip и 4 дублирующих копии, приходящих каждому участнику. Выбрав одного лидера, fan-out делает только это устройство — отправитель либо сам выполняет fan-out (если он лидер), либо передаёт одну копию лидеру, который затем fan-out'ит.
Если лидер оффлайн, отправчик откатывается к Result.NoLeader, запускает
rediscovery пира (чтобы IP лидера был найден) и очищает статус, чтобы
пользователь мог повторить.
Маршрутизация по ключу канала
Сообщения канала шифруются ключом ChaCha20 канала, а не парным
peer-ключом. Это и позволяет участнику, который познакомился с остальными
только через канал (никогда не сопрягаясь 1-к-1), получать сообщения — его
строка peers имеет status="channel" и key="". Отправитель ставит
HTTP-заголовок c-cid в id канала; приёмник ищет
ChannelCacher.getKeyBytes(channelId) вместо парного ключа.
Повторная попытка по получателю
Каждый sendToMember возвращает DMessageDeliveryResult. Агрегированный
DMessageStatusData сохраняется как JSON status_data chat item. UI
показывает «Доставлено Alice, Bob; Сбой для Carol» и позволяет пользователю
нажать Retry конкретно для Carol —
ChatManager.sendToChannelMembers перезапускает sendToRecipients для
подмножества повторных попыток и слияет новые результаты с
существующими, заменяя только повторяемых пиров.
Системные сообщения канала {#channel-system-messages}
Управляющие сообщения канала (invite, accept, decline, update, kick, leave)
обмениваются через peer-GraphQL мутацию channelSystemMessage. Это
JSON-полезные нагрузки, типизированные строкой type:
| Тип | Направление | Подписано? | Назначение |
|---|---|---|---|
channel_invite | Owner → invitee | Да | Приглашение пира; несёт ключ канала + участников. |
channel_invite_accept | Invitee → owner | Нет | Принятие; несёт публичный ключ принимающего. |
channel_invite_decline | Invitee → owner | Нет | Отклонение; владелец удаляет участника. |
channel_update | Owner → all members | Да | Широковещательное оповещение об изменении состава/имени. |
channel_kick | Owner → kicked peer | Да | Целевой кик; также broadcast при удалении канала. |
channel_leave | Member → owner | Нет | Инициированное участником уведомление о выходе. |
Формат подписанной полезной нагрузки
Три подписанных типа (invite, update, kick) используют каноническую
pipe-разделённую строку: "$channelId|$version|$action|$target", где action
— один из invite, update, kick, а target — id приглашаемого/кикаемого
пира (пусто для широковещательного kick).
Владелец подписывает эту строку своим ключом Ed25519. Приёмники отбрасывают
любое сообщение, где channel.owner != fromId, до проверки подписи, и
отбрасывают ChannelUpdate-полезные нагрузки, чей version ≤ локальной
версии (защита от устаревшей версии против out-of-order доставки).
Ленивая гидратация пиров
ChannelInvite и ChannelUpdate несут список
memberPeers: List<MemberPeerInfo> — лёгкая информация о пире
(id, name, publicKey, deviceType, ip, port) для каждого участника.
ensureChannelPeer приёмника создаёт строку DPeer со status="channel"
для любого участника, которого он раньше не видел. Это критично, поскольку
маршрутизация fan-out требует peer-записи каждого участника для отправки
сообщений.
Жизненный цикл канала {#channel-lifecycle}
Слой peer-транспорта (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter — цепочка стратегий с circuit breaking.
Упорядоченный список транспортов:
LanTransport— первый выбор. Использует OkHttp с ChaCha20 crypto-интерсептором поверх HTTPS. Полностью пропускается, когдаpeer.ipпуст (peer в другой подсети, ещё не обнаруженный).WifiAwareTransport(только Android 13+) — использует Wi-Fi Aware (NAN) data paths. Быстрый skip, когда флагawareRunningпира равен false (обновляется BLE-prewarmer сканированием). IPv6 пира разрешается через кастомный DNS, отображающий hostnameplain-aware-peerна link-local-адрес.BleTransport— гарантированный fallback для любого сопряжённого пира. Стримит чанковый RPC через GATT. Медленнее, но работает без какой-либо IP-связности.
Почему такой порядок?
- LAN самый быстрый (один HTTPS round trip, ~10 мс тайм-аут).
- Wi-Fi Aware средний (setup data-path ~5 с, затем ~10 мс round trip) и
работает кросс-подсеть (например, одно устройство на guest Wi-Fi, другое на
IoT Wi-Fi). Настроен на быстрый skip, когда Aware-сервис пира не запущен,
избегая 10-секундного тайм-аута
buildLink. - BLE самый медленный, но работает без какой-либо IP-связности — даже без Wi-Fi сообщение всё равно проходит. Используется как гарантированный fallback для сопряжённых пиров.
Circuit breaker обеспечивает, что нестабильный транспорт (особенно Wi-Fi Aware при сетевых колебаниях) пропускается на 30 с после 2 сбоев, поэтому fallback происходит быстро, не дожидаясь повторных 10-секундных тайм-аутов.
Рукопожатие Wi-Fi Aware
AwareSession делает двухсообщенийное рукопожатие перед открытием data path:
MSG_HELLO(subscriber → publisher): «Я вижу тебя, вот мой peer handle.»MSG_READY(publisher → subscriber): «Я зарегистрировал свой network specifier, ты можешь делатьrequestNetworkсейчас.»
Это синхронизирует вызовы connectivityManager.requestNetwork(...) обеих
сторон в пределах ~500 мс окна Android-фреймворка. Subscriber — сторона
с меньшим clientId (детерминированное разделение ролей — обе стороны
согласны без координации), и именно он владеет циклом повторных попыток.
Статус и presence пира {#peer-status--presence}
Presence отслеживается через длинноживущие WebSocket-соединения. Только
одна сторона каждой пары открывает сокет — решается детерминированным
правилом TempData.clientId < peer.id. Другая сторона принимает входящее
соединение на /peer_status.
PeerCacher.onlineMap — источник истины для presence. Он предоставляется как
onlinePeerIds: StateFlow<Set<String>>, который потребляется алгоритмом
выбора лидера канала (electLeader(onlinePeerIds, myId)).
Слой кэширования {#caching-layer}
Два кэша зеркалируют таблицы БД в памяти и предоставляют StateFlow, которые
Compose собирает напрямую:
Почему copy-on-write?
MutableStateFlow.distinctUntilChanged в Kotlin использует структурное
равенство. Если бы мы мутировали DPeer на месте, производный список
pairedPeers содержал бы ту же ссылку DPeer до и после, и
distinctUntilChanged не увидел бы разницы и подавил эмиссию. Копируя
сущность сначала, мутируя копию и заменяя запись в карте на новый
PeerRuntime/ChannelRuntime, производный список получает новый список
из новых ссылок, и flow срабатывает.
Загрузка файлов {#file-downloads}
Входящие файлы/изображения загружаются автоматически ограниченным пулом
воркеров. Каждая загрузка стримится через любой доступный транспорт
(PeerTransportRouter.downloadFile) и пишется во временный файл, затем
импортируется в медиа-хранилище приложения и патчит поле uri chat item.
Транспортно-агностичный стриминг
Абстракция
DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable
позволяет LAN и Wi-Fi Aware стримить живое HTTP-тело, тогда как BLE стримит
чанковый RPC (чанки 16 KiB через GET /fs?id=…&offset=…&length=…) через тот
же ByteReadChannel. Колбэк onClose позволяет BLE отменить фоновую
корутину загрузки, когда потребитель закрывает ответ раньше (например, при
паузе).
Сводка паттернов проектирования {#design-patterns-recap}
| Паттерн | Где | Почему |
|---|---|---|
| Фасад | ChatManager | Единая точка входа; вызывающие никогда не касаются БД/транспорта напрямую. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Подключаемые транспорты с TransportUnavailable как сигналом проваливания. |
| Circuit Breaker | PeerCircuitBreaker | 2 сбоя / 30 с открывают leg (peer, transport), чтобы Wi-Fi Aware не блокировал fallback. |
| Конечный автомат | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Явные переходы для жизненного цикла сокета и жизненного цикла NDP-линка. |
| Producer/Consumer + Pool | DownloadQueue (3 воркера, Channel.BUFFERED) | Ограниченная параллельность для загрузок файлов. |
| Observer / Reactive | StateFlow везде | Compose собирает напрямую; без ручного обновления. |
| Защита от повтора | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Отбрасывать дубликаты от двойной доставки LAN+BLE; отвергать out-of-window метки времени. |
| Экспоненциальный backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — кэп на 64 с. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Заставляет StateFlow.distinctUntilChanged срабатывать на каждой мутации. |
| Подписанный конверт | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — привязывает метку времени к телу для защиты от повторов. |
| Детерминированное разделение ролей | TempData.clientId < peer.id | Решает WebSocket-клиент против сервера и Wi-Fi Aware subscriber против publisher. |
| Ленивая гидратация | ensureChannelPeer на invite/update | Создаёт строки peers для невиданных участников канала, чтобы работала fan-out маршрутизация. |
| Шифрованная идентификация | LANDiscoverManager.discoverSpecificDevice | Направленный DISCOVER шифрует целевой id ключом пира — только target его распознаёт. |
Дополнительная литература
- Pairing Flow — как два устройства устанавливают доверие и обмениваются общим ключом ChaCha20, используемым каждым транспортом в этой статье.
apitest/groups/chat-messages.shиapitest/groups/chat-channels.sh— исполняемый тест-план, проверяющий каждую GraphQL-мутацию end-to-end.