Skip to content

feat(bindings): add the Flutter plugin - #581

Merged
bahdotsh merged 23 commits into
mainfrom
feat/flutter-binding-pr1-scaffold
Oct 21, 2026
Merged

bahdotsh merged 23 commits into
mainfrom
feat/flutter-binding-pr1-scaffold

Conversation

@Ankush523

@Ankush523 Ankush523 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds the Flutter plugin, bindings/flutter/offline_protocol, for iOS and Android. This branch carries what was planned as four PRs: #587 (events, sendMessage, Internet transport), #588 (BLE, Wi-Fi Direct, event replay, lifecycle) and #589 (CI job, bridge docs, guards) were merged into it, so it is reviewed as one change.

  • Dart API: createFromConfig, initializeMlsWithSecureStorage, start, stop, destroy, localAddress, sendMessage, enableTransport / enableInternetTransport, and OfflineProtocol.events with the same seq gap detection and native replayEvents as React Native.
  • Native hosts: OfflineProtocolPlugin.kt and OfflineProtocolPlugin.swift run every UniFFI call on one engine thread per process, shared by every plugin instance, and answer on the main thread. Detach queues the teardown and never blocks the main thread, and the next engine's calls queue behind it. create validates its config before it tears down the running engine.
  • Shared sources (ADR 0025): the hosts compile the React Native bridge sources where they are, less a named list of the ones that need React or host Nostr and Reticulum. Nothing is moved or committed as a copy. On iOS, pod install copies them into a gitignored BridgeRN/ because CocoaPods does not follow directory symlinks.
  • Extracted from the RN modules, no behaviour change: ProtocolConfigParser.swift, InitialRuntimeConfigApplier.{swift,kt}, InternetTransportBootstrap.{swift,kt}.
  • Read, not written again: the plugin's version, Android SDK levels, Android dependency versions and iOS deployment target come from the React Native package. pubspec.yaml cannot read another file, so the release gate now checks it against the tag.
  • Refused: a config with reticulumEnabled or nostrEnabled fails at create, since the plugin hosts no manager for either.
  • CI: the Flutter Binding job runs flutter test and flutter analyze, and on native-relevant changes builds the arm64 library under the mobile profile, validates the example's Gradle wrapper, assembles the release AAR and checks it against a size baseline.
  • Also: bumps rustls-webpki 0.103.16 and webpki-roots 1.0.10 (RUSTSEC-2026-0381 / 0382) in Cargo.lock and tools/capi-fuzz/Cargo.lock, with the three notices files.

Not in this PR: Nostr, Reticulum, the telemetry pipe, the mesh foreground service, a pub.dev release, and a CI job that compiles the iOS plugin. docs/bridges/flutter.md has the manual iOS typecheck until that job exists.

Test plan

  • cargo test -p offline-protocol-uniffi --lib: 277 pass, including the seven Flutter guards. Each new guard was mutation-checked (stale exclude, excluded shared source, include list restored, podspec and rsync lists disagreeing, version literal restored) and fails.
  • cd bindings/flutter/offline_protocol && flutter test && flutter analyze: 10 pass, no issues.
  • iOS plugin typecheck against Flutter.xcframework and the shared sources at iOS 13.0: no errors. The 12 warnings under the Xcode 26.5 SDK are all in shared React Native sources, none in the plugin's own classes; a deliberately broken copy fails.
  • Android: arm64 library built with --profile mobile, :offline_protocol:assembleRelease, size baseline re-measured (5,447,562 bytes; the old baseline measured the --release profile).
  • cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings.
  • Device run of the example on Android and iOS after these changes.

Ankush523 and others added 13 commits October 18, 2026 10:31
#587)

* feat(bindings): Flutter events, sendMessage, and Internet transport (PR2)

* chore(bindings): update Android build configuration and dependencies for Flutter integration

* fix(bindings): address Flutter PR2 review (compile, Internet, hygiene)

* fix(bindings): Flutter PR2 review polish (auth, lazy Internet, guards)

* chore(bindings): Swift Internet bootstrap guard and example lints

* refactor(bindings): remove unused library declaration from offline_protocol.dart
#588)

* feat(bindings): Flutter PR3 BLE, Wi‑Fi Direct, event replay, lifecycle

* fix(bindings): Flutter event session lifecycle and PR3 review follow-ups

* chore(bindings): pin Flutter method channel surface and add seq reset test

* chore(bindings): add Flutter plugin AAR size baseline and measure script
)

* feat(bindings): Flutter CI, bridge docs, and workflow guards (PR4)

* fix(bindings): harden Flutter CI pins, path filter, and single Gradle gate

* fix(bindings): split Flutter CI dart vs native path filters

* fix(uniffi): isolate Flutter guards and gate lib.rs on native CI

* fix(bindings): trigger Flutter native CI on lockfile and engine crates

* perf(bindings): skip Flutter native gate on engine-only PRs
Fixes CI Format check on feat/flutter-binding-pr1-scaffold.
CocoaPods PathList does not index symlinked RN bridge sources, so the pod
rsyncs shared iOS bridge files into BridgeRN on install. Fix plugin teardown
and smokeTestConfig so the example boots on device without requiring BLE
background modes until BLE is actually started.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Ankush523 Ankush523 changed the title feat(bindings): add Flutter plugin scaffold (PR1) feat(bindings): add Flutter plugin scaffold Oct 21, 2026
Ankush523 and others added 7 commits October 20, 2026 21:06
Drop PR-scoped sections and duplicate CI narrative from integration docs.
Align example and baseline READMEs with kotlin/swift/RN style; scrub PR
labels from user-facing comments and baseline metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve root README install table: keep Flutter and Java rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
…1/0382

Dependency Audit (cargo-deny) requires patched TLS trust-store crates used by ureq.

Co-authored-by: Cursor <cursoragent@cursor.com>
The workspace lock got rustls-webpki 0.103.16 and webpki-roots
1.0.10 for RUSTSEC-2026-0381 and 0382 earlier on this branch. The
capi-fuzz workspace has its own lockfile, and it still pinned the
vulnerable 0.103.15 and 1.0.9.

Nothing gates that lockfile, so it would have sat there until
somebody happened to run cargo audit inside tools/. Bump it to the
same versions.
Moving the iOS config parser and the runtime config applier out
of the React Native modules was supposed to be a pure move. It
wasn't.

Every comment that names a failure went missing on the way: why
maxGroupMembers is UInt32(clamping:), why initialTtl is clamped,
why historyWindowSize is clamped in the unsigned domain, why the
data and security sections stay absent, why username discovery
and enforceAdminCommits default to false. Those comments exist so
that nobody "simplifies" the shape back into a trap. The Rust
guards strip comments before matching, so nothing noticed. Put
them back.

It also changed behaviour. The RN iOS module now passed
`context ?? [:]` to emitDiagnostic, which omits the key for nil,
so every info diagnostic from the DORS and relay path suddenly
grew a `"context": {}`. Pass the optional through.

While at it: drop readStoredConfig and startIfConfigured from
both Internet bootstraps, since nothing calls them; have the RN
Kotlin module take its battery and history bounds from the
applier instead of keeping a second copy; and get the em dashes
out of the two bootstrap parity guards' messages.
The plugin had two lists of sources to take, one in the podspec
and one in the Gradle build, with Rust guards pinning both and
the bridge doc describing them as the compile surface. Neither
did anything. The podspec never assigned its list, and the Kotlin
plugin ignores `java { include }`. The excludes were doing all the
work, and a file in neither list compiled fine. Delete the
take-lists and their guards, and guard what actually decides the
build: every excluded name must be a real file, the podspec and
the rsync copy must exclude the same sources, and no shared
transport may be excluded. That is what ADR 0025 asked for in the
first place.

The version, SDK levels, dependency versions and iOS deployment
target were all literals. ADR 0025 decision 5 exists for exactly
this. Now the podspec and Gradle build read them from the React
Native package. The pubspec can't read another file, so the
release gate checks it against the tag. Before this, 0.30.0 would
have shipped a plugin that says 0.29.0, and nothing would fail.

The native hosts had real bugs too:

 - Android caught Exception, but a missing .so throws
   UnsatisfiedLinkError. On the engine thread that kills the
   process. Catch Throwable, report LinkageError as
   NATIVE_LIBRARY_UNAVAILABLE.
 - Both hosts blocked the main thread on detach, Android for up
   to ten seconds. Queue the teardown instead. Android keeps one
   executor that is never shut down, so a re-attach queues behind
   the old teardown rather than racing it.
 - iOS read eventSink from the callback thread with no lock, and
   deinit did engineQueue.sync, which deadlocks if deinit runs on
   that queue. Lock the sink; tear down directly in deinit.
 - A config enabling Reticulum or Nostr was accepted, but there is
   no manager for either. Refuse it at create.

In CI, the native path filter missed the plugin's own android
tree, so a PR touching only the plugin's Kotlin never compiled it.
Add it, and validate the example's committed Gradle wrapper before
running it. Build the CI library with the mobile profile Android
actually ships, and make the size check subtract other ABIs so a
local four-ABI tree measures what CI does. The re-measured
baseline is 5.2 MiB; the old one measured the release profile.

While at it: drop the dead branch in the Dart replay path, handle
errors on the native event stream, remove the PR numbers from
identifiers, document the stale BridgeRN copy and a manual iOS
typecheck, and add the CHANGELOG entry this binding never had.
@bahdotsh bahdotsh changed the title feat(bindings): add Flutter plugin scaffold feat(bindings): add the Flutter plugin Oct 21, 2026
The last round made detach queue its teardown instead of blocking
the main thread on it. The comment said a later attach "queues
behind that teardown on the same thread". It doesn't.

It turns out that Flutter creates a *new* plugin instance for every
engine, on both platforms, and each instance had its own executor
on Android and its own serial queue on iOS. So after an Android
activity is destroyed and relaunched, the new engine's create runs
on a fresh thread while the old engine's teardown is still sitting
in stop(), blocked on transport I/O. Two cores, one identity, both
on BLE and the relay, and the old one free to flush protocol state
over whatever the new one just loaded.

The previous head didn't have this problem only because it waited
on the main thread, which is the thing we were trying to stop
doing. React Native doesn't have it because invalidate() tears down
synchronously.

Make the engine thread one per process: the executor moves into the
companion object and the iOS queue becomes a static let. Every call
from every instance now lines up behind the teardown, which is what
the comment claimed all along. A guard refuses a per-instance thread
coming back, and the bridge doc says why it has to be this way.
create() on both hosts tore down the running engine first and only
then parsed the new config and checked it for transports the plugin
doesn't host. So a create with nostrEnabled set got a perfectly good
refusal, and also a dead engine. The app asked for one thing and
lost another.

Parse and refuse first, tear down only once the config is accepted.
A rejected create now leaves the current session exactly as it was.

While at it, make stop() on iOS report the core's failure. It wrapped
everything in try? and always answered success, while Android rethrows
the first teardown failure. Same failure, two different answers in
Dart. The transport managers' stop() can't throw, so only the core's
error is left to report, and now it is.
Two paths in the Dart event pipeline had no test at all: dropping a
malformed event from the native channel, and the cap that flushes
held events when a replay never answers.

The second one matters. Without the cap, a native side that never
answers replayEvents would hold every later event until the timer
fires, and a busy session can queue a lot of events in two seconds.

The cap test uses a replay that never completes, so the only thing
that can release the held events is the cap itself. It checks that
everything past the gap comes out in order and the gap is reported.
@bahdotsh
bahdotsh merged commit b512e5b into main Oct 21, 2026
31 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants