Blog
Engineering

SDK 0.28 on iOS and Android without React Native

SDK 0.28 on iOS and Android without React Native

TL;DR

SDK 0.28.0 packages the iOS and Android bridges as an early Swift package and Android library for native apps. The iOS peer stream moves to Network framework, Android can form its own Wi-Fi Direct group, and Bluetooth LE and session setup are fixed.

The SDK’s iOS and Android code has always lived in the React Native module: the transport managers that drive Bluetooth LE and Wi-Fi, the storage providers, and the bindings generated from the Rust core. A native app could not use any of it without bringing React Native along. Version 0.28.0 builds those same sources, in place, into a Swift package and an Android library.

Both packages are early, and we want to be exact about how early. Most of what follows is not about the packages at all. It is about the transports inside them, which changed a great deal in this release and which React Native apps get too, because it is the same code.

Offline Protocol Swift package for iOS

The package lives at Offline-Protocol/offline-protocol-swift, tagged 0.28.0:

dependencies: [
    .package(url: "https://github.com/Offline-Protocol/offline-protocol-swift.git", from: "0.28.0")
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "OfflineProtocolSDK", package: "offline-protocol-swift")
    ])
]

It needs iOS 13 or later and is iOS only: the binary has no macOS or Mac Catalyst slice. Inside are the Rust library as an XCFramework, the generated bindings (OfflineProtocol, ProtocolConfig, MeshServices, DataStore and the rest), the transport managers for Bluetooth LE, internet relay, the peer stream, Nostr and Reticulum, and the Keychain and file storage providers.

Here is the status, in the package README’s own words: the storage providers are not yet public, so an application cannot construct them, and wiring the transports to the engine is the application’s to write. What is public is what the React Native module happened to need public. It is not yet a decided API, and names in it may change. A public entry point that does both jobs is the next addition to the package. Until then, use it to evaluate and to build against, and expect to write that glue yourself.

The repository is generated. A workflow there downloads the package and its XCFramework from the SDK release, verifies their attestations, and commits and tags them, so no credential for the Swift repository exists in the SDK repository. Changes made there are overwritten by the next release, and issues belong in the main repository.

Offline Protocol Android library and Gradle

The Android library’s coordinates are com.offlineprotocol:offline-protocol-sdk, but it is not on Maven Central yet, so a Gradle dependency on those coordinates will not resolve. It is built the same way as the Swift package, from the bridge sources the React Native module compiles, minus the files that need React. CI builds and tests it on every pull request, along with a separate application that depends on it by those coordinates and minifies. The 0.28.0 release built it with native libraries for all four ABIs and ran the same checks.

The upload to Maven Central sits behind a switch that was off for this release. What you can download is the same archive earlier releases carried: offline-protocol-0.28.0-android.zip on the release page, with the generated Kotlin bindings and libuniffi_offline_protocol.so for arm64-v8a, armeabi-v7a, x86 and x86_64. The library itself can be built from the repository with the commands in bindings/kotlin. Its public surface has the same caveat as the Swift package’s.

Two Android fixes in this release matter to any app, native or not. A release build that minifies failed in R8, because Tink, which androidx.security:security-crypto brings in for the MLS store, refers to annotation classes it does not ship. The SDK’s consumer rules now cover it. And on Android 7, relay timestamps were parsed with java.time, which only exists from Android 8, so those calls threw. They are parsed without it now.

iOS peer stream on Network framework

In 0.28 the iOS peer stream moves from MultipeerConnectivity to Network framework. On iOS, the transport slot named wifiDirect has never been Wi-Fi Direct, which iOS does not expose to apps. It was a peer stream over MultipeerConnectivity. It now opens TCP streams with NWListener, NWBrowser and NWConnection, over the local network or, when two iPhones share no network, over AWDL, so they still reach each other with no access point.

It advertises and browses _offlineprotocol._tcp with the same TXT record the Python PeerStreamManager uses. An iPhone and a Python host on one LAN now find and talk to each other. Multipeer’s seven-peer cap is gone. At most sixteen streams are open, twelve of them inbound and four from any one address. The hop is plain TCP, as on Android and Python, where Multipeer encrypted it. Payloads are sealed end to end either way.

Your Info.plist needs the service type and the usage string:

<key>NSBonjourServices</key>
<array>
    <string>_offlineprotocol._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Finds people nearby who use this app.</string>

The _udp entry can go. A denied local-network permission is now reported as an error diagnostic instead of failing silently.

The release notes list one break at run time, and this is it. An iPhone on 0.28 does not see an iPhone on 0.27 or earlier over this slot. Upgrade a fleet together, or expect mixed versions to meet only over Bluetooth LE or the relay.

Peer stream traffic on Android and iOS

In 0.28, peer streams carry traffic on phones. Android’s Wi-Fi Direct manager and the iOS peer-stream manager now exchange the identity preamble from the stream framing chapter. Before 0.28, both dropped every inbound frame and announced no peer, because nothing on the wire said who the peer was. Each side sends its identity assertion first, checks the other’s with the strict verifier, and announces the peer only under the address it proved. A peer that sends anything else first, or nothing for ten seconds, is disconnected unannounced.

Two smaller fixes make the slot usable. wifiDirect: { enabled: true } now starts the transport from start(). Before, it did nothing unless the app also called enableTransport('wifiDirect'). On iOS this means the Local Network prompt appears on the first start(). And Android Wi-Fi Direct registered its send callback only if the manager already existed when the module was created, which it never did, so every message and acknowledgement waited for a two-second fallback poll. On two phones the median delivery went from about three seconds to about 85 milliseconds once the callback was registered.

Android Wi-Fi Direct groups without system settings

Android apps can now form a Wi-Fi Direct group on their own. With autoAccept: true on Android 10 and later, devices of the same app find each other over Wi-Fi P2P service discovery and join one group whose name and passphrase are derived from the app id. The device with the lowest address creates the group, the others join, and no system dialog appears on either phone. Until now, Android devices joined a Wi-Fi Direct group only if someone had formed one in the system’s Wi-Fi Direct settings.

import { OfflineProtocol } from '@offline-protocol/mesh-sdk';

const protocol = new OfflineProtocol({
  appId: 'field-crew',
  profile: 'tablet-07',
  transports: {
    ble: { enabled: true },
    wifiDirect: { enabled: true, autoAccept: true },
  },
});

await protocol.start();

Discovery between phones is lopsided, because a device that owns or is joining a group answers no discovery queries, so the formation logic depends on nobody hearing the owner. A device that heard a lower peer creates the group itself after three joins found none. An owner whose group stays empty for 30 to 60 seconds dissolves it, so two groups that formed at once merge. A client whose owner proves nothing for three dials in a row, which is what an owner whose app died looks like, leaves the group and remembers that owner for five minutes. stop() removes an app-named group this device owns.

On two phones, Android 13 and Android 15, placed next to two Wi-Fi Direct televisions, the group formed in all eight clean starts, in 18 to 225 seconds with a median of about a minute. Groups of three or more devices have not been tested. The passphrase is derived from the app id and is not a secret. It keeps apps apart, and the identity preamble and end-to-end encryption protect the traffic.

It is off by default. One upgrade trap: the integration guide’s example used to set autoAccept: true while the option did nothing, so an app that copied it starts forming groups on upgrade. Set it to false to keep the old behaviour. Android also rejoins its group after the app restarts, comes back when Wi-Fi P2P is switched off and on, and declares its own Wi-Fi Direct permissions, with NEARBY_WIFI_DEVICES marked neverForLocation, so Android 13 and later no longer need a location grant for it.

Bluetooth LE fixes on iOS and Android

SDK 0.28 fixes Bluetooth LE in busy rooms, after Bluetooth is toggled or the stack crashes, on the first message from a new peer, in the timing of neighbor_lost, and during bursts of document edits. These came out of device testing and apply to React Native and native apps alike:

  • Busy rooms. The dense-mesh filters on both platforms counted every advert in range, televisions, earbuds and watches included, as mesh density, and in a “dense” mesh they passed over up to 80% of peers. Two Android phones a metre apart in a house read a density of 32 and passed each other over on every advert for the fifteen minutes they were watched. Density now counts distinct mesh candidates seen recently, and a peer passed over is reconsidered after a minute.
  • An iPhone’s Bluetooth turned off and on. Power-off removed the GATT service while the bridge still marked it published, so power-on advertised a service with nothing behind it, and BLE was never reported available again. Both ends now recover, including the Android side, which kept redialling the iPhone’s old random address.
  • Android toggles and stack crashes. Android polled the adapter once a minute, came back 12 to 29 seconds after Bluetooth returned, and never noticed a stack crash at all. It now listens for the adapter’s state broadcast, drops the dead links and rebuilds its scan, GATT server and advertising at once.
  • First message from a new peer. An Android phone that received a write from an unmapped address used to dial that address back to learn who it was, which took 11 seconds at the 95th percentile in a 30-round soak and 40 seconds after a Bluetooth toggle. An Android central now writes its identity assertion to a new optional GATT characteristic before its first message.
  • neighbor_lost means nothing reaches the peer. It fires once, when the last Bluetooth LE or Wi-Fi Direct link to the peer ends, rather than twice for one departure or while another carrier still reaches it. On Android it fires 15 seconds after the last link closes.
  • Bursts of document edits. iOS drained fragments from the core faster than CoreBluetooth could send them, then evicted the oldest and tore messages. The drain now stops at three quarters full. A device also re-offers its latest document versions three seconds after its local edits go quiet, so a replica that missed the last change of a burst catches up in seconds.

Encrypted session failures between phones

Two fixes affect whether two phones get an encrypted session at all: a lost key package push is now repeated, and a key package refused because of the clock is now reported. A device pushed its key package to a new peer once, and if that one frame was lost, nothing sent it again. A device test saw this in about one fresh first contact in five. A push that has produced no session after 30 seconds is now repeated on the next discovery, backing off up to ten minutes.

The second is clocks. A key package is valid from an hour before it was minted until 30 days after, judged by the receiver’s clock. Phones that have never been online often have the wrong date: two Android phones set to 2024 and 2025 reproduced it, and no session formed, with only a debug line to show for it. The refusal is now a security_warning with a new code, once per peer:

import type { SecurityWarningEvent } from '@offline-protocol/mesh-sdk';

protocol.on<SecurityWarningEvent>('security_warning', (event) => {
  if (event.reason_code === 'KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW') {
    promptToCheckDateAndTime(); // your UI
  }
});

An exhaustive TypeScript switch over SecurityWarningCode needs a case for the new member.

Upgrading a mobile app to SDK 0.28

For mobile apps, the changes that compile fine and behave differently are the iOS peer stream (upgrade iPhones together, update NSBonjourServices), wifiDirect: { enabled: true } now starting the transport, autoAccept: true now forming groups, the new neighbor_lost timing, and a new native_event_gap diagnostic that React Native emits when an event from the native module never reached JavaScript. The full list is in UPGRADING §26.

React Native apps keep installing from npm:

npm install @offline-protocol/mesh-sdk

Native apps can start with the Swift package or the Android archive on the release page, with the caveats above. The full notes are in the 0.28.0 changelog. The rest of the release is covered in SDK 0.28 on servers and desktops and the leaf node builds for ESP32 RISC-V and Cortex-M0.

Frequently asked questions

Does the Offline Protocol Swift package work on macOS?

No. It needs iOS 13 or later and is iOS only, because the binary has no macOS or Mac Catalyst slice.

Can I add com.offlineprotocol:offline-protocol-sdk to Gradle from Maven Central?

Not yet. The upload was switched off for 0.28.0, so a Gradle dependency on those coordinates will not resolve. Download offline-protocol-0.28.0-android.zip from the release page, or build the library from the repository with the commands in bindings/kotlin.

Will an iPhone on SDK 0.28 see an iPhone on 0.27 over the peer stream?

No. This is the one break at run time the release notes list. Upgrade a fleet together, or expect mixed versions to meet only over Bluetooth LE or the relay.

What Info.plist entries does the iOS peer stream need in SDK 0.28?

NSBonjourServices with _offlineprotocol._tcp, and an NSLocalNetworkUsageDescription string. The _udp entry can go.

Is the passphrase of the app-named Wi-Fi Direct group a secret?

No. It is derived from the app id and keeps apps apart; the identity preamble and end-to-end encryption protect the traffic.

Why do two phones with the wrong date fail to form an encrypted session?

A key package is valid from an hour before it was minted until 30 days after, judged by the receiver's clock. Since 0.28 the refusal is reported as a security_warning with the code KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW, once per peer.

More posts
Engineering Oct 6, 2026

SDK 0.28 on servers and desktops: Python on PyPI and a local API

Engineering Oct 6, 2026

SDK 0.28: the leaf node compiles for ESP32 RISC-V, Cortex-M0 and WASI