Tabla de contenidos
- Arquitectura de alto nivel
- Modelo de datos
- Superficie de la API GraphQL
- Chat entre pares: envío de un mensaje
- Chat entre pares: recepción de un mensaje
- Chat de canal: elección de líder y distribución
- Mensajes de sistema de canal
- Ciclo de vida del canal
- Capa de transporte entre pares (LAN → Wi-Fi Aware → BLE)
- Estado y presencia de pares
- Capa de caché
- Descargas de archivos
- Resumen de patrones de diseño
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:
| Tipo | Constante | Descripción |
|---|---|---|
PEER | ChatTargetType.PEER | Chat directo 1 a 1 entre dos dispositivos emparejados. |
CHANNEL | ChatTargetType.CHANNEL | Chat 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
La arquitectura es intencionalmente por capas:
- Los puntos de entrada UI / GraphQL nunca tocan transportes ni BD directamente.
ChatManageres una fachada — cada llamador (UI, resolver de GraphQL, receptor de pares) pasa por él.ChatSenderes un despachador que se ramifica segúnChatTargetTypey delega a los remitentes de par o canal.- 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:
| Tabla | Entidad | Propósito |
|---|---|---|
chats | DChat | Una fila por mensaje (texto / imagen / archivo). |
chat_channels | DChatChannel | Una fila por canal grupal. |
peers | DPeer | Una fila por dispositivo conocido (emparejado o solo canal). |
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. Sukeyestá 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 suclientIdsea estable;isOwnedByMe()acepta tanto"me"comoTempData.clientId.
Superficie de la API GraphQL {#graphql-api-surface}
PlainApp expone dos esquemas GraphQL:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — servido por el servidor Ktor local a la UI del navegador y al harnessapitest/. Autenticado por un token cifrado con ChaCha20. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — expuesto en/peer_graphqlpara 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:
Los invariantes clave aplicados en cada salto:
ChatManager.createChatItemsiempre 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.PeerGraphQLClient.buildSignedRequestconstruye un sobre del formatosignature|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.PeerTransportRouter.senditera los transportes en ordenLan → WifiAware → Ble. Cada transporte puede lanzarTransportUnavailablepara que el enrutador pruebe el siguiente.- En el lado receptor,
PeerChatParser.decryptcomprueba que la marca de tiempo esté dentro de±5 miny verifica la firma Ed25519 antes de que la mutación GraphQL se ejecute siquiera. ChatMessageReceiver.receivemantiene un conjuntoseenSignaturesclaveado por"$fromPeerId|$signature|$timestamp"y lanzaReplayedMessageExceptionen 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:
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)
- Filtra a los miembros unidos que estén actualmente en línea (el dispositivo local siempre se considera en línea).
- Si el propietario está entre los miembros unidos en línea → el propietario es el líder.
- En caso contrario, el líder es el miembro unido en línea con el
clientIdmás pequeño (desempate determinista, sin coordinación requerida). - Devuelve
nullsi no existen miembros unidos en línea.
Flujo de envío
¿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:
| Tipo | Dirección | ¿Firmado? | Propósito |
|---|---|---|---|
channel_invite | Propietario → invitado | Sí | Invitar a un par; transporta la clave del canal + miembros. |
channel_invite_accept | Invitado → propietario | No | Aceptación; transporta la clave pública del aceptador. |
channel_invite_decline | Invitado → propietario | No | Rechazo; el propietario elimina al miembro. |
channel_update | Propietario → todos los miembros | Sí | Difusión de cambio de miembros/nombre. |
channel_kick | Propietario → par expulsado | Sí | Expulsión dirigida; también difundida al borrar el canal. |
channel_leave | Miembro → propietario | No | Aviso 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).
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}
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:
LanTransport— primera opción. Usa OkHttp con un interceptor criptográfico ChaCha20 sobre HTTPS. Se omite completamente cuandopeer.ipestá vacío (par entre subredes que aún no hemos descubierto).WifiAwareTransport(solo Android 13+) — usa rutas de datos Wi-Fi Aware (NAN). Salto rápido cuando la banderaawareRunningdel par es falsa (refrescada por el escaneo precalentador BLE). El IPv6 del par se resuelve vía un DNS personalizado que mapea el hostnameplain-aware-peera la dirección link-local.BleTransport— fallback garantizado para cualquier par emparejado. Hace streaming de RPC fragmentado sobre GATT. Más lento pero funciona sin ninguna conectividad IP.
¿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
buildLinkde 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 puedesrequestNetwork.»
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.
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:
¿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.
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ón | Dónde | Por qué |
|---|---|---|
| Fachada | ChatManager | Punto de entrada único; los llamadores nunca tocan BD/transporte directamente. |
| Estrategia + Cadena de resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Transportes conectables con TransportUnavailable como señal de caída. |
| Disyuntor | PeerCircuitBreaker | 2 fallos / 30 s abre una pata (par, transporte) para que Wi-Fi Aware no bloquee el fallback. |
| Máquina de estados | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Transiciones explícitas para el ciclo de vida del socket y el ciclo de vida del enlace NDP. |
| Productor/Consumidor + Pool | DownloadQueue (3 workers, Channel.BUFFERED) | Concurrencia acotada para descargas de archivos. |
| Observador / Reactivo | StateFlow en todas partes | Compose recolecta directamente; sin refresco manual. |
| Protección contra reproducción | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Descartar duplicados de la entrega dual LAN+BLE; rechazar marcas de tiempo fuera de ventana. |
| Backoff exponencial | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — topa en 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Fuerza a StateFlow.distinctUntilChanged a dispararse en cada mutación. |
| Sobre firmado | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — vincula la marca de tiempo al cuerpo para prevenir reproducción. |
| División de rol determinista | TempData.clientId < peer.id | Decide cliente vs servidor WebSocket, y suscriptor vs publicador Wi-Fi Aware. |
| Hidratación perezosa | ensureChannelPeer al invitar/actualizar | Crea filas peers para miembros de canal no vistos para que el enrutamiento de distribución funcione. |
| Identidad cifrada | LANDiscoverManager.discoverSpecificDevice | El 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.shyapitest/groups/chat-channels.sh— plan de pruebas ejecutable que ejercita cada mutación GraphQL de punta a punta.