Volver al blog
Architecture12 min read

Arquitectura de chat entre pares y canales

Este artículo explica cómo funciona el chat offline-first de PlainApp de punta a punta: cómo un mensaje viaja desde un toque en la UI hasta otro dispositivo a través del transporte entre pares, cómo los canales grupales distribuyen mensajes a muchos miembros, y cómo el sistema se mantiene resiliente cuando las redes desaparecen. El emparejamiento (la confianza e intercambio de claves que arranca dos dispositivos) se cubre en el artículo independiente Flujo de emparejamiento.

Tabla de contenidos

Arquitectura de alto nivel {#high-level-architecture}

El chat de PlainApp es serverless. Cada dispositivo ejecuta un servidor HTTP Ktor embebido, y los dispositivos se comunican directamente entre sí por la red local, Wi-Fi Aware (NAN) o Bluetooth Low Energy. No hay servidor relé, ni buzón en la nube, ni identidad basada en número de teléfono. Los dispositivos se identifican mediante un clientId autogenerado y se autentican mediante un apretón de manos Ed25519 + ECDH realizado durante el emparejamiento.

Existen dos tipos de conversaciones:

TipoConstanteDescripción
PEERChatTargetType.PEERChat directo 1 a 1 entre dos dispositivos emparejados.
CHANNELChatTargetType.CHANNELChat grupal multi-party propiedad de un dispositivo; los miembros distribuyen mensajes entre sí.

Un destino especial "local" es el bloc de notas del propio dispositivo (notas para uno mismo) — enviar a él es un no-op por la red.

Mapa de componentes

Diagram 1
1

La arquitectura es intencionalmente por capas:

  1. Los puntos de entrada UI / GraphQL nunca tocan transportes ni BD directamente.
  2. ChatManager es una fachada — cada llamador (UI, resolver de GraphQL, receptor de pares) pasa por él.
  3. ChatSender es un despachador que se ramifica según ChatTargetType y delega a los remitentes de par o canal.
  4. La capa de transporte es una cadena de estrategias conectable con disyuntor, de modo que un enlace Wi-Fi Aware inestable nunca bloquea un mensaje que podría ir por BLE.

Modelo de datos {#data-model}

ChatTarget

La unidad mínima de enrutamiento es un ChatTarget — un par (toId, type) donde type es PEER o CHANNEL. Expone un encodedToId (peer:<id> o channel:<id>) que la UI usa como clave de enrutamiento estable (p. ej. TempData.activeToId para que el receptor sepa si emitir una notificación), una comprobación isLocal() (toId == "local"), y un companion parseId que reconstruye el destino desde una cadena almacenada.

Tablas de la base de datos

Toda la persistencia usa Room. Tres tablas importan para el chat:

TablaEntidadPropósito
chatsDChatUna fila por mensaje (texto / imagen / archivo).
chat_channelsDChatChannelUna fila por canal grupal.
peersDPeerUna fila por dispositivo conocido (emparejado o solo canal).

Diagram 2
2

Algunos aspectos a destacar:

  • La identidad es clientId, nunca MAC. Android aleatoriza la MAC BLE en cada conexión, así que la base de datos usa un id autogenerado estable de 13 caracteres. Solo un prefijo SHA-256 de 8 bytes (shortId) se difunde por BLE para permitir el descubrimiento.
  • Los pares con status="channel" son miembros de un canal con el que este dispositivo nunca se ha emparejado directamente. Su key está vacía — se autentican usando la clave de canal en vez de una clave compartida por pares.
  • owner="me" es un centinela que permite a un dispositivo recién instalado actuar como propietario antes de que su clientId sea estable; isOwnedByMe() acepta tanto "me" como TempData.clientId.

Superficie de la API GraphQL {#graphql-api-surface}

PlainApp expone dos esquemas GraphQL:

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema) — servido por el servidor Ktor local a la UI del navegador y al harness apitest/. Autenticado por un token cifrado con ChaCha20.
  2. Peer GraphQL (PeerGraphQLService.applyPeerSchema) — expuesto en /peer_graphql para otros dispositivos por el transporte cifrado entre pares. Autenticado por firma Ed25519 + cifrado de cuerpo ChaCha20.

Los dos esquemas comparten los mismos singletons de lógica de negocio (ChannelManager, ChatMessageReceiver, …) pero exponen superficies distintas porque el modelo de confianza difiere: el GraphQL web confía en la UI local, mientras que el GraphQL entre pares solo confía en pares autenticados criptográficamente.

Superficie web GraphQL (chat)

Queries: chatChannels (listar todos los canales), chatItems(id) (mensajes para un destino — id es "local", peer:<id> o channel:<id>), y latestChatItems (vista previa en todos los chats).

Mutaciones de chat: sendChatItem(toId, content), deleteChatItem(id), deleteChatItems(query) y retryChatItem(id).

Mutaciones de canal: createChatChannel(name), updateChatChannel(id, name), deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id) y declineChatChannelInvite(id).

Superficie Peer GraphQL (transporte)

Expuesta en /peer_graphql y autenticada por firma Ed25519 + cifrado de cuerpo ChaCha20. Solo tres mutaciones cruzan la frontera del transporte: createChatItem(content) (un mensaje de par entrante), channelSystemMessage(type, payload) (eventos del ciclo de vida del canal como invite/leave), y startAware (un empujón pidiendo al par que inicie su servicio Wi-Fi Aware para que un transporte más rápido pueda tomar el control).

La cabecera HTTP c-id transporta el clientId del remitente; la cabecera c-cid transporta un id de canal cuando la solicitud es de ámbito de canal (para que el receptor use la clave de canal en vez de la clave de par para el descifrado).

Chat entre pares: envío de un mensaje {#peer-chat-sending-a-message}

Cuando el usuario pulsa Enviar en una conversación de par, la cadena de llamadas es:

Diagram 3
3

Los invariantes clave aplicados en cada salto:

  1. ChatManager.createChatItem siempre inserta una fila primero, después envía. Esto significa que la UI ve una burbuja «pendiente» inmediatamente y el mensaje sobrevive a caídas de la aplicación incluso si aún no se ha entregado.
  2. PeerGraphQLClient.buildSignedRequest construye un sobre del formato signature|timestamp|requestJson. La firma es Ed25519 sobre "$timestamp$requestJson", vinculando la marca de tiempo al cuerpo para que no pueda reproducirse con una marca de tiempo fresca.
  3. PeerTransportRouter.send itera los transportes en orden Lan → WifiAware → Ble. Cada transporte puede lanzar TransportUnavailable para que el enrutador pruebe el siguiente.
  4. En el lado receptor, PeerChatParser.decrypt comprueba que la marca de tiempo esté dentro de ±5 min y verifica la firma Ed25519 antes de que la mutación GraphQL se ejecute siquiera.
  5. ChatMessageReceiver.receive mantiene un conjunto seenSignatures claveado por "$fromPeerId|$signature|$timestamp" y lanza ReplayedMessageException en duplicados — esencial porque el transporte puede entregar la misma carga útil dos veces (LAN + BLE).

Si PeerChatSender.send devuelve una cadena de error no nula, ChatSender llama a triggerPeerRediscovery(peerId), que dispara una difusión DISCOVER dirigida y cifrada para que el par pueda reanunciar su IP/puerto actuales.

Chat entre pares: recepción de un mensaje {#peer-chat-receiving-a-message}

Las solicitudes entrantes aterrizan en la ruta /peer_graphql del servidor Ktor local, atendidas por PeerGraphQLService:

Diagram 4
4

Notificaciones

emitNotificationIfNeeded es el paso final. Suprime la notificación cuando TempData.activeToId == targetId (es decir, el usuario está viendo actualmente esa conversación) o cuando canShowNotifications() es falso. Las notificaciones de canal se prefijan con el nombre del remitente.

Chat de canal: elección de líder y distribución {#channel-chat-leader-election--fan-out}

Los canales son multi-party pero serverless. Para evitar que cada miembro distribuya el mismo mensaje N veces, el lado remitente elige un único líder cuyo trabajo es difundir a todos los miembros unidos.

Algoritmo de elección de líder (DChatChannel.electLeader)

  1. Filtra a los miembros unidos que estén actualmente en línea (el dispositivo local siempre se considera en línea).
  2. Si el propietario está entre los miembros unidos en línea → el propietario es el líder.
  3. En caso contrario, el líder es el miembro unido en línea con el clientId más pequeño (desempate determinista, sin coordinación requerida).
  4. Devuelve null si no existen miembros unidos en línea.

Flujo de envío

Diagram 5
5

¿Por qué un líder en absoluto?

Imagine un canal de 5 miembros donde todos difunden a todos los demás: un único mensaje generaría 20 viajes de red y 4 copias duplicadas llegando a cada miembro. Al elegir un líder, solo ese dispositivo hace la distribución — el remitente o bien realiza la distribución él mismo (si es el líder) o reenvía una única copia al líder, que entonces distribuye.

Si el líder está desconectado, el remitente cae a Result.NoLeader, dispara el redescubrimiento de pares (para que la IP del líder pueda encontrarse) y limpia el estado para permitir al usuario reintentar.

Enrutamiento por clave de canal

Los mensajes de canal se cifran con la clave ChaCha20 del canal, no con la clave de par. Esto es lo que permite a un miembro que solo ha conocido a los demás miembros vía el canal (nunca emparejado 1 a 1) recibir mensajes — su fila peers tiene status="channel" y key="". El remitente establece la cabecera HTTP c-cid al id del canal; el receptor busca ChannelCacher.getKeyBytes(channelId) en vez de la clave por pares.

Reintento por destinatario

Cada sendToMember devuelve un DMessageDeliveryResult. El DMessageStatusData agregado se persiste como el JSON status_data del ítem de chat. La UI muestra «Entregado a Alice, Bob; Fallido para Carol» y permite al usuario pulsar Reintentar para Carol específicamente — ChatManager.sendToChannelMembers vuelve a ejecutar sendToRecipients para el subconjunto de reintento y fusiona los nuevos resultados con los existentes, reemplazando solo los pares reintentados.

Mensajes de sistema de canal {#channel-system-messages}

Los mensajes del plano de control del canal (invite, accept, decline, update, kick, leave) se intercambian por la mutación channelSystemMessage del GraphQL entre pares. Son cargas útiles JSON tipadas por una cadena type:

TipoDirección¿Firmado?Propósito
channel_invitePropietario → invitadoInvitar a un par; transporta la clave del canal + miembros.
channel_invite_acceptInvitado → propietarioNoAceptación; transporta la clave pública del aceptador.
channel_invite_declineInvitado → propietarioNoRechazo; el propietario elimina al miembro.
channel_updatePropietario → todos los miembrosDifusión de cambio de miembros/nombre.
channel_kickPropietario → par expulsadoExpulsión dirigida; también difundida al borrar el canal.
channel_leaveMiembro → propietarioNoAviso de salida iniciado por el miembro.

Formato de carga firmada

Los tres tipos firmados (invite, update, kick) usan una cadena canónica delimitada por barras verticales: "$channelId|$version|$action|$target", donde action es uno de invite, update, kick, y target es el id del par invitado/expulsado (vacío para kick de difusión).

El propietario firma esta cadena con su clave Ed25519. Los receptores rechazan cualquier mensaje donde channel.owner != fromId antes incluso de comprobar la firma, y rechazan las cargas útiles ChannelUpdate cuya version sea la versión local (salvaguarda de versión obsoleta contra entrega fuera de orden).

Diagram 6
6

Hidratación perezosa de pares

ChannelInvite y ChannelUpdate transportan una lista memberPeers: List<MemberPeerInfo> — información ligera de par (id, name, publicKey, deviceType, ip, port) para cada miembro. El ensureChannelPeer del receptor crea una fila DPeer con status="channel" para cualquier miembro que nunca haya visto. Esto es crítico porque el enrutamiento de distribución necesita el registro de par de cada miembro para enviar mensajes.

Ciclo de vida del canal {#channel-lifecycle}

Diagram 7
7

Capa de transporte entre pares (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}

PeerTransportRouter es una cadena de estrategias con disyuntor. La lista ordenada de transportes es:

  1. LanTransport — primera opción. Usa OkHttp con un interceptor criptográfico ChaCha20 sobre HTTPS. Se omite completamente cuando peer.ip está vacío (par entre subredes que aún no hemos descubierto).
  2. WifiAwareTransport (solo Android 13+) — usa rutas de datos Wi-Fi Aware (NAN). Salto rápido cuando la bandera awareRunning del par es falsa (refrescada por el escaneo precalentador BLE). El IPv6 del par se resuelve vía un DNS personalizado que mapea el hostname plain-aware-peer a la dirección link-local.
  3. BleTransport — fallback garantizado para cualquier par emparejado. Hace streaming de RPC fragmentado sobre GATT. Más lento pero funciona sin ninguna conectividad IP.

Diagram 8
8

¿Por qué este orden?

  • LAN es el más rápido (único ida y vuelta HTTPS, ~10 ms de tiempo de espera).
  • Wi-Fi Aware es medio (configuración de ruta de datos ~5 s, luego ~10 ms de ida y vuelta) y funciona entre subredes (p. ej. un dispositivo en Wi-Fi de invitados, otro en Wi-Fi IoT). Ajustado para saltar rápido cuando el servicio Aware del par no está corriendo, evitando un tiempo de espera buildLink de 10 s.
  • BLE es el más lento pero funciona sin ninguna conectividad IP — incluso sin Wi-Fi, el mensaje llega. Usado como fallback garantizado para pares emparejados.

El disyuntor asegura que un transporte inestable (especialmente Wi-Fi Aware durante turbulencias de red) se omita durante 30 s tras 2 fallos, así que el fallback ocurre rápidamente en vez de esperar repetidos tiempos de espera de 10 s.

Apretón de manos Wi-Fi Aware

La AwareSession hace un apretón de manos de dos mensajes antes de abrir una ruta de datos:

  • MSG_HELLO (suscriptor → publicador): «Te veo, aquí está mi manejador de par.»
  • MSG_READY (publicador → suscriptor): «He registrado mi network specifier, ya puedes requestNetwork

Esto sincroniza las llamadas connectivityManager.requestNetwork(...) de ambas partes dentro de la ventana de ~500 ms del framework Android. El suscriptor es el lado con el clientId menor (división de roles determinista — ambas partes acuerdan sin coordinación), y es el dueño del bucle de reintento.

Estado y presencia de pares {#peer-status--presence}

La presencia se rastrea mediante conexiones WebSocket de larga duración. Solo un lado de cada par abre el socket — decidido por la regla determinista TempData.clientId < peer.id. El otro lado acepta la conexión entrante en /peer_status.

Diagram 9
9

PeerCacher.onlineMap es la fuente de verdad para la presencia. Se expone como onlinePeerIds: StateFlow<Set<String>>, que es consumido por la elección de líder del canal (electLeader(onlinePeerIds, myId)).

Capa de caché {#caching-layer}

Dos cachés reflejan las tablas de la base de datos en memoria y exponen StateFlows que Compose recolecta directamente:

Diagram 10
10

¿Por qué copy-on-write?

El MutableStateFlow.distinctUntilChanged de Kotlin usa igualdad estructural. Si mutáramos el DPeer in situ, la lista pairedPeers derivada contendría la misma referencia DPeer antes y después, y distinctUntilChanged no vería diferencia y suprimiría la emisión. Al copiar primero la entidad, mutar la copia, y reemplazar la entrada del mapa con un nuevo PeerRuntime/ChannelRuntime, la lista derivada obtiene una nueva lista-de-nuevas-referencias y el flujo se dispara.

Descargas de archivos {#file-downloads}

Los mensajes entrantes de archivo/imagen se descargan automáticamente por un pool de workers acotado. Cada descarga hace streaming a través del transporte disponible (PeerTransportRouter.downloadFile) y escribe a un archivo temporal, luego importa al almacén de medios de la aplicación y parchea el campo uri del ítem de chat.

Diagram 11
11

Streaming agnóstico del transporte

La abstracción DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable permite a LAN y Wi-Fi Aware hacer streaming del cuerpo HTTP en vivo, mientras que BLE hace streaming de RPC fragmentado (fragmentos de 16 KiB vía GET /fs?id=…&offset=…&length=…) a través del mismo ByteReadChannel. El callback onClose permite a BLE cancelar su corrutina de descarga en segundo plano cuando el consumidor cierra la respuesta prematuramente (p. ej. al pausar).

Resumen de patrones de diseño {#design-patterns-recap}

PatrónDóndePor qué
FachadaChatManagerPunto de entrada único; los llamadores nunca tocan BD/transporte directamente.
Estrategia + Cadena de resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportTransportes conectables con TransportUnavailable como señal de caída.
DisyuntorPeerCircuitBreaker2 fallos / 30 s abre una pata (par, transporte) para que Wi-Fi Aware no bloquee el fallback.
Máquina de estadosPeerStatusManager.PeerState, AwarePeerLink.LinkStateTransiciones explícitas para el ciclo de vida del socket y el ciclo de vida del enlace NDP.
Productor/Consumidor + PoolDownloadQueue (3 workers, Channel.BUFFERED)Concurrencia acotada para descargas de archivos.
Observador / ReactivoStateFlow en todas partesCompose recolecta directamente; sin refresco manual.
Protección contra reproducciónChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSDescartar duplicados de la entrega dual LAN+BLE; rechazar marcas de tiempo fuera de ventana.
Backoff exponencialPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — topa en 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelFuerza a StateFlow.distinctUntilChanged a dispararse en cada mutación.
Sobre firmadoPeerGraphQLClient.buildSignedRequestsignature|timestamp|body — vincula la marca de tiempo al cuerpo para prevenir reproducción.
División de rol deterministaTempData.clientId < peer.idDecide cliente vs servidor WebSocket, y suscriptor vs publicador Wi-Fi Aware.
Hidratación perezosaensureChannelPeer al invitar/actualizarCrea filas peers para miembros de canal no vistos para que el enrutamiento de distribución funcione.
Identidad cifradaLANDiscoverManager.discoverSpecificDeviceEl DISCOVER dirigido cifra el id de destino con la clave del par — solo el destino lo reconoce.

Lecturas adicionales

  • Flujo de emparejamiento — cómo dos dispositivos establecen confianza e intercambian la clave ChaCha20 compartida usada por cada transporte en este artículo.
  • apitest/groups/chat-messages.sh y apitest/groups/chat-channels.sh — plan de pruebas ejecutable que ejercita cada mutación GraphQL de punta a punta.