Para a arquitetura de chat mais ampla que consome este transporte, consulte Arquitetura de Chat. Para entender como dois dispositivos obtêm a chave ChaCha20 compartilhada usada para criptografar cada payload BLE, consulte Fluxo de Pareamento.
Sumário
- Por que um Transporte BLE?
- Layout do Serviço GATT
- Identificação de Pares: shortId, não MAC
- Design de Fragmentação em Duas Camadas
- A Primitiva RPC:
BleDeviceApi.requestAsync - Formato do Envelope do Protocolo
- Caminho de Envio de Mensagens (Ponta a Ponta)
- Caminho de Download de Arquivos (Ponta a Ponta)
- Priorização: Como o Chat Vence os Arquivos na Prática
- Controle de Concorrência e a Fila GATT Estática
- Ciclo de Vida da Conexão e Negociação de MTU
- Controle de Fluxo para Notificações
- Tratamento de Erros: TransportUnavailable vs Falha Real
- Referência de Constantes-Chave
- Recapitulação das Trocas de Design
Por que um Transporte BLE? {#why-a-ble-transport-at-all}
O PlainApp é serverless e offline-first. A camada de transporte é uma
cadeia de fallback ordenada: LAN → Wi-Fi Aware → BLE. A LAN é o caminho
feliz (HTTPS sobre Wi-Fi, ~10 ms de ida e volta). O Wi-Fi Aware cobre pares em
sub-redes diferentes (SSIDs diferentes, VLAN de visitantes vs IoT). Ambos
exigem conectividade IP de algum tipo. O BLE é o único transporte que
funciona:
- Quando os dispositivos não estão na mesma rede IP.
- Quando o Wi-Fi está desligado ou em modo avião (o rádio do BLE é separado).
- Quando o Wi-Fi Aware não é suportado (Android < 13, todas as variantes iOS do PlainApp).
O BLE é lento — dezenas de KB/s, segundos de latência por requisição — mas é
garantido para qualquer par pareado, porque a única coisa de que precisa é
do clientId do par, que é sempre broadcastado na scan response do BLE.
Layout do Serviço GATT {#gatt-service-layout}
O PlainApp anuncia um único serviço GATT customizado com duas
características. Não há UUID de 16 bits registrado — o serviço usa um UUID de
128 bits cujos bytes finais decodificam em ASCII para plpai\x01:
Por que duas características?
Os dois protocolos têm modelos de confiança e formatos de payload completamente diferentes:
- NEARBY carrega mensagens de pareamento. Elas chegam antes de o par estar pareado (ainda sem chave compartilhada), então usam seus próprios payloads JSON assinados com Ed25519, com seu próprio roteamento por prefixo. O corpo é uma string simples.
- HTTP carrega todo o tráfego pós-pareamento (chat, arquivos, presença).
É sempre criptografado com ChaCha20 usando a chave compartilhada e usa o
mesmo
HttpRouteRegistrydo servidor Ktor da LAN, então os handlers de rota (/peer_graphql,/fs,/peer_status) são escritos uma vez e reutilizados para ambos os transportes.
Por que notificações em vez de leituras?
O protocolo BLE ATT limita uma única leitura de atributo a 512 bytes. Uma
resposta GraphQL ou um chunk de arquivo de 16 KB pode ser bem maior. O
PlainApp contorna isso nunca usando readCharacteristic para dados reais
— o onCharacteristicReadRequest do servidor retorna um payload vazio com
GATT_SUCCESS. Em vez disso, o cliente grava sua requisição na
característica, e o servidor responde enviando uma sequência de notificações
fragmentadas que o cliente remonta. Isso está documentado em
BleDeviceApi.requestAsync, BleServerProtocol.handleWrite e
AndroidBleGattServer.sendChunkedResponse.
Identificação de Pares: shortId, não MAC {#peer-identification-shortid-not-mac}
Os pacotes de advertising do BLE são minúsculos (31 bytes) e o endereço MAC do
BLE é randomizado pelo Android a cada ~15 minutos — então não pode ser
usado como identificador estável. O PlainApp, em vez disso, transmite um
payload serviceData de 9 bytes na scan response:
Por que um hash truncado em vez do clientId completo?
Um clientId de 13 caracteres caberia em 13 bytes, mas o PlainApp opta por um SHA-256 truncado de 8 bytes por dois motivos:
- Orçamento de bytes estável. 9 bytes no total cabem confortavelmente no payload de advertising de 31 bytes ao lado do UUID do serviço (16 bytes), dos campos de length e de type (~27 bytes usados, 4 bytes de folga).
- Privacidade. Um observador passivo escaneando o BLE não consegue recuperar o clientId a partir do shortId (o prefixo de 8 bytes de um hash SHA-256 é, na prática, irreversível). Ele só consegue reconhecer um par que já tenha visto anunciar o mesmo shortId — não consegue enumerar usuários do PlainApp.
O clientId completo só é revelado a um par que efetivamente tenha conectado
via GATT e trocado um DDiscoverReply — ou seja, um par com o qual o usuário
já escolheu interagir.
Design de Fragmentação em Duas Camadas {#two-layer-chunking-design}
Esta é a parte mais sutil do transporte BLE, e é essencial entender ambas as camadas porque elas têm tamanhos e propósitos completamente diferentes:
Por que 380 caracteres?
O ATT MTU negociado é de 517 bytes no Android (requestMtu(517) — o máximo
permitido pela especificação BLE) e ~185+ no iOS (auto-negociado pela
CoreBluetooth). Subtraindo o cabeçalho ATT (~3 bytes) e o overhead do wrapper
JSON de BleSegmentData ({"d":"...","s":N} adiciona ~12 bytes), 380
caracteres de payload cabem confortavelmente dentro de um único ATT MTU nas
duas plataformas. O valor é simétrico (tanto fragmentos de requisição do
cliente quanto fragmentos de notificação do servidor usam 380), o que mantém o
código simples.
Por que 16 KiB para chunks de arquivo?
Um chunk de arquivo de 16 KiB codificado em base64 vira ~22 KiB de JSON, que
se fragmenta em ~58 segmentos de notificação GATT. Cada ida e volta de
requestAsync leva segundos via BLE, então chunks menores em maior quantidade
reduzem o overhead por chunk. Ir muito além disso arriscaria atingir timeouts
de RPC do BLE e produzir feedback de progresso ruim (o usuário vê o progresso
atualizar apenas uma vez por chunk). 16 KiB é o ponto ótimo empiricamente
ajustado — grande o bastante para vazão, pequeno o bastante para uma UI de
progresso responsiva.
A Primitiva RPC: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}
Cada mensagem de chat via BLE e cada chunk de arquivo é uma chamada a
BleDeviceApi.requestAsync(service, requestData) — uma suspend function que
retorna um BleResult. É síncrona do ponto de vista do chamador: uma
requisição → uma resposta totalmente remontada, sem pipelining.
Invariantes chave
- Uma requisição → uma resposta.
requestAsyncé síncrona do ponto de vista do chamador — só retorna depois que a resposta completa foi remontada. Não há pipelining. - Notificações habilitadas por chamada. O cliente grava o CCCD no início
de cada
requestAsynce o desabilita ao final. Isso é desperdiçador (duas gravações GATT extras por chamada), mas mantém o protocolo stateless — o servidor não precisa rastrear quais clientes estão "escutando". - Sem retry dentro de um RPC. Se algum
writeCharacteristicindividual der timeout (5 s), o RPC inteiro é abortado. ApenasensureConnectedfaz retry (3 tentativas em caso de falha de conexão). Backoff grosseiro em nível de transporte é fornecido porPeerCircuitBreaker, não pela camada de RPC.
Formato do Envelope do Protocolo {#wire-envelope-format}
O payload dentro dos segmentos da Camada A é um envelope JSON aninhado. Removendo a fragmentação, a estrutura lógica é:
Formato da resposta
A resposta flui na direção oposta pela mesma fragmentação da Camada A, mas o
JSON interno é um BleHttpResponse com três campos: s (código de status
HTTP), h (mapa de cabeçalhos da resposta) e b (corpo). O corpo é sempre
codificado em base64 por BleHttpCall.encodeResponse(), mesmo quando
vazio — a resposta pode ser binária (bytes GraphQL criptografados, bytes de
arquivo /fs brutos) e o transporte BLE é apenas para strings, então o mesmo
envelope JSON carrega tanto payloads de texto quanto binários.
Caminho de Envio de Mensagens (Ponta a Ponta) {#message-send-path-end-to-end}
Juntando tudo — o que acontece quando uma mensagem de chat é enviada via BLE:
Escolhas de design notáveis
- Mesma chave da LAN. A chave ChaCha20 compartilhada do pareamento é
reutilizada para o BLE — não há uma chave BLE separada. O interceptador de
crypto do OkHttp usado pelo
LanTransporte ochaCha20Encrypt/chaCha20Decryptmanual noBleTransportsão a mesma primitiva, apenas invocados de forma diferente. - Mesmos handlers de rota da LAN. O
BleHttpRequesté despachado viaHttpRouteRegistry.matchRoute(path), que é o mesmo registro usado pelo servidor Ktor da LAN. Então/peer_graphql,/fs,/peer_statusetc. são implementados exatamente uma vez e funcionam de forma idêntica em ambos os transportes. - Sem reuso de conexão. O bloco
finally { scanner.teardownConnection(client) }sempre roda. Cada mensagem paga o custo total de connect→discoverServices→MTU (~segundos). Essa é uma troca deliberada — consulte Trocas de Design.
Caminho de Download de Arquivos (Ponta a Ponta) {#file-download-path-end-to-end}
Downloads via BLE são streaming — o arquivo é lido em chunks de 16 KiB e
gravado em um arquivo temporário conforme chega, então um arquivo de 10 MB não
precisa de 10 MB de RAM. O truque é que o RPC de cada chunk é uma chamada
requestAsync separada, e os chunks são empurrados para um ByteChannel
que o consumidor lê concorrentemente.
Por que streaming em vez de um único RPC grande?
Um arquivo de 10 MB enviado como um único RPC significaria ~280 000 segmentos de notificação, todos retidos em memória em ambos os lados antes que a resposta pudesse sequer começar — e a transferência inteira teria de ser bem-sucedida antes que qualquer progresso fosse reportado. Pior, uma única notificação perdida no meio corromperia tudo.
O design fragmentado tem três vitórias:
- Memória constante. Apenas um chunk de 16 KiB está em trânsito por vez.
- Progresso ao vivo.
DownloadQueue.notifyProgressUpdate()dispara a cada segundo, e a UI mostra uma barra de download. - Resiliência. Um chunk falhado pode ser repetido independentemente (o
DownloadQueuesuporta pause/resume/retry em nível de tarefa; uma falha no meio do stream deixa o arquivo temporário parcial, embora atualmente o downloader o exclua em caso de falha — consulte as trocas).
Por que onClose cancela o job de download
O callback DownloadedResponse.onClose chama downloadJob.cancel(). Isso é
essencial porque o loop de download roda em uma corrotina filha que, de outra
forma, continuaria rodando para sempre se o consumidor abandonasse o canal
cedo (por exemplo, o usuário tocou em Pausar). O contrato AutoCloseable
em DownloadedResponse significa que o bloco use { ... } do consumidor
invoca onClose automaticamente na saída, cancelando a corrotina de download
do BLE e encerrando a conexão GATT no bloco finally da corrotina.
Priorização: Como o Chat Vence os Arquivos na Prática {#prioritization-how-chat-beats-files-in-practice}
Esta é a pergunta mais importante para qualquer aplicativo de chat: quando um download lento de arquivo via BLE está em andamento, uma nova mensagem de chat pode ultrapassá-lo?
A resposta honesta: não há esquema explícito de prioridade
Não há campo de prioridade, nem fila de prioridade, nem preempção em
nenhum lugar do código do BLE ou da fila de download. Verifiquei isso por
grep exaustivo — as únicas ocorrências de priority em shared/src são
níveis de prioridade de log e metadados EXIF, nada relacionado a ordenação de
mensagem vs download.
O que existe, em vez disso, é um conjunto de separações arquiteturais que produzem o comportamento desejado como propriedade emergente:
Por que funciona na prática
A separação que faz o chat "parecer priorizado" é estrutural:
- Envios de chat não passam pelo
DownloadQueue. Eles são emitidos diretamente porPeerGraphQLClient→PeerTransportRouter→BleTransport.send. Então uma mensagem de chat nunca fica atrás de uma fila de downloads de arquivos. - Cada chamada do
BleTransportabre sua própria conexão GATT. Um download demorado segurando uma conexão não impede que um envio de chat abra uma segunda conexão para o mesmo par. O Android suporta múltiplas conexões GATT simultâneas. - RPCs de chat são curtos. Uma única mensagem de chat é uma ida e volta
de
requestAsync(~1 s após conectar). Mesmo que o rádio esteja ocupado com um download, o envio do chat completa em poucos segundos.
Onde o design fica aquém
As trocas de "nenhuma prioridade explícita":
- Latência de conexão. Tanto chat quanto download pagam o custo de connect→discover→MTU (~segundos) toda vez, porque as conexões não são reutilizadas. Uma mensagem de chat chegando durante um download não pode pegar carona na conexão existente do download — ela abre uma nova.
- Fila estática no Android. A
operationQueuede processo inteiro noAndroidBleGattClientserializa operações GATT entre todos os pares e todas as conexões. Então, enquanto duas conexões GATT podem coexistir, suas operações de write/read/notify são intercaladas em nível de fila. Na prática isso é fine (cada op é ~ms), mas é um sutil gargalo global sob alta concorrência. - Sem preempção. Um download em andamento não pode ser pausado para deixar uma mensagem de chat passar. O envio do chat simplesmente roda concorrentemente e compete por tempo de rádio.
Uma melhoria futura poderia ser um Mutex por par em volta de
BleTransport.send e downloadFile, além de um campo de prioridade na fila —
mas o design atual conta com o fato de que RPCs de chat são curtos o bastante
para que a contenção raramente seja visível ao usuário.
Controle de Concorrência e a Fila GATT Estática {#concurrency-control--the-static-gatt-queue}
Isso merece sua própria seção porque é o aspecto mais sutil da implementação BLE no Android.
Por que estática (de processo inteiro)?
A stack BLE do Android não permite operações GATT concorrentes em uma única
instância de BluetoothGatt — chamar writeCharacteristic enquanto outra
gravação está em trânsito retorna false e silenciosamente descarta a segunda
gravação. A solução padrão é uma fila por BluetoothGatt. O PlainApp vai um
passo além e usa uma fila de processo inteiro (no companion object), o
que é excessivamente conservador, mas correto: garante que nenhuma duas
operações GATT em qualquer lugar do app rodem simultaneamente.
O custo é que as operações de write/read/notify de um download longo de arquivo via BLE ficam atrás (e deixam atrás) as operações GATT de qualquer outro par. Como cada op individual é ~ms, isso raramente é um gargalo visível ao usuário — mas sob tráfego BLE concorrente pesado para múltiplos pares, pode se tornar um.
Sem lock por par na camada de transporte
BleDeviceApi.requestAsync é uma suspend fun comum, sem mutex, sem fila,
sem serialização por par. Duas chamadas concorrentes a BleTransport.send
para o mesmo par abrirão cada uma sua própria conexão GATT e prosseguirão
independentemente. A serialização acontece implicitamente em nível de
operação GATT (via a fila estática no Android, ou via await sequencial no
iOS).
Ciclo de Vida da Conexão e Negociação de MTU {#connection-lifecycle--mtu-negotiation}
Por que requestMtu(517)?
O ATT MTU padrão é de 23 bytes (apenas 20 bytes de payload após o cabeçalho ATT de 3 bytes). Com o MTU padrão, cada segmento de 380 caracteres exigiria ~19 gravações GATT em vez de 1 — uma desaceleração de 19×. Solicitar o MTU máximo permitido pela especificação BLE (517 bytes) permite que os segmentos de 380 caracteres caibam em uma única operação ATT, melhorando drasticamente a vazão.
O iOS não expõe uma API explícita de requisição de MTU — a CoreBluetooth o negocia automaticamente com o periférico durante a conexão. Dispositivos iOS modernos tipicamente negociam ~185 bytes, o que ainda acomoda confortavelmente os segmentos de 380 caracteres (após subtrair o cabeçalho ATT e o overhead do wrapper JSON).
Controle de Fluxo para Notificações {#flow-control-for-notifications}
O servidor envia fragmentos de resposta como notificações, mas as notificações BLE não têm controle de fluxo embutido — se o servidor enviar notificações mais rápido do que o controlador consegue transmiti-las, elas são silenciosamente descartadas. O PlainApp implementa controle de fluxo explícito baseado em ack:
Sem esse controle de fluxo, notificações consecutivas seriam silenciosamente
descartadas pelo controlador BLE quando sua fila interna de envio enchesse —
um problema bem conhecido do BLE no Android, documentado nos comentários da
interface BleGattServer. A regra de um-em-trânsito por dispositivo garante
que toda notificação ou é transmitida ou dispara um timeout (que então é
tratado como falha de transporte).
Tratamento de Erros: TransportUnavailable vs Falha Real {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable é o sinal que diz ao PeerTransportRouter para cair
para o próximo transporte. Qualquer outra coisa é uma falha real retornada
ao chamador.
A sutileza da falha de download
BleTransport.downloadFile retorna DownloadedResponse(200, channel, onClose)imediatamente — o loop de download fragmentado roda em uma corrotina em
background que grava no canal. Se um RPC de chunk falhar no meio do stream, o
loop chama channel.close(TransportUnavailable(...)), o que significa que o
consumidor (PeerFileDownloader.downloadAsync) vê o erro como uma exceção
lançada a partir de channel.readAvailable(buf).
Isso significa que a chamada PeerTransportRouter.downloadFile em si
bem-sucedida (retornou um DownloadedResponse), então o circuit breaker
não registra uma falha para erros de download no meio do stream. Apenas
falhas em tempo de conexão e em tempo de scan são capturadas pelo router. Essa
é uma escolha de design deliberada — uma falha no meio do stream não deveria
desabilitar permanentemente o BLE para aquele par (o par pode apenas ter saído
de alcance temporariamente).
Referência de Constantes-Chave {#key-constants-reference}
| Constante | Valor | Onde | Propósito |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | Fragmentação de segmento GATT | Tamanho de cada BleSegmentData.data (cabe no ATT MTU após overhead JSON) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Intervalo de bytes do download | Tamanho de cada requisição de chunk /fs |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | Scan BLE | Timeout para scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | Resposta RPC | Espera por notificação em requestAsync |
AndroidBleGattClient MTU | 517 | Setup de conexão | requestMtu(517) — máximo permitido pela spec BLE |
AndroidBleGattClient connect timeout | 10 000 | Setup de conexão | Espera por STATE_CONNECTED |
AndroidBleGattClient MTU timeout | 5 000 | Setup de conexão | Espera por onMtuChanged |
AndroidBleGattClient write timeout | 5 000 | Gravação GATT | Espera por onCharacteristicWrite |
AndroidBleGattClient read timeout | 10 000 | Leitura GATT | Espera por onCharacteristicRead (não usado para dados reais) |
AndroidBleGattClient notify-state timeout | 5 000 | Gravação CCCD | Espera por gravação do descritor CCCD |
ensureConnected retries | 3 | Setup de conexão | Até 4 tentativas totais (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Controle de fluxo de notificação | Espera por onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Fragmentação de resposta | Mesmo que BleDeviceApi.CHUNK_SIZE |
IosBleGattServer retry cap | 10 | Controle de fluxo de notificação | Máximo de retries de updateValue antes de desistir |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Circuit breaker de transporte | Duração aberto após threshold |
PeerCircuitBreaker.MAX_FAILURES | 2 | Circuit breaker de transporte | Falhas dentro da janela para abrir |
DownloadQueue.MAX_CONCURRENT | 3 | Pool de workers de download | Corrotinas de download concorrentes |
BleServiceData.SHORT_ID_BYTES | 8 | Identificação de par | Bytes do prefixo SHA256 truncado |
BleServiceData.PAYLOAD_BYTES | 9 | Identificação de par | 1 byte de flags + 8 bytes de shortId |
BleSegmentData.STATE_START_BIT | 1 | Sinalização de EOF da Camada A | Primeiro segmento de uma mensagem multi-segmento |
BleSegmentData.STATE_END_BIT | 2 | Sinalização de EOF da Camada A | Último segmento (ou segmento único) |
Recapitulação das Trocas de Design {#design-trade-offs-recap}
Leitura Adicional
- Arquitetura de Chat — como o
BleTransportse encaixa na cadeia de fallbackLAN → Aware → BLEe no pipeline mais amplo de envio/recebimento de chat. - Fluxo de Pareamento — como a chave ChaCha20 compartilhada usada por cada payload BLE é estabelecida e como a característica NEARBY é usada para o handshake de pareamento.