SDK 0.33: delivery receipts that cross a mesh of phones, and Android permissions in one call
TL;DR
SDK 0.33.0 makes messages and their delivery receipts cross a mesh of phones: in a simulated crowd of forty phones every receipt now comes back. React Native requests Android mesh permissions in one call, and development builds flag integration mistakes.
SDK 0.33.0 makes resends and delivery receipts travel as far as the first copy did, across every hop of a phone mesh.
Version 0.33.0 shipped on 11 November 2026 to npm, PyPI, crates.io, Maven Central and RubyGems, with the same engine under every binding.
Mesh network message retries
Relays carry every resend generation across the whole mesh. A resend that reaches a neighbour a full acknowledgement timeout or more after the previous one moves to the next generation, at most three, carried in the reserved metadata key __resend, and forwarders suppress each generation separately. Resends inside the first timeout stay copies of the first flood, so a receipt that is merely slow costs nothing extra.
In the forty-phone simulation with one hand-off in ten dropped, every message arrived and more than nine in ten were acknowledged within 35 seconds.
Delivery receipts on a crowded mesh
A recipient answers each delivery once, however many paths it arrives by. A relayed copy is answered unless the same message, under the same resend generation, was answered in the last five seconds, and a copy straight from the sender or under a new generation is always answered. In the forty-phone simulation this cut receipt frames by a third to two thirds and total airtime by up to a half.
Receipts also move faster through each relay:
- Receipts have their own relay allowance. A neighbour’s acknowledgements get an allowance of half its
peerBurstandpeerRatePerSecthat its messages cannot spend, so receipts keep flowing through a burst of messages. - Receipts settle with less traffic. A receipt re-drives a peer’s waiting messages at once when that peer was parked as unreachable; otherwise they follow one acknowledgement timeout later, spaced at half the rate a neighbour accepts.
- Resends follow their first copy. A resend whose earlier copy is still in a local carrier queue waits one more rung, and the browser hands its Bluetooth link at most four fragments at a time, so a receipt can go ahead of queued data.
On a two-hop chain of phones, over ten bursts of ten messages each way:
| Measure | Before | After |
|---|---|---|
| Receipts slower than 5 s, of 200 | 118 | 15 |
| Frames sent by the first device for about 107 messages | 846 | 308 |
| Slowest receipt | 34 s | 18 s |
| Frames refused by the next hop | 189 | 13 |
The first row is a run with all three receipt changes; the other three rows measure the resend change on its own.
Mesh fan-out and session probes
- A device’s own frame goes to every link. A frame the device sends itself goes to four neighbours, every link a phone holds; a forward fans out to three.
- One slot per carrier first. The fan-out gives the strongest link on each carrier a slot before any carrier gets a second, so a Python service with phones on Bluetooth LE also reaches the engines behind its peer stream.
- A fan-out finishes when the budget refills. Neighbours a forward has not yet reached get the frame as the send budget refills. With six conversations in the forty-phone simulation, this change on its own raised returned receipts from 13 to 16 of about 20 to 18 to 20.
- Session probes back off for absent peers. A pending session the mesh carries is probed from 15 seconds up to 600, and again at once when the peer is discovered.
React Native Android Bluetooth permissions
requestMeshPermissions, requestMeshPermissionsForConfig and meshPermissionOptionsFromConfig are exported from @offline-protocol/mesh-sdk. Before start(), they request what the mesh needs on the Android version the app runs on: the Bluetooth grants (fine location on Android 11 and lower) and, with Wi-Fi Direct on, NEARBY_WIFI_DEVICES (fine location on Android 12 and lower). They prompt only for what is not granted yet. On iOS they return at once, because the system prompts when the transports start.
import { OfflineProtocol, requestMeshPermissionsForConfig } from '@offline-protocol/mesh-sdk';
const config = { appId: 'my-app', profile: 'default', transports: { ble: { enabled: true } } };
const permissions = await requestMeshPermissionsForConfig(config);
if (!permissions.granted) {
// The transport that needs the refused grant stays off. Once the user
// allows it, call enableTransport(...) for that transport after start().
}
const protocol = new OfflineProtocol(config);
await protocol.start();
ACCESS_LOCAL_NETWORK is requested only when you pass localNetwork: true, since only an app that targets API 37 and declares it needs it. The mobile permissions guide lists every grant by platform and API level.
React Native and Flutter development warnings
With __DEV__ set, React Native logs one console warning per method and id when a peer id is not a 44-character off1… address, which is what a username, a profile or a phone number passed as a recipient looks like. It also warns once per mistake when calls come in the wrong order:
- a send or
enableTransportbeforeawait start(); - a
DataStorecall other thansetInterestbefore the store opens; start()with no listener registered;- a send with encryption on and MLS not initialized;
- a
setInterestafterstart()that narrows a space’s interest.
No call is blocked, and a release build logs nothing. A host built on ProtocolEngineHost, the Flutter plugin among them, gets ordering hints through onDiagnostic at level warning: on iOS when the app builds with DEBUG, and on Android when the host sets integratorLifecycleWarnings, which the Flutter plugin does in a debuggable build. The integrator map shows the order the calls belong in.
Upgrading to SDK 0.33
No API is removed. What to check:
- Android hosts on
ProtocolEngineHost: the constructor addsintegratorLifecycleWarningsbeforeonDiagnostic. PassonDiagnosticby name or as a trailing lambda, or add the new argument. - Relay limits: a neighbour can be admitted at up to one and a half times
peerBurstandpeerRatePerSec. To keep those as a hard ceiling, set them a third lower. - Tests: tests that assert frame counts or resend timing after a receipt take the new numbers.
- Python: the Bluetooth peripheral announces each phone under its proven address; key on that address in
neighbor_discoveredand the peer list. - Browser: a Bluetooth write that stays pending for ten seconds closes its link; a custom sink that takes longer passes
writeTimeoutMstoBluetoothCarrier.
The full list is in UPGRADING §31.
npm install @offline-protocol/mesh-sdk@0.33.0
pip install offline-protocol-sdk==0.33.0
gem install offline_protocol -v 0.33.0
Get the packages from npm, PyPI, RubyGems, crates.io or Maven Central, read the source on GitHub, or talk to us about running it on your devices.
Frequently asked questions
What is an off1 address in Offline Protocol?
It is a device's 44-character mesh address, the value that goes in recipient and in group membership. The SDK derives it from an identity key it generates and stores for each appId and profile pair; the profile is a local account slot on that device and never goes on the wire.
Can devices on an older SDK version share a mesh with 0.33 devices?
Yes. Older senders and relays work as before, and resend generations travel as far as the upgraded devices on the path, so upgrade the devices that relay as well as the ones that send.
Does requestMeshPermissions work on older React Native versions?
Yes. The package supports React Native 0.70 and later, and the helper names ACCESS_LOCAL_NETWORK by its string because the PermissionsAndroid constant only exists from React Native 0.87. Every permission it requests must already be declared in the merged Android manifest, or Android denies it without a prompt.
How do I keep the new React Native warnings out of a test suite?
Set devPeerAddressWarnings and devLifecycleWarnings to false on ProtocolConfig, or fix what the warnings name. Release builds never log them.