SDK 0.30: smaller libraries, C, Java and Flutter bindings, and a telemetry security fix
TL;DR
SDK 0.30.0 shrinks the iOS static library from 138 MB to 23 MB and the npm package from 466.4 MB to 100.7 MB. It adds C, Java and preview Flutter bindings. Apps calling enableTelemetry on 0.26.0 to 0.29.0 should update for a security fix.
A coordination SDK earns its place on a device when it is small enough to ship everywhere, reachable from the language the app is already written in, and steady when the radio link is busy. Version 0.30.0, released on 21 October 2026, works on all three.
The libraries it ships are a fraction of their old size, three new bindings bring the SDK to C, Java and Flutter, and a volume test on two phones led to a set of Bluetooth LE fixes. It also carries a security fix for apps that turn on telemetry.
SDK 0.30 telemetry security fix
Apps that call enableTelemetry on SDK 0.26.0 to 0.29.0 should update. The telemetry uploader in those versions trusted every root in the Mozilla set it bundled, and one of them was an eMudhra root withdrawn after eight eMudhra intermediate certificates were found with weak keys in October 2026. Someone holding one of those keys on the network path between the device and the ingest could have presented a certificate the uploader accepted, and read the telemetry API key and the batches.
From 0.30.0 the uploader trusts sixteen roots: the server roots of the four authorities the ingest’s certificates come from, Let’s Encrypt, Google Trust Services, SSL.com, and Sectigo for Cloudflare’s backup certificate. A test pins them by SHA-256, so a compromise of any other authority no longer reaches the SDK. The certificate validation library also moves to a release that refuses any chain through the compromised intermediates’ keys, whichever root it ends at.
Nothing else changes for your app: the endpoint, the proxy handling and the device-store exclusion are as before. The reasoning is in ADR 0031.
Smaller SDK libraries in 0.30
The static library the iOS framework is built from falls from 138 MB to 23 MB, and the npm package from 466.4 MB to 100.7 MB unpacked. Link-time optimisation was always asked for in the release profile, but cargo only runs it when every crate type of a library can take it, and the bindings crate also listed one that cannot. So every native library built since the move to UniFFI, through 0.29.0, including the iOS framework, the Android libraries, the desktop libraries and the Python wheels, was built without it, and no build said so. 0.30.0 fixes that, and a test refuses a crate type that would turn it off again.
The iOS and Android libraries are also now built for size. Measured on the macOS arm64 build:
| What | Before | After |
|---|---|---|
| Static library the iOS framework is built from | 138 MB | 23 MB |
| Shared library for iOS and Android, built for size | 10.9 MB | 8.5 MB |
npm package, unpacked, as npm view reports it | 466.4 MB (0.29.0) | 100.7 MB (0.30.0) |
The shared-library row compares two builds that both have link-time optimisation, before and after building for size; with neither, as 0.29.0 shipped it, it was 11.5 MB. The exported functions are the same. Building for size has a cost on CPU-bound work: on the SDK’s benches, MLS sealing and opening take about 40% longer, and work takes at most a third longer on other paths, a fraction of a millisecond per message. A smaller setting was measured and rejected, because it made MLS twice as slow. The desktop libraries and the Python wheels stay built for speed, now with link-time optimisation. A panic in the SDK still reaches Swift and Kotlin as an exception, as it always did (ADR 0029).
Offline Protocol C API
Include offline_protocol.h and link the native library that every release already ships. The header declares about a dozen functions: create an engine from a JSON configuration, call any method of the interface definition by name with JSON parameters, receive events as JSON through a callback or a poll queue, and attach storage providers written in C. The method names and JSON forms are the local API’s, so a method added to the SDK is callable from C with no change to the header.
/* Error handling and frees left out; see bindings/c/README.md. */
offline_protocol_create(config_json, &engine, &err);
/* Storage before start(): the built-in file stores need no callbacks.
* The store key seals key material at rest; keep it out of the source. */
offline_protocol_call(engine, "initialize_mls_with_file_stores",
"{\"mls_root\":\"/var/lib/app/mls\",\"state_root\":\"/var/lib/app/state\","
"\"store_key\":\"<32 bytes, base64>\"}", &result, &err);
offline_protocol_call(engine, "start", NULL, &result, &err);
offline_protocol_call(engine, "send_message",
"{\"recipient\":\"off1...\",\"content\":\"hi\",\"priority\":\"Medium\"}",
&result, &err);
cc -I include app.c -L <libdir> -loffline_protocol_uniffi -Wl,-rpath,<libdir>
The header ships in every desktop archive on the GitHub release: Linux x86_64 and aarch64, macOS arm64, and Windows x86_64 with the DLL’s import library. The iOS XCFramework carries the same C functions; the Android libraries do not. Because the surface is a fixed set of functions that pass JSON, a wrapper in Go, C# or C++ binds it once and keeps working as the SDK grows. Every entry point catches a panic rather than aborting the host. CI builds C programs with -Wpedantic -Werror against the shared and static libraries, runs two engines exchanging messages through a C host, runs every C program under AddressSanitizer and UBSan, and fuzzes the JSON boundary nightly. The contract, including ownership and threading, is in the C guide.
Offline Protocol Java binding
Add com.offlineprotocol:offline-protocol-java from Maven Central and run on JDK 22 or later. The binding is generated from the same interface definition as the Swift, Kotlin and Python ones, and it calls the native library through the Java foreign function API, so it brings no JNA, no JNI and no native glue of its own.
// build.gradle.kts
dependencies {
implementation("com.offlineprotocol:offline-protocol-java:0.30.0")
}
The jar holds the Java and no native code. Take the library for your platform from the release’s desktop archives, give the JVM its directory with -Djava.library.path, and run with --enable-native-access=ALL-UNNAMED. OfflineProtocol, MeshServices, DataStore and every record and enum are classes, the callbacks a host implements are interfaces, and each engine refusal is its own ProtocolException subclass. The generated records have no defaults, so a ProtocolConfig is spelled out in full. CI builds and tests the binding against the library on Linux, macOS and Windows. On Android, use the Android library, com.offlineprotocol:offline-protocol-sdk, instead. The Java README has a full quick start.
Offline Protocol Flutter plugin
The Flutter plugin is a preview for iOS and Android. Until it is on pub.dev, you add it as a path dependency from the SDK repository, after a one-time build of the mobile libraries with the Rust toolchain, the same steps as React Native:
dependencies:
offline_protocol:
path: ../bindings/flutter/offline_protocol # adjust for your layout
From Dart it covers creating the engine, MLS with the platform secure stores, start, stop and destroy, the local address, sendMessage, the Internet, Bluetooth LE and Wi-Fi Direct transports, and core events with the same sequence numbers and gap replay as React Native. Its Android and iOS hosts compile the React Native bridge sources where they are, so a transport fix reaches both. Nostr, Reticulum, the telemetry pipe and the React Native foreground mesh service are not in the plugin yet, and a configuration that enables Nostr or Reticulum is refused at create. CI runs the Dart tests and assembles the Android plugin; nothing in CI compiles the iOS plugin yet. The Flutter README has the setup.
Large files and byte signatures in React Native
React Native gains two methods that keep large payloads off the bridge. sendFileFromPath passes a path or a file:// URI to the Rust core, which reads the file itself, so a large attachment is no longer staged as a base64 string in JavaScript and again in native buffers. On constrained Android phones those copies are what ran out of memory on files in the tens of megabytes.
const fileId = await protocol.sendFileFromPath({
recipient: 'off1...',
filePath: '/data/user/0/com.example.app/files/report.pdf',
fileName: 'report.pdf',
});
The file is still read whole into the Rust heap, not streamed, and a file over the 100 MiB transfer limit is refused before a byte is read. send_file_from_path is reachable from every binding. signDataBytes(Uint8Array) and verifySignatureBytes sit beside signData and verifySignature and produce the same signatures, without one boxed number per byte on the way across.
Bluetooth LE reliability under load
Most of the reliability fixes in 0.30 address what a 10,000-message volume test on two phones found when a Bluetooth LE link was offered more than it could carry. Before these fixes, two phones sending five messages a second each way over Bluetooth LE delivered 70.5% of them: acknowledgements queued behind data, every resend cost another acknowledgement, and the link collapsed into mass retries. In 0.30.0:
- A sender keeps at most 16 messages in flight to one nearby peer. The rest wait in the outbox, reported as
message_deferredwith reasonpeer_window, and go out in order as acknowledgements return. The relay is not windowed. - On Bluetooth LE, acknowledgements go ahead of data, and up to five are coalesced into one frame for a peer that supports it.
- A full acknowledgement table no longer stops the engine. Once 1,000 messages were waiting for acknowledgements, every later send used to fail and nothing more was retried.
sendMessageno longer reports a failure for a message it sent, so an app stops showing messages as failed that the recipient received.- A receiver that falls far behind a sender re-keys instead of failing every later message from that sender.
- Large Bluetooth LE messages are no longer torn under load. The Android and iOS fragment queues used to discard everything queued for a peer when they filled, and one 16 KB message could fill them.
- A full outbox refuses the newest message instead of failing the oldest. The same outage used to fail about 2,500 queued messages per phone to make room.
- One lost inbound Bluetooth LE fragment no longer marks the link as failing, which used to read Bluetooth LE’s delivery ratio as 0% and could switch traffic away from it.
React Native also recovers events it used to lose on the way to JavaScript: each native module keeps its last 256 events and replays any the JavaScript side missed. On Linux, the Python package no longer makes bluetoothd 5.87 exit at start, keeps Bluetooth LE running when bluetoothd restarts, and resets a controller the kernel lost track of. Each fix, with its cause, is in the 0.30.0 changelog.
Upgrading to SDK 0.30
The API compiles unchanged, apart from two unused methods removed from the Rust transport crate. These behave differently:
- Building the native library from source needs Rust 1.91, and the iOS bridge needs Swift 5.10 (Xcode 15.3). A React Native app rebuilds its native code to pick up the regenerated bindings.
- Telemetry counts only your app’s own messages as delivered or failed, so dashboard delivered counts from 0.30.0 devices read lower.
MessageDelivered.latency_msruns from the first send, so latency for messages that needed retries reads higher.- Show a
peer_windowdeferral as queued, not failed. On a full outbox, a new message to the busiest recipient getsmessage_failedwith “Outbox capacity exceeded”; treat it as “try later”. - In React Native,
message_deliveredandmessage_failedare now held while JavaScript cannot take them, such as while the app is in the background, and delivered later, so keep their handlers idempotent onmessage_id.
The full list is in UPGRADING §28.
npm install @offline-protocol/mesh-sdk@0.30.0
Get the package from npm, PyPI or crates.io, read the source on GitHub, or talk to us about bringing the SDK to the languages your devices already run.
Frequently asked questions
Which apps need the SDK 0.30 telemetry fix?
Apps that call enableTelemetry on SDK 0.26.0 to 0.29.0. In those versions the uploader trusted every root in the bundled Mozilla set, including an eMudhra root withdrawn after the October 2026 key compromise. Version 0.30.0 trusts only the sixteen roots the ingest's issuers use, and nothing else in the app changes.
How much smaller is the Offline Protocol npm package in 0.30?
100.7 MB unpacked, down from 466.4 MB in 0.29.0, as npm reports it. Most of the difference is the iOS static libraries the package carries, which link-time optimisation now reaches.
Is the Java binding on Maven Central?
Yes, as com.offlineprotocol:offline-protocol-java. The jar holds no native code, so you take the native library for your platform from the release's desktop archives, and it needs JDK 22 or later. On Android, use the com.offlineprotocol:offline-protocol-sdk library instead.
Is the Flutter plugin on pub.dev?
Not yet. It is a preview you add as a path dependency from the SDK repository, and it needs the Rust toolchain and a one-time build of the mobile libraries, the same steps as React Native.
Are the 0.30 mobile libraries slower?
Somewhat, on CPU-bound work. On the SDK's benches, MLS sealing and opening take about 40% longer, and work takes at most a third longer on other paths, a fraction of a millisecond per message. Desktop libraries and the Python wheels are not built for size.
What do I need to build the SDK's native library from source in 0.30?
Rust 1.91, because UniFFI moves to 0.32. The generated Swift needs Swift 5.10, so Xcode 15.3 or newer. The library crates still build on Rust 1.87.