SDK 0.31: a Ruby gem, Flutter parity, live BLE signal and sessions that heal
TL;DR
SDK 0.31.0 adds a Ruby gem on RubyGems, brings Flutter to the React Native module's full native surface, and reports live Bluetooth LE signal strength. Devices sharing only a Nostr relay now form sessions, and split sessions heal themselves.
An SDK that coordinates devices has to be reachable from the language a team already writes, the same on every platform it claims, and quiet when two devices disagree about their keys. Version 0.31.0, released on 27 October 2026, adds Ruby to the languages that reach the engine and brings the Flutter plugin to the full surface of the React Native module.
It also makes live Bluetooth LE signal strength a first-class event, lets two phones that share only a Nostr relay form an encrypted session, and heals the cases where two devices ended up holding different MLS sessions for one another. The browser binding, the other half of this release, has its own post.
Offline Protocol Ruby gem
The offline_protocol gem is on RubyGems at 0.31.0. It is one generated file, written by UniFFI’s own Ruby generator from the same interface definition as the Swift, Kotlin, Python and Java bindings, and it calls the native library through the ffi gem, with no native glue of its own and not through the C API. OfflineProtocol::OfflineProtocol, MeshServices, DataStore, every record and enum, and the objects a host implements are all there, and an engine refusal is an exception named for its variant.
# Gemfile, Ruby 3.3 or later
gem "offline_protocol", "~> 0.31.0"
The gem carries no native code. Take liboffline_protocol_uniffi.so, .dylib or offline_protocol_uniffi.dll from the release’s desktop archives and put its directory on the loader’s search path before Ruby starts (LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, or RUBY_DLL_PATH on Windows). Unlike the generated Java, the records keep the definition’s field defaults, so a configuration names only the fields that have none. From the binding’s README:
require "offline_protocol"
Sdk = OfflineProtocol
config = Sdk::ProtocolConfig.new(
app_id: "com.example.ruby", profile: "ruby-example",
ble_enabled: false, wifi_direct_enabled: false, internet_enabled: true,
reticulum_enabled: false, nostr_enabled: false, prefer_online: false,
initial_ttl: 8, encryption_enabled: true, auto_key_exchange: true,
store_pending: true, max_pending_per_peer: 64, max_pending_global: 4096,
pending_ttl_ms: 86_400_000, overflow_policy: Sdk::OverflowPolicy::DROP_OLDEST
)
engine = Sdk::OfflineProtocol.new(config)
engine.initialize_mls_with_file_stores(mls_dir, state_dir, store_key_32_bytes)
Two integration steps come from the generator: keep the engine reachable for as long as the process runs, and mask a byte array the engine returns with map { |b| b & 0xFF } before handing it back. CI regenerates the Ruby with the other bindings and runs a minitest consumer check against the library on Linux, macOS and Windows. examples/ruby-app runs two devices in one process that pair, form an end-to-end encrypted session, send a file checked byte for byte, and talk in an MLS group. The Ruby article in the Learning Center covers when to pick it.
Flutter plugin at parity with React Native
In 0.30.0 the Flutter plugin covered the core calls. In 0.31.0, MeshSdk exposes the React Native module’s native methods to Dart: Dart calls go through a generated FlutterEngineChannel on Android and iOS into the same shared engine host React Native uses, with one manifest of methods that a generator gate keeps in step. A few calls have typed helpers, and every other method has a map-argument wrapper.
import 'package:offline_protocol/offline_protocol.dart';
final transports = await MeshSdk.getActiveTransportsTyped();
final waiting = await MeshSdk.hasPendingKeyPackageTyped(peerId);
final messageId = await MeshSdk.sendMessageTyped(recipient: 'off1…', content: 'Hello');
- Nostr and Reticulum start. The plugin used to refuse those configurations at create; the shared host now serves both.
- The Android foreground service.
start()runs the mesh foreground service React Native runs, so the plugin’s manifest addsFOREGROUND_SERVICE,FOREGROUND_SERVICE_CONNECTED_DEVICE,CHANGE_WIFI_STATEand aconnectedDeviceservice, and the device shows the service’s notification while the mesh runs. - Parity guards and iOS CI. Rust tests pin React Native parity for the foreground keep-alive, sticky one-shot events and inbound event buffering on Android and iOS, and a new CI job typechecks the iOS plugin’s Swift with the shared bridge sources the pod compiles.
The plugin is still added as a path dependency from the SDK repository until it is on pub.dev. React Native’s opt-in wake after a process kill stays React Native only. The Flutter bridge contract has the threading and event rules.
Live Bluetooth LE RSSI with peer_rssi
A new peer_rssi event reports a linked peer’s signal strength from every scan the SDK already runs, so it costs no extra radio time. The engine attributes each reading to the peer itself, so an app no longer has to join scan results to peers, and it is meant for proximity features such as “who is closest”.
interface PeerRssiEvent {
type: 'peer_rssi';
peer_id: string; // canonical address, as in neighbor_discovered
transport: string; // 'BLE'
rssi: number; // dBm, raw
}
- On while the app listens. React Native emits it only while an
on('peer_rssi', ...)listener is registered, and a native app turns it on withset_peer_rssi_events(true). At most four readings a second reach the app per peer. - Raw readings. They swing by about ±8 dB between samples, so smooth them for display. The React Native README’s example keeps an exponential moving average:
const smoothed = new Map<string, number>();
protocol.on('peer_rssi', ({ peer_id, rssi }) => {
const prev = smoothed.get(peer_id);
smoothed.set(peer_id, prev === undefined ? rssi : 0.7 * prev + 0.3 * rssi);
});
While readings flow, each link’s quality follows them, smoothed so one noisy sample does not reorder neighbours, and the mesh relay ranks neighbours by it. An Android app can also set transports.ble.advertiseNodeHash: true, off by default, so an iPhone can name an Android peer across Bluetooth address rotation. The digest never changes, so read the threat-model entry R29 before turning it on.
Nostr sessions between devices that share only a relay
Two phones that knew each other only by address and shared only a Nostr relay never formed a session before 0.31.0: a send to a peer with no session queued, and nothing asked the relays for the key packages that peer had published. Now a send or establish_secure_session that finds no key package for the peer asks the application’s relays for it, on iOS, Android and Flutter as in the browser, and asks again every five minutes while a message waits.
A peer on a live Bluetooth LE or Wi-Fi link, a blocked peer, and every peer while nostr_cold_contact_enabled is false are never looked up. The switch defaults to on and Nostr itself defaults to off, so only an application that turned Nostr on sends the new queries.
Healing split MLS sessions
When two devices created a session with each other at the same moment and one Welcome was lost, each could end up holding a different group for the same pair. Every message then failed as INVALID_CIPHERTEXT, and nothing healed it, because the two groups sat at the same epoch. In 0.31.0 a confirmation probe and its acknowledgement carry an MLS exporter tag that only a member of the same group can compute. A match confirms the session; a mismatch is a split, and the side with the greater address gives up its group, so the other side resends its Welcome. It acts only on an answer to a challenge it issued under its current session, so a late or replayed probe cannot tear down a healthy one.
- Probes on failure. Any decrypt failure on a 1:1 session sends a probe, at most once per peer every 30 seconds, and the side that gives up its group emits
convergence_diagwith stagesession_split_detected. - A lost re-key recovers. A key package now says when its sender holds no session (
no_session), and the side that still holds one resends its Welcome or rebuilds, so a lost reset no longer leaves messages waiting on both sides until they expire. - Failures say why. The
reasonofmessage_decryption_failednow carries the MLS failure’s name, for example(InvalidCiphertext, aead), which tells a split session from a frame that is simply too old.
Both devices need 0.31.0: a 0.30.0 peer ignores the new fields and keeps the old rules. The reasoning is in ADR 0033.
Group messages that no longer block a peer
A group frame further behind the receiver’s ratchet than the out-of-order window can never decrypt, and the sender resends the same ciphertext. The receiver used to hold it without an acknowledgement, so the sender kept resending it for the outbox’s seven-day lifetime, and each such frame held one of the sixteen slots in that peer’s send window. Between a Mac and a phone that had been through a day of app kills and restarts, sixteen of them filled the window, and for three and a half hours nothing else got through. The receiver now acknowledges such a frame from a group member and drops it.
iOS Hello characteristic and other Bluetooth LE fixes
The iOS peripheral now serves the Hello characteristic, as Android and the Python service do. A central writes its identity assertion once, and the iPhone binds the link to the address it proves instead of queueing every fragment behind a dial-back, so a central with no GATT server of its own, such as a browser over Web Bluetooth, is named. The iPhone waits two seconds for a hello before it dials a new central back, and a peer whose last link was a central that left is reported lost after 15 seconds, as on Android.
Security and packaging changes in SDK 0.31
Nostr signing and NIP-44 key agreement move to k256 0.14 on crypto-bigint 0.7.10, off the big-integer release RUSTSEC-2026-0387 reports as timing-leaky; keys, tags and the official NIP-44 vectors are unchanged. React Native engine operations now live in a shared engine host with generated Kotlin and Swift adapters, and the Swift package and the Android library leave out the React-only bridge files. A runnable Java example is built and run in CI, and the SDK README lists the Android and Java packages on Maven Central.
Upgrading to SDK 0.31
Nothing is removed from any binding. These are the changes to check:
- Rust:
EventgainsPeerRssi,SendMessageOptionsandGroupSendOptionsgainephemeral, andKeyPackagePayloadinoffline-protocol-sealedgainsno_session. Amatchwith no_arm, or a struct literal that names every field, needs them. A hand-written conformer of the generated interface needsble_peer_rssi,set_peer_rssi_eventsandble_node_hash. - TypeScript:
ProtocolEventgainsPeerRssiEvent, so an exhaustiveswitchthat ends in anevercheck needs the case. - Flutter: the package now exports
MeshSdk, so an app with its own class of that name imports the package with a prefix. On Android 14 or later, declare the foreground service type in the Play Console. - Decrypt reasons: match
message_decryption_failedreasons on a prefix, not the whole string. - Groups: a group frame that can never decrypt reports as delivered, because the receiver acknowledges and drops it.
The full list is in UPGRADING §29.
npm install @offline-protocol/mesh-sdk@0.31.0
pip install offline-protocol-sdk==0.31.0
gem install offline_protocol -v 0.31.0
Get the packages from npm, PyPI, RubyGems, crates.io or Maven Central, read the source on GitHub, or talk to us about the devices and languages your team runs.
Frequently asked questions
How do I install the Offline Protocol Ruby gem?
Add the offline_protocol gem from RubyGems on Ruby 3.3 or later. The gem holds no native code, so take the native library for your platform from the release's desktop archives and put its directory on the loader's search path before Ruby starts.
Is the Offline Protocol Flutter plugin on pub.dev?
Not yet. You add it as a path dependency from the SDK repository after a one-time build of the mobile libraries, the same steps as React Native. In 0.31.0 its MeshSdk class reaches the React Native module's native methods from Dart.
How do I read Bluetooth LE RSSI with the Offline Protocol SDK?
Listen for the peer_rssi event, which carries peer_id, transport and rssi in dBm for a linked peer. React Native emits it only while an on('peer_rssi') listener is registered, and a native app turns it on with set_peer_rssi_events(true). Readings come at most four times a second per peer.
Do both devices need SDK 0.31 for session healing?
Yes. The probe tag that detects a split session and the no_session flag on key packages are new in 0.31.0, and a 0.30.0 peer ignores them and keeps the old rules, so a pair split across versions stays split until both update.
What breaks when upgrading to SDK 0.31?
Nothing is removed from any binding. Rust code that matches every Event case or writes SendMessageOptions, GroupSendOptions or KeyPackagePayload in full needs the new fields, and an exhaustive TypeScript switch over events needs a peer_rssi case.