Sumário
- Arquitetura de Alto Nível
- Modelo de Dados
- Superfície da API GraphQL
- Chat entre Pares: Enviando uma Mensagem
- Chat entre Pares: Recebendo uma Mensagem
- Chat em Canal: Eleição de Líder e Fan-Out
- Mensagens de Sistema de Canal
- Ciclo de Vida do Canal
- Camada de Transporte entre Pares (LAN → Wi-Fi Aware → BLE)
- Status e Presença entre Pares
- Camada de Cache
- Downloads de Arquivos
- Recapitulação dos Padrões de Design
Arquitetura de Alto Nível {#high-level-architecture}
O chat do PlainApp é serverless. Cada dispositivo roda um servidor HTTP Ktor embutido, e os dispositivos conversam diretamente entre si pela rede local, Wi-Fi Aware (NAN) ou Bluetooth Low Energy. Não há servidor de relay, nem caixa de entrada na nuvem, nem identidade baseada em número de telefone. Os dispositivos são identificados por um clientId autogerado e autenticados por meio de um handshake Ed25519 + ECDH realizado durante o pareamento.
Existem dois tipos de conversas:
| Tipo | Constante | Descrição |
|---|---|---|
PEER | ChatTargetType.PEER | Chat direto 1-a-1 entre dois dispositivos pareados. |
CHANNEL | ChatTargetType.CHANNEL | Chat em grupo multi-partes pertencente a um dispositivo; os membros distribuem mensagens entre si. |
Um alvo especial "local" é o rascunho do próprio dispositivo (notas para si
mesmo) — enviar para ele é um no-op na rede.
Mapa de componentes
A arquitetura é intencionalmente em camadas:
- Pontos de entrada da UI / GraphQL nunca tocam diretamente em transportes ou no BD.
ChatManageré uma fachada — todo chamador (UI, resolver GraphQL, receptor de par) passa por ele.ChatSenderé um dispatcher que ramifica conforme oChatTargetTypee delega para os senders de par ou de canal.- A camada de transporte é uma cadeia de estratégias plugável com circuit breaking, de modo que um link Wi-Fi Aware instável nunca bloqueie uma mensagem que poderia ir por BLE.
Modelo de Dados {#data-model}
ChatTarget
A menor unidade de roteamento é um ChatTarget — um par (toId, type) em que
type é PEER ou CHANNEL. Ele expõe um encodedToId (peer:<id> ou
channel:<id>) que a UI usa como chave estável de roteamento (por exemplo,
TempData.activeToId para que o receptor saiba se deve emitir uma notificação),
um isLocal() (toId == "local") e um companion parseId que reconstrói o
alvo a partir de uma string armazenada.
Tabelas do banco de dados
Toda persistência usa Room. Três tabelas importam para o chat:
| Tabela | Entity | Propósito |
|---|---|---|
chats | DChat | Uma linha por mensagem (texto / imagem / arquivo). |
chat_channels | DChatChannel | Uma linha por canal de grupo. |
peers | DPeer | Uma linha por dispositivo conhecido (pareado ou só de canal). |
Algumas coisas valem destacar:
- A identidade é o
clientId, nunca o MAC. O Android randomiza o MAC do BLE a cada conexão, então o banco de dados usa um id estável de 13 caracteres autogerado. Apenas um prefixo SHA-256 de 8 bytes (shortId) é broadcastado via BLE para permitir a descoberta. - Pares com
status="channel"são membros de um canal com o qual este dispositivo nunca pareou diretamente. Suakeyé vazia — eles se autenticam usando a chave do canal em vez de uma chave compartilhada par a par. owner="me"é um sentinela que permite a um dispositivo recém-instalado agir como dono antes que seuclientIdseja estável;isOwnedByMe()aceita tanto"me"quantoTempData.clientId.
Superfície da API GraphQL {#graphql-api-surface}
O PlainApp expõe dois schemas GraphQL:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchemaemshared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) — servido pelo servidor Ktor local para a UI do navegador e para o harnessapitest/. Autenticado por um token ChaCha20 criptografado. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — exposto em/peer_graphqlpara outros dispositivos pelo transporte de par criptografado. Autenticado por assinatura Ed25519 + criptografia de corpo ChaCha20.
Os dois schemas compartilham os mesmos singletons de lógica de negócio
(ChannelManager, ChatMessageReceiver, …), mas expõem superfícies
diferentes porque o modelo de confiança difere: o web GraphQL confia na
UI local, enquanto o peer GraphQL só confia em pares autenticados
criptograficamente.
Superfície Web GraphQL (chat)
Queries: chatChannels (lista todos os canais), chatItems(id) (mensagens
de um alvo — id é "local", peer:<id> ou channel:<id>) e
latestChatItems (prévia entre todos os chats).
Mutações de chat: sendChatItem(toId, content), deleteChatItem(id),
deleteChatItems(query) e retryChatItem(id).
Mutações de canal: createChatChannel(name), updateChatChannel(id, name),
deleteChatChannel(id), leaveChatChannel(id),
addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId),
acceptChatChannelInvite(id) e declineChatChannelInvite(id).
Superfície Peer GraphQL (transporte)
Exposto em /peer_graphql e autenticado por assinatura Ed25519 + criptografia
de corpo ChaCha20. Apenas três mutações cruzam a fronteira de transporte:
createChatItem(content) (uma mensagem de par recebida),
channelSystemMessage(type, payload) (eventos de ciclo de vida de canal como
invite/leave) e startAware (um empurrão pedindo ao par que inicie seu
serviço Wi-Fi Aware para que um transporte mais rápido assuma).
O cabeçalho HTTP c-id carrega o clientId do remetente; o cabeçalho c-cid
carrega um id de canal quando a requisição é escopada ao canal (para que o
receptor use a chave do canal em vez da chave de par para descriptografia).
Chat entre Pares: Enviando uma Mensagem {#peer-chat-sending-a-message}
Quando o usuário toca em Enviar em uma conversa de par, a cadeia de chamadas é:
As invariáveis-chave impostas em cada salto:
ChatManager.createChatItemsempre insere uma linha primeiro, depois envia. Isso significa que a UI vê um balão "pendente" imediatamente e a mensagem sobrevive a crashes do app mesmo se a entrega ainda não aconteceu.PeerGraphQLClient.buildSignedRequestconstrói um envelope no formatosignature|timestamp|requestJson. A assinatura é Ed25519 sobre"$timestamp$requestJson", vinculando o timestamp ao corpo para que não possa ser repetido com um timestamp novo.PeerTransportRouter.senditera transportes na ordemLan → WifiAware → Ble. Cada transporte pode lançarTransportUnavailablepara deixar o router tentar o próximo.- Do lado receptor,
PeerChatParser.decryptverifica se o timestamp está dentro de±5 mine valida a assinatura Ed25519 antes mesmo de a mutação GraphQL ser executada. ChatMessageReceiver.receivemantém um conjuntoseenSignatureschaveado por"$fromPeerId|$signature|$timestamp"e lançaReplayedMessageExceptionem duplicatas — essencial porque o transporte pode entregar o mesmo payload duas vezes (LAN + BLE).
Se PeerChatSender.send retornar uma string de erro não nula, ChatSender
chama triggerPeerRediscovery(peerId), que dispara um broadcast
DISCOVER direcionado e criptografado para que o par possa reanunciar seu
IP/porta atuais.
Chat entre Pares: Recebendo uma Mensagem {#peer-chat-receiving-a-message}
Requisições de entrada chegam na rota /peer_graphql do servidor Ktor local,
tratada por PeerGraphQLService:
Notificações
emitNotificationIfNeeded é o passo final. Ele suprime a notificação quando
TempData.activeToId == targetId (ou seja, o usuário está olhando
aquela conversa no momento) ou quando canShowNotifications() é falso.
Notificações de canal são prefixadas com o nome do remetente.
Chat em Canal: Eleição de Líder e Fan-Out {#channel-chat-leader-election--fan-out}
Canais são multi-partes, mas serverless. Para evitar que todo membro faça fan-out da mesma mensagem N vezes, o lado remetente elege um único líder cujo trabalho é broadcastar para todos os membros que entraram.
Algoritmo de eleição de líder (DChatChannel.electLeader)
- Filtra para membros que entraram e estão online no momento (o dispositivo local é sempre considerado online).
- Se o dono está entre os membros online que entraram → o dono é o líder.
- Caso contrário, o líder é o membro online que entrou com o menor
clientId(desempate determinístico, sem coordenação necessária). - Retorna
nullse não existirem membros online que entraram.
Fluxo de envio
Por que um líder afinal?
Imagine um canal de 5 membros em que todo mundo broadcasta para todos os outros: uma única mensagem geraria 20 idas e voltas de rede e 4 cópias duplicadas chegando a cada membro. Ao eleger um líder, apenas esse dispositivo faz o fan-out — o remetente ou realiza o fan-out ele mesmo (se for o líder) ou retransmite uma única cópia ao líder, que então faz o fan-out.
Se o líder está offline, o remetente cai para Result.NoLeader, dispara o
rediscovery do par (para que o IP do líder possa ser encontrado) e limpa o
status para deixar o usuário tentar de novo.
Roteamento por chave de canal
Mensagens de canal são criptografadas com a chave ChaCha20 do canal, não
com a chave de par. É isso que permite que um membro que só conheceu os outros
membros via o canal (nunca pareou 1-a-1) receba mensagens — sua linha em
peers tem status="channel" e key="". O remetente define o cabeçalho
HTTP c-cid com o id do canal; o receptor procura
ChannelCacher.getKeyBytes(channelId) em vez da chave par a par.
Retry por destinatário
Cada sendToMember retorna um DMessageDeliveryResult. O
DMessageStatusData agregado é persistido como o JSON status_data do item
de chat. A UI mostra "Entregue a Alice, Bob; Falhou para Carol" e deixa o
usuário tocar em Tentar novamente especificamente para Carol —
ChatManager.sendToChannelMembers re-executa sendToRecipients para o
subconjunto de retry e mescla os novos resultados com os existentes,
substituindo apenas os pares repetidos.
Mensagens de Sistema de Canal {#channel-system-messages}
Mensagens de plano de controle de canal (invite, accept, decline, update,
kick, leave) são trocadas pela mutação channelSystemMessage do peer
GraphQL. São payloads JSON tipados por uma string type:
| Tipo | Direção | Assinado? | Propósito |
|---|---|---|---|
channel_invite | Owner → invitee | Yes | Convida um par; carrega chave do canal + membros. |
channel_invite_accept | Invitee → owner | No | Aceitação; carrega a chave pública de quem aceita. |
channel_invite_decline | Invitee → owner | No | Recusa; o dono remove o membro. |
channel_update | Owner → all members | Yes | Broadcast de mudança de associação/nome. |
channel_kick | Owner → kicked peer | Yes | Kick direcionado; também broadcastado na exclusão do canal. |
channel_leave | Member → owner | No | Aviso de saída iniciado pelo membro. |
Formato do payload assinado
Os três tipos assinados (invite, update, kick) usam uma string canônica
delimitada por pipe: "$channelId|$version|$action|$target", em que action
é um de invite, update, kick, e target é o id do par convidado/expulso
(vazio para kick de broadcast).
O dono assina essa string com sua chave Ed25519. Receptores rejeitam qualquer
mensagem em que channel.owner != fromId antes mesmo de verificar a
assinatura, e rejeitam payloads ChannelUpdate cujo version seja ≤ a
versão local (proteção contra versão velha por entrega fora de ordem).
Hidratação preguiçosa de pares
ChannelInvite e ChannelUpdate carregam uma lista
memberPeers: List<MemberPeerInfo> — informações leves do par (id, name,
publicKey, deviceType, ip, port) para cada membro. O ensureChannelPeer do
receptor cria uma linha DPeer com status="channel" para qualquer membro
que ele nunca tenha visto antes. Isso é crítico porque o roteamento de fan-out
precisa do registro de par de cada membro para enviar mensagens.
Ciclo de Vida do Canal {#channel-lifecycle}
Camada de Transporte entre Pares (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter é uma cadeia de estratégias com circuit breaking. A
lista ordenada de transportes é:
LanTransport— primeira escolha. Usa OkHttp com um interceptador de crypto ChaCha20 sobre HTTPS. Inteiramente ignorado quandopeer.ipé vazio (par de sub-rede diferente que ainda não descobrimos).WifiAwareTransport(apenas Android 13+) — usa caminhos de dados Wi-Fi Aware (NAN). Skip rápido quando a flagawareRunningdo par é falsa (atualizada pelo prewarmer do BLE). O IPv6 do par é resolvido por um DNS customizado que mapeia o hostnameplain-aware-peerpara o endereço link-local.BleTransport— fallback garantido para qualquer par pareado. Faz streaming de RPC fragmentado sobre GATT. Mais lento, mas funciona sem qualquer conectividade IP.
Por que esta ordem?
- LAN é o mais rápido (uma única ida e volta HTTPS, ~10 ms de timeout).
- Wi-Fi Aware é médio (setup de data-path ~5 s, depois ~10 ms de ida e
volta) e funciona entre sub-redes (por exemplo, um dispositivo no Wi-Fi de
visitantes, outro no Wi-Fi IoT). Ajustado para pular rápido quando o serviço
Aware do par não está rodando, evitando um timeout de 10 s em
buildLink. - BLE é o mais lento, mas funciona sem qualquer conectividade IP — mesmo sem Wi-Fi, a mensagem ainda chega. Usado como fallback garantido para pares pareados.
O circuit breaker garante que um transporte instável (especialmente o Wi-Fi Aware durante churn de rede) seja pulado por 30 s após 2 falhas, de modo que o fallback aconteça rapidamente em vez de esperar por timeouts repetidos de 10 s.
Handshake Wi-Fi Aware
O AwareSession faz um handshake de duas mensagens antes de abrir um
data path:
MSG_HELLO(subscriber → publisher): "Eu vejo você, aqui está meu peer handle."MSG_READY(publisher → subscriber): "Eu registrei meu network specifier, você pode chamarrequestNetworkagora."
Isso sincroniza as chamadas connectivityManager.requestNetwork(...) de
ambos os lados dentro da janela de ~500 ms do framework Android. O
subscriber é o lado com o menor clientId (divisão de papel
determinística — ambos os lados concordam sem coordenação), e ele é dono do
loop de retry.
Status e Presença entre Pares {#peer-status--presence}
A presença é rastreada via conexões WebSocket de longa duração. Apenas
um lado de cada par abre o socket — decidido pela regra determinística
TempData.clientId < peer.id. O outro lado aceita a conexão de entrada em
/peer_status.
PeerCacher.onlineMap é a fonte da verdade para presença. É exposto como
onlinePeerIds: StateFlow<Set<String>>, consumido pela eleição de líder do
canal (electLeader(onlinePeerIds, myId)).
Camada de Cache {#caching-layer}
Dois caches espelham as tabelas do banco de dados em memória e expõem
StateFlows que o Compose coleta diretamente:
Por que copy-on-write?
O MutableStateFlow.distinctUntilChanged do Kotlin usa igualdade estrutural.
Se mutássemos o DPeer no lugar, a lista pairedPeers derivada conteria a
mesma referência de DPeer antes e depois, e o distinctUntilChanged não
veria diferença e suprimiria a emissão. Ao copiar a entity primeiro, mutar a
cópia e substituir a entrada do mapa por um novo
PeerRuntime/ChannelRuntime, a lista derivada recebe uma nova lista de
novas referências e o flow dispara.
Downloads de Arquivos {#file-downloads}
Mensagens de arquivo/imagem recebidas são baixadas automaticamente por um
pool de workers limitado. Cada download faz streaming por qualquer transporte
disponível (PeerTransportRouter.downloadFile) e grava em um arquivo
temporário, depois importa para a media store do app e ajusta o campo uri
do item de chat.
Streaming agnóstico a transporte
A abstração DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable permite que a LAN e o Wi-Fi Aware façam streaming do corpo
HTTP ao vivo, enquanto o BLE faz streaming de RPC fragmentado (chunks de
16 KiB via GET /fs?id=…&offset=…&length=…) pelo mesmo ByteReadChannel. O
callback onClose permite que o BLE cancele sua corrotina de download em
background quando o consumidor fecha a resposta cedo (por exemplo, ao pausar).
Recapitulação dos Padrões de Design {#design-patterns-recap}
| Padrão | Onde | Por quê |
|---|---|---|
| Fachada | ChatManager | Ponto único de entrada; chamadores nunca tocam BD/transporte diretamente. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Transportes plugáveis com TransportUnavailable como sinal de queda. |
| Circuit Breaker | PeerCircuitBreaker | 2 falhas / 30 s abre a perna (par, transporte) para que o Wi-Fi Aware não bloqueie o fallback. |
| Máquina de Estados | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Transições explícitas para o ciclo de vida do socket e do link NDP. |
| Producer/Consumer + Pool | DownloadQueue (3 workers, Channel.BUFFERED) | Concorrência limitada para downloads de arquivos. |
| Observer / Reativo | StateFlow em todo lugar | Compose coleta diretamente; sem refresh manual. |
| Proteção contra Replay | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Descarta duplicatas da entrega dupla LAN+BLE; rejeita timestamps fora da janela. |
| Backoff Exponencial | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — limita a 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Força StateFlow.distinctUntilChanged a disparar em cada mutação. |
| Envelope Assinado | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — vincula timestamp ao corpo para evitar replay. |
| Divisão de Papel Determinística | TempData.clientId < peer.id | Decide cliente vs servidor WebSocket, e subscriber vs publisher no Wi-Fi Aware. |
| Hidratação Preguiçosa | ensureChannelPeer em invite/update | Cria linhas em peers para membros de canal não vistos, para que o roteamento de fan-out funcione. |
| Identidade Criptografada | LANDiscoverManager.discoverSpecificDevice | DISCOVER direcionado criptografa o id alvo com a chave do par — apenas o alvo o reconhece. |
Leitura Adicional
- Fluxo de Pareamento — como dois dispositivos estabelecem confiança e trocam a chave ChaCha20 compartilhada usada por cada transporte neste artigo.
apitest/groups/chat-messages.sheapitest/groups/chat-channels.sh— plano de testes executável que exercita cada mutação GraphQL de ponta a ponta.