Voltar ao blog
Architecture12 min read

Arquitetura de Chat entre Pares e Canais

Este artigo explica como o chat offline-first do PlainApp funciona de ponta a ponta: como uma mensagem viaja de um toque na UI até outro dispositivo pelo transporte entre pares, como canais de grupo distribuem mensagens para muitos membros e como o sistema se mantém resiliente quando as redes desaparecem. O pareamento (a troca de confiança e chaves que inicializa dois dispositivos) é abordado no artigo separado Fluxo de Pareamento.

Sumário

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:

TipoConstanteDescrição
PEERChatTargetType.PEERChat direto 1-a-1 entre dois dispositivos pareados.
CHANNELChatTargetType.CHANNELChat 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

Diagram 1
1

A arquitetura é intencionalmente em camadas:

  1. Pontos de entrada da UI / GraphQL nunca tocam diretamente em transportes ou no BD.
  2. ChatManager é uma fachada — todo chamador (UI, resolver GraphQL, receptor de par) passa por ele.
  3. ChatSender é um dispatcher que ramifica conforme o ChatTargetType e delega para os senders de par ou de canal.
  4. 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:

TabelaEntityPropósito
chatsDChatUma linha por mensagem (texto / imagem / arquivo).
chat_channelsDChatChannelUma linha por canal de grupo.
peersDPeerUma linha por dispositivo conhecido (pareado ou só de canal).

Diagram 2
2

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. Sua key é 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 seu clientId seja estável; isOwnedByMe() aceita tanto "me" quanto TempData.clientId.

Superfície da API GraphQL {#graphql-api-surface}

O PlainApp expõe dois schemas GraphQL:

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema em shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) — servido pelo servidor Ktor local para a UI do navegador e para o harness apitest/. Autenticado por um token ChaCha20 criptografado.
  2. Peer GraphQL (PeerGraphQLService.applyPeerSchema) — exposto em /peer_graphql para 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 é:

Diagram 3
3

As invariáveis-chave impostas em cada salto:

  1. ChatManager.createChatItem sempre 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.
  2. PeerGraphQLClient.buildSignedRequest constrói um envelope no formato signature|timestamp|requestJson. A assinatura é Ed25519 sobre "$timestamp$requestJson", vinculando o timestamp ao corpo para que não possa ser repetido com um timestamp novo.
  3. PeerTransportRouter.send itera transportes na ordem Lan → WifiAware → Ble. Cada transporte pode lançar TransportUnavailable para deixar o router tentar o próximo.
  4. Do lado receptor, PeerChatParser.decrypt verifica se o timestamp está dentro de ±5 min e valida a assinatura Ed25519 antes mesmo de a mutação GraphQL ser executada.
  5. ChatMessageReceiver.receive mantém um conjunto seenSignatures chaveado por "$fromPeerId|$signature|$timestamp" e lança ReplayedMessageException em 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:

Diagram 4
4

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)

  1. Filtra para membros que entraram e estão online no momento (o dispositivo local é sempre considerado online).
  2. Se o dono está entre os membros online que entraram → o dono é o líder.
  3. Caso contrário, o líder é o membro online que entrou com o menor clientId (desempate determinístico, sem coordenação necessária).
  4. Retorna null se não existirem membros online que entraram.

Fluxo de envio

Diagram 5
5

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:

TipoDireçãoAssinado?Propósito
channel_inviteOwner → inviteeYesConvida um par; carrega chave do canal + membros.
channel_invite_acceptInvitee → ownerNoAceitação; carrega a chave pública de quem aceita.
channel_invite_declineInvitee → ownerNoRecusa; o dono remove o membro.
channel_updateOwner → all membersYesBroadcast de mudança de associação/nome.
channel_kickOwner → kicked peerYesKick direcionado; também broadcastado na exclusão do canal.
channel_leaveMember → ownerNoAviso 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).

Diagram 6
6

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}

Diagram 7
7

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 é:

  1. LanTransport — primeira escolha. Usa OkHttp com um interceptador de crypto ChaCha20 sobre HTTPS. Inteiramente ignorado quando peer.ip é vazio (par de sub-rede diferente que ainda não descobrimos).
  2. WifiAwareTransport (apenas Android 13+) — usa caminhos de dados Wi-Fi Aware (NAN). Skip rápido quando a flag awareRunning do par é falsa (atualizada pelo prewarmer do BLE). O IPv6 do par é resolvido por um DNS customizado que mapeia o hostname plain-aware-peer para o endereço link-local.
  3. BleTransport — fallback garantido para qualquer par pareado. Faz streaming de RPC fragmentado sobre GATT. Mais lento, mas funciona sem qualquer conectividade IP.

Diagram 8
8

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 chamar requestNetwork agora."

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.

Diagram 9
9

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:

Diagram 10
10

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.

Diagram 11
11

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ãoOndePor quê
FachadaChatManagerPonto único de entrada; chamadores nunca tocam BD/transporte diretamente.
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportTransportes plugáveis com TransportUnavailable como sinal de queda.
Circuit BreakerPeerCircuitBreaker2 falhas / 30 s abre a perna (par, transporte) para que o Wi-Fi Aware não bloqueie o fallback.
Máquina de EstadosPeerStatusManager.PeerState, AwarePeerLink.LinkStateTransições explícitas para o ciclo de vida do socket e do link NDP.
Producer/Consumer + PoolDownloadQueue (3 workers, Channel.BUFFERED)Concorrência limitada para downloads de arquivos.
Observer / ReativoStateFlow em todo lugarCompose coleta diretamente; sem refresh manual.
Proteção contra ReplayChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSDescarta duplicatas da entrega dupla LAN+BLE; rejeita timestamps fora da janela.
Backoff ExponencialPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — limita a 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelForça StateFlow.distinctUntilChanged a disparar em cada mutação.
Envelope AssinadoPeerGraphQLClient.buildSignedRequestsignature|timestamp|body — vincula timestamp ao corpo para evitar replay.
Divisão de Papel DeterminísticaTempData.clientId < peer.idDecide cliente vs servidor WebSocket, e subscriber vs publisher no Wi-Fi Aware.
Hidratação PreguiçosaensureChannelPeer em invite/updateCria linhas em peers para membros de canal não vistos, para que o roteamento de fan-out funcione.
Identidade CriptografadaLANDiscoverManager.discoverSpecificDeviceDISCOVER 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.sh e apitest/groups/chat-channels.sh — plano de testes executável que exercita cada mutação GraphQL de ponta a ponta.