Para la arquitectura de chat más amplia que consume este transporte, consulte Arquitectura de Chat. Para saber cómo dos dispositivos obtienen la clave ChaCha20 compartida utilizada para cifrar cada carga útil BLE, consulte Flujo de emparejamiento.
Tabla de contenidos
- ¿Por qué un transporte BLE?
- Disposición del servicio GATT
- Identificación de pares: shortId, no MAC
- Diseño de fragmentación de dos capas
- La primitiva RPC:
BleDeviceApi.requestAsync - Formato del sobre de transmisión
- Ruta de envío de mensajes (de punta a punta)
- Ruta de descarga de archivos (de punta a punta)
- Priorización: cómo el chat vence a los archivos en la práctica
- Control de concurrencia y la cola GATT estática
- Ciclo de vida de la conexión y negociación MTU
- Control de flujo para notificaciones
- Gestión de errores: TransportUnavailable frente a fallo real
- Referencia de constantes clave
- Resumen de contrapartidas de diseño
¿Por qué un transporte BLE? {#why-a-ble-transport-at-all}
PlainApp es serverless y offline-first. La capa de transporte es una
cadena ordenada de fallback: LAN → Wi-Fi Aware → BLE. La LAN es el camino
feliz (HTTPS sobre Wi-Fi, ~10 ms de ida y vuelta). Wi-Fi Aware cubre pares
entre subredes (distintos SSID, VLAN de invitados vs IoT). Ambos requieren
algún tipo de conectividad IP. BLE es el único transporte que funciona:
- Cuando los dispositivos no están en la misma red IP en absoluto.
- Cuando el Wi-Fi está apagado o en modo avión (la radio BLE es independiente).
- Cuando Wi-Fi Aware no es compatible (Android < 13, todas las variantes iOS de PlainApp).
BLE es lento — decenas de KB/s, segundos de latencia por solicitud — pero está
garantizado para cualquier par emparejado, porque lo único que necesita es
el clientId del par, que siempre se difunde en la scan response de BLE.
Disposición del servicio GATT {#gatt-service-layout}
PlainApp anuncia un único servicio GATT personalizado con dos
características. No hay UUID de 16 bits registrado — el servicio usa un UUID
de 128 bits cuyos bytes finales se decodifican en ASCII como plpai\x01:
¿Por qué dos características?
Los dos protocolos tienen modelos de confianza y formas de carga útil completamente distintos:
- NEARBY transporta mensajes de emparejamiento. Llegan antes de que el par esté emparejado (aún no hay clave compartida), así que usan sus propias cargas útiles JSON firmadas con Ed25519 con su propio enrutamiento por prefijo. El cuerpo es una cadena simple.
- HTTP transporta todo el tráfico posterior al emparejamiento (chat,
archivos, presencia). Siempre está cifrado con ChaCha20 usando la clave
compartida y utiliza el mismo
HttpRouteRegistryque el servidor Ktor de LAN, de modo que los manejadores de ruta (/peer_graphql,/fs,/peer_status) se escriben una vez y se reutilizan para ambos transportes.
¿Por qué notificaciones en vez de lecturas?
El protocolo BLE ATT limita una lectura de atributo único a 512 bytes.
Una respuesta GraphQL o un fragmento de archivo de 16 KB puede ser mucho más
grande. PlainApp lo resuelve nunca usando readCharacteristic para datos
reales — el onCharacteristicReadRequest del servidor devuelve una carga
útil vacía con GATT_SUCCESS. En su lugar, el cliente escribe su solicitud
en la característica, y el servidor responde enviando una secuencia de
notificaciones fragmentadas que el cliente reensambla. Esto está
documentado en BleDeviceApi.requestAsync, BleServerProtocol.handleWrite y
AndroidBleGattServer.sendChunkedResponse.
Identificación de pares: shortId, no MAC {#peer-identification-shortid-not-mac}
Los paquetes de publicidad BLE son diminutos (31 bytes) y la dirección MAC
BLE es aleatorizada por Android cada ~15 minutos — así que no puede
usarse como identificador estable. PlainApp en su lugar difunde una carga
útil serviceData de 9 bytes en la scan response:
¿Por qué un hash truncado en vez del clientId completo?
Un clientId de 13 caracteres cabría en 13 bytes, pero PlainApp opta por un SHA-256 truncado de 8 bytes por dos razones:
- Presupuesto de bytes estable. 9 bytes en total caben cómodamente en la carga útil de publicidad de 31 bytes junto con el UUID del servicio (16 bytes), longitud y campos de tipo (~27 bytes usados, 4 bytes de margen).
- Privacidad. Un observador pasivo escaneando BLE no puede recuperar el clientId a partir del shortId (el prefijo de 8 bytes de un hash SHA-256 es irreversible en la práctica). Solo puede reconocer un par que ya haya visto anunciar el mismo shortId — no puede enumerar usuarios de PlainApp.
El clientId completo solo se revela a un par que se haya conectado
realmente por GATT e intercambiado un DDiscoverReply — es decir, un par con
el que el usuario ya ha elegido interactuar.
Diseño de fragmentación de dos capas {#two-layer-chunking-design}
Esta es la parte más sutil del transporte BLE, y es esencial entender ambas capas porque tienen tamaños y propósitos completamente distintos:
¿Por qué 380 caracteres?
El ATT MTU negociado es de 517 bytes en Android (requestMtu(517) — el
máximo permitido por la especificación BLE) y ~185+ en iOS (auto-negociado
por CoreBluetooth). Restando la cabecera ATT (~3 bytes) y la sobrecarga del
envoltorio JSON de BleSegmentData ({"d":"...","s":N} añade ~12 bytes),
380 caracteres de carga útil caben cómodamente dentro de un único ATT MTU en
ambas plataformas. El valor es simétrico (tanto los fragmentos de
solicitud del cliente como los fragmentos de notificación del servidor usan
380), lo que mantiene el código simple.
¿Por qué 16 KiB para los fragmentos de archivo?
Un fragmento de archivo de 16 KiB se codifica en base64 a ~22 KiB de JSON,
que se fragmenta en ~58 segmentos de notificación GATT. Cada ida y vuelta de
requestAsync lleva segundos por BLE, así que menos fragmentos pero más
grandes reducen la sobrecarga por fragmento. Ir mucho más grande
arriesgaría superar los tiempos de espera del RPC BLE y produciría mala
retroalimentación de progreso (el usuario ve el progreso actualizarse solo
una vez por fragmento). 16 KiB es el punto óptimo ajustado empíricamente —
suficientemente grande para rendimiento, suficientemente pequeño para una UI
de progreso receptiva.
La primitiva RPC: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}
Cada mensaje de chat BLE y cada fragmento de archivo es una llamada a
BleDeviceApi.requestAsync(service, requestData) — una función suspend que
devuelve un BleResult. Es síncrona desde la perspectiva del llamador:
una solicitud → una respuesta completamente reensamblada, sin
pipelining.
Invariantes clave
- Una solicitud → una respuesta.
requestAsynces síncrona desde la perspectiva del llamador — solo devuelve tras reensamblar la respuesta completa. No hay pipelining. - Notificaciones habilitadas por llamada. El cliente escribe el CCCD al
inicio de cada
requestAsyncy lo deshabilita al final. Esto es despilfarrador (dos escrituras GATT extra por llamada) pero mantiene el protocolo sin estado — el servidor no tiene que rastrear qué clientes están «escuchando». - Sin reintento dentro de un RPC. Si un único
writeCharacteristicagota el tiempo de espera (5 s), el RPC completo se aborta. SoloensureConnectedreintenta (3 intentos en fallo de conexión). El backoff grueso a nivel de transporte lo proporcionaPeerCircuitBreaker, no la capa RPC.
Formato del sobre de transmisión {#wire-envelope-format}
La carga útil dentro de los segmentos de la Capa A es un sobre JSON anidado. Despojando la fragmentación, la estructura lógica es:
Forma de la respuesta
La respuesta fluye en la dirección opuesta a través de la misma fragmentación
de la Capa A, pero el JSON interno es un BleHttpResponse con tres campos:
s (código de estado HTTP), h (mapa de cabeceras de respuesta) y b
(cuerpo). El cuerpo siempre está codificado en base64 por
BleHttpCall.encodeResponse(), incluso cuando está vacío — la respuesta
puede ser binaria (bytes GraphQL cifrados, bytes de archivo /fs en bruto) y
el transporte BLE es solo de cadenas, así que el mismo sobre JSON transporta
tanto cargas útiles de texto como binarias.
Ruta de envío de mensajes (de punta a punta) {#message-send-path-end-to-end}
Poniéndolo todo junto — qué ocurre cuando se envía un mensaje de chat por BLE:
Decisiones de diseño notables
- La misma clave que LAN. La clave compartida ChaCha20 del emparejamiento
se reutiliza para BLE — no hay una clave BLE separada. El interceptor
criptográfico OkHttp usado por
LanTransporty elchaCha20Encrypt/chaCha20Decryptmanual enBleTransportson la misma primitiva, solo invocados de forma distinta. - Los mismos manejadores de ruta que LAN.
BleHttpRequestse despacha a través deHttpRouteRegistry.matchRoute(path), que es el mismo registro que usa el servidor LAN Ktor. Así que/peer_graphql,/fs,/peer_statusetc. se implementan exactamente una vez y funcionan idénticamente sobre ambos transportes. - Sin reutilización de conexión. El bloque
finally { scanner.teardownConnection(client) }siempre se ejecuta. Cada mensaje paga el coste completo de connect→discoverServices→MTU (~segundos). Es una contrapartida deliberada — véase Contrapartidas de diseño.
Ruta de descarga de archivos (de punta a punta) {#file-download-path-end-to-end}
Las descargas por BLE son streaming — el archivo se lee en fragmentos de
16 KiB y se escribe en un archivo temporal a medida que llega, así que un
archivo de 10 MB no necesita 10 MB de RAM. El truco es que el RPC de cada
fragmento es una llamada separada a requestAsync, y los fragmentos se
empujan a un ByteChannel que el consumidor lee concurrentemente.
¿Por qué streaming en vez de un único RPC grande?
Un archivo de 10 MB enviado como un único RPC significaría ~280 000 segmentos de notificación, todos retenidos en memoria en ambos lados antes de que la respuesta pudiera siquiera empezar — y toda la transferencia tendría que tener éxito antes de informar de cualquier progreso. Peor, una única notificación perdida en el medio corrompería el conjunto entero.
El diseño fragmentado tiene tres ventajas:
- Memoria constante. Solo hay un fragmento de 16 KiB en vuelo a la vez.
- Progreso en vivo.
DownloadQueue.notifyProgressUpdate()se dispara cada segundo, y la UI muestra una barra de descarga. - Resiliencia. Un fragmento fallido puede reintentarse de forma
independiente (el
DownloadQueuesoporta pausa/reanudación/reintento a nivel de tarea; un fallo en medio del flujo deja el archivo temporal parcial, aunque actualmente el descargador lo borra en caso de fallo — véase contrapartidas).
Por qué onClose cancela el trabajo de descarga
El callback DownloadedResponse.onClose llama a
downloadJob.cancel(). Esto es esencial porque el bucle de descarga se
ejecuta en una corrutina hija que de otro modo seguiría ejecutándose para
siempre si el consumidor abandonara el canal prematuramente (p. ej. el
usuario pulsó Pausa). El contrato AutoCloseable en
DownloadedResponse significa que el bloque use { ... } del consumidor
invoca automáticamente onClose al salir, cancelando la corrutina de
descarga BLE y derribando la conexión GATT en el bloque finally de la
corrutina.
Priorización: cómo el chat vence a los archivos en la práctica {#prioritization-how-chat-beats-files-in-practice}
Esta es la pregunta más importante para cualquier aplicación de chat: cuando una descarga de archivo BLE lenta está en curso, ¿puede un nuevo mensaje de chat saltársela?
La respuesta honesta: no hay esquema de prioridad explícito
No hay campo de prioridad, ni cola de prioridad, ni apropiación en
absoluto en el código BLE ni en la cola de descarga. Lo verifiqué por grep
exhaustivo — las únicas coincidencias de priority en shared/src son
niveles de prioridad de log y metadatos EXIF, nada relacionado con el
orden mensajes-vs-descarga.
Lo que existe en su lugar es un conjunto de separaciones arquitectónicas que producen el comportamiento deseado como propiedad emergente:
Por qué funciona en la práctica
La separación que hace que el chat «se sienta priorizado» es estructural:
- Los envíos de chat no pasan por
DownloadQueue. Se emiten directamente porPeerGraphQLClient→PeerTransportRouter→BleTransport.send. Así que un mensaje de chat nunca se sienta detrás de una cola de descargas de archivos. - Cada llamada a
BleTransportabre su propia conexión GATT. Una descarga larga que mantiene una conexión no impide que un envío de chat abra una segunda conexión al mismo par. Android soporta múltiples conexiones GATT simultáneas. - Los RPC de chat son cortos. Un único mensaje de chat es un ida y
vuelta de
requestAsync(~1 s tras conectar). Incluso si la radio está ocupada con una descarga, el envío de chat se completa en unos pocos segundos.
Dónde el diseño falla
Las contrapartidas de «sin prioridad explícita»:
- Latencia de conexión. Tanto el chat como la descarga pagan el coste connect→discover→MTU (~segundos) cada vez, porque las conexiones no se reutilizan. Un mensaje de chat que llega durante una descarga no puede aprovechar la conexión existente de la descarga — abre una nueva.
- Cola estática en Android. La
operationQueuea nivel de proceso enAndroidBleGattClientserializa las operaciones GATT entre todos los pares y todas las conexiones. Así que aunque dos conexiones GATT pueden coexistir, sus operaciones write/read/notify se intercalan a nivel de cola. En la práctica esto está bien (cada op es ~ms) pero es un sutil cuello de botella global bajo alta concurrencia. - Sin apropiación. Una descarga en curso no puede pausarse para dejar pasar a un mensaje de chat. El envío de chat simplemente se ejecuta concurrentemente y compite por tiempo de radio.
Una mejora futura podría ser un Mutex por par alrededor de
BleTransport.send y downloadFile, más un campo de prioridad en la cola —
pero el diseño actual se apoya en el hecho de que los RPC de chat son lo
suficientemente cortos como para que la contención rara vez sea visible para
el usuario.
Control de concurrencia y la cola GATT estática {#concurrency-control--the-static-gatt-queue}
Esto merece su propia sección porque es el aspecto más sutil de la implementación BLE de Android.
¿Por qué estática (a nivel de proceso)?
La pila BLE de Android no permite operaciones GATT concurrentes en una
única instancia BluetoothGatt — llamar a writeCharacteristic mientras
otra escritura está en vuelo devuelve false y silenciosamente descarta la
segunda escritura. La solución estándar es una cola por BluetoothGatt.
PlainApp va un paso más allá y usa una cola a nivel de proceso (en el
companion object), lo cual es excesivamente conservador pero correcto:
garantiza que ninguna dos operaciones GATT en ninguna parte de la
aplicación se ejecuten simultáneamente.
El coste es que las operaciones write/read/notify de una larga descarga BLE de archivos se encolan detrás (y son encoladas detrás de) las operaciones GATT de cualquier otro par. Dado que cada op individual es ~ms, rara vez es un cuello de botella visible para el usuario — pero bajo tráfico BLE concurrente pesado hacia múltiples pares, podría convertirse en uno.
Sin bloqueo por par en la capa de transporte
BleDeviceApi.requestAsync es una suspend fun simple sin mutex, sin
cola, sin serialización por par. Dos llamadas concurrentes a
BleTransport.send para el mismo par abrirán cada una su propia conexión
GATT y procederán de forma independiente. La serialización ocurre
implícitamente a nivel de operación GATT (vía la cola estática en Android, o
vía await secuencial en iOS).
Ciclo de vida de la conexión y negociación MTU {#connection-lifecycle--mtu-negotiation}
¿Por qué requestMtu(517)?
El ATT MTU por defecto es de 23 bytes (solo 20 bytes de carga útil tras la cabecera ATT de 3 bytes). Con el MTU por defecto, cada segmento de 380 caracteres requeriría ~19 escrituras GATT en vez de 1 — una ralentización de 19×. Solicitar el MTU máximo permitido por la especificación BLE (517 bytes) permite que los segmentos de 380 caracteres quepan en una única operación ATT, mejorando drásticamente el rendimiento.
iOS no expone una API explícita de solicitud de MTU — CoreBluetooth lo negocia automáticamente con el periférico durante la conexión. Los dispositivos iOS modernos típicamente negocian ~185 bytes, lo que todavía cabe cómodamente los segmentos de 380 caracteres (tras restar la cabecera ATT + la sobrecarga del envoltorio JSON).
Control de flujo para notificaciones {#flow-control-for-notifications}
El servidor envía fragmentos de respuesta como notificaciones, pero las notificaciones BLE no tienen control de flujo integrado — si el servidor envía notificaciones más rápido de lo que el controlador puede transmitirlas, se descartan silenciosamente. PlainApp implementa control de flujo explícito basado en ack:
Sin este control de flujo, las notificaciones consecutivas serían
descartadas silenciosamente por el controlador BLE cuando su cola interna de
envío se llenase — un problema bien conocido de BLE en Android documentado
en los comentarios de la interfaz BleGattServer. La regla de un único
envío en vuelo por dispositivo garantiza que cada notificación se transmite
o dispara un tiempo de espera (que luego se trata como fallo de transporte).
Gestión de errores: TransportUnavailable frente a fallo real {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable es la señal que indica a PeerTransportRouter que
caiga al siguiente transporte. Cualquier otra cosa es un fallo real
devuelto al llamador.
La sutileza del fallo de descarga
BleTransport.downloadFile devuelve
DownloadedResponse(200, channel, onClose) inmediatamente — el bucle de
descarga fragmentada se ejecuta en una corrutina en segundo plano que escribe
en el canal. Si un RPC de fragmento falla a mitad de flujo, el bucle llama a
channel.close(TransportUnavailable(...)), lo que significa que el
consumidor (PeerFileDownloader.downloadAsync) ve el error como una
excepción lanzada desde channel.readAvailable(buf).
Esto significa que la propia llamada PeerTransportRouter.downloadFile tuvo
éxito (devolvió un DownloadedResponse), así que el disyuntor no registra
un fallo para errores de descarga a mitad de flujo. Solo los fallos a nivel
de conexión y de escaneo son capturados por el enrutador. Es una decisión de
diseño deliberada — un fallo a mitad de flujo no debería deshabilitar BLE
permanentemente para ese par (el par puede simplemente haberse salido del
alcance temporalmente).
Referencia de constantes clave {#key-constants-reference}
| Constante | Valor | Dónde | Propósito |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | Fragmentación de segmento GATT | Tamaño de cada BleSegmentData.data (cabe dentro del ATT MTU tras la sobrecarga JSON) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Rango de bytes de descarga de archivos | Tamaño de cada solicitud de fragmento /fs |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | Escaneo BLE | Tiempo de espera para scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | Respuesta RPC | Espera por notificación en requestAsync |
AndroidBleGattClient MTU | 517 | Configuración de conexión | requestMtu(517) — máximo permitido por la especificación BLE |
AndroidBleGattClient connect timeout | 10 000 | Configuración de conexión | Espera de STATE_CONNECTED |
AndroidBleGattClient MTU timeout | 5 000 | Configuración de conexión | Espera de onMtuChanged |
AndroidBleGattClient write timeout | 5 000 | Escritura GATT | Espera de onCharacteristicWrite |
AndroidBleGattClient read timeout | 10 000 | Lectura GATT | Espera de onCharacteristicRead (sin uso para datos reales) |
AndroidBleGattClient notify-state timeout | 5 000 | Escritura CCCD | Espera de escritura del descriptor CCCD |
ensureConnected retries | 3 | Configuración de conexión | Hasta 4 intentos totales (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Control de flujo de notificación | Espera de onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Fragmentación de respuesta | Igual que BleDeviceApi.CHUNK_SIZE |
IosBleGattServer retry cap | 10 | Control de flujo de notificación | Máx. reintentos de updateValue antes de rendirse |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Disyuntor de transporte | Duración abierta tras el umbral |
PeerCircuitBreaker.MAX_FAILURES | 2 | Disyuntor de transporte | Fallos dentro de la ventana para abrir |
DownloadQueue.MAX_CONCURRENT | 3 | Pool de workers de descarga | Corrutinas de descarga concurrentes |
BleServiceData.SHORT_ID_BYTES | 8 | Identificación de par | Bytes del prefijo SHA256 truncado |
BleServiceData.PAYLOAD_BYTES | 9 | Identificación de par | 1 byte de banderas + 8 bytes shortId |
BleSegmentData.STATE_START_BIT | 1 | Señalización EOF Capa A | Primer segmento de un mensaje multi-segmento |
BleSegmentData.STATE_END_BIT | 2 | Señalización EOF Capa A | Último segmento (o segmento único) |
Resumen de contrapartidas de diseño {#design-trade-offs-recap}
Lecturas adicionales
- Arquitectura de Chat — cómo encaja
BleTransporten la cadena de fallbackLAN → Aware → BLEy el pipeline más amplio de envío/recepción de chat. - Flujo de emparejamiento — cómo se establece la clave ChaCha20 compartida usada por cada carga útil BLE, y cómo se usa la característica NEARBY para el apretón de manos de emparejamiento.