Voltar ao blog
Transport15 min read

Design do Transporte BLE — Mensagens e Downloads de Arquivos

Este artigo explica como o PlainApp envia mensagens de chat e baixa arquivos via Bluetooth Low Energy quando nem a LAN nem o Wi-Fi Aware estão disponíveis. O BLE é o fallback garantido: lento, mas funciona sem qualquer conectividade IP. O artigo cobre o formato do protocolo, o design de fragmentação em duas camadas, como o tráfego concorrente é (e não é) priorizado e por que toda conexão é encerrada após cada requisição.

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? {#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.

Diagram 1
1

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:

Diagram 2
2

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 HttpRouteRegistry do 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:

Diagram 3
3

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:

  1. 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).
  2. 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:

Diagram 4
4

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.

Diagram 5
5

Invariantes chave

  1. 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.
  2. Notificações habilitadas por chamada. O cliente grava o CCCD no início de cada requestAsync e 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".
  3. Sem retry dentro de um RPC. Se algum writeCharacteristic individual der timeout (5 s), o RPC inteiro é abortado. Apenas ensureConnected faz retry (3 tentativas em caso de falha de conexão). Backoff grosseiro em nível de transporte é fornecido por PeerCircuitBreaker, 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 é:

Diagram 6
6

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:

Diagram 7
7

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 LanTransport e o chaCha20Encrypt/ chaCha20Decrypt manual no BleTransport são a mesma primitiva, apenas invocados de forma diferente.
  • Mesmos handlers de rota da LAN. O BleHttpRequest é despachado via HttpRouteRegistry.matchRoute(path), que é o mesmo registro usado pelo servidor Ktor da LAN. Então /peer_graphql, /fs, /peer_status etc. 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.

Diagram 8
8

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:

  1. Memória constante. Apenas um chunk de 16 KiB está em trânsito por vez.
  2. Progresso ao vivo. DownloadQueue.notifyProgressUpdate() dispara a cada segundo, e a UI mostra uma barra de download.
  3. Resiliência. Um chunk falhado pode ser repetido independentemente (o DownloadQueue suporta 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:

Diagram 9
9

Por que funciona na prática

A separação que faz o chat "parecer priorizado" é estrutural:

  1. Envios de chat não passam pelo DownloadQueue. Eles são emitidos diretamente por PeerGraphQLClientPeerTransportRouterBleTransport.send. Então uma mensagem de chat nunca fica atrás de uma fila de downloads de arquivos.
  2. Cada chamada do BleTransport abre 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.
  3. 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 operationQueue de processo inteiro no AndroidBleGattClient serializa 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.

Diagram 10
10

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}

Diagram 11
11

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:

Diagram 12
12

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.

Diagram 13
13

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}

ConstanteValorOndePropósito
BleDeviceApi.CHUNK_SIZE380Fragmentação de segmento GATTTamanho de cada BleSegmentData.data (cabe no ATT MTU após overhead JSON)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Intervalo de bytes do downloadTamanho de cada requisição de chunk /fs
BleTransport.SCAN_TIMEOUT_MS10 000Scan BLETimeout para scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000Resposta RPCEspera por notificação em requestAsync
AndroidBleGattClient MTU517Setup de conexãorequestMtu(517) — máximo permitido pela spec BLE
AndroidBleGattClient connect timeout10 000Setup de conexãoEspera por STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000Setup de conexãoEspera por onMtuChanged
AndroidBleGattClient write timeout5 000Gravação GATTEspera por onCharacteristicWrite
AndroidBleGattClient read timeout10 000Leitura GATTEspera por onCharacteristicRead (não usado para dados reais)
AndroidBleGattClient notify-state timeout5 000Gravação CCCDEspera por gravação do descritor CCCD
ensureConnected retries3Setup de conexãoAté 4 tentativas totais (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Controle de fluxo de notificaçãoEspera por onNotificationSent
AndroidBleGattServer notifyChunkSize380Fragmentação de respostaMesmo que BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Controle de fluxo de notificaçãoMáximo de retries de updateValue antes de desistir
PeerCircuitBreaker.WINDOW_MS30 000Circuit breaker de transporteDuração aberto após threshold
PeerCircuitBreaker.MAX_FAILURES2Circuit breaker de transporteFalhas dentro da janela para abrir
DownloadQueue.MAX_CONCURRENT3Pool de workers de downloadCorrotinas de download concorrentes
BleServiceData.SHORT_ID_BYTES8Identificação de parBytes do prefixo SHA256 truncado
BleServiceData.PAYLOAD_BYTES9Identificação de par1 byte de flags + 8 bytes de shortId
BleSegmentData.STATE_START_BIT1Sinalização de EOF da Camada APrimeiro segmento de uma mensagem multi-segmento
BleSegmentData.STATE_END_BIT2Sinalização de EOF da Camada AÚltimo segmento (ou segmento único)

Recapitulação das Trocas de Design {#design-trade-offs-recap}

Diagram 14
14

Leitura Adicional

  • Arquitetura de Chat — como o BleTransport se encaixa na cadeia de fallback LAN → Aware → BLE e 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.