diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a694539..b41aaf77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1327,3 +1327,130 @@ jobs: - name: Build a minified application against the published library working-directory: bindings/kotlin/consumer-check run: gradle --no-daemon --stacktrace -PVERSION_NAME=0.0.0 :app:assembleRelease + + flutter-binding: + name: Flutter Binding + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + # paths-filter compares the PR diff to the base branch. + fetch-depth: 0 + + # Native AAR gate: structural/UniFFI paths or lockfile (engine-only PRs skip + # NDK/Gradle; pushes to main always run the full job). + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: flutter_paths + with: + filters: | + flutter_dart: + - 'bindings/flutter/**' + - 'docs/bridges/flutter.md' + - '.github/workflows/ci.yml' + - 'crates/offline-protocol-uniffi/src/tests/flutter_bridge_guards.rs' + flutter_native: + # The plugin's own Kotlin and Gradle build: `flutter analyze` + # excludes android/**, so without these a pull request that + # touches only them never compiles them, and main is where it + # breaks. + - 'bindings/flutter/offline_protocol/android/**' + - 'bindings/flutter/offline_protocol/example/android/**' + - 'bindings/flutter/baselines/**' + - 'bindings/flutter/scripts/**' + - 'bindings/react-native/android/**' + - 'scripts/generate-bindings.sh' + - 'crates/offline-protocol-uniffi/Cargo.toml' + - 'crates/offline-protocol-uniffi/build.rs' + - 'crates/offline-protocol-uniffi/src/lib.rs' + - 'crates/offline-protocol-uniffi/src/offline_protocol.udl' + - 'crates/offline-protocol-uniffi/src/capi/**' + - 'crates/offline-protocol-uniffi/src/host_log.rs' + flutter_lock: + - 'Cargo.lock' + - 'Cargo.toml' + + - name: Decide Flutter native (NDK + AAR) gate + id: flutter_native_gate + run: | + run=false + if [[ "${{ github.event_name }}" == "push" ]]; then + run=true + elif [[ "${{ steps.flutter_paths.outputs.flutter_native }}" == "true" ]]; then + run=true + elif [[ "${{ steps.flutter_paths.outputs.flutter_lock }}" == "true" ]]; then + run=true + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + + - name: Skip (no Flutter-related changes on this PR) + if: >- + github.event_name == 'pull_request' + && steps.flutter_paths.outputs.flutter_dart != 'true' + && steps.flutter_paths.outputs.flutter_native != 'true' + && steps.flutter_paths.outputs.flutter_lock != 'true' + run: echo "No matching paths; Flutter Binding checks skipped for this PR." + + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + if: >- + github.event_name == 'push' + || steps.flutter_paths.outputs.flutter_dart == 'true' + || steps.flutter_paths.outputs.flutter_native == 'true' + || steps.flutter_paths.outputs.flutter_lock == 'true' + with: + channel: stable + flutter-version: "3.27.4" + cache: true + + - name: Dart unit tests and analyzer + if: >- + github.event_name == 'push' + || steps.flutter_paths.outputs.flutter_dart == 'true' + || steps.flutter_paths.outputs.flutter_native == 'true' + || steps.flutter_paths.outputs.flutter_lock == 'true' + working-directory: bindings/flutter/offline_protocol + run: | + flutter pub get + flutter test + flutter analyze + + - uses: actions/setup-java@v6 + if: steps.flutter_native_gate.outputs.run == 'true' + with: + distribution: temurin + java-version: "17" + + - name: Setup Android NDK + if: steps.flutter_native_gate.outputs.run == 'true' + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0 + id: setup-ndk + with: + ndk-version: r26b + add-to-path: true + + - uses: dtolnay/rust-toolchain@stable + if: steps.flutter_native_gate.outputs.run == 'true' + with: + targets: aarch64-linux-android + + - uses: Swatinem/rust-cache@v2 + if: steps.flutter_native_gate.outputs.run == 'true' + with: + shared-key: ci-flutter-binding + + - name: Build arm64 UniFFI for Flutter Android + if: steps.flutter_native_gate.outputs.run == 'true' + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + run: bash bindings/flutter/scripts/ci-build-android-jni-arm64.sh + + # The example's Gradle wrapper is committed and the next step runs it, + # so its jar is checked against Gradle's published checksums first. + - uses: gradle/actions/wrapper-validation@v6 + if: steps.flutter_native_gate.outputs.run == 'true' + + - name: Assemble release AAR and check size baseline + if: steps.flutter_native_gate.outputs.run == 'true' + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + run: bindings/flutter/scripts/measure-plugin-aar-size.sh --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a570c138..1008b527 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1103,7 +1103,19 @@ jobs: exit 1 fi - echo "Workspace and Python versions ($CARGO_VERSION) match tag v$TAG_VERSION." + # The Flutter plugin's podspec and Gradle build read the React Native + # package.json, but pub has no way to read another file, so the + # pubspec carries the number itself and is checked here. Without + # this a release ships a plugin that says the previous version. + FLUTTER_VERSION="$(grep -m1 '^version: ' bindings/flutter/offline_protocol/pubspec.yaml \ + | sed 's/^version: *//' || true)" + + if [ "$TAG_CORE" != "$FLUTTER_VERSION" ]; then + echo "::error title=Flutter plugin version does not match the tag::Tag v$TAG_VERSION wants $TAG_CORE, but bindings/flutter/offline_protocol/pubspec.yaml reads '${FLUTTER_VERSION}'. It moves with the workspace version: bump it, then delete and re-push the tag (nothing has published yet)." + exit 1 + fi + + echo "Workspace, Python and Flutter versions ($CARGO_VERSION) match tag v$TAG_VERSION." # --------------------------------------------------------------------------- # Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2f7663..223b8a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,17 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **A Flutter plugin (preview).** `bindings/flutter/offline_protocol` hosts + the engine on iOS and Android from Dart: create, MLS with the platform + secure stores, start, stop, destroy, the local address, `sendMessage`, the + Internet, BLE 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, less a named list + of the ones that need React, so a transport fix reaches both. Nostr and + Reticulum are not hosted yet, and a config that enables either is refused + at create. Until it is on pub.dev it is a path dependency. CI runs the Dart + tests and assembles the Android plugin against a size baseline; nothing in + CI compiles the iOS plugin yet. The contract is `docs/bridges/flutter.md`. - **A Java binding.** `bindings/java` is a typed Java API generated from the interface definition by `uniffi-bindgen-java`, the fourth member of the artifact set that `scripts/generate-bindings.sh` writes with Swift, Kotlin diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28692b0c..96266212 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -269,7 +269,8 @@ Version files to bump together: | `Cargo.lock` | any `cargo` command refreshes it once the manifests change | | `tools/embedded-footprint/Cargo.lock`, `tools/mls-interop/Cargo.lock` | Separate workspaces that depend on the SDK crates by path, so their lockfiles pin the versions too. CI builds both with `--locked`, so a stale one fails the run. `cargo metadata --manifest-path /Cargo.toml` refreshes each | | `bindings/python/pyproject.toml` | `version` — release.yml gates this one against the tag too | -| `bindings/react-native/package.json` + `package-lock.json` | `npm version X.Y.Z --no-git-tag-version --allow-same-version` from `bindings/react-native/` | +| `bindings/react-native/package.json` + `package-lock.json` | `npm version X.Y.Z --no-git-tag-version --allow-same-version` from `bindings/react-native/`. The Flutter plugin's podspec and Gradle build read their version from this file | +| `bindings/flutter/offline_protocol/pubspec.yaml` | `version`. Pub cannot read another file, so this one carries the number itself, and release.yml gates it against the tag | | `THIRD-PARTY-NOTICES.md` (×3) | `scripts/generate-third-party-notices.sh` — the notices list our own crates by version, and the CI drift gate fails on a stale copy | Then the prose: `CHANGELOG.md` (replace `## [Unreleased]` with diff --git a/Cargo.lock b/Cargo.lock index 39682072..78f95db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3186,9 +3186,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4162,9 +4162,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/README.md b/README.md index 47e2a837..d7f120aa 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Every channel carries the version number of the release it came from. | Swift, iOS (preview) | [`offline-protocol-swift`](https://github.com/Offline-Protocol/offline-protocol-swift), product `OfflineProtocolSDK` | Swift Package Manager, see below | | Android, Kotlin (preview) | A download on each [GitHub release](https://github.com/Offline-Protocol/offline-protocol-sdk/releases); not on Maven Central yet | See below | | C, and any language that calls C | `offline_protocol.h` and the native library in each [GitHub release](https://github.com/Offline-Protocol/offline-protocol-sdk/releases)'s desktop archives | See [bindings/c](bindings/c/README.md) | +| Flutter (iOS, Android, preview) | [`offline_protocol`](bindings/flutter/README.md) plugin in this repository | Path dependency; see [bindings/flutter](bindings/flutter/README.md) | | Java (JVM 22+, desktop and server) | Generated sources in [bindings/java](bindings/java/README.md), over the native library in each release's desktop archives; not on Maven Central yet | See [bindings/java](bindings/java/README.md) | **Python.** Wheels are published for Python 3.10 or later on macOS arm64 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 9947b0b2..07374833 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -826,7 +826,7 @@ DEALINGS IN THE SOFTWARE. Used by: -- [webpki-roots 1.0.9](https://github.com/rustls/webpki-roots) +- [webpki-roots 1.0.10](https://github.com/rustls/webpki-roots) ``` # Community Data License Agreement - Permissive - Version 2.0 @@ -949,7 +949,7 @@ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Used by: -- [rustls-webpki 0.103.15](https://github.com/rustls/webpki) +- [rustls-webpki 0.103.16](https://github.com/rustls/webpki) ``` Except as otherwise noted, this project is licensed under the following diff --git a/bindings/flutter/.gitignore b/bindings/flutter/.gitignore new file mode 100644 index 00000000..17fa0c70 --- /dev/null +++ b/bindings/flutter/.gitignore @@ -0,0 +1,14 @@ +.dart_tool/ +.packages +build/ +**/example/build/ +**/example/.dart_tool/ +**/example/.flutter-plugins-dependencies +**/example/ios/Flutter/ +**/example/ios/Pods/ +**/example/ios/.symlinks/ +**/example/ios/Runner/GeneratedPluginRegistrant.h +**/example/ios/Runner/GeneratedPluginRegistrant.m +**/example/android/.gradle/ +**/example/android/local.properties +**/example/android/app/src/main/java/io/flutter/plugins/ diff --git a/bindings/flutter/README.md b/bindings/flutter/README.md new file mode 100644 index 00000000..317c5821 --- /dev/null +++ b/bindings/flutter/README.md @@ -0,0 +1,172 @@ +# Flutter bindings + +Flutter plugin for the [Offline Protocol SDK](https://github.com/Offline-Protocol/offline-protocol-sdk): offline-first mesh messaging with MLS encryption. + +Hand-written native bridge code is shared with the React Native module under +`bindings/react-native/{ios,android}` +([ADR 0025](../../docs/adr/0025-native-packages-are-assembled-in-place.md)). +The plugin links the same UniFFI artifacts (Kotlin/Swift generated from the UDL) +as React Native, not a separate bindgen output. + +Bridge contract (for contributors changing the boundary): +[docs/bridges/flutter.md](../../docs/bridges/flutter.md). + +## Requirements + +| Platform | Version | +|----------|---------| +| Flutter | >= 3.10 | +| iOS | >= 13.0 | +| Android | >= API 24 (Android 7.0) | + +You also need the Rust toolchain and a one-time build of the mobile UniFFI +libraries (same steps as React Native). + +## Native libraries + +From the repository root: + +```bash +./scripts/generate-bindings.sh +cd bindings/react-native +npm run build:uniffi:all +``` + +This produces: + +- `bindings/react-native/android/src/main/jniLibs/**/*.so` +- `bindings/react-native/ios/libs/offline_protocol_uniffi.xcframework` +- Generated Kotlin/Swift under the React Native tree + +## Add the plugin + +Until the plugin is published to pub.dev, depend on the path in this repository: + +```yaml +dependencies: + offline_protocol: + path: ../bindings/flutter/offline_protocol # adjust for your layout +``` + +## Platform setup + +Use the same host permissions and manifests as React Native: + +- **iOS:** Bluetooth usage descriptions and `UIBackgroundModes` (`bluetooth-central`, + `bluetooth-peripheral`) before BLE starts. See + [React Native iOS permissions](../react-native/README.md#ios-permissions). +- **Android:** Runtime Bluetooth and (where applicable) location / nearby Wi‑Fi + permissions. See + [React Native Android permissions](../react-native/README.md#android-permissions). + +### iOS: shared bridge copy (`BridgeRN`) + +CocoaPods indexes sources only under the pod root. On `pod install`, the plugin +copies shared Swift bridge files from `bindings/react-native/ios` into +`offline_protocol/ios/BridgeRN/` (gitignored). After you change shared iOS +bridge sources, refresh and reinstall: + +```bash +bash bindings/flutter/offline_protocol/ios/sync_bridge_rn.sh +cd your_app/ios && pod install +``` + +## Quick start + +Subscribe to events **before** `createFromConfig` if you need every event or +gap recovery via native replay (same contract as React Native). + +```dart +import 'package:offline_protocol/offline_protocol.dart'; + +final sub = OfflineProtocol.events.listen((e) { + final type = e.decodeEvent()['type']; + // message_received, diagnostics, … +}); + +await OfflineProtocol.createFromConfig({ + 'appId': 'com.example.app', + 'profile': 'default', + 'bleEnabled': true, + 'internetEnabled': true, + 'transports': { + 'internet': { + 'enabled': true, + 'serverAddress': 'wss://relay.example.com', + }, + 'wifiDirect': {'enabled': true}, + }, +}); +await OfflineProtocol.initializeMlsWithSecureStorage(); +await OfflineProtocol.start(); + +final messageId = await OfflineProtocol.sendMessage( + recipient: 'off1…', + content: 'Hello', +); +``` + +UniFFI and transport work run on a dedicated engine thread; method-channel and +event-channel callbacks are delivered on the main isolate’s platform thread +([F2](../../docs/bridges/flutter.md#f2-engine-thread-and-main-thread-callbacks-k2-parity)). + +## API surface + +| Area | Dart API | +|------|----------| +| Lifecycle | `createFromConfig`, `initializeMlsWithSecureStorage`, `start`, `stop`, `destroy` | +| Identity | `localAddress` | +| Messaging | `sendMessage` | +| Events | `OfflineProtocol.events`, native `replayEvents` via `NativeEventPipeline` | +| Internet | `enableInternetTransport`, auto-enable after `start()` when `transports.internet` has `serverAddress` | +| BLE | Wired when `bleEnabled` is true; `enableTransport('ble', …)` | +| Wi‑Fi Direct / peer stream | `enableTransport('wifiDirect', …)`, auto-enable when configured | +| Config | Optional top-level `dors` and `relay` JSON (shared `InitialRuntimeConfigApplier`) | + +**Not in the Flutter plugin yet:** Nostr, Reticulum, telemetry pipe, and the +React Native foreground mesh service. `createFromConfig` refuses a config with +`reticulumEnabled` or `nostrEnabled` set, rather than enabling a transport +nothing serves. Use React Native for those, or host their managers in the +plugin and take them off its exclude lists. + +Configuration fields and transport behavior match the core and React Native; +see [Configuration](../../docs/configuration.md) and the +[React Native README](../react-native/README.md). + +## Example app + +**Android** (minimal Gradle tree is in the repository): + +```bash +cd bindings/flutter/offline_protocol/example +flutter pub get +flutter run -d android +``` + +**iOS** (generate the Xcode `Runner` project once; not committed because Flutter +tooling versions differ): + +```bash +bindings/flutter/scripts/bootstrap-example-platforms.sh +cd bindings/flutter/offline_protocol/example +flutter pub get +flutter run -d # flutter devices +``` + +The example uses a minimal config (Internet transport flag only, no relay or BLE +start), subscribes to events, and prints the device `off1…` address. Details: +[example/README.md](offline_protocol/example/README.md). + +## Maintainers + +- **CI:** GitHub Actions job `Flutter Binding` (`.github/workflows/ci.yml`): + `flutter test`, `flutter analyze`, arm64 UniFFI into jniLibs, release AAR + assembly, and `measure-plugin-aar-size.sh --check`. Path filters skip the + native gate when only Dart or guard tests change; pushes to `main` run the + full job. No CI job compiles the iOS plugin yet; the manual typecheck is in + [docs/bridges/flutter.md](../../docs/bridges/flutter.md#what-ci-runs). +- **Version:** the podspec and the Gradle build read it from + `bindings/react-native/package.json`. Bump `offline_protocol/pubspec.yaml` + with it; the release refuses a tag the pubspec does not match. +- **AAR footprint:** [baselines/README.md](baselines/README.md). +- **Rust guards:** `crates/offline-protocol-uniffi/src/tests/flutter_bridge_guards.rs`. diff --git a/bindings/flutter/baselines/README.md b/bindings/flutter/baselines/README.md new file mode 100644 index 00000000..7a7fe7aa --- /dev/null +++ b/bindings/flutter/baselines/README.md @@ -0,0 +1,33 @@ +# Flutter plugin size baselines + +Release artifact sizes for CI and local checks against unexpected growth in +the Flutter plugin native surface. + +| File | What it measures | +|------|------------------| +| `offline-protocol-release-aar.json` | `offline_protocol-release.aar` from `:offline_protocol:assembleRelease` | + +The AAR includes shared bridge Kotlin and UniFFI `jniLibs` from +`bindings/react-native/android`. It measures the plugin native surface, not a +full example APK (which also includes the Flutter engine and app code). + +The baseline is the arm64-v8a library built with the `mobile` profile, the one +the Android release ships, which is what +`bindings/flutter/scripts/ci-build-android-jni-arm64.sh` builds. A local tree +that also holds other ABIs (after `npm run build:uniffi:android`) measures the +same, because the script subtracts the bytes every other ABI occupies in the +archive. + +## Update a baseline + +After an intentional change to the plugin compile surface or UniFFI libraries: + +```bash +bindings/flutter/scripts/measure-plugin-aar-size.sh --write-baseline +``` + +## Verify locally (same check as CI) + +```bash +bindings/flutter/scripts/measure-plugin-aar-size.sh --check +``` diff --git a/bindings/flutter/baselines/offline-protocol-release-aar.json b/bindings/flutter/baselines/offline-protocol-release-aar.json new file mode 100644 index 00000000..017f58a4 --- /dev/null +++ b/bindings/flutter/baselines/offline-protocol-release-aar.json @@ -0,0 +1,8 @@ +{ + "artifact": "offline_protocol-release.aar", + "gradle_module": ":offline_protocol:assembleRelease", + "working_directory": "bindings/flutter/offline_protocol/example/android", + "bytes": 5447562, + "tolerance_bytes": 1048576, + "description": "Flutter plugin release AAR: the shared Kotlin bridge and the arm64-v8a UniFFI library built with the mobile profile, as CI builds it. measure-plugin-aar-size.sh subtracts any other ABI under react-native jniLibs. Re-baseline with bindings/flutter/scripts/measure-plugin-aar-size.sh --write-baseline after intentional native surface changes." +} diff --git a/bindings/flutter/offline_protocol/analysis_options.yaml b/bindings/flutter/offline_protocol/analysis_options.yaml new file mode 100644 index 00000000..4cafe968 --- /dev/null +++ b/bindings/flutter/offline_protocol/analysis_options.yaml @@ -0,0 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_print: false diff --git a/bindings/flutter/offline_protocol/android/build.gradle b/bindings/flutter/offline_protocol/android/build.gradle new file mode 100644 index 00000000..58ecfdb3 --- /dev/null +++ b/bindings/flutter/offline_protocol/android/build.gradle @@ -0,0 +1,105 @@ +group 'com.offlineprotocol.flutter' + +buildscript { + ext.kotlin_version = '1.9.24' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.3.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +// Bridge sources have one home (ADR 0025). The compile surface is every +// shared bridge source except the ones the KotlinCompile excludes below name: +// a list of what to leave out, never a list of what to take, so a new shared +// source reaches this plugin without being registered (decision 3). +def bridgeDir = file('../../../react-native/android') +def bridgeJava = bridgeDir.path + '/src/main/java' + +// Values the React Native module declares are read from the line that declares +// them, never written again here (decision 5): a copy is right on the day it is +// made and drifts with nothing failing. Each must be declared exactly once. +def moduleBuild = new File(bridgeDir, 'build.gradle').text +def exactlyOne = { String name, String pattern -> + def found = (moduleBuild =~ pattern).collect { it[1] } + if (found.size() != 1) { + throw new GradleException( + "react-native/android/build.gradle must declare ${name} exactly once (found ${found.size()})") + } + found[0] +} +def sdkLevel = { String name -> + exactlyOne(name, /(?m)^[ \t]*${java.util.regex.Pattern.quote(name)}[ \t]+(\d+)[ \t]*$/).toInteger() +} +def moduleDependency = { String module -> + def quoted = java.util.regex.Pattern.quote(module) + exactlyOne(module, /(?m)^[ \t]*implementation[ \t(]+['"]${quoted}:([^'"@]+)(?:@aar)?['"]/) +} +def rnPackage = new groovy.json.JsonSlurper().parse(file('../../../react-native/package.json')) +version rnPackage.version + +android { + namespace 'com.offlineprotocol' + compileSdk sdkLevel('compileSdk') + + defaultConfig { + minSdkVersion sdkLevel('minSdkVersion') + consumerProguardFiles bridgeDir.path + '/consumer-rules.pro' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main { + manifest.srcFile 'src/main/AndroidManifest.xml' + java { + srcDirs bridgeJava, 'src/main/kotlin' + } + jniLibs.srcDirs bridgeDir.path + '/src/main/jniLibs' + } + } +} + +// The Kotlin plugin compiles every `.kt` under [bridgeJava] unless excluded here. +// These need React, or are transports the plugin does not host yet. A Rust guard +// (flutter_android_excludes_name_real_files) refuses a name that matches no file, +// which is a rename nobody carried over and would let the renamed file in. +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + exclude('com/offlineprotocol/OfflineProtocolModule.kt') + exclude('com/offlineprotocol/OfflineProtocolPackage.kt') + exclude('com/offlineprotocol/MeshHeadlessWakeService.kt') + exclude('com/offlineprotocol/MeshForegroundService.kt') + exclude('com/offlineprotocol/ReticulumManager.kt') + exclude('com/offlineprotocol/NostrManager.kt') + exclude('com/offlineprotocol/NostrQueryTracker.kt') + exclude('com/offlineprotocol/StickyEventBuffer.kt') + exclude('com/offlineprotocol/StickyEventDispatcher.kt') + exclude('com/offlineprotocol/MeshWakePolicy.kt') +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "net.java.dev.jna:jna:${moduleDependency('net.java.dev.jna:jna')}@aar" + implementation "androidx.security:security-crypto:${moduleDependency('androidx.security:security-crypto')}" + implementation "com.squareup.okhttp3:okhttp:${moduleDependency('com.squareup.okhttp3:okhttp')}" +} diff --git a/bindings/flutter/offline_protocol/android/src/main/AndroidManifest.xml b/bindings/flutter/offline_protocol/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a2f47b60 --- /dev/null +++ b/bindings/flutter/offline_protocol/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/bindings/flutter/offline_protocol/android/src/main/kotlin/com/offlineprotocol/flutter/OfflineProtocolPlugin.kt b/bindings/flutter/offline_protocol/android/src/main/kotlin/com/offlineprotocol/flutter/OfflineProtocolPlugin.kt new file mode 100644 index 00000000..fe930618 --- /dev/null +++ b/bindings/flutter/offline_protocol/android/src/main/kotlin/com/offlineprotocol/flutter/OfflineProtocolPlugin.kt @@ -0,0 +1,648 @@ +package com.offlineprotocol.flutter + +import android.app.Activity +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import com.offlineprotocol.AppContainerProtocolStateStorage +import com.offlineprotocol.EventReplayRing +import com.offlineprotocol.ForegroundReconnectPolicy +import com.offlineprotocol.InitialRuntimeConfigApplier +import com.offlineprotocol.InternetManager +import com.offlineprotocol.InternetTransportBootstrap +import com.offlineprotocol.MlsSecureStorage +import com.offlineprotocol.ProtocolConfigParser +import com.offlineprotocol.StorageNamespace +import com.offlineprotocol.TeardownSequence +import com.offlineprotocol.TransportManagerListener +import com.offlineprotocol.TransportState +import com.offlineprotocol.WifiDirectGroupFormation +import com.offlineprotocol.WifiDirectManager +import com.offlineprotocol.ble.BleTransportFacade +import com.offlineprotocol.mapProtocolBridgeError +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import org.json.JSONObject +import uniffi.offline_protocol.BleTransportCallback +import uniffi.offline_protocol.EventCallback +import uniffi.offline_protocol.MessagePriority +import uniffi.offline_protocol.OfflineProtocol +import uniffi.offline_protocol.ProtocolConfig +import uniffi.offline_protocol.WifiDirectTransportCallback +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Flutter host for the Offline Protocol engine. + * + * All UniFFI calls run on a dedicated engine thread ([engineExecutor]); results + * and [EventChannel] deliveries are posted on the main looper ([mainHandler]). + * See bridge contract K2. + * + * The executor is one per process, in the companion object, and is never shut + * down. Flutter creates a new plugin instance for every engine, so an executor + * per instance would let a new engine's `create` run while the previous + * engine's teardown is still blocked on transport I/O: two cores under one + * identity, both on BLE and the relay. Detach queues the teardown behind every + * call already submitted and returns, so the main thread never waits, and any + * later call from any instance queues behind that teardown on the same thread. + * The one thread exits after it has been idle for [ENGINE_IDLE_SECONDS]. + */ +class OfflineProtocolPlugin : + FlutterPlugin, + MethodCallHandler, + EventChannel.StreamHandler, + ActivityAware { + + private lateinit var channel: MethodChannel + private var eventsChannel: EventChannel? = null + @Volatile + private var applicationContext: android.content.Context? = null + private var protocol: OfflineProtocol? = null + private var currentConfig: ProtocolConfig? = null + private var internetManager: InternetManager? = null + private var bleTransport: BleTransportFacade? = null + private var wifiDirectManager: WifiDirectManager? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + private val attached = AtomicBoolean(false) + + private val foregroundReconnectPolicy = ForegroundReconnectPolicy() + private var activityBinding: ActivityPluginBinding? = null + private var activityLifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null + + @Volatile + private var eventSink: EventChannel.EventSink? = null + private val eventSeqLock = Any() + private var eventSeq = 0 + private val eventReplay = EventReplayRing() + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = binding.applicationContext + attached.set(true) + channel = MethodChannel(binding.binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + eventsChannel = EventChannel(binding.binaryMessenger, EVENTS_CHANNEL_NAME).also { + it.setStreamHandler(this) + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + if (!attached.getAndSet(false)) { + return + } + detachActivityLifecycle() + eventsChannel?.setStreamHandler(null) + eventsChannel = null + eventSink = null + if (::channel.isInitialized) { + channel.setMethodCallHandler(null) + } + // Cleared here, on the main thread, and not by the queued teardown: + // a teardown that ran after a re-attach would clear the new context. + applicationContext = null + // Queued, never waited on: engine stop can block on transport I/O, and + // the main thread is not where that wait belongs (K2). + runOnEngine("detach teardown") { tearDownProtocol() } + } + + private fun runOnEngine(what: String, block: () -> Unit) { + try { + engineExecutor.execute { + try { + block() + } catch (e: Throwable) { + Log.w(TAG, "$what failed", e) + } + } + } catch (e: RejectedExecutionException) { + Log.w(TAG, "$what rejected by the engine executor", e) + } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + val trackedActivity = binding.activity + val callbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityPaused(activity: Activity) { + if (activity !== trackedActivity) return + runOnEngine("background mark") { + foregroundReconnectPolicy.didEnterBackground( + nowMs = SystemClock.elapsedRealtime(), + ) + } + } + + override fun onActivityResumed(activity: Activity) { + if (activity !== trackedActivity) return + runOnEngine("foreground reconnect") { + if (foregroundReconnectPolicy.shouldReconnectOnForeground( + nowMs = SystemClock.elapsedRealtime(), + ) + ) { + try { + internetManager?.forceReconnect() + } catch (e: Exception) { + Log.w(TAG, "Internet forceReconnect on foreground failed", e) + } + } + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: android.os.Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: android.os.Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + activityBinding = binding + activityLifecycleCallbacks = callbacks + trackedActivity.application.registerActivityLifecycleCallbacks(callbacks) + } + + override fun onDetachedFromActivityForConfigChanges() { + detachActivityLifecycle() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + detachActivityLifecycle() + } + + private fun detachActivityLifecycle() { + val callbacks = activityLifecycleCallbacks + if (callbacks != null) { + activityBinding?.activity?.application?.unregisterActivityLifecycleCallbacks(callbacks) + } + activityLifecycleCallbacks = null + activityBinding = null + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + override fun onMethodCall(call: MethodCall, result: Result) { + if (!attached.get()) { + postDetachedError(result) + return + } + try { + engineExecutor.execute { + try { + val payload: Any? = when (call.method) { + "create" -> { + createOnEngine(call.arguments) + null + } + "initializeMlsWithSecureStorage" -> { + initializeMlsOnEngine() + null + } + "start" -> { + startOnEngine() + null + } + "stop" -> { + stopOnEngine() + null + } + "destroy" -> { + tearDownProtocol() + null + } + "localAddress" -> protocol?.localAddress() + "sendMessage" -> sendMessageOnEngine(call.arguments) + "enableInternetTransport" -> { + enableTransportOnEngine("internet", call.arguments) + null + } + "enableTransport" -> { + val map = call.arguments as? Map<*, *> + ?: throw IllegalArgumentException("enableTransport expects a map") + val type = map["type"] as? String + ?: throw IllegalArgumentException("enableTransport requires type") + val configJson = map["config"] as? String + enableTransportOnEngine(type, configJson) + null + } + "replayEvents" -> replayEventsOnEngine(call.arguments) + else -> { + mainHandler.post { result.notImplemented() } + return@execute + } + } + mainHandler.post { result.success(payload) } + } catch (e: Throwable) { + // Throwable, not Exception: the generated bindings load the + // native library on first use, and a missing or mismatched + // .so throws UnsatisfiedLinkError. Uncaught on this thread it + // kills the process and the call is never answered. + Log.e(TAG, "${call.method} failed", e) + mainHandler.post { + val mapped = mapProtocolBridgeError(e) + when { + mapped != null -> result.error(mapped.code, mapped.message, null) + e is LinkageError -> result.error( + NATIVE_LIBRARY_ERROR, + "The Offline Protocol native library could not be loaded: ${e.message}", + null, + ) + else -> result.error("ERROR", e.message, null) + } + } + } + } + } catch (e: RejectedExecutionException) { + Log.w(TAG, "Method call rejected: engine executor shut down", e) + postDetachedError(result) + } + } + + private fun postDetachedError(result: Result) { + mainHandler.post { + result.error("ERROR", "Plugin detached", null) + } + } + + private fun emitEventJson(eventJson: String) { + val sink = eventSink ?: return + val seq: Int + synchronized(eventSeqLock) { + seq = eventSeq + eventReplay.record(seq, eventJson) + eventSeq += 1 + } + mainHandler.post { + sink.success( + mapOf( + "eventJson" to eventJson, + "seq" to seq, + ), + ) + } + } + + private fun replayEventsOnEngine(arguments: Any?): List> { + val map = arguments as? Map<*, *> + ?: throw IllegalArgumentException("replayEvents expects a map") + val fromSeq = (map["fromSeq"] as? Number)?.toInt() + ?: throw IllegalArgumentException("replayEvents requires fromSeq") + val toSeq = (map["toSeq"] as? Number)?.toInt() + ?: throw IllegalArgumentException("replayEvents requires toSeq") + return eventReplay.replay(fromSeq, toSeq).map { entry -> + mapOf( + "seq" to entry.seq, + "eventJson" to entry.eventJson, + ) + } + } + + private fun createOnEngine(arguments: Any?) { + val configJson = arguments as? String + ?: throw IllegalArgumentException("create expects a JSON string") + // Validated before the running engine is torn down: a config this + // plugin refuses must leave the current session as it was. + val parsed = ProtocolConfigParser.parse(configJson) + refuseUnhostedTransports(parsed.coreConfig) + tearDownProtocol() + val proto = OfflineProtocol(parsed.coreConfig) + proto.setEventCallback(object : EventCallback { + override fun onEvent(eventJson: String) { + try { + emitEventJson(eventJson) + } catch (e: Throwable) { + Log.e(TAG, "Core event not handed to Dart", e) + } + } + }) + InitialRuntimeConfigApplier.apply(proto, parsed.rawJson) + protocol = proto + currentConfig = parsed.coreConfig + + val ctx = applicationContext + val config = parsed.coreConfig + if (config.bleEnabled && ctx != null) { + bleTransport = BleTransportFacade( + ctx, + proto, + config.profile, + config.appId, + ) { level, message, context -> + Log.println( + when (level) { + "error" -> Log.ERROR + "warning" -> Log.WARN + else -> Log.INFO + }, + TAG, + "$message ${context ?: emptyMap()}", + ) + }.also { manager -> + manager.listener = object : TransportManagerListener { + override fun onTransportStateChanged( + manager: com.offlineprotocol.TransportManager, + state: TransportState, + ) { + Log.i(TAG, "BLE transport state: ${state.name.lowercase()}") + } + + override fun onTransportError( + manager: com.offlineprotocol.TransportManager, + error: Throwable, + ) { + Log.w(TAG, "BLE transport error", error) + } + + override fun onTransportDiagnostic( + manager: com.offlineprotocol.TransportManager, + level: String, + message: String, + context: Map, + ) { + Log.println( + when (level) { + "error" -> Log.ERROR + "warning" -> Log.WARN + else -> Log.INFO + }, + TAG, + "$message $context", + ) + } + } + } + } + wireTransportCallbacks(proto) + } + + /** + * Refuses a config that enables a transport this plugin has no manager + * for. Accepted, the core would route to a transport nothing serves and + * messages would wait on it with no error anywhere. + */ + private fun refuseUnhostedTransports(config: ProtocolConfig) { + val unhosted = buildList { + if (config.reticulumEnabled) add("reticulumEnabled") + if (config.nostrEnabled) add("nostrEnabled") + } + if (unhosted.isNotEmpty()) { + throw IllegalArgumentException( + "${unhosted.joinToString()} is not supported by the Flutter plugin yet; set it to false", + ) + } + } + + private fun wireTransportCallbacks(proto: OfflineProtocol) { + bleTransport?.let { manager -> + try { + proto.setBleTransportCallback(object : BleTransportCallback { + override fun onFragmentsAvailable() { + manager.onFragmentsAvailable() + } + }) + } catch (e: Throwable) { + Log.w(TAG, "BLE transport callback not available", e) + } + } + if (currentConfig?.wifiDirectEnabled == true) { + try { + proto.setWifiDirectTransportCallback(object : WifiDirectTransportCallback { + override fun onMessagesAvailable() { + wifiDirectManager?.onMessagesAvailable() + } + }) + } catch (e: Throwable) { + Log.w(TAG, "Wi-Fi Direct transport callback not available", e) + } + } + } + + private fun initializeMlsOnEngine() { + val ctx = applicationContext + ?: throw IllegalStateException("Plugin not attached") + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + val config = currentConfig ?: throw IllegalStateException("Protocol config not initialized") + val accountNamespace = StorageNamespace.account(config.appId, config.profile) + val secureStorage = MlsSecureStorage(ctx, accountNamespace) + val protocolStateStorage = AppContainerProtocolStateStorage(ctx, accountNamespace) + proto.initializeMls(secureStorage, protocolStateStorage) + } + + private fun startOnEngine() { + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + proto.start() + bleTransport?.let { manager -> + try { + manager.start() + } catch (e: Exception) { + Log.e(TAG, "Failed to start BLE manager", e) + } + } + } + + private fun stopOnEngine() { + val teardown = TeardownSequence() + teardown.step("BLE manager") { bleTransport?.stop() } + teardown.step("Internet manager") { internetManager?.stop() } + teardown.step("Wi-Fi Direct manager") { wifiDirectManager?.stop() } + teardown.step("protocol") { protocol?.stop() } + teardown.firstFailureOrNull()?.let { throw it } + } + + private fun enableTransportOnEngine(type: String, arguments: Any?) { + val configJson = arguments as? String + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + val config = currentConfig ?: throw IllegalStateException("Protocol config not initialized") + val ctx = applicationContext ?: throw IllegalStateException("Plugin not attached") + when (type.lowercase()) { + "internet" -> { + val json = configJson?.let { JSONObject(it) } ?: JSONObject() + val manager = ensureInternetManager(proto, config) + if (manager.state == TransportState.RUNNING || + manager.state == TransportState.STARTING + ) { + manager.stop() + } + InternetTransportBootstrap.configureAndStart(manager, json) + } + "wifidirect", "wifi_direct" -> { + if (wifiDirectManager == null) { + wifiDirectManager = WifiDirectManager( + ctx, + proto, + config.profile, + appId = config.appId, + ) { level, message, context -> + Log.println( + when (level) { + "error" -> Log.ERROR + "warning" -> Log.WARN + else -> Log.INFO + }, + TAG, + "$message ${context ?: emptyMap()}", + ) + } + } + val manager = wifiDirectManager + ?: throw IllegalStateException("Failed to create Wi-Fi Direct manager") + wireTransportCallbacks(proto) + val autoAccept = configJson?.let { + val json = JSONObject(it) + if (json.has("autoAccept") && !json.isNull("autoAccept")) { + json.getBoolean("autoAccept") + } else { + null + } + } + if (manager.state == TransportState.RUNNING) { + manager.stop() + } + manager.formGroups = + WifiDirectGroupFormation.formGroupsAfterEnable(manager.formGroups, autoAccept) + manager.start() + } + "ble" -> { + if (bleTransport == null) { + bleTransport = BleTransportFacade( + ctx, + proto, + config.profile, + config.appId, + ) { level, message, context -> + Log.println( + when (level) { + "error" -> Log.ERROR + "warning" -> Log.WARN + else -> Log.INFO + }, + TAG, + "$message ${context ?: emptyMap()}", + ) + } + } + val manager = bleTransport + ?: throw IllegalStateException("Failed to create BLE manager") + wireTransportCallbacks(proto) + if (manager.state != TransportState.RUNNING) { + manager.start() + } + } + "reticulum", "nostr" -> throw UnsupportedOperationException( + "Transport $type is not compiled into the Flutter plugin yet", + ) + else -> throw IllegalArgumentException("Unsupported transport type: $type") + } + } + + private fun ensureInternetManager( + proto: OfflineProtocol, + config: ProtocolConfig, + ): InternetManager { + internetManager?.let { return it } + val ctx = applicationContext ?: throw IllegalStateException("Plugin not attached") + return InternetTransportBootstrap.createManager( + ctx, + proto, + config.profile, + config.appId, + ) { level, message, context -> + Log.println( + when (level) { + "error" -> Log.ERROR + "warning" -> Log.WARN + else -> Log.INFO + }, + TAG, + "$message ${context ?: emptyMap()}", + ) + }.also { manager -> + InternetTransportBootstrap.attachEmitters(manager, ::emitEventJson) + internetManager = manager + } + } + + private fun sendMessageOnEngine(arguments: Any?): String { + val map = arguments as? Map<*, *> + ?: throw IllegalArgumentException("sendMessage expects a map") + val recipient = map["recipient"] as? String + ?: throw IllegalArgumentException("sendMessage requires recipient") + val content = map["content"] as? String + ?: throw IllegalArgumentException("sendMessage requires content") + val priorityValue = (map["priority"] as? Number)?.toInt() ?: 1 + val replyTo = map["replyToMsg"] as? String + val msgPriority = when (priorityValue) { + 0 -> MessagePriority.LOW + 1 -> MessagePriority.MEDIUM + 2 -> MessagePriority.HIGH + 3 -> MessagePriority.CRITICAL + else -> MessagePriority.MEDIUM + } + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + return proto.sendMessage(recipient, content, msgPriority, replyTo) + } + + private fun tearDownProtocol() { + val teardown = TeardownSequence() + teardown.step("BLE manager") { bleTransport?.stop() } + bleTransport = null + teardown.step("Internet manager") { internetManager?.stop() } + internetManager = null + teardown.step("Wi-Fi Direct manager") { wifiDirectManager?.stop() } + wifiDirectManager = null + try { + protocol?.stop() + } catch (e: Exception) { + Log.w(TAG, "stop during teardown", e) + } + try { + protocol?.destroy() + } catch (e: Exception) { + Log.w(TAG, "destroy during teardown", e) + } + protocol = null + currentConfig = null + synchronized(eventSeqLock) { + eventSeq = 0 + eventReplay.clear() + } + } + + companion object { + private const val TAG = "OfflineProtocolPlugin" + private const val CHANNEL_NAME = "com.offlineprotocol/offline_protocol" + private const val EVENTS_CHANNEL_NAME = "com.offlineprotocol/offline_protocol_events" + private const val ENGINE_IDLE_SECONDS = 30L + + /** Error code for a native library that is missing or does not match the bindings. */ + private const val NATIVE_LIBRARY_ERROR = "NATIVE_LIBRARY_UNAVAILABLE" + + /** + * The engine thread for every plugin instance in the process, so calls + * from all engines run in submission order (see the class comment). + * It exits when idle and is recreated by the next submission. + */ + private val engineExecutor: ThreadPoolExecutor = + ThreadPoolExecutor(1, 1, ENGINE_IDLE_SECONDS, TimeUnit.SECONDS, LinkedBlockingQueue()) { runnable -> + Thread(runnable, "OfflineProtocolEngine").apply { isDaemon = true } + }.apply { allowCoreThreadTimeOut(true) } + } +} diff --git a/bindings/flutter/offline_protocol/example/README.md b/bindings/flutter/offline_protocol/example/README.md new file mode 100644 index 00000000..8a7da022 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/README.md @@ -0,0 +1,30 @@ +# Offline Protocol Flutter example + +Minimal app: boot the engine, listen for core events, print this device’s +`off1…` address. + +Build native libraries first ([bindings/flutter/README.md](../../README.md)). + +## Android + +```bash +flutter pub get +flutter run -d android +``` + +## iOS + +The Xcode `Runner` project is not committed. Generate it once: + +```bash +../../scripts/bootstrap-example-platforms.sh +flutter pub get +flutter run -d +``` + +After bootstrap, merge Bluetooth `Info.plist` keys from +[React Native iOS permissions](../../../react-native/README.md#ios-permissions) +if you enable BLE in your own config (the example smoke config does not start BLE). + +For iOS pods and the `BridgeRN` copy step, see +[bindings/flutter/README.md](../../README.md). diff --git a/bindings/flutter/offline_protocol/example/analysis_options.yaml b/bindings/flutter/offline_protocol/example/analysis_options.yaml new file mode 100644 index 00000000..0ba68834 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/analysis_options.yaml @@ -0,0 +1,6 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** +include: package:flutter_lints/flutter.yaml diff --git a/bindings/flutter/offline_protocol/example/android/app/build.gradle b/bindings/flutter/offline_protocol/example/android/app/build.gradle new file mode 100644 index 00000000..211795b9 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/app/build.gradle @@ -0,0 +1,38 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace "com.offlineprotocol.flutter.example" + compileSdk 34 + ndkVersion flutter.ndkVersion + + defaultConfig { + applicationId "com.offlineprotocol.flutter.example" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildTypes { + release { + signingConfig signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/bindings/flutter/offline_protocol/example/android/app/src/main/AndroidManifest.xml b/bindings/flutter/offline_protocol/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d050e6d9 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/bindings/flutter/offline_protocol/example/android/app/src/main/kotlin/com/offlineprotocol/flutter/example/MainActivity.kt b/bindings/flutter/offline_protocol/example/android/app/src/main/kotlin/com/offlineprotocol/flutter/example/MainActivity.kt new file mode 100644 index 00000000..b26b43fb --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/app/src/main/kotlin/com/offlineprotocol/flutter/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.offlineprotocol.flutter.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/bindings/flutter/offline_protocol/example/android/app/src/main/res/values/styles.xml b/bindings/flutter/offline_protocol/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..75f92afd --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/bindings/flutter/offline_protocol/example/android/build.gradle b/bindings/flutter/offline_protocol/example/android/build.gradle new file mode 100644 index 00000000..d2ffbffa --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/bindings/flutter/offline_protocol/example/android/gradle.properties b/bindings/flutter/offline_protocol/example/android/gradle.properties new file mode 100644 index 00000000..f2122b21 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/bindings/flutter/offline_protocol/example/android/gradle/wrapper/gradle-wrapper.jar b/bindings/flutter/offline_protocol/example/android/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 00000000..13372aef Binary files /dev/null and b/bindings/flutter/offline_protocol/example/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/bindings/flutter/offline_protocol/example/android/gradle/wrapper/gradle-wrapper.properties b/bindings/flutter/offline_protocol/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e4ef43fb --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/bindings/flutter/offline_protocol/example/android/gradlew b/bindings/flutter/offline_protocol/example/android/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/bindings/flutter/offline_protocol/example/android/gradlew.bat b/bindings/flutter/offline_protocol/example/android/gradlew.bat new file mode 100755 index 00000000..aec99730 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/bindings/flutter/offline_protocol/example/android/settings.gradle b/bindings/flutter/offline_protocol/example/android/settings.gradle new file mode 100644 index 00000000..ff03f22b --- /dev/null +++ b/bindings/flutter/offline_protocol/example/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false +} + +include ":app" diff --git a/bindings/flutter/offline_protocol/example/ios/Podfile b/bindings/flutter/offline_protocol/example/ios/Podfile new file mode 100644 index 00000000..91890a4c --- /dev/null +++ b/bindings/flutter/offline_protocol/example/ios/Podfile @@ -0,0 +1,36 @@ +platform :ios, '13.0' + +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. Run flutter pub get in the example directory first." + end + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/bindings/flutter/offline_protocol/example/lib/main.dart b/bindings/flutter/offline_protocol/example/lib/main.dart new file mode 100644 index 00000000..859924cc --- /dev/null +++ b/bindings/flutter/offline_protocol/example/lib/main.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:offline_protocol/offline_protocol.dart'; + +void main() { + runApp(const OfflineProtocolExampleApp()); +} + +class OfflineProtocolExampleApp extends StatelessWidget { + const OfflineProtocolExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Offline Protocol', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + useMaterial3: true, + ), + home: const HomePage(), + ); + } +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + String _status = 'Tap Start to boot the engine.'; + bool _busy = false; + StreamSubscription? _eventSub; + int _eventCount = 0; + String? _lastEventType; + + @override + void initState() { + super.initState(); + // Subscribe before create/start so native assigns seq and replay can recover gaps. + _eventSub = OfflineProtocol.events.listen((event) { + if (!mounted) return; + setState(() { + _eventCount += 1; + try { + _lastEventType = event.decodeEvent()['type']?.toString(); + } catch (_) { + _lastEventType = '(unparsed)'; + } + }); + }); + } + + @override + void dispose() { + _eventSub?.cancel(); + super.dispose(); + } + + Future _boot() async { + setState(() { + _busy = true; + _status = 'Creating engine…'; + _eventCount = 0; + _lastEventType = null; + }); + try { + final address = await OfflineProtocol.bootstrapSmokeTest( + appId: 'com.offlineprotocol.flutter.example', + profile: 'default', + ); + if (!mounted) return; + setState(() { + _status = address == null + ? 'Engine started but local address is not ready yet.' + : 'This device: $address\nEvents received: $_eventCount' + '${_lastEventType != null ? ' (last: $_lastEventType)' : ''}'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _status = 'Error: $e'; + }); + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + Future _teardown() async { + setState(() { + _busy = true; + _status = 'Stopping…'; + }); + try { + await OfflineProtocol.destroy(); + if (!mounted) return; + setState(() { + _status = 'Engine destroyed.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _status = 'Teardown error: $e'; + }); + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Offline Protocol'), + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _status, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : _boot, + child: const Text('Start engine'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _busy ? null : _teardown, + child: const Text('Destroy engine'), + ), + ], + ), + ), + ); + } +} diff --git a/bindings/flutter/offline_protocol/example/pubspec.lock b/bindings/flutter/offline_protocol/example/pubspec.lock new file mode 100644 index 00000000..2fe0b401 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/pubspec.lock @@ -0,0 +1,212 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + offline_protocol: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.29.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" +sdks: + dart: ">=3.11.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/bindings/flutter/offline_protocol/example/pubspec.yaml b/bindings/flutter/offline_protocol/example/pubspec.yaml new file mode 100644 index 00000000..70023174 --- /dev/null +++ b/bindings/flutter/offline_protocol/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: offline_protocol_example +description: Example app for the offline_protocol Flutter plugin. +publish_to: 'none' +version: 0.0.1 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + offline_protocol: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true diff --git a/bindings/flutter/offline_protocol/ios/.gitignore b/bindings/flutter/offline_protocol/ios/.gitignore new file mode 100644 index 00000000..e2a89478 --- /dev/null +++ b/bindings/flutter/offline_protocol/ios/.gitignore @@ -0,0 +1,2 @@ +# Written by offline_protocol.podspec prepare_command: a copy of the shared RN iOS bridge. +BridgeRN diff --git a/bindings/flutter/offline_protocol/ios/Classes/FlutterTransportCallbacks.swift b/bindings/flutter/offline_protocol/ios/Classes/FlutterTransportCallbacks.swift new file mode 100644 index 00000000..b970874b --- /dev/null +++ b/bindings/flutter/offline_protocol/ios/Classes/FlutterTransportCallbacks.swift @@ -0,0 +1,25 @@ +import Foundation + +final class FlutterBleTransportCallback: BleTransportCallback, @unchecked Sendable { + weak var bleManager: BleManager? + + init(bleManager: BleManager) { + self.bleManager = bleManager + } + + func onFragmentsAvailable() { + bleManager?.onFragmentsAvailable() + } +} + +final class FlutterWifiDirectTransportCallback: WifiDirectTransportCallback, @unchecked Sendable { + weak var wifiDirectManager: WifiDirectManager? + + init(wifiDirectManager: WifiDirectManager) { + self.wifiDirectManager = wifiDirectManager + } + + func onMessagesAvailable() { + wifiDirectManager?.onMessagesAvailable() + } +} diff --git a/bindings/flutter/offline_protocol/ios/Classes/OfflineProtocolPlugin.swift b/bindings/flutter/offline_protocol/ios/Classes/OfflineProtocolPlugin.swift new file mode 100644 index 00000000..a9b601fa --- /dev/null +++ b/bindings/flutter/offline_protocol/ios/Classes/OfflineProtocolPlugin.swift @@ -0,0 +1,553 @@ +import Flutter +import UIKit + +/// Flutter host for the Offline Protocol engine (lifecycle, events, transports). +/// +/// UniFFI calls run on `engineQueue`; Flutter callbacks run on the main queue (bridge K2). +/// Nothing on the main thread waits for the engine queue: engine stop can block on +/// transport I/O, so detach queues the teardown and returns. +public class OfflineProtocolPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var channel: FlutterMethodChannel? + private var eventsChannel: FlutterEventChannel? + private var protocolInstance: OfflineProtocol? + private var currentConfig: ProtocolConfig? + private var internetManager: InternetManager? + private var bleManager: BleManager? + private var wifiDirectManager: WifiDirectManager? + + /// One serial queue for every plugin instance in the process. Flutter + /// registers a new instance for every engine, so a queue per instance would + /// let a new engine's `create` run while the previous engine's queued + /// teardown is still blocked on transport I/O: two cores under one identity, + /// both on BLE and the relay. Shared, the teardown always runs first. + private static let engineQueue = DispatchQueue( + label: "com.offlineprotocol.flutter.engine", + qos: .userInitiated + ) + private let attachmentLock = NSLock() + private var isPluginAttached = false + + /// Guards `eventSink`, `eventSeq` and the replay ring together. The sink is + /// written on the main thread and read on the UniFFI callback thread, and an + /// unsynchronised read of a reference-typed property is a memory-safety race. + private let eventLock = NSLock() + private var eventSeq = 0 + private let eventReplay = EventReplayRing() + private var eventSink: FlutterEventSink? + + private let foregroundReconnectPolicy = ForegroundReconnectPolicy() + private var foregroundObserver: NSObjectProtocol? + private var backgroundObserver: NSObjectProtocol? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = OfflineProtocolPlugin() + let channel = FlutterMethodChannel( + name: "com.offlineprotocol/offline_protocol", + binaryMessenger: registrar.messenger() + ) + let events = FlutterEventChannel( + name: "com.offlineprotocol/offline_protocol_events", + binaryMessenger: registrar.messenger() + ) + instance.channel = channel + instance.eventsChannel = events + registrar.addMethodCallDelegate(instance, channel: channel) + events.setStreamHandler(instance) + registrar.publish(instance) + instance.setAttached(true) + instance.installForegroundObserver() + } + + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + attachmentLock.lock() + guard isPluginAttached else { + attachmentLock.unlock() + return + } + isPluginAttached = false + attachmentLock.unlock() + + removeForegroundObserver() + eventsChannel?.setStreamHandler(nil) + setEventSink(nil) + channel?.setMethodCallHandler(nil) + // Queued behind every call already submitted, never waited on. The block + // holds `self` until the teardown has run. + Self.engineQueue.async { [self] in + self.tearDownProtocol() + } + } + + deinit { + removeForegroundObserver() + // Directly, never `engineQueue.sync`: every block on that queue holds + // `self` strongly or weakly, so deinit can run on the engine queue + // itself, where a sync onto it deadlocks. No block can be running + // against this instance by the time it is deallocating. + tearDownProtocol() + } + + private func installForegroundObserver() { + let center = NotificationCenter.default + backgroundObserver = center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + OfflineProtocolPlugin.engineQueue.async { + self?.foregroundReconnectPolicy.didEnterBackground( + nowMs: MonotonicClock.nowMs() + ) + } + } + foregroundObserver = center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + OfflineProtocolPlugin.engineQueue.async { + self?.handleAppBecameActive() + } + } + } + + private func removeForegroundObserver() { + let center = NotificationCenter.default + if let token = foregroundObserver { + center.removeObserver(token) + foregroundObserver = nil + } + if let token = backgroundObserver { + center.removeObserver(token) + backgroundObserver = nil + } + } + + private func handleAppBecameActive() { + if foregroundReconnectPolicy.shouldReconnectOnForeground(nowMs: MonotonicClock.nowMs()) { + internetManager?.forceReconnect() + } + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + setEventSink(events) + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + setEventSink(nil) + return nil + } + + private func setEventSink(_ sink: FlutterEventSink?) { + eventLock.lock() + eventSink = sink + eventLock.unlock() + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard isAttached() else { + postDetachedError(to: result) + return + } + Self.engineQueue.async { [self] in + guard self.isAttached() else { + self.postDetachedError(to: result) + return + } + do { + switch call.method { + case "create": + try self.createOnEngine(call.arguments) + DispatchQueue.main.async { result(nil) } + case "initializeMlsWithSecureStorage": + try self.initializeMlsOnEngine() + DispatchQueue.main.async { result(nil) } + case "start": + try self.startOnEngine() + DispatchQueue.main.async { result(nil) } + case "stop": + try self.stopOnEngine() + DispatchQueue.main.async { result(nil) } + case "destroy": + self.tearDownProtocol() + DispatchQueue.main.async { result(nil) } + case "localAddress": + let address = self.protocolInstance?.localAddress() + DispatchQueue.main.async { result(address) } + case "sendMessage": + let messageId = try self.sendMessageOnEngine(call.arguments) + DispatchQueue.main.async { result(messageId) } + case "enableInternetTransport": + try self.enableTransportOnEngine(type: "internet", configJson: call.arguments as? String) + DispatchQueue.main.async { result(nil) } + case "enableTransport": + guard let map = call.arguments as? [String: Any], + let type = map["type"] as? String else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "enableTransport expects type and config"] + ) + } + let configJson = map["config"] as? String + try self.enableTransportOnEngine(type: type, configJson: configJson) + DispatchQueue.main.async { result(nil) } + case "replayEvents": + guard let map = call.arguments as? [String: Any], + let fromSeq = (map["fromSeq"] as? NSNumber)?.intValue, + let toSeq = (map["toSeq"] as? NSNumber)?.intValue else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "replayEvents expects fromSeq and toSeq"] + ) + } + let replayed = self.eventReplay.replay(fromSeq: fromSeq, toSeq: toSeq) + let payload = replayed.map { + ["seq": $0.seq, "eventJson": $0.eventJson] as [String: Any] + } + DispatchQueue.main.async { result(payload) } + default: + DispatchQueue.main.async { result(FlutterMethodNotImplemented) } + } + } catch { + DispatchQueue.main.async { + if let mapped = mapProtocolBridgeError(error) { + result(FlutterError(code: mapped.code, message: mapped.message, details: nil)) + } else { + result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) + } + } + } + } + } + + private func isAttached() -> Bool { + attachmentLock.lock() + defer { attachmentLock.unlock() } + return isPluginAttached + } + + private func setAttached(_ attached: Bool) { + attachmentLock.lock() + isPluginAttached = attached + attachmentLock.unlock() + } + + private func postDetachedError(to result: @escaping FlutterResult) { + DispatchQueue.main.async { + result(FlutterError(code: "ERROR", message: "Plugin detached", details: nil)) + } + } + + private func emitEventJson(_ eventJson: String) { + eventLock.lock() + guard let sink = eventSink else { + eventLock.unlock() + return + } + let seq = eventSeq + eventReplay.record(seq: seq, eventJson: eventJson) + eventSeq += 1 + eventLock.unlock() + DispatchQueue.main.async { + sink([ + "eventJson": eventJson, + "seq": seq, + ]) + } + } + + private func createOnEngine(_ arguments: Any?) throws { + guard let configJson = arguments as? String else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "create expects a JSON string"] + ) + } + // Validated before the running engine is torn down: a config this + // plugin refuses must leave the current session as it was. + let parsed = try ProtocolConfigParser.parse(configJson) + try refuseUnhostedTransports(parsed.config) + tearDownProtocol() + let proto = try OfflineProtocol(config: parsed.config) + proto.setEventCallback(callback: FlutterEventCallback(emit: { [weak self] json in + self?.emitEventJson(json) + })) + InitialRuntimeConfigApplier.apply(proto: proto, rawConfig: parsed.raw) + protocolInstance = proto + currentConfig = parsed.config + + if parsed.config.bleEnabled { + let manager = BleManager( + protocol: proto, + deviceId: parsed.config.profile, + appId: parsed.config.appId + ) + bleManager = manager + proto.setBleTransportCallback(callback: FlutterBleTransportCallback(bleManager: manager)) + } + wireWifiDirectCallbackIfNeeded(proto: proto) + } + + /// Refuses a config that enables a transport this plugin has no manager for. + /// Accepted, the core would route to a transport nothing serves and messages + /// would wait on it with no error anywhere. + private func refuseUnhostedTransports(_ config: ProtocolConfig) throws { + var unhosted: [String] = [] + if config.reticulumEnabled { unhosted.append("reticulumEnabled") } + if config.nostrEnabled { unhosted.append("nostrEnabled") } + guard unhosted.isEmpty else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "\(unhosted.joined(separator: ", ")) is not supported by the Flutter plugin yet; set it to false", + ] + ) + } + } + + private func wireWifiDirectCallbackIfNeeded(proto: OfflineProtocol) { + guard currentConfig?.wifiDirectEnabled == true, + let manager = wifiDirectManager else { return } + proto.setWifiDirectTransportCallback( + callback: FlutterWifiDirectTransportCallback(wifiDirectManager: manager) + ) + } + + private func initializeMlsOnEngine() throws { + guard let proto = protocolInstance else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Protocol not initialized"] + ) + } + guard let config = currentConfig else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Protocol config not initialized"] + ) + } + let accountNamespace = StorageNamespace.account( + appId: config.appId, + profile: config.profile + ) + let secureStorage = try MlsSecureStorage(accountNamespace: accountNamespace) + let protocolStateStorage = try AppContainerProtocolStateStorage( + accountNamespace: accountNamespace + ) + try proto.initializeMls( + secureStorage: secureStorage, + protocolStateStorage: protocolStateStorage + ) + } + + private func startOnEngine() throws { + guard let proto = protocolInstance else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Protocol not initialized"] + ) + } + try proto.start() + if let manager = bleManager, manager.state != .running { + try manager.start() + } + } + + /// The transports stop first and cannot fail; the core's failure reaches + /// Dart, as Android's `TeardownSequence` reports the first failure. + private func stopOnEngine() throws { + bleManager?.stop() + internetManager?.stop() + wifiDirectManager?.stop() + try protocolInstance?.stop() + } + + private func enableTransportOnEngine(type: String, configJson: String?) throws { + guard let proto = protocolInstance else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Protocol not initialized"] + ) + } + guard let config = currentConfig else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Protocol config not initialized"] + ) + } + let normalized = type.lowercased() + switch normalized { + case "internet": + var object: [String: Any] = [:] + if let configJson, + let data = configJson.data(using: .utf8), + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + object = parsed + } + let manager = try ensureInternetManager(proto: proto, config: config) + if manager.state == .running || manager.state == .starting { + manager.stop() + } + try InternetTransportBootstrap.configureAndStart(manager: manager, config: object) + case "wifidirect", "wifi_direct": + if wifiDirectManager == nil { + let manager = WifiDirectManager(protocol: proto, deviceId: config.profile) + wifiDirectManager = manager + proto.setWifiDirectTransportCallback( + callback: FlutterWifiDirectTransportCallback(wifiDirectManager: manager) + ) + } + guard let manager = wifiDirectManager else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create Wi-Fi Direct manager"] + ) + } + if manager.state == .running || manager.state == .starting { + manager.stop() + } + try manager.start() + case "ble": + if bleManager == nil { + let manager = BleManager( + protocol: proto, + deviceId: config.profile, + appId: config.appId + ) + bleManager = manager + proto.setBleTransportCallback(callback: FlutterBleTransportCallback(bleManager: manager)) + } + guard let manager = bleManager else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create BLE manager"] + ) + } + if manager.state != .running { + try manager.start() + } + case "reticulum", "nostr": + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Transport \(type) is not compiled into the Flutter plugin yet", + ] + ) + default: + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Unsupported transport type: \(type)"] + ) + } + } + + private func ensureInternetManager( + proto: OfflineProtocol, + config: ProtocolConfig + ) throws -> InternetManager { + if let manager = internetManager { + return manager + } + let manager = InternetManager( + protocol: proto, + deviceId: config.profile, + appId: config.appId + ) + InternetTransportBootstrap.attachEmitters(manager: manager, emitEventJson: { [weak self] json in + self?.emitEventJson(json) + }) + internetManager = manager + return manager + } + + private func sendMessageOnEngine(_ arguments: Any?) throws -> String { + guard let map = arguments as? [String: Any] else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "sendMessage expects a map"] + ) + } + guard let recipient = map["recipient"] as? String else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "sendMessage requires recipient"] + ) + } + guard let content = map["content"] as? String else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "sendMessage requires content"] + ) + } + let priorityValue = (map["priority"] as? NSNumber)?.intValue ?? 1 + let replyTo = map["replyToMsg"] as? String + let msgPriority: MessagePriority + switch priorityValue { + case 0: msgPriority = .low + case 1: msgPriority = .medium + case 2: msgPriority = .high + case 3: msgPriority = .critical + default: msgPriority = .medium + } + guard let proto = protocolInstance else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Protocol not initialized"] + ) + } + return try proto.sendMessage( + recipient: recipient, + content: content, + priority: msgPriority, + replyToMsg: replyTo + ) + } + + private func tearDownProtocol() { + bleManager?.stop() + bleManager = nil + internetManager?.stop() + internetManager = nil + wifiDirectManager?.stop() + wifiDirectManager = nil + try? protocolInstance?.stop() + protocolInstance = nil + currentConfig = nil + eventLock.lock() + eventSeq = 0 + eventReplay.clear() + eventLock.unlock() + } +} + +private final class FlutterEventCallback: EventCallback, @unchecked Sendable { + private let emit: (String) -> Void + + init(emit: @escaping (String) -> Void) { + self.emit = emit + } + + func onEvent(eventJson: String) { + emit(eventJson) + } +} diff --git a/bindings/flutter/offline_protocol/ios/offline_protocol.podspec b/bindings/flutter/offline_protocol/ios/offline_protocol.podspec new file mode 100644 index 00000000..69845b9b --- /dev/null +++ b/bindings/flutter/offline_protocol/ios/offline_protocol.podspec @@ -0,0 +1,83 @@ +require 'json' + +# Shared React Native iOS bridge sources (ADR 0025). CocoaPods PathList walks the +# pod root with Dir.glob and does not follow directory symlinks, so prepare_command +# rsyncs the RN tree into BridgeRN/ (gitignored; refreshed on each pod install). +rn_ios = 'BridgeRN' +plugin_ios = File.dirname(File.realpath(__FILE__)) + +# Values the React Native package declares are read from it, never written +# again here (ADR 0025 decision 5): a copy is right on the day it is made and +# drifts with nothing failing. The version comes from package.json, which the +# release gate keeps in step with the tag; the deployment target comes from the +# line in MeshSdk.podspec that declares it, which has to be there exactly once. +rn_root = File.expand_path('../../../react-native', plugin_ios) +rn_package = JSON.parse(File.read(File.join(rn_root, 'package.json'))) +rn_targets = File.read(File.join(rn_root, 'MeshSdk.podspec')) + .scan(/s\.platforms\s*=\s*\{\s*:ios\s*=>\s*"([0-9.]+)"\s*\}/).flatten +unless rn_targets.length == 1 + raise "MeshSdk.podspec must declare the iOS deployment target exactly once (found #{rn_targets.length})" +end +ios_deployment_target = rn_targets.first + +# The compile surface is every shared bridge source except the ones named here +# (ADR 0025 decision 3): a list of what to leave out, never a list of what to +# take, so a new shared source reaches this pod without being registered. +# These need React, or are transports the plugin does not host yet. Keep in +# step with the rsync excludes in sync_bridge_rn.sh; a Rust guard +# (flutter_ios_exclude_lists_agree_and_name_real_files) holds both to the +# sources that exist. +rn_only_swift = [ + 'BridgeRN/OfflineProtocolModule.swift', + 'BridgeRN/OfflineProtocolModule.m', + 'BridgeRN/NostrManager.swift', + 'BridgeRN/NostrQueryTracker.swift', + 'BridgeRN/ReticulumManager.swift', + 'BridgeRN/InboundEventBuffer.swift', +] + +Pod::Spec.new do |s| + s.name = 'offline_protocol' + s.version = rn_package['version'] + s.summary = 'Flutter bindings for the Offline Protocol SDK' + s.description = 'Offline-first mesh messaging with MLS encryption (Flutter plugin).' + s.homepage = 'https://github.com/Offline-Protocol/offline-protocol-sdk' + s.license = { :type => 'AGPL-3.0-only' } + s.author = { 'Offline Protocol, Inc.' => 'support@offlineprotocol.com' } + s.source = { :path => '.' } + s.platform = :ios, ios_deployment_target + s.swift_version = '5.5' + + sync_script = File.join(plugin_ios, 'sync_bridge_rn.sh') + s.prepare_command = "bash #{sync_script.shellescape}" + + s.source_files = [ + 'Classes/**/*.swift', + 'BridgeRN/**/*.swift', + 'BridgeRN/Generated/*.h', + ] + s.exclude_files = rn_only_swift + + s.resource_bundles = { + 'OfflineProtocolPrivacy' => [File.join(rn_ios, 'PrivacyInfo.xcprivacy')], + } + + s.preserve_paths = [ + File.join(rn_ios, 'Generated/offline_protocolFFI.modulemap'), + ] + + s.vendored_frameworks = File.join(rn_ios, 'libs/offline_protocol_uniffi.xcframework') + + s.public_header_files = [ + File.join(rn_ios, 'Generated/offline_protocolFFI.h'), + ] + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_INCLUDE_PATHS' => '$(PODS_TARGET_SRCROOT)/BridgeRN/Generated', + 'HEADER_SEARCH_PATHS' => '$(inherited) $(PODS_TARGET_SRCROOT)/BridgeRN/Generated', + } + + s.frameworks = 'Foundation', 'Security', 'Network', 'CoreBluetooth' + s.dependency 'Flutter' +end diff --git a/bindings/flutter/offline_protocol/ios/sync_bridge_rn.sh b/bindings/flutter/offline_protocol/ios/sync_bridge_rn.sh new file mode 100755 index 00000000..bf6f2ee2 --- /dev/null +++ b/bindings/flutter/offline_protocol/ios/sync_bridge_rn.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copy shared React Native iOS bridge sources into BridgeRN/ for CocoaPods. +# PathList only indexes files under the pod root; see offline_protocol.podspec. +set -euo pipefail + +IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RN_IOS="$(cd "$IOS_DIR/../../../react-native/ios" && pwd)" +DEST="$IOS_DIR/BridgeRN" + +rm -rf "$DEST" +mkdir -p "$DEST" +rsync -a \ + --exclude OfflineProtocolModule.swift \ + --exclude OfflineProtocolModule.m \ + --exclude NostrManager.swift \ + --exclude NostrQueryTracker.swift \ + --exclude ReticulumManager.swift \ + --exclude InboundEventBuffer.swift \ + --exclude tests/ \ + --exclude Package.swift \ + "$RN_IOS/" "$DEST/" diff --git a/bindings/flutter/offline_protocol/lib/offline_protocol.dart b/bindings/flutter/offline_protocol/lib/offline_protocol.dart new file mode 100644 index 00000000..4f92f620 --- /dev/null +++ b/bindings/flutter/offline_protocol/lib/offline_protocol.dart @@ -0,0 +1 @@ +export 'src/offline_protocol.dart'; diff --git a/bindings/flutter/offline_protocol/lib/src/native_event_constants.dart b/bindings/flutter/offline_protocol/lib/src/native_event_constants.dart new file mode 100644 index 00000000..5e2cde34 --- /dev/null +++ b/bindings/flutter/offline_protocol/lib/src/native_event_constants.dart @@ -0,0 +1,9 @@ +/// Must match `EventReplayRing.DEFAULT_CAPACITY` on Android and iOS (RN +/// `NATIVE_EVENT_REPLAY_CAPACITY`, pinned in `offline-protocol-uniffi`). +const int nativeEventReplayCapacity = 256; + +/// How long dispatch waits for native `replayEvents` before continuing. +const int nativeEventReplayTimeoutMs = 2000; + +/// Cap on events held while replay is in flight. +const int maxDeferredNativeEvents = 1024; diff --git a/bindings/flutter/offline_protocol/lib/src/native_event_pipeline.dart b/bindings/flutter/offline_protocol/lib/src/native_event_pipeline.dart new file mode 100644 index 00000000..560776fe --- /dev/null +++ b/bindings/flutter/offline_protocol/lib/src/native_event_pipeline.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'native_event_constants.dart'; +import 'offline_protocol_event.dart'; + +typedef NativeEventReplayFn = Future>> Function( + int fromSeq, + int toSeq, +); + +/// Ordered delivery of native events with optional `seq` gap recovery (RN +/// `admitNativeEvent` parity). +class NativeEventPipeline { + NativeEventPipeline({ + required NativeEventReplayFn replayEvents, + void Function(String diagnosticJson)? onDiagnosticEvent, + }) : _replayEvents = replayEvents, + _onDiagnosticEvent = onDiagnosticEvent; + + final NativeEventReplayFn _replayEvents; + final void Function(String diagnosticJson)? _onDiagnosticEvent; + + final StreamController _out = + StreamController.broadcast(); + + int? _lastEventSeq; + _ReplayState? _replay; + + Stream get stream => _out.stream; + + void close() { + _finishEventReplay(); + _out.close(); + } + + void reset() { + _finishEventReplay(); + _lastEventSeq = null; + } + + void admitRaw(dynamic raw) { + final parsed = _parseNativeEvent(raw); + if (parsed == null) return; + admit(parsed); + } + + void admit(OfflineProtocolNativeEvent event) { + final seq = event.seq; + if (seq == null) { + _dispatchEventJson(event.eventJson); + return; + } + + final expected = _lastEventSeq == null ? seq : _lastEventSeq! + 1; + if (seq < expected) { + _finishEventReplay(); + _lastEventSeq = seq; + _dispatchEventJson(event.eventJson); + return; + } + _lastEventSeq = seq; + + if (seq > expected) { + _requestEventReplay(expected, seq - 1); + } + + final replay = _replay; + if (replay == null) { + _dispatchEventJson(event.eventJson); + return; + } + replay.deferred.add(event); + if (replay.deferred.length > maxDeferredNativeEvents) { + _finishEventReplay(); + } + } + + /// Asks native for `fromSeq..toSeq` and holds later events until it answers, + /// the timer fires, or [maxDeferredNativeEvents] are held. Always leaves a + /// replay in flight, so the caller defers the event that exposed the gap. + void _requestEventReplay(int fromSeq, int toSeq) { + var replay = _replay; + if (replay == null) { + replay = _ReplayState( + timer: Timer( + const Duration(milliseconds: nativeEventReplayTimeoutMs), + _finishEventReplay, + ), + ); + _replay = replay; + } + replay.ranges.add([fromSeq, toSeq]); + replay.outstanding += 1; + + final activeReplay = replay; + _replayEvents(fromSeq, toSeq).then( + (served) { + if (_replay != activeReplay) return; + for (final entry in served) { + final seqRaw = entry['seq']; + final json = entry['eventJson']; + if (seqRaw is! int || json is! String) continue; + if (seqRaw < fromSeq || seqRaw > toSeq) continue; + activeReplay.recovered[seqRaw] = json; + } + activeReplay.outstanding -= 1; + if (activeReplay.outstanding == 0) { + _finishEventReplay(); + } + }, + onError: (Object error, StackTrace st) { + debugPrint('[OfflineProtocol] native event replay failed: $error\n$st'); + if (_replay == activeReplay) { + activeReplay.outstanding -= 1; + if (activeReplay.outstanding == 0) { + _finishEventReplay(); + } + } + }, + ); + } + + void _finishEventReplay() { + final replay = _replay; + if (replay == null) return; + _replay = null; + replay.timer?.cancel(); + + final present = Map.from(replay.recovered); + for (final event in replay.deferred) { + final seq = event.seq; + if (seq != null) { + present[seq] = event.eventJson; + } + } + + if (replay.ranges.isEmpty && replay.deferred.isEmpty) { + return; + } + + final first = replay.ranges.isEmpty + ? present.keys.reduce((a, b) => a < b ? a : b) + : replay.ranges.map((r) => r[0]).reduce((a, b) => a < b ? a : b); + final last = [ + if (present.isNotEmpty) present.keys.reduce((a, b) => a > b ? a : b), + ...replay.ranges.map((r) => r[1]), + ].reduce((a, b) => a > b ? a : b); + + for (final range in replay.ranges) { + final from = range[0]; + final to = range[1]; + var recovered = 0; + for (var seq = from; seq <= to; seq++) { + if (replay.recovered.containsKey(seq)) recovered++; + } + if (recovered > 0) { + _emitDiagnostic( + level: 'info', + message: 'native_event_replayed', + context: { + 'from': from, + 'to': to, + 'recovered': recovered, + 'missing': to - from + 1 - recovered, + }, + ); + } + } + + // Linear in the seq span being flushed (same bound as React Native). Capped + // in practice by [maxDeferredNativeEvents] and [nativeEventReplayCapacity]. + int? missingFrom; + for (var seq = first; seq <= last; seq++) { + final eventJson = present[seq]; + if (eventJson == null) { + missingFrom ??= seq; + continue; + } + if (missingFrom != null) { + _reportEventGap(missingFrom, seq); + missingFrom = null; + } + _dispatchEventJson(eventJson); + } + if (missingFrom != null) { + _reportEventGap(missingFrom, last + 1); + } + } + + void _reportEventGap(int expected, int received) { + final context = { + 'expected': expected, + 'received': received, + 'missing': received - expected, + }; + debugPrint('[OfflineProtocol] native events lost before Dart: $context'); + _emitDiagnostic( + level: 'warning', + message: 'native_event_gap', + context: context, + ); + } + + void _emitDiagnostic({ + required String level, + required String message, + required Map context, + }) { + final payload = jsonEncode({ + 'type': 'diagnostic', + 'level': level, + 'message': message, + 'context': context, + }); + _onDiagnosticEvent?.call(payload); + _out.add(OfflineProtocolNativeEvent(eventJson: payload)); + } + + void _dispatchEventJson(String eventJson) { + _out.add(OfflineProtocolNativeEvent(eventJson: eventJson)); + } + + static OfflineProtocolNativeEvent? _parseNativeEvent(dynamic raw) { + try { + if (raw is! Map) { + throw FormatException('Expected a Map, got ${raw.runtimeType}'); + } + final eventJson = raw['eventJson']; + if (eventJson is! String) { + throw FormatException('Missing eventJson string'); + } + final seqRaw = raw['seq']; + final int? seq = seqRaw is int ? seqRaw : (seqRaw as num?)?.toInt(); + return OfflineProtocolNativeEvent(eventJson: eventJson, seq: seq); + } catch (e, st) { + debugPrint('[OfflineProtocol] Dropping malformed native event: $e\n$st'); + return null; + } + } +} + +class _ReplayState { + _ReplayState({required this.timer}); + + final List> ranges = []; + int outstanding = 0; + final Map recovered = {}; + final List deferred = []; + Timer? timer; +} diff --git a/bindings/flutter/offline_protocol/lib/src/offline_protocol.dart b/bindings/flutter/offline_protocol/lib/src/offline_protocol.dart new file mode 100644 index 00000000..fdfb1f14 --- /dev/null +++ b/bindings/flutter/offline_protocol/lib/src/offline_protocol.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'native_event_pipeline.dart'; +import 'offline_protocol_event.dart'; + +export 'native_event_constants.dart'; +export 'offline_protocol_event.dart'; + +/// Host for the Offline Protocol engine on iOS and Android (lifecycle, events, +/// messaging, Internet, BLE, Wi‑Fi Direct, native event replay). +class OfflineProtocol { + OfflineProtocol._(); + + static const MethodChannel _channel = + MethodChannel('com.offlineprotocol/offline_protocol'); + + static const EventChannel _eventsChannel = + EventChannel('com.offlineprotocol/offline_protocol_events'); + + static Map? _lastCreateConfig; + static NativeEventPipeline? _eventPipeline; + static StreamSubscription? _nativeEventSubscription; + + /// Core protocol events (`message_received`, diagnostics, Internet status, …). + /// + /// Subscribe **before** [createFromConfig] if you need every event and gap + /// recovery: native code only assigns `seq` and fills the replay ring for + /// events delivered while a listener is attached (same contract as React + /// Native). Events emitted before you listen are not replayable. + /// + /// Delivery uses the same `seq` / gap recovery contract as React Native + /// (`admitNativeEvent` + native `replayEvents`). + static Stream get events { + _ensureEventPipeline(); + return _eventPipeline!.stream; + } + + /// Minimal [ProtocolConfig] for local engine smoke tests. + /// + /// The core requires at least one transport flag; Internet is enabled so the + /// example can boot without starting BLE (iOS needs `bluetooth-central` / + /// `bluetooth-peripheral` background modes before [start] may call + /// `BleManager.start()`). No relay is configured, so the Internet transport + /// is not auto-started. Production apps should set flags explicitly. + static Map smokeTestConfig({ + required String appId, + required String profile, + }) { + return { + 'appId': appId, + 'profile': profile, + 'bleEnabled': false, + 'wifiDirectEnabled': false, + 'internetEnabled': true, + 'reticulumEnabled': false, + 'nostrEnabled': false, + }; + } + + /// Creates the Rust engine from a JSON config object (camelCase or snake_case). + /// + /// Optional top-level `dors` and `relay` sections are applied the same way as + /// on React Native after [ProtocolConfig] is constructed. When Internet or + /// Wi‑Fi Direct are configured, [start] auto-enables them (React Native JS layer). + /// + /// Throws a [PlatformException] when `reticulumEnabled` or `nostrEnabled` is + /// true: the plugin hosts no manager for either, and the core would otherwise + /// route messages to a transport nothing serves. + /// + /// Resets Dart `seq` tracking to match the native session (native [create] + /// tears down the previous engine and restarts event numbering from zero). + static Future createFromConfig(Map config) async { + _lastCreateConfig = Map.from(config); + _ensureEventPipeline(); + _eventPipeline!.reset(); + await _channel.invokeMethod('create', jsonEncode(config)); + } + + /// Initializes MLS with the platform secure storage providers. + static Future initializeMlsWithSecureStorage() async { + await _channel.invokeMethod('initializeMlsWithSecureStorage'); + } + + /// Starts the protocol core and auto-enables configured transports. + static Future start() async { + await _channel.invokeMethod('start'); + await _autoEnableTransportsIfConfigured(); + } + + /// Stops the protocol core and active transports. + /// + /// Resets Dart `seq` tracking so a later [start] on the same engine instance + /// stays aligned with native numbering (native does not rewind `seq` on stop). + static Future stop() async { + await _channel.invokeMethod('stop'); + _ensureEventPipeline(); + _eventPipeline!.reset(); + } + + /// Releases native resources. Safe to call more than once. + /// + /// Clears Dart event state and drops the EventChannel subscription; the next + /// [createFromConfig] or access to [events] reattaches it. Your existing + /// [StreamSubscription] on [events] remains valid (same broadcast stream). + static Future destroy() async { + _lastCreateConfig = null; + await _channel.invokeMethod('destroy'); + _detachNativeEventSubscription(resetPipeline: true); + } + + /// Method-channel entry points implemented by the Flutter plugin (pinned in tests). + @visibleForTesting + static const List nativeMethodChannelMethods = [ + 'create', + 'initializeMlsWithSecureStorage', + 'start', + 'stop', + 'destroy', + 'localAddress', + 'sendMessage', + 'enableInternetTransport', + 'enableTransport', + 'replayEvents', + ]; + + /// This device's `off1…` address, or null before MLS is initialized. + static Future localAddress() async { + return _channel.invokeMethod('localAddress'); + } + + /// Sends a message to [recipient] and returns the assigned message id. + static Future sendMessage({ + required String recipient, + required String content, + MessagePriority priority = MessagePriority.medium, + String? replyToMsg, + }) async { + final id = await _channel.invokeMethod('sendMessage', { + 'recipient': recipient, + 'content': content, + 'priority': priority.value, + if (replyToMsg != null) 'replyToMsg': replyToMsg, + }); + if (id == null || id.isEmpty) { + throw StateError('sendMessage returned no message id'); + } + return id; + } + + /// Enables a transport by name (`internet`, `ble`, `wifiDirect`, …). + /// + /// [config] uses the same JSON shape as React Native `enableTransport`. + static Future enableTransport( + String type, + Map config, + ) async { + await _channel.invokeMethod('enableTransport', { + 'type': type, + 'config': jsonEncode(config), + }); + } + + /// Configures and starts the Internet relay transport. + /// + /// [config] uses the same shape as React Native `enableTransport('internet', …)`: + /// `serverAddress`, optional `authToken`, `autoReconnect`, `port`, … + static Future enableInternetTransport( + Map config, + ) async { + await enableTransport('internet', config); + } + + /// Convenience: create (smoke config) → MLS → start → read address. + static Future bootstrapSmokeTest({ + required String appId, + required String profile, + }) async { + await createFromConfig(smokeTestConfig(appId: appId, profile: profile)); + await initializeMlsWithSecureStorage(); + await start(); + return localAddress(); + } + + static void _ensureEventPipeline() { + _eventPipeline ??= NativeEventPipeline( + replayEvents: _replayEventsFromNative, + ); + if (_nativeEventSubscription != null) return; + _nativeEventSubscription = _eventsChannel.receiveBroadcastStream().listen( + (raw) { + _eventPipeline?.admitRaw(raw); + }, + // Without a handler, an error from the native stream handler is an + // unhandled error in the root zone. Events keep flowing after it. + onError: (Object error, StackTrace st) { + debugPrint('[OfflineProtocol] native event channel error: $error\n$st'); + }, + ); + } + + /// Cancels the EventChannel listener and optionally clears pipeline seq state. + static void _detachNativeEventSubscription({required bool resetPipeline}) { + _nativeEventSubscription?.cancel(); + _nativeEventSubscription = null; + if (resetPipeline) { + _eventPipeline?.reset(); + } + } + + static Future>> _replayEventsFromNative( + int fromSeq, + int toSeq, + ) async { + final result = await _channel.invokeMethod>('replayEvents', { + 'fromSeq': fromSeq, + 'toSeq': toSeq, + }); + if (result == null) return const []; + final out = >[]; + for (final entry in result) { + if (entry is! Map) continue; + final seqRaw = entry['seq']; + final json = entry['eventJson']; + if (json is! String) continue; + final int? seq = seqRaw is int + ? seqRaw + : seqRaw is num + ? seqRaw.toInt() + : null; + if (seq == null) continue; + out.add({'seq': seq, 'eventJson': json}); + } + return out; + } + + /// Mirrors the React Native JavaScript `start()` auto-enable + /// (`transports.internet.enabled` with a `serverAddress`), and also accepts + /// the flat `internetEnabled` / `internetServerAddress` spellings the native + /// config parsers take. Exposed for unit tests only. + @visibleForTesting + static Map? resolveInternetAutoEnableConfig( + Map root, + ) => + _internetAutoEnableConfig(root); + + /// Wi‑Fi Direct / peer-stream auto-enable after [start]. Exposed for tests. + @visibleForTesting + static Map? resolveWifiDirectAutoEnableConfig( + Map root, + ) => + _wifiDirectAutoEnableConfig(root); + + static Map? _internetAutoEnableConfig(Map root) { + final transports = root['transports']; + Map internet = {}; + final hasNestedInternet = transports is Map && transports['internet'] is Map; + if (hasNestedInternet) { + internet = Map.from(transports['internet'] as Map); + } + + final serverRaw = internet['serverAddress'] ?? + internet['server_address'] ?? + root['internetServerAddress'] ?? + root['internet_server_address']; + if (serverRaw is! String || serverRaw.trim().isEmpty) { + return null; + } + + final nestedEnabled = hasNestedInternet && internet['enabled'] == true; + final topLevelEnabled = root['internetEnabled'] == true; + if (!nestedEnabled && !topLevelEnabled) { + return null; + } + + final autoReconnect = internet['autoReconnect'] ?? + internet['auto_reconnect'] ?? + root['internetAutoReconnect'] ?? + root['internet_auto_reconnect'] ?? + true; + + final enableConfig = { + ...internet, + 'enabled': true, + 'serverAddress': serverRaw.trim(), + 'autoReconnect': autoReconnect, + }; + final auth = internet['authToken'] ?? + internet['auth_token'] ?? + root['authToken'] ?? + root['auth_token']; + if (auth is String && auth.isNotEmpty) { + enableConfig['authToken'] = auth; + } + return enableConfig; + } + + static Map? _wifiDirectAutoEnableConfig( + Map root, + ) { + final transports = root['transports']; + if (transports is! Map || transports['wifiDirect'] is! Map) { + if (root['wifiDirectEnabled'] != true) { + return null; + } + return {'enabled': true}; + } + final wifi = Map.from(transports['wifiDirect'] as Map); + if (wifi['enabled'] != true && root['wifiDirectEnabled'] != true) { + return null; + } + return {...wifi, 'enabled': true}; + } + + static Future _autoEnableTransportsIfConfigured() async { + final root = _lastCreateConfig; + if (root == null) return; + + final internetConfig = _internetAutoEnableConfig(root); + if (internetConfig != null) { + try { + await enableInternetTransport(internetConfig); + } catch (e, st) { + debugPrint( + '[OfflineProtocol] Failed to auto-enable internet transport: $e\n$st', + ); + } + } + + final wifiConfig = _wifiDirectAutoEnableConfig(root); + if (wifiConfig != null) { + try { + await enableTransport('wifiDirect', wifiConfig); + } catch (e, st) { + debugPrint( + '[OfflineProtocol] Failed to auto-enable Wi-Fi Direct transport: $e\n$st', + ); + } + } + } +} diff --git a/bindings/flutter/offline_protocol/lib/src/offline_protocol_event.dart b/bindings/flutter/offline_protocol/lib/src/offline_protocol_event.dart new file mode 100644 index 00000000..bfc52785 --- /dev/null +++ b/bindings/flutter/offline_protocol/lib/src/offline_protocol_event.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +/// One event from the native engine, as delivered on the event channel. +/// +/// [eventJson] is the same opaque JSON string React Native receives on +/// `OfflineProtocol_Event`. Parse it with [decodeEvent] when you need fields. +class OfflineProtocolNativeEvent { + const OfflineProtocolNativeEvent({ + required this.eventJson, + this.seq, + }); + + final String eventJson; + final int? seq; + + /// Parses [eventJson] into a map. Throws [FormatException] on invalid JSON. + Map decodeEvent() { + final decoded = jsonDecode(eventJson); + if (decoded is! Map) { + throw FormatException('Expected a JSON object', eventJson); + } + return decoded; + } +} + +/// Message priority for [OfflineProtocol.sendMessage]. Values match UniFFI. +enum MessagePriority { + low(0), + medium(1), + high(2), + critical(3); + + const MessagePriority(this.value); + final int value; +} diff --git a/bindings/flutter/offline_protocol/pubspec.lock b/bindings/flutter/offline_protocol/pubspec.lock new file mode 100644 index 00000000..62822e8a --- /dev/null +++ b/bindings/flutter/offline_protocol/pubspec.lock @@ -0,0 +1,205 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" +sdks: + dart: ">=3.11.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/bindings/flutter/offline_protocol/pubspec.yaml b/bindings/flutter/offline_protocol/pubspec.yaml new file mode 100644 index 00000000..bab049fe --- /dev/null +++ b/bindings/flutter/offline_protocol/pubspec.yaml @@ -0,0 +1,28 @@ +name: offline_protocol +description: Flutter bindings for the Offline Protocol SDK (offline-first mesh messaging with MLS encryption). +version: 0.29.0 +homepage: https://github.com/Offline-Protocol/offline-protocol-sdk +repository: https://github.com/Offline-Protocol/offline-protocol-sdk +issue_tracker: https://github.com/Offline-Protocol/offline-protocol-sdk/issues + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + plugin: + platforms: + android: + package: com.offlineprotocol.flutter + pluginClass: OfflineProtocolPlugin + ios: + pluginClass: OfflineProtocolPlugin diff --git a/bindings/flutter/offline_protocol/test/native_event_pipeline_test.dart b/bindings/flutter/offline_protocol/test/native_event_pipeline_test.dart new file mode 100644 index 00000000..115f688f --- /dev/null +++ b/bindings/flutter/offline_protocol/test/native_event_pipeline_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:offline_protocol/src/native_event_constants.dart'; +import 'package:offline_protocol/src/native_event_pipeline.dart'; +import 'package:offline_protocol/src/offline_protocol_event.dart'; + +void main() { + test('in-order events dispatch without replay', () async { + final pipeline = NativeEventPipeline( + replayEvents: (_, __) async => [], + ); + addTearDown(pipeline.close); + + final events = []; + final sub = pipeline.stream.listen(events.add); + + pipeline.admit(OfflineProtocolNativeEvent(eventJson: '{"type":"a"}', seq: 0)); + pipeline.admit(OfflineProtocolNativeEvent(eventJson: '{"type":"b"}', seq: 1)); + + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(events.length, 2); + expect(events[0].decodeEvent()['type'], 'a'); + expect(events[1].decodeEvent()['type'], 'b'); + }); + + test('gap triggers replay and recovers missing seq', () async { + final pipeline = NativeEventPipeline( + replayEvents: (from, to) async { + expect(from, 1); + expect(to, 1); + return [ + {'seq': 1, 'eventJson': '{"type":"middle"}'}, + ]; + }, + ); + addTearDown(pipeline.close); + + final types = []; + final sub = pipeline.stream.listen((event) { + types.add(event.decodeEvent()['type'] as String); + }); + + pipeline.admit(OfflineProtocolNativeEvent(eventJson: '{"type":"first"}', seq: 0)); + pipeline.admit(OfflineProtocolNativeEvent(eventJson: '{"type":"third"}', seq: 2)); + + await Future.delayed(const Duration(milliseconds: 50)); + await sub.cancel(); + expect( + types.where((t) => t != 'diagnostic').toList(), + ['first', 'middle', 'third'], + ); + }); + + test('malformed native events are dropped, not dispatched', () async { + final pipeline = NativeEventPipeline(replayEvents: (_, __) async => []); + addTearDown(pipeline.close); + + final events = []; + final sub = pipeline.stream.listen(events.add); + + pipeline.admitRaw('not a map'); + pipeline.admitRaw({'seq': 0}); + pipeline.admitRaw({'eventJson': 42, 'seq': 0}); + pipeline.admitRaw({'eventJson': '{"type":"ok"}', 'seq': 0}); + + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(events.map((e) => e.decodeEvent()['type']), ['ok']); + }); + + test('deferred cap flushes and reports the gap without waiting', () async { + // A replay that never answers: only the cap can release the held events. + final never = Completer>>(); + final pipeline = NativeEventPipeline(replayEvents: (_, __) => never.future); + addTearDown(pipeline.close); + + final types = []; + final messages = []; + final sub = pipeline.stream.listen((event) { + final map = event.decodeEvent(); + if (map['type'] == 'diagnostic') { + messages.add(map['message'] as String); + } else { + types.add(map['type'] as String); + } + }); + + pipeline.admit(OfflineProtocolNativeEvent(eventJson: '{"type":"e0"}', seq: 0)); + // seq 1 is lost; everything from 2 is held behind the replay. + for (var seq = 2; seq <= maxDeferredNativeEvents + 2; seq++) { + pipeline.admit( + OfflineProtocolNativeEvent(eventJson: '{"type":"e$seq"}', seq: seq), + ); + } + + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(types.length, maxDeferredNativeEvents + 2); + expect(types.first, 'e0'); + expect(types[1], 'e2'); + expect(messages, contains('native_event_gap')); + }); + + test('reset after pause accepts contiguous seq without false gap', () async { + var replayCalls = 0; + final pipeline = NativeEventPipeline( + replayEvents: (_, __) async { + replayCalls += 1; + return []; + }, + ); + addTearDown(pipeline.close); + + final messages = []; + final sub = pipeline.stream.listen((event) { + try { + final map = event.decodeEvent(); + if (map['type'] == 'diagnostic') { + messages.add(map['message'] as String? ?? ''); + } else { + messages.add(map['type'] as String? ?? ''); + } + } catch (_) {} + }); + + pipeline.admit( + OfflineProtocolNativeEvent(eventJson: '{"type":"before_stop"}', seq: 4), + ); + pipeline.reset(); + pipeline.admit( + OfflineProtocolNativeEvent(eventJson: '{"type":"after_start"}', seq: 5), + ); + pipeline.admit( + OfflineProtocolNativeEvent(eventJson: '{"type":"after_start_2"}', seq: 6), + ); + + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(replayCalls, 0); + expect(messages, contains('after_start')); + expect(messages, isNot(contains('native_event_gap'))); + }); +} diff --git a/bindings/flutter/offline_protocol/test/offline_protocol_test.dart b/bindings/flutter/offline_protocol/test/offline_protocol_test.dart new file mode 100644 index 00000000..e009905e --- /dev/null +++ b/bindings/flutter/offline_protocol/test/offline_protocol_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:offline_protocol/offline_protocol.dart'; + +void main() { + test('smokeTestConfig enables Internet flag only', () { + final config = OfflineProtocol.smokeTestConfig( + appId: 'com.test.app', + profile: 'user', + ); + expect(config['appId'], 'com.test.app'); + expect(config['profile'], 'user'); + expect(config['bleEnabled'], isFalse); + expect(config['internetEnabled'], isTrue); + expect(config['nostrEnabled'], isFalse); + }); + + test('resolveInternetAutoEnableConfig nested transports.internet', () { + final config = OfflineProtocol.resolveInternetAutoEnableConfig({ + 'transports': { + 'internet': { + 'enabled': true, + 'serverAddress': 'relay.example.com', + 'authToken': 'secret', + }, + }, + }); + expect(config, isNotNull); + expect(config!['serverAddress'], 'relay.example.com'); + expect(config['authToken'], 'secret'); + expect(config['autoReconnect'], isTrue); + }); + + test('resolveInternetAutoEnableConfig legacy flat server key', () { + final config = OfflineProtocol.resolveInternetAutoEnableConfig({ + 'internetEnabled': true, + 'internetServerAddress': 'wss://legacy.example.com', + 'internetAutoReconnect': false, + }); + expect(config, isNotNull); + expect(config!['serverAddress'], 'wss://legacy.example.com'); + expect(config['autoReconnect'], isFalse); + }); + + test('resolveInternetAutoEnableConfig root authToken with legacy server', () { + final config = OfflineProtocol.resolveInternetAutoEnableConfig({ + 'internetEnabled': true, + 'internetServerAddress': 'relay.example.com', + 'authToken': 'root-secret', + }); + expect(config, isNotNull); + expect(config!['authToken'], 'root-secret'); + }); + + test('resolveWifiDirectAutoEnableConfig nested transports.wifiDirect', () { + final config = OfflineProtocol.resolveWifiDirectAutoEnableConfig({ + 'transports': { + 'wifiDirect': { + 'enabled': true, + 'autoAccept': true, + }, + }, + }); + expect(config, isNotNull); + expect(config!['enabled'], isTrue); + expect(config['autoAccept'], isTrue); + }); + + test('nativeMethodChannelMethods includes replay and enableTransport', () { + expect( + OfflineProtocol.nativeMethodChannelMethods, + containsAll(['replayEvents', 'enableTransport', 'enableInternetTransport']), + ); + expect(OfflineProtocol.nativeMethodChannelMethods, isNotEmpty); + expect(OfflineProtocol.nativeMethodChannelMethods.toSet().length, + OfflineProtocol.nativeMethodChannelMethods.length, + reason: 'method names must be unique'); + }); + + test('resolveInternetAutoEnableConfig requires enabled flag', () { + expect( + OfflineProtocol.resolveInternetAutoEnableConfig({ + 'transports': { + 'internet': { + 'serverAddress': 'relay.example.com', + }, + }, + }), + isNull, + ); + }); +} diff --git a/bindings/flutter/scripts/bootstrap-example-platforms.sh b/bindings/flutter/scripts/bootstrap-example-platforms.sh new file mode 100755 index 00000000..884fed73 --- /dev/null +++ b/bindings/flutter/scripts/bootstrap-example-platforms.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Materialize Flutter platform folders for the example app (Runner, Gradle wrapper). +# +# The example ships Dart and a minimal Android tree. The Xcode Runner project +# is generated locally because Flutter platform files are toolchain-versioned +# and are not committed. +# +# Usage (from repository root): +# bindings/flutter/scripts/bootstrap-example-platforms.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXAMPLE_DIR="$(cd "$SCRIPT_DIR/../offline_protocol/example" && pwd)" + +if ! command -v flutter >/dev/null 2>&1; then + echo "Error: flutter not found on PATH." >&2 + echo "Install Flutter 3.10+ and retry." >&2 + exit 1 +fi + +cd "$EXAMPLE_DIR" +flutter pub get + +# Regenerates missing platform folders without overwriting lib/ or pubspec.yaml. +flutter create . --platforms=android,ios --org com.offlineprotocol.flutter + +echo "Example platforms ready under $EXAMPLE_DIR" diff --git a/bindings/flutter/scripts/ci-build-android-jni-arm64.sh b/bindings/flutter/scripts/ci-build-android-jni-arm64.sh new file mode 100755 index 00000000..5c96b2aa --- /dev/null +++ b/bindings/flutter/scripts/ci-build-android-jni-arm64.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Builds the arm64-v8a UniFFI shared library for Flutter CI and local checks. +# One ABI keeps pull-request jobs fast; release still builds all ABIs. The +# profile is `mobile`, the one the Android release ships (ADR 0029), so the +# footprint baseline measures the library applications actually get. +# +# Requires: ANDROID_NDK_HOME, Rust aarch64-linux-android target. +# Writes: bindings/react-native/android/src/main/jniLibs/arm64-v8a/libuniffi_offline_protocol.so + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +JNI_OUT="${REPO_ROOT}/bindings/react-native/android/src/main/jniLibs/arm64-v8a" +TARGET=aarch64-linux-android +LINKER_BIN=aarch64-linux-android21-clang + +if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then + echo "ANDROID_NDK_HOME is not set" >&2 + exit 1 +fi + +NDK_TOOLCHAIN="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin" +if [[ ! -d "$NDK_TOOLCHAIN" ]]; then + # macOS local runs (CI uses linux-x86_64). + NDK_TOOLCHAIN="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/darwin-x86_64/bin" +fi +if [[ ! -x "${NDK_TOOLCHAIN}/${LINKER_BIN}" ]]; then + echo "NDK clang not found at ${NDK_TOOLCHAIN}/${LINKER_BIN}" >&2 + exit 1 +fi + +export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${NDK_TOOLCHAIN}/${LINKER_BIN}" +export AR="${NDK_TOOLCHAIN}/llvm-ar" +export CC_aarch64_linux_android="${NDK_TOOLCHAIN}/${LINKER_BIN}" +export CXX_aarch64_linux_android="${NDK_TOOLCHAIN}/${LINKER_BIN}++" + +cd "${REPO_ROOT}" +cargo build --profile mobile --locked --target "${TARGET}" \ + --package offline-protocol-uniffi --no-default-features + +SO_ROOT="target/${TARGET}/mobile" +SO_PATH="${SO_ROOT}/liboffline_protocol_uniffi.so" +if [[ ! -f "${SO_PATH}" ]]; then + SO_PATH="${SO_ROOT}/deps/liboffline_protocol_uniffi.so" +fi +if [[ ! -f "${SO_PATH}" ]]; then + echo "liboffline_protocol_uniffi.so not found under ${SO_ROOT}" >&2 + exit 1 +fi + +mkdir -p "${JNI_OUT}" +cp "${SO_PATH}" "${JNI_OUT}/libuniffi_offline_protocol.so" +python3 bindings/react-native/scripts/check-elf-alignment.py \ + "${JNI_OUT}/libuniffi_offline_protocol.so" + +echo "Installed ${JNI_OUT}/libuniffi_offline_protocol.so" diff --git a/bindings/flutter/scripts/measure-plugin-aar-size.sh b/bindings/flutter/scripts/measure-plugin-aar-size.sh new file mode 100755 index 00000000..8f773cd7 --- /dev/null +++ b/bindings/flutter/scripts/measure-plugin-aar-size.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# Measures the Flutter plugin release AAR (shared Kotlin bridge + jniLibs). +# CI uses this single Gradle invocation (:offline_protocol:assembleRelease) to +# compile shared Kotlin and gate footprint; no separate compileDebugKotlin step. +# +# bindings/flutter/scripts/measure-plugin-aar-size.sh # print size +# bindings/flutter/scripts/measure-plugin-aar-size.sh --check # fail if over baseline + tolerance +# bindings/flutter/scripts/measure-plugin-aar-size.sh --write-baseline +# +# Requires: JDK 17+, example android/gradlew (Flutter example project). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +BASELINE="${REPO_ROOT}/bindings/flutter/baselines/offline-protocol-release-aar.json" +ANDROID_DIR="${REPO_ROOT}/bindings/flutter/offline_protocol/example/android" +AAR_NAME="offline_protocol-release.aar" + +MODE="${1:-}" + +if [[ ! -x "${ANDROID_DIR}/gradlew" ]]; then + echo "missing ${ANDROID_DIR}/gradlew: bootstrap the Flutter example first" >&2 + exit 1 +fi + +JNI_DIR="${REPO_ROOT}/bindings/react-native/android/src/main/jniLibs" +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 required to measure the AAR" >&2 + exit 1 +fi +if ! compgen -G "${JNI_DIR}/arm64-v8a/*.so" >/dev/null 2>&1; then + echo "UniFFI jniLibs not found under ${JNI_DIR}. Build React Native artifacts first:" >&2 + echo " cd bindings/react-native && npm run build:uniffi:android" >&2 + exit 1 +fi + +cd "${ANDROID_DIR}" +./gradlew :offline_protocol:assembleRelease --quiet + +AAR_PATH="$(find "${REPO_ROOT}/bindings/flutter/offline_protocol/example/build" -name "${AAR_NAME}" -type f 2>/dev/null | head -1)" +if [[ -z "${AAR_PATH}" || ! -f "${AAR_PATH}" ]]; then + echo "could not find ${AAR_NAME} under example/build" >&2 + exit 1 +fi + +# The baseline is the arm64-v8a plugin, which is all CI builds. A developer +# who ran `npm run build:uniffi:android` has four ABIs under jniLibs and the +# AAR packs all of them, so the bytes every other ABI occupies in the archive +# are subtracted. In CI there are none and this is the file size exactly. +BYTES="$(python3 - "${AAR_PATH}" <<'MEASURE' +import os, sys, zipfile +path = sys.argv[1] +other = 0 +with zipfile.ZipFile(path) as aar: + for info in aar.infolist(): + parts = info.filename.split("/") + if len(parts) > 2 and parts[0] == "jni" and parts[1] != "arm64-v8a": + # The data, its local header (30 bytes + name + extra) and its + # central directory entry (46 bytes + name + extra + comment). + name = len(info.filename.encode()) + other += info.compress_size + 30 + name + len(info.extra) + other += 46 + name + len(info.extra) + len(info.comment) +print(os.path.getsize(path) - other) +MEASURE +)" +KIB="$(awk -v b="${BYTES}" 'BEGIN { printf "%.1f", b / 1024 }')" +MIB="$(awk -v b="${BYTES}" 'BEGIN { printf "%.2f", b / 1024 / 1024 }')" + +case "${MODE}" in + --write-baseline) + python3 - "${BASELINE}" "${BYTES}" <<'PY' +import json, sys +path, nbytes = sys.argv[1], int(sys.argv[2]) +with open(path, encoding="utf-8") as f: + data = json.load(f) +data["bytes"] = nbytes +with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") +print(f"updated {path} -> {nbytes} bytes") +PY + ;; + --check) + python3 - "${BASELINE}" "${BYTES}" <<'PY' +import json, sys +path, measured = sys.argv[1], int(sys.argv[2]) +with open(path, encoding="utf-8") as f: + data = json.load(f) +expected = int(data["bytes"]) +tol = int(data.get("tolerance_bytes", 0)) +ceiling = expected + tol +if measured > ceiling: + print( + f"AAR size regression: {measured} bytes > baseline {expected} + tolerance {tol} (= {ceiling})", + file=sys.stderr, + ) + sys.exit(1) +print(f"OK: {measured} bytes within baseline {expected} + tolerance {tol}") +PY + ;; + "") + ;; + *) + echo "usage: $0 [--check|--write-baseline]" >&2 + exit 1 + ;; +esac + +printf '| Artifact | Bytes | KiB | MiB |\n' +printf '|---|--:|--:|--:|\n' +printf '| `%s` | %'"'"'d | %s | %s |\n' "${AAR_NAME}" "${BYTES}" "${KIB}" "${MIB}" +printf '\nPath: `%s`\n' "${AAR_PATH#${REPO_ROOT}/}" diff --git a/bindings/python/THIRD-PARTY-NOTICES.md b/bindings/python/THIRD-PARTY-NOTICES.md index 9947b0b2..07374833 100644 --- a/bindings/python/THIRD-PARTY-NOTICES.md +++ b/bindings/python/THIRD-PARTY-NOTICES.md @@ -826,7 +826,7 @@ DEALINGS IN THE SOFTWARE. Used by: -- [webpki-roots 1.0.9](https://github.com/rustls/webpki-roots) +- [webpki-roots 1.0.10](https://github.com/rustls/webpki-roots) ``` # Community Data License Agreement - Permissive - Version 2.0 @@ -949,7 +949,7 @@ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Used by: -- [rustls-webpki 0.103.15](https://github.com/rustls/webpki) +- [rustls-webpki 0.103.16](https://github.com/rustls/webpki) ``` Except as otherwise noted, this project is licensed under the following diff --git a/bindings/react-native/MeshSdk.podspec b/bindings/react-native/MeshSdk.podspec index 131e1efc..afccd8c8 100644 --- a/bindings/react-native/MeshSdk.podspec +++ b/bindings/react-native/MeshSdk.podspec @@ -21,6 +21,8 @@ Pod::Spec.new do |s| # Source files (paths are relative to this file, i.e. the package root) s.source_files = [ "ios/OfflineProtocolModule.{m,swift}", + "ios/ProtocolConfigParser.swift", + "ios/InitialRuntimeConfigApplier.swift", "ios/EncryptionConfigReader.swift", "ios/MeshRelayConfigReader.swift", "ios/CustodyConfigReader.swift", @@ -28,6 +30,7 @@ Pod::Spec.new do |s| "ios/TransportManager.swift", "ios/BleManager.swift", "ios/InternetManager.swift", + "ios/InternetTransportBootstrap.swift", "ios/ForcedPresenceCheckQueue.swift", "ios/EventReplayRing.swift", "ios/InboundEventBuffer.swift", diff --git a/bindings/react-native/THIRD-PARTY-NOTICES.md b/bindings/react-native/THIRD-PARTY-NOTICES.md index 9947b0b2..07374833 100644 --- a/bindings/react-native/THIRD-PARTY-NOTICES.md +++ b/bindings/react-native/THIRD-PARTY-NOTICES.md @@ -826,7 +826,7 @@ DEALINGS IN THE SOFTWARE. Used by: -- [webpki-roots 1.0.9](https://github.com/rustls/webpki-roots) +- [webpki-roots 1.0.10](https://github.com/rustls/webpki-roots) ``` # Community Data License Agreement - Permissive - Version 2.0 @@ -949,7 +949,7 @@ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Used by: -- [rustls-webpki 0.103.15](https://github.com/rustls/webpki) +- [rustls-webpki 0.103.16](https://github.com/rustls/webpki) ``` Except as otherwise noted, this project is licensed under the following diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/InitialRuntimeConfigApplier.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/InitialRuntimeConfigApplier.kt new file mode 100644 index 00000000..6433e92f --- /dev/null +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/InitialRuntimeConfigApplier.kt @@ -0,0 +1,180 @@ +package com.offlineprotocol + +import kotlin.math.max +import kotlin.math.min +import org.json.JSONObject +import uniffi.offline_protocol.OfflineProtocol +import uniffi.offline_protocol.RelayConfig +import uniffi.offline_protocol.RelayPriority + +/** + * Applies optional `dors` and `relay` sections from a host config JSON onto a + * live [OfflineProtocol] instance after [ProtocolConfig] construction. + * + * Shared by the React Native module and the Flutter plugin (ADR 0025). Mirrors + * `applyInitialRuntimeConfig` on iOS. + */ +internal object InitialRuntimeConfigApplier { + + /** Bounds shared with the React Native module's own config paths. */ + internal object Limits { + const val MIN_BATTERY_LEVEL = 0 + const val MAX_BATTERY_LEVEL = 100 + const val MIN_HISTORY_WINDOW = 1L + const val MAX_HISTORY_WINDOW = 100L + } + + fun apply( + proto: OfflineProtocol, + json: JSONObject, + onDiagnostic: ((level: String, message: String, context: Map?) -> Unit)? = null, + ) { + json.optJSONObject("dors")?.let { dorsJson -> + try { + val baseConfig = proto.getDorsConfig() + val updatedConfig = baseConfig.copy( + preferOnline = dorsJson.optBooleanCompat("preferOnline", "prefer_online") + ?: baseConfig.preferOnline, + switchHysteresis = dorsJson.optDoubleCompat("switchHysteresis", "switch_hysteresis") + ?.toFloat() + ?.coerceAtLeast(0f) + ?: baseConfig.switchHysteresis, + switchCooldownSecs = dorsJson.optLongCompat("switchCooldownSecs", "switch_cooldown_secs") + ?.coerceAtLeast(0) + ?.toULong() + ?: baseConfig.switchCooldownSecs, + bleToWifiRetryThreshold = dorsJson.optIntCompat("bleToWifiRetryThreshold", "ble_to_wifi_retry_threshold") + ?.coerceAtLeast(0) + ?.toUInt() + ?: baseConfig.bleToWifiRetryThreshold, + minSuccessRateBeforeEscalation = dorsJson.optDoubleCompat( + "minSuccessRateBeforeEscalation", + "min_success_rate_before_escalation", + ) + ?.toFloat() + ?.coerceIn(0f, 1f) + ?: baseConfig.minSuccessRateBeforeEscalation, + minBleSamplesBeforeSuccessRateEscalation = dorsJson.optLongCompat( + "minBleSamplesBeforeSuccessRateEscalation", + "min_ble_samples_before_success_rate_escalation", + ) + ?.coerceAtLeast(0) + ?.toULong() + ?: baseConfig.minBleSamplesBeforeSuccessRateEscalation, + rssiSwitchThreshold = dorsJson.optIntCompat("rssiSwitchThreshold", "rssi_switch_threshold") + ?.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()) + ?.toShort() + ?: baseConfig.rssiSwitchThreshold, + congestionQueueThreshold = dorsJson.optLongCompat("congestionQueueThreshold", "congestion_queue_threshold") + ?.coerceAtLeast(0) + ?.toULong() + ?: baseConfig.congestionQueueThreshold, + stabilityWindowSecs = dorsJson.optLongCompat("stabilityWindowSecs", "stability_window_secs") + ?.coerceAtLeast(0) + ?.toULong() + ?: baseConfig.stabilityWindowSecs, + poorSignalDurationSecs = dorsJson.optLongCompat("poorSignalDurationSecs", "poor_signal_duration_secs") + ?.coerceAtLeast(0) + ?.toULong() + ?: baseConfig.poorSignalDurationSecs, + ttlEscalationThreshold = dorsJson.optIntCompat("ttlEscalationThreshold", "ttl_escalation_threshold") + ?.coerceIn(0, UByte.MAX_VALUE.toInt()) + ?.toUByte() + ?: baseConfig.ttlEscalationThreshold, + congestionDurationSecs = dorsJson.optLongCompat("congestionDurationSecs", "congestion_duration_secs") + ?.coerceAtLeast(0) + ?.toULong() + ?: baseConfig.congestionDurationSecs, + ttlEscalationHoldSecs = dorsJson.optLongCompat("ttlEscalationHoldSecs", "ttl_escalation_hold_secs") + ?.coerceAtLeast(1) + ?.toULong() + ?: baseConfig.ttlEscalationHoldSecs, + historyWindowSize = dorsJson.optLongCompat("historyWindowSize", "history_window_size") + ?.let { max(Limits.MIN_HISTORY_WINDOW, min(Limits.MAX_HISTORY_WINDOW, it)) } + ?.toULong() + ?: baseConfig.historyWindowSize, + queueRecoveryRatio = dorsJson.optDoubleCompat("queueRecoveryRatio", "queue_recovery_ratio") + ?.toFloat() + ?.coerceIn(0f, 1f) + ?: baseConfig.queueRecoveryRatio, + lowBatteryThreshold = dorsJson.optIntCompat("lowBatteryThreshold", "low_battery_threshold") + ?.coerceIn(Limits.MIN_BATTERY_LEVEL, Limits.MAX_BATTERY_LEVEL) + ?.toUByte() + ?: baseConfig.lowBatteryThreshold, + relayMinBatteryLevel = dorsJson.optIntCompat("relayMinBatteryLevel", "relay_min_battery_level") + ?.coerceIn(Limits.MIN_BATTERY_LEVEL, Limits.MAX_BATTERY_LEVEL) + ?.toUByte() + ?: baseConfig.relayMinBatteryLevel, + relayOptimalConnectionCount = dorsJson.optIntCompat( + "relayOptimalConnectionCount", + "relay_optimal_connection_count", + ) + ?.coerceIn(0, UByte.MAX_VALUE.toInt()) + ?.toUByte() + ?: baseConfig.relayOptimalConnectionCount, + ) + proto.updateDorsConfig(updatedConfig) + onDiagnostic?.invoke("info", "Applied initial DORS config", null) + } catch (e: Exception) { + onDiagnostic?.invoke( + "warning", + "Failed to apply initial DORS config", + mapOf("message" to (e.message ?: "unknown")), + ) + } + } + + // The whole relay section, not just the priority: allowRelay and + // minBatteryForRelay used to be parsed by nothing, leaving + // `config.relay` on mobile permanently at its defaults. + json.optJSONObject("relay")?.let { relayJson -> + try { + val current = proto.getRelayConfig() + val priorityRaw = relayJson.safeOptString("relayPriority", relayJson.safeOptString("relay_priority")) + val priority = normalizeRelayPriority(priorityRaw) ?: current.relayPriority + val updated = RelayConfig( + minBatteryForRelay = relayJson.optIntCompat("minBatteryForRelay", "min_battery_for_relay") + ?.coerceIn(Limits.MIN_BATTERY_LEVEL, Limits.MAX_BATTERY_LEVEL)?.toUByte() + ?: current.minBatteryForRelay, + allowRelay = relayJson.optBooleanCompat("allowRelay", "allow_relay") ?: current.allowRelay, + relayPriority = priority, + ) + proto.updateRelayConfig(updated) + onDiagnostic?.invoke( + "info", + "Applied initial relay config", + mapOf("priority" to relayPriorityName(updated.relayPriority)), + ) + } catch (e: Exception) { + onDiagnostic?.invoke( + "warning", + "Failed to apply initial relay config", + mapOf("message" to (e.message ?: "unknown")), + ) + } + } + } + + /** + * Accepts both the current vocabulary and the pre-0.22 `low`/`medium`/`high` + * spelling of the same three values, so an app that has not migrated its + * config keeps working. + */ + internal fun normalizeRelayPriority(priority: String?): RelayPriority? { + if (priority.isNullOrBlank()) { + return null + } + return when (priority.lowercase()) { + "never", "low" -> RelayPriority.NEVER + "auto", "medium" -> RelayPriority.AUTO + "always", "high" -> RelayPriority.ALWAYS + else -> null + } + } + + internal fun relayPriorityName(priority: RelayPriority?): String = when (priority) { + RelayPriority.NEVER -> "never" + RelayPriority.ALWAYS -> "always" + else -> "auto" + } +} diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/InternetTransportBootstrap.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/InternetTransportBootstrap.kt new file mode 100644 index 00000000..b144efe1 --- /dev/null +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/InternetTransportBootstrap.kt @@ -0,0 +1,103 @@ +package com.offlineprotocol + +import android.util.Log +import org.json.JSONObject +import java.net.URI + +/** + * Shared Internet transport wiring for host wrappers (Flutter, and optionally RN). + * + * Mirrors the enable/configure path in [OfflineProtocolModule.configureAndStartInternet]. + * ADR 0025: one home for bridge behaviour that must stay aligned across + * wrappers. React Native still inlines its path today; a follow-up should + * delegate here. CI guard: + * `internet_transport_bootstrap_keeps_rn_configure_invariants` in + * `offline-protocol-uniffi`. + */ +internal object InternetTransportBootstrap { + private const val TAG = "InternetTransportBootstrap" + + fun createManager( + context: android.content.Context, + proto: uniffi.offline_protocol.OfflineProtocol, + profile: String, + appId: String, + onDiagnostic: ((String, String, Map) -> Unit)? = null, + ): InternetManager = + InternetManager(context, proto, profile, appId, onDiagnostic) + + /** + * Attaches relay-side emitters that become `eventJson` payloads on the host event stream. + */ + fun attachEmitters(manager: InternetManager, emitEventJson: (String) -> Unit) { + manager.serverMessageEmitter = { rawJson -> + try { + val json = JSONObject() + json.put("type", "internet_server_message") + json.put("json", rawJson) + emitEventJson(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to build server message event", e) + } + } + manager.connectionStatusEmitter = { connected, authenticated -> + try { + val json = JSONObject() + json.put("type", "internet_status_changed") + json.put("connected", connected) + json.put("authenticated", authenticated) + emitEventJson(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to build internet status event", e) + } + } + manager.supersededEmitter = { reason -> + try { + emitEventJson(SupersededLatchPolicy.eventJson(reason)) + } catch (e: Exception) { + Log.e(TAG, "Failed to build internet superseded event", e) + } + } + } + + /** + * Configures and starts [manager] from an enable-transport config object (camelCase or snake_case). + */ + fun configureAndStart(manager: InternetManager, config: JSONObject) { + val serverAddress = config.optStringCompat("serverAddress", "server_url", "server_address") + ?: throw IllegalArgumentException("Internet transport requires a serverAddress") + + var wsUrl = serverAddress.trim() + if (!wsUrl.startsWith("ws://") && !wsUrl.startsWith("wss://")) { + wsUrl = "wss://$wsUrl" + } + + val port = when { + config.has("port") && !config.isNull("port") -> config.optDouble("port").toInt() + config.has("serverPort") && !config.isNull("serverPort") -> config.optDouble("serverPort").toInt() + else -> null + } + if (port != null && port > 0) { + val uri = URI(wsUrl) + if (uri.port == -1) { + wsUrl = "$wsUrl:$port" + } + } + + val autoReconnect = config.optBooleanCompat("autoReconnect", "auto_reconnect") ?: true + val maxRetries = if (config.has("maxReconnectAttempts") && !config.isNull("maxReconnectAttempts")) { + config.optDouble("maxReconnectAttempts").toInt() + } else { + 0 + } + + config.optStringCompat("authToken", "auth_token")?.let { manager.setAuthToken(it) } + + if (manager.state == TransportState.RUNNING || manager.state == TransportState.STARTING) { + manager.stop() + } + + manager.configure(wsUrl, autoReconnect, maxRetries) + manager.start() + } +} diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt index 9555fc87..4205cd8c 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt @@ -264,10 +264,11 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : } private object Constants { - const val MIN_BATTERY_LEVEL = 0 - const val MAX_BATTERY_LEVEL = 100 - const val MIN_HISTORY_WINDOW = 1L - const val MAX_HISTORY_WINDOW = 100L + // One definition, in the applier both bridges share. + const val MIN_BATTERY_LEVEL = InitialRuntimeConfigApplier.Limits.MIN_BATTERY_LEVEL + const val MAX_BATTERY_LEVEL = InitialRuntimeConfigApplier.Limits.MAX_BATTERY_LEVEL + const val MIN_HISTORY_WINDOW = InitialRuntimeConfigApplier.Limits.MIN_HISTORY_WINDOW + const val MAX_HISTORY_WINDOW = InitialRuntimeConfigApplier.Limits.MAX_HISTORY_WINDOW const val BLE_RESTART_DELAY_MS = 1000L // Matches iOS 100ms tick. Handles retries, ACK timeouts, welcome // processing, and DORS. Latency-sensitive work is also event-driven. @@ -581,133 +582,15 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : * spelling of the same three values, so an app that has not migrated its * config keeps working. */ - private fun normalizeRelayPriority(priority: String?): RelayPriority? { - if (priority.isNullOrBlank()) { - return null - } - return when (priority.lowercase()) { - "never", "low" -> RelayPriority.NEVER - "auto", "medium" -> RelayPriority.AUTO - "always", "high" -> RelayPriority.ALWAYS - else -> null - } - } + private fun normalizeRelayPriority(priority: String?): RelayPriority? = + InitialRuntimeConfigApplier.normalizeRelayPriority(priority) - private fun relayPriorityName(priority: RelayPriority?): String = when (priority) { - RelayPriority.NEVER -> "never" - RelayPriority.ALWAYS -> "always" - else -> "auto" - } + private fun relayPriorityName(priority: RelayPriority?): String = + InitialRuntimeConfigApplier.relayPriorityName(priority) private fun applyInitialRuntimeConfig(proto: OfflineProtocol, json: JSONObject) { - json.optJSONObject("dors")?.let { dorsJson -> - try { - val baseConfig = proto.getDorsConfig() - val updatedConfig = baseConfig.copy( - preferOnline = dorsJson.optBooleanCompat("preferOnline", "prefer_online") - ?: baseConfig.preferOnline, - switchHysteresis = dorsJson.optDoubleCompat("switchHysteresis", "switch_hysteresis") - ?.toFloat() - ?.coerceAtLeast(0f) - ?: baseConfig.switchHysteresis, - switchCooldownSecs = dorsJson.optLongCompat("switchCooldownSecs", "switch_cooldown_secs") - ?.coerceAtLeast(0) - ?.toULong() - ?: baseConfig.switchCooldownSecs, - bleToWifiRetryThreshold = dorsJson.optIntCompat("bleToWifiRetryThreshold", "ble_to_wifi_retry_threshold") - ?.coerceAtLeast(0) - ?.toUInt() - ?: baseConfig.bleToWifiRetryThreshold, - minSuccessRateBeforeEscalation = dorsJson.optDoubleCompat("minSuccessRateBeforeEscalation", "min_success_rate_before_escalation") - ?.toFloat() - ?.coerceIn(0f, 1f) - ?: baseConfig.minSuccessRateBeforeEscalation, - minBleSamplesBeforeSuccessRateEscalation = dorsJson.optLongCompat("minBleSamplesBeforeSuccessRateEscalation", "min_ble_samples_before_success_rate_escalation") - ?.coerceAtLeast(0) - ?.toULong() - ?: baseConfig.minBleSamplesBeforeSuccessRateEscalation, - rssiSwitchThreshold = dorsJson.optIntCompat("rssiSwitchThreshold", "rssi_switch_threshold") - ?.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()) - ?.toShort() - ?: baseConfig.rssiSwitchThreshold, - congestionQueueThreshold = dorsJson.optLongCompat("congestionQueueThreshold", "congestion_queue_threshold") - ?.coerceAtLeast(0) - ?.toULong() - ?: baseConfig.congestionQueueThreshold, - stabilityWindowSecs = dorsJson.optLongCompat("stabilityWindowSecs", "stability_window_secs") - ?.coerceAtLeast(0) - ?.toULong() - ?: baseConfig.stabilityWindowSecs, - poorSignalDurationSecs = dorsJson.optLongCompat("poorSignalDurationSecs", "poor_signal_duration_secs") - ?.coerceAtLeast(0) - ?.toULong() - ?: baseConfig.poorSignalDurationSecs, - ttlEscalationThreshold = dorsJson.optIntCompat("ttlEscalationThreshold", "ttl_escalation_threshold") - ?.coerceIn(0, UByte.MAX_VALUE.toInt()) - ?.toUByte() - ?: baseConfig.ttlEscalationThreshold, - congestionDurationSecs = dorsJson.optLongCompat("congestionDurationSecs", "congestion_duration_secs") - ?.coerceAtLeast(0) - ?.toULong() - ?: baseConfig.congestionDurationSecs, - ttlEscalationHoldSecs = dorsJson.optLongCompat("ttlEscalationHoldSecs", "ttl_escalation_hold_secs") - ?.coerceAtLeast(1) - ?.toULong() - ?: baseConfig.ttlEscalationHoldSecs, - historyWindowSize = dorsJson.optLongCompat("historyWindowSize", "history_window_size") - ?.let { max(Constants.MIN_HISTORY_WINDOW, min(Constants.MAX_HISTORY_WINDOW, it)) } - ?.toULong() - ?: baseConfig.historyWindowSize, - queueRecoveryRatio = dorsJson.optDoubleCompat("queueRecoveryRatio", "queue_recovery_ratio") - ?.toFloat() - ?.coerceIn(0f, 1f) - ?: baseConfig.queueRecoveryRatio, - lowBatteryThreshold = dorsJson.optIntCompat("lowBatteryThreshold", "low_battery_threshold") - ?.coerceIn(Constants.MIN_BATTERY_LEVEL, Constants.MAX_BATTERY_LEVEL) - ?.toUByte() - ?: baseConfig.lowBatteryThreshold, - relayMinBatteryLevel = dorsJson.optIntCompat("relayMinBatteryLevel", "relay_min_battery_level") - ?.coerceIn(Constants.MIN_BATTERY_LEVEL, Constants.MAX_BATTERY_LEVEL) - ?.toUByte() - ?: baseConfig.relayMinBatteryLevel, - relayOptimalConnectionCount = dorsJson.optIntCompat("relayOptimalConnectionCount", "relay_optimal_connection_count") - ?.coerceIn(0, UByte.MAX_VALUE.toInt()) - ?.toUByte() - ?: baseConfig.relayOptimalConnectionCount - ) - proto.updateDorsConfig(updatedConfig) - emitDiagnostic("info", "Applied initial DORS config") - } catch (e: Exception) { - emitDiagnostic("warning", "Failed to apply initial DORS config", mapOf( - "message" to (e.message ?: "unknown") - )) - } - } - - // The whole relay section, not just the priority: allowRelay and - // minBatteryForRelay used to be parsed by nothing, leaving - // `config.relay` on mobile permanently at its defaults. - json.optJSONObject("relay")?.let { relayJson -> - try { - val current = proto.getRelayConfig() - val priorityRaw = relayJson.safeOptString("relayPriority", relayJson.safeOptString("relay_priority")) - val priority = normalizeRelayPriority(priorityRaw) ?: current.relayPriority - val updated = RelayConfig( - minBatteryForRelay = relayJson.optIntCompat("minBatteryForRelay", "min_battery_for_relay") - ?.coerceIn(Constants.MIN_BATTERY_LEVEL, Constants.MAX_BATTERY_LEVEL)?.toUByte() - ?: current.minBatteryForRelay, - allowRelay = relayJson.optBooleanCompat("allowRelay", "allow_relay") ?: current.allowRelay, - relayPriority = priority - ) - proto.updateRelayConfig(updated) - emitDiagnostic("info", "Applied initial relay config", mapOf( - "priority" to relayPriorityName(updated.relayPriority) - )) - } catch (e: Exception) { - emitDiagnostic("warning", "Failed to apply initial relay config", mapOf( - "message" to (e.message ?: "unknown") - )) - } + InitialRuntimeConfigApplier.apply(proto, json) { level, message, context -> + emitDiagnostic(level, message, context ?: emptyMap()) } } diff --git a/bindings/react-native/ios/EventReplayRing.swift b/bindings/react-native/ios/EventReplayRing.swift index 0126b263..3d484ba8 100644 --- a/bindings/react-native/ios/EventReplayRing.swift +++ b/bindings/react-native/ios/EventReplayRing.swift @@ -57,4 +57,11 @@ final class EventReplayRing { defer { lock.unlock() } return entries.count } + + /// Forgets everything, for a host that starts numbering from zero again. + func clear() { + lock.lock() + defer { lock.unlock() } + entries.removeAll(keepingCapacity: false) + } } diff --git a/bindings/react-native/ios/InitialRuntimeConfigApplier.swift b/bindings/react-native/ios/InitialRuntimeConfigApplier.swift new file mode 100644 index 00000000..f73417bc --- /dev/null +++ b/bindings/react-native/ios/InitialRuntimeConfigApplier.swift @@ -0,0 +1,179 @@ +// +// InitialRuntimeConfigApplier.swift +// +// Applies optional `dors` and `relay` sections from host config JSON onto a live +// OfflineProtocol after ProtocolConfig construction. Shared by the React +// Native module and the Flutter plugin (ADR 0025). +// + +import Foundation + +enum InitialRuntimeConfigApplier { + typealias DiagnosticHandler = (_ level: String, _ message: String, _ context: [String: Any]?) -> Void + + static func apply( + proto: OfflineProtocol, + rawConfig: [String: Any], + onDiagnostic: DiagnosticHandler? = nil + ) { + if let dorsDict = rawConfig["dors"] as? [String: Any] { + let preferOnline = dorsDict["preferOnline"] as? Bool ?? dorsDict["prefer_online"] as? Bool ?? false + let switchHysteresis = Float((dorsDict["switchHysteresis"] as? NSNumber)?.doubleValue + ?? (dorsDict["switch_hysteresis"] as? NSNumber)?.doubleValue + ?? 15.0) + let switchCooldown = UInt64((dorsDict["switchCooldownSecs"] as? NSNumber)?.uint64Value + ?? (dorsDict["switch_cooldown_secs"] as? NSNumber)?.uint64Value + ?? 20) + let bleRetry = UInt32((dorsDict["bleToWifiRetryThreshold"] as? NSNumber)?.uint32Value + ?? (dorsDict["ble_to_wifi_retry_threshold"] as? NSNumber)?.uint32Value + ?? 2) + let minSuccessRate = Float((dorsDict["minSuccessRateBeforeEscalation"] as? NSNumber)?.floatValue + ?? (dorsDict["min_success_rate_before_escalation"] as? NSNumber)?.floatValue + ?? 0.3) + let minSuccessRateClamped = min(max(minSuccessRate, 0.0), 1.0) + let minBleSamples = UInt64((dorsDict["minBleSamplesBeforeSuccessRateEscalation"] as? NSNumber)?.uint64Value + ?? (dorsDict["min_ble_samples_before_success_rate_escalation"] as? NSNumber)?.uint64Value + ?? 5) + let rssiThreshold = Int16((dorsDict["rssiSwitchThreshold"] as? NSNumber)?.int16Value + ?? (dorsDict["rssi_switch_threshold"] as? NSNumber)?.int16Value + ?? -85) + let congestionThreshold = UInt64((dorsDict["congestionQueueThreshold"] as? NSNumber)?.uint64Value + ?? (dorsDict["congestion_queue_threshold"] as? NSNumber)?.uint64Value + ?? 50) + let stabilityWindow = UInt64((dorsDict["stabilityWindowSecs"] as? NSNumber)?.uint64Value + ?? (dorsDict["stability_window_secs"] as? NSNumber)?.uint64Value + ?? 8) + let poorSignalDuration = UInt64((dorsDict["poorSignalDurationSecs"] as? NSNumber)?.uint64Value + ?? (dorsDict["poor_signal_duration_secs"] as? NSNumber)?.uint64Value + ?? 10) + let ttlThreshold = UInt8((dorsDict["ttlEscalationThreshold"] as? NSNumber)?.uint8Value + ?? (dorsDict["ttl_escalation_threshold"] as? NSNumber)?.uint8Value + ?? 2) + let congestionDuration = UInt64((dorsDict["congestionDurationSecs"] as? NSNumber)?.uint64Value + ?? (dorsDict["congestion_duration_secs"] as? NSNumber)?.uint64Value + ?? 10) + let ttlHold = UInt64((dorsDict["ttlEscalationHoldSecs"] as? NSNumber)?.uint64Value + ?? (dorsDict["ttl_escalation_hold_secs"] as? NSNumber)?.uint64Value + ?? 20) + let historyWindowRaw = UInt64((dorsDict["historyWindowSize"] as? NSNumber)?.uint64Value + ?? (dorsDict["history_window_size"] as? NSNumber)?.uint64Value + ?? 10) + // Clamped in the unsigned domain, never through `Int`: a negative + // `historyWindowSize` from JavaScript reaches `uint64Value` as + // `UInt64.max`, and narrowing that to `Int` traps and aborts the + // application before the clamp around it can run. + let historyWindow = max(1, min(100, historyWindowRaw)) + let rawQueueRecovery = Float((dorsDict["queueRecoveryRatio"] as? NSNumber)?.floatValue + ?? (dorsDict["queue_recovery_ratio"] as? NSNumber)?.floatValue + ?? 0.5) + let queueRecovery = min(max(rawQueueRecovery, 0.0), 1.0) + let lowBattery = UInt8(min(100, max(0, Int((dorsDict["lowBatteryThreshold"] as? NSNumber)?.intValue + ?? (dorsDict["low_battery_threshold"] as? NSNumber)?.intValue + ?? 20)))) + let relayMinBattery = UInt8(min(100, max(0, Int((dorsDict["relayMinBatteryLevel"] as? NSNumber)?.intValue + ?? (dorsDict["relay_min_battery_level"] as? NSNumber)?.intValue + ?? 30)))) + let relayOptimalConnections = UInt8(min(255, max(0, Int((dorsDict["relayOptimalConnectionCount"] as? NSNumber)?.intValue + ?? (dorsDict["relay_optimal_connection_count"] as? NSNumber)?.intValue + ?? 4)))) + + let dorsConfig = DorsConfig( + preferOnline: preferOnline, + switchHysteresis: switchHysteresis, + switchCooldownSecs: switchCooldown, + bleToWifiRetryThreshold: bleRetry, + minSuccessRateBeforeEscalation: minSuccessRateClamped, + minBleSamplesBeforeSuccessRateEscalation: minBleSamples, + rssiSwitchThreshold: rssiThreshold, + congestionQueueThreshold: congestionThreshold, + stabilityWindowSecs: stabilityWindow, + poorSignalDurationSecs: poorSignalDuration, + ttlEscalationThreshold: ttlThreshold, + congestionDurationSecs: congestionDuration, + ttlEscalationHoldSecs: ttlHold, + historyWindowSize: historyWindow, + queueRecoveryRatio: queueRecovery, + lowBatteryThreshold: lowBattery, + relayMinBatteryLevel: relayMinBattery, + relayOptimalConnectionCount: relayOptimalConnections + ) + + do { + try proto.updateDorsConfig(config: dorsConfig) + onDiagnostic?("info", "Applied initial DORS config", nil) + } catch { + onDiagnostic?("warning", "Failed to apply initial DORS config", [ + "error": error.localizedDescription, + ]) + } + } + + // The whole relay section, not just the priority: allowRelay and + // minBatteryForRelay used to be parsed by nothing, leaving + // `config.relay` on mobile permanently at its defaults. + if let relayDict = rawConfig["relay"] as? [String: Any] { + do { + let updated = mergedRelayConfig(current: proto.getRelayConfig(), from: relayDict) + try proto.updateRelayConfig(config: updated) + onDiagnostic?("info", "Applied initial relay config", [ + "priority": relayPriorityName(updated.relayPriority), + ]) + } catch { + onDiagnostic?("warning", "Failed to apply initial relay config", [ + "error": error.localizedDescription, + ]) + } + } + } + + static func mergedRelayConfig(current: RelayConfig, from dict: [String: Any]) -> RelayConfig { + func number(_ keys: String...) -> NSNumber? { + for key in keys { + if let value = dict[key] as? NSNumber { return value } + } + return nil + } + func flag(_ keys: String...) -> Bool? { + for key in keys { + if let value = dict[key] as? Bool { return value } + if let value = dict[key] as? NSNumber { return value.boolValue } + } + return nil + } + let priorityRaw = (dict["relayPriority"] as? String) ?? (dict["relay_priority"] as? String) + return RelayConfig( + minBatteryForRelay: number("minBatteryForRelay", "min_battery_for_relay") + .map { UInt8(min(100, max(0, $0.intValue))) } ?? current.minBatteryForRelay, + allowRelay: flag("allowRelay", "allow_relay") ?? current.allowRelay, + relayPriority: normalizeRelayPriority(priorityRaw) ?? current.relayPriority + ) + } + + /// Accepts both the current vocabulary and the pre-0.22 `low`/`medium`/`high` + /// spelling of the same three values, so an app that has not migrated its + /// config keeps working. + static func normalizeRelayPriority(_ priority: String?) -> RelayPriority? { + guard let value = priority?.lowercased(), !value.isEmpty else { + return nil + } + switch value { + case "never", "low": + return .never + case "auto", "medium": + return .auto + case "always", "high": + return .always + default: + return nil + } + } + + static func relayPriorityName(_ priority: RelayPriority) -> String { + switch priority { + case .never: return "never" + case .auto: return "auto" + case .always: return "always" + @unknown default: return "auto" + } + } +} diff --git a/bindings/react-native/ios/InternetTransportBootstrap.swift b/bindings/react-native/ios/InternetTransportBootstrap.swift new file mode 100644 index 00000000..a336d11d --- /dev/null +++ b/bindings/react-native/ios/InternetTransportBootstrap.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Shared Internet transport wiring for host wrappers (Flutter). ADR 0025. +/// +/// React Native still inlines `configureAndStartInternet` in +/// `OfflineProtocolModule.swift`; a follow-up should delegate here. CI guards: +/// `internet_transport_bootstrap_swift_keeps_rn_configure_invariants` and the +/// Android counterpart in `offline-protocol-uniffi`. +enum InternetTransportBootstrap { + static func attachEmitters( + manager: InternetManager, + emitEventJson: @escaping (String) -> Void + ) { + manager.serverMessageEmitter = { rawJson in + let payload: [String: Any] = [ + "type": "internet_server_message", + "json": rawJson, + ] + if let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) { + emitEventJson(json) + } + } + manager.connectionStatusEmitter = { connected, authenticated in + let payload: [String: Any] = [ + "type": "internet_status_changed", + "connected": connected, + "authenticated": authenticated, + ] + if let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) { + emitEventJson(json) + } + } + manager.supersededEmitter = { reason in + emitEventJson(SupersededLatchPolicy.eventJson(reason: reason)) + } + } + + static func configureAndStart(manager: InternetManager, config: [String: Any]) throws { + guard let serverAddress = (config["serverAddress"] as? String) + ?? (config["server_address"] as? String) + ?? (config["server_url"] as? String), + !serverAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Internet transport requires a serverAddress"] + ) + } + + var wsUrl = serverAddress.trimmingCharacters(in: .whitespacesAndNewlines) + if !wsUrl.hasPrefix("ws://") && !wsUrl.hasPrefix("wss://") { + wsUrl = "wss://\(wsUrl)" + } + + if let port = config["port"] as? NSNumber ?? config["serverPort"] as? NSNumber, + port.intValue > 0, + let url = URL(string: wsUrl), + url.port == nil { + wsUrl = "\(wsUrl):\(port.intValue)" + } + + let autoReconnect = (config["autoReconnect"] as? Bool) + ?? (config["auto_reconnect"] as? Bool) + ?? true + let maxRetries = (config["maxReconnectAttempts"] as? NSNumber)?.intValue ?? 0 + + if let token = config["authToken"] as? String ?? config["auth_token"] as? String { + manager.setAuthToken(token) + } + + if manager.state == .running || manager.state == .starting { + manager.stop() + } + + try manager.configure(serverUrl: wsUrl, autoReconnect: autoReconnect, maxReconnectAttempts: maxRetries) + try manager.start() + } +} diff --git a/bindings/react-native/ios/OfflineProtocolModule.swift b/bindings/react-native/ios/OfflineProtocolModule.swift index 1ce3bf2d..b6cca821 100644 --- a/bindings/react-native/ios/OfflineProtocolModule.swift +++ b/bindings/react-native/ios/OfflineProtocolModule.swift @@ -471,256 +471,15 @@ class OfflineProtocolModule: RCTEventEmitter { // MARK: - Configuration Parsing - private func parseConfig(_ configJson: String) throws -> (config: ProtocolConfig, raw: [String: Any]) { - guard let jsonData = configJson.data(using: .utf8), - let raw = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { - throw NSError(domain: "OfflineProtocol", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"]) - } - - // Encryption section (four flags, compact-envelope kill switch, - // pending queue): read by EncryptionConfigReader — Foundation-only so - // the SwiftPM suite covers it; mirrors ProtocolConfigParser.kt on - // Android, keep the read order and precedence in sync. - let encryption = EncryptionConfigReader.read(raw) - let overflowPolicy: OverflowPolicy - switch encryption.overflowPolicyRaw.lowercased() { - case "drop_newest": - overflowPolicy = .dropNewest - default: - overflowPolicy = .dropOldest - } - // binaryWireEnabled (kill switch, default on): top level only — that - // IS its home in both the JS config and the flat UniFFI dictionary. - // camelCase or snake_case. - let binaryWireEnabled = raw["binaryWireEnabled"] as? Bool - ?? raw["binary_wire_enabled"] as? Bool ?? true - - // Nostr sealing (kill switch, default on). Nested home under - // `transports.nostr.sealingEnabled` — where the rest of the Nostr - // transport settings live — then the flat key, both cases. Same - // nested-wins-over-flat rule as `encryption` and `group`; mirrors - // ProtocolConfigParser.kt, keep in sync. - let nostrRaw = (raw["transports"] as? [String: Any])?["nostr"] as? [String: Any] - let nostrSealingEnabled = nostrRaw?["sealingEnabled"] as? Bool - ?? nostrRaw?["sealing_enabled"] as? Bool - ?? raw["nostrSealingEnabled"] as? Bool - ?? raw["nostr_sealing_enabled"] as? Bool - ?? true - - // Nostr cold contact (key-package publication + peer resolution, kill - // switch, default on). Same nested-then-flat shape as sealing above. - let nostrColdContactEnabled = nostrRaw?["coldContactEnabled"] as? Bool - ?? nostrRaw?["cold_contact_enabled"] as? Bool - ?? raw["nostrColdContactEnabled"] as? Bool - ?? raw["nostr_cold_contact_enabled"] as? Bool - ?? true - - // Nostr username discovery (claim publication + username resolution). - // Same nested-then-flat shape, but the default is FALSE: publishing - // binds a human-readable name to an address in a public place, which - // an app must opt into rather than inherit. - let nostrUsernameDiscoveryEnabled = nostrRaw?["usernameDiscoveryEnabled"] as? Bool - ?? nostrRaw?["username_discovery_enabled"] as? Bool - ?? raw["nostrUsernameDiscoveryEnabled"] as? Bool - ?? raw["nostr_username_discovery_enabled"] as? Bool - ?? false - - // Group section (nested home under `group`, then top level, both - // cases — same shape rules as `encryption`; mirrors - // ProtocolConfigParser.kt, keep in sync). These were UniFFI-only - // until the broadcast default flipped on: with no JS-reachable - // opt-out, an RN app could not force per-member fan-out. - let groupRaw = raw["group"] as? [String: Any] - let maxGroupMembers = groupRaw?["maxGroupMembers"] as? Int - ?? groupRaw?["max_group_members"] as? Int - ?? raw["maxGroupMembers"] as? Int - ?? raw["max_group_members"] as? Int - ?? 256 - let groupRelayEnabled = groupRaw?["relayEnabled"] as? Bool - ?? groupRaw?["relay_enabled"] as? Bool - ?? raw["groupRelayEnabled"] as? Bool - ?? raw["group_relay_enabled"] as? Bool - ?? true - let groupRelayBroadcastEnabled = groupRaw?["relayBroadcastEnabled"] as? Bool - ?? groupRaw?["relay_broadcast_enabled"] as? Bool - ?? raw["groupRelayBroadcastEnabled"] as? Bool - ?? raw["group_relay_broadcast_enabled"] as? Bool - ?? true - // Default false — see GroupConfig::enforce_admin_commits. Enabling it - // makes this device refuse membership commits it cannot authorize, - // which forks it from every member that accepted them. - let groupEnforceAdminCommits = groupRaw?["enforceAdminCommits"] as? Bool - ?? groupRaw?["enforce_admin_commits"] as? Bool - ?? raw["groupEnforceAdminCommits"] as? Bool - ?? raw["group_enforce_admin_commits"] as? Bool - ?? false - - // Mesh forwarding section: read by MeshRelayConfigReader — Foundation-only - // so the SwiftPM suite covers it; mirrors ProtocolConfigParser.kt, keep - // the read order and precedence in sync. Every field stays optional all - // the way to the core, which owns the defaults. - let meshRelay = MeshRelayConfigReader.read(raw).map { values in - MeshRelayConfig( - maxTtl: values.maxTtl, - denseMaxTtl: values.denseMaxTtl, - denseDegree: values.denseDegree, - fanout: values.fanout, - jitterMinMs: values.jitterMinMs, - jitterMaxMs: values.jitterMaxMs, - ratePerSec: values.ratePerSec, - burst: values.burst, - peerRatePerSec: values.peerRatePerSec, - peerBurst: values.peerBurst, - queueCapacity: values.queueCapacity, - biasMinScale: values.biasMinScale, - biasMaxHandicapMs: values.biasMaxHandicapMs, - activityWindowMs: values.activityWindowMs, - activityMinForwards: values.activityMinForwards, - activityIdleWindows: values.activityIdleWindows - ) - } - - // Custody section: read by CustodyConfigReader, Foundation-only like - // the mesh forwarding reader and for the same reason; mirrors - // ProtocolConfigParser.kt, keep the read order in sync. Absent stays - // absent all the way to the core, whose default is off; the reader - // carries the overflow policy as the app spelled it, and an unknown - // spelling stays absent rather than becoming a default written here. - let custody = CustodyConfigReader.read(raw).map { values in - CustodyConfig( - enabled: values.enabled, - holdMs: values.holdMs, - maxEntriesPerDepositor: values.maxEntriesPerDepositor, - maxBytesPerDepositor: values.maxBytesPerDepositor, - maxEntries: values.maxEntries, - maxBytes: values.maxBytes, - strangerMaxEntries: values.strangerMaxEntries, - strangerMaxBytes: values.strangerMaxBytes, - overflowPolicy: values.overflowPolicy.flatMap(custodyOverflowPolicy) - ) - } - - // Data layer section (nested home under `data`, both cases). Same - // rule as meshRelay: absent stays absent, so the Rust default is the - // only default. Kept in step with android/ ProtocolConfigParser — - // read order and precedence must match. - let dataSection = raw["data"] as? [String: Any] - let dataEnabled = dataSection?["enabled"] as? Bool - - // Security section, with a top-level spelling accepted too. Absent - // stays absent, so the Rust default is the only default — see the - // assignment below for why a literal here would be the bug. - // - // Unlike `data`, a flat spelling is honoured: this flag is the lever an - // app reaches for when its fleet's clocks are wrong and its control - // plane has gone quiet, and a lever that silently does nothing because - // it was written one level too high is worse than no lever. - let securitySection = raw["security"] as? [String: Any] - let controlFreshnessEnforced = - securitySection?["controlFreshnessEnforced"] as? Bool - ?? securitySection?["control_freshness_enforced"] as? Bool - ?? raw["controlFreshnessEnforced"] as? Bool - ?? raw["control_freshness_enforced"] as? Bool - - var config = ProtocolConfig( - appId: raw["appId"] as? String ?? raw["app_id"] as? String ?? "", - profile: raw["profile"] as? String ?? "", - bleEnabled: raw["bleEnabled"] as? Bool ?? raw["ble_enabled"] as? Bool ?? true, - wifiDirectEnabled: raw["wifiDirectEnabled"] as? Bool ?? raw["wifi_direct_enabled"] as? Bool ?? true, - internetEnabled: raw["internetEnabled"] as? Bool ?? raw["internet_enabled"] as? Bool ?? true, - reticulumEnabled: raw["reticulumEnabled"] as? Bool ?? raw["reticulum_enabled"] as? Bool ?? false, - nostrEnabled: raw["nostrEnabled"] as? Bool ?? raw["nostr_enabled"] as? Bool ?? false, - preferOnline: raw["preferOnline"] as? Bool ?? raw["prefer_online"] as? Bool ?? false, - // Clamped, not converted: `initialTtl` is a public config field - // of unbounded `number` type in TypeScript, so an application - // passing 300 would trap here and abort inside `create()`. - // Android truncates the same value through `toUByte()`. - initialTtl: UInt8(min(255, max(0, raw["initialTtl"] as? Int ?? raw["initial_ttl"] as? Int ?? 8))), - encryptionEnabled: encryption.enabled, - autoKeyExchange: encryption.autoKeyExchange, - storePending: encryption.storePending, - requireEncryption: encryption.requireEncryption, - maxPendingPerPeer: encryption.maxPendingPerPeer, - maxPendingGlobal: encryption.maxPendingGlobal, - pendingTtlMs: encryption.pendingTtlMs, - overflowPolicy: overflowPolicy, - // `clamping:` and not `UInt32(_:)` — the value is app-supplied JS, - // and a negative or oversized number traps the initializer. - maxGroupMembers: UInt32(clamping: maxGroupMembers), - groupRelayEnabled: groupRelayEnabled, - groupRelayBroadcastEnabled: groupRelayBroadcastEnabled, - groupEnforceAdminCommits: groupEnforceAdminCommits, - binaryWireEnabled: binaryWireEnabled, - nostrSealingEnabled: nostrSealingEnabled, - nostrColdContactEnabled: nostrColdContactEnabled, - nostrUsernameDiscoveryEnabled: nostrUsernameDiscoveryEnabled, - compactEnvelopeEnabled: encryption.compactEnvelopeEnabled, - richPayloadEnabled: encryption.richPayloadEnabled, - cryptoRecoveryEnabled: encryption.cryptoRecoveryEnabled, - meshRelay: meshRelay, - custody: custody - ) - - // Assigned only when the app actually sent it. Writing `?? false` - // above would restate the default as a literal here, and the literal - // is what survives a change to the real default: the release that - // flips it on would keep forcing `false` for every app that omits the - // section, exactly as `config.relay` sat at its defaults for several - // releases. - // Always assigned: the reader resolves it with the same default the - // UDL declares, so there is no literal here to outlive a change to it. - config.encryptServiceMessages = encryption.encryptServiceMessages - if let dataEnabled { - config.dataEnabled = dataEnabled - } - if let controlFreshnessEnforced { - config.controlFreshnessEnforced = controlFreshnessEnforced - } - - return (config, raw) - } - - /// The custody overflow policy as the app spelled it, or nil for a - /// spelling this build does not know: nil reaches the core as "keep the - /// default", never as a default chosen here. - private func custodyOverflowPolicy(_ raw: String) -> OverflowPolicy? { - switch raw.lowercased() { - case "drop_newest", "dropnewest": - return .dropNewest - case "drop_oldest", "dropoldest": - return .dropOldest - default: - return nil - } - } - /// Accepts both the current vocabulary and the pre-0.22 `low`/`medium`/`high` /// spelling of the same three values, so an app that has not migrated its /// config keeps working. private func normalizeRelayPriority(_ priority: String?) -> RelayPriority? { - guard let value = priority?.lowercased(), !value.isEmpty else { - return nil - } - switch value { - case "never", "low": - return .never - case "auto", "medium": - return .auto - case "always", "high": - return .always - default: - return nil - } + InitialRuntimeConfigApplier.normalizeRelayPriority(priority) } private func relayPriorityName(_ priority: RelayPriority) -> String { - switch priority { - case .never: return "never" - case .auto: return "auto" - case .always: return "always" - @unknown default: return "auto" - } + InitialRuntimeConfigApplier.relayPriorityName(priority) } /// Rejects with the stable typed code when the error maps (see @@ -765,113 +524,10 @@ class OfflineProtocolModule: RCTEventEmitter { } private func applyInitialRuntimeConfig(_ proto: OfflineProtocol, rawConfig: [String: Any]) { - if let dorsDict = rawConfig["dors"] as? [String: Any] { - let preferOnline = dorsDict["preferOnline"] as? Bool ?? dorsDict["prefer_online"] as? Bool ?? false - let switchHysteresis = Float((dorsDict["switchHysteresis"] as? NSNumber)?.doubleValue - ?? (dorsDict["switch_hysteresis"] as? NSNumber)?.doubleValue - ?? 15.0) - let switchCooldown = UInt64((dorsDict["switchCooldownSecs"] as? NSNumber)?.uint64Value - ?? (dorsDict["switch_cooldown_secs"] as? NSNumber)?.uint64Value - ?? 20) - let bleRetry = UInt32((dorsDict["bleToWifiRetryThreshold"] as? NSNumber)?.uint32Value - ?? (dorsDict["ble_to_wifi_retry_threshold"] as? NSNumber)?.uint32Value - ?? 2) - let minSuccessRate = Float((dorsDict["minSuccessRateBeforeEscalation"] as? NSNumber)?.floatValue - ?? (dorsDict["min_success_rate_before_escalation"] as? NSNumber)?.floatValue - ?? 0.3) - let minSuccessRateClamped = min(max(minSuccessRate, 0.0), 1.0) - let minBleSamples = UInt64((dorsDict["minBleSamplesBeforeSuccessRateEscalation"] as? NSNumber)?.uint64Value - ?? (dorsDict["min_ble_samples_before_success_rate_escalation"] as? NSNumber)?.uint64Value - ?? 5) - let rssiThreshold = Int16((dorsDict["rssiSwitchThreshold"] as? NSNumber)?.int16Value - ?? (dorsDict["rssi_switch_threshold"] as? NSNumber)?.int16Value - ?? -85) - let congestionThreshold = UInt64((dorsDict["congestionQueueThreshold"] as? NSNumber)?.uint64Value - ?? (dorsDict["congestion_queue_threshold"] as? NSNumber)?.uint64Value - ?? 50) - let stabilityWindow = UInt64((dorsDict["stabilityWindowSecs"] as? NSNumber)?.uint64Value - ?? (dorsDict["stability_window_secs"] as? NSNumber)?.uint64Value - ?? 8) - let poorSignalDuration = UInt64((dorsDict["poorSignalDurationSecs"] as? NSNumber)?.uint64Value - ?? (dorsDict["poor_signal_duration_secs"] as? NSNumber)?.uint64Value - ?? 10) - let ttlThreshold = UInt8((dorsDict["ttlEscalationThreshold"] as? NSNumber)?.uint8Value - ?? (dorsDict["ttl_escalation_threshold"] as? NSNumber)?.uint8Value - ?? 2) - let congestionDuration = UInt64((dorsDict["congestionDurationSecs"] as? NSNumber)?.uint64Value - ?? (dorsDict["congestion_duration_secs"] as? NSNumber)?.uint64Value - ?? 10) - let ttlHold = UInt64((dorsDict["ttlEscalationHoldSecs"] as? NSNumber)?.uint64Value - ?? (dorsDict["ttl_escalation_hold_secs"] as? NSNumber)?.uint64Value - ?? 20) - let historyWindowRaw = UInt64((dorsDict["historyWindowSize"] as? NSNumber)?.uint64Value - ?? (dorsDict["history_window_size"] as? NSNumber)?.uint64Value - ?? 10) - // Clamped in the unsigned domain, never through `Int`: a negative - // `historyWindowSize` from JavaScript reaches `uint64Value` as - // `UInt64.max`, and narrowing that to `Int` traps and aborts the - // application before the clamp around it can run. - let historyWindow = max(1, min(100, historyWindowRaw)) - let rawQueueRecovery = Float((dorsDict["queueRecoveryRatio"] as? NSNumber)?.floatValue - ?? (dorsDict["queue_recovery_ratio"] as? NSNumber)?.floatValue - ?? 0.5) - let queueRecovery = min(max(rawQueueRecovery, 0.0), 1.0) - let lowBattery = UInt8(min(100, max(0, Int((dorsDict["lowBatteryThreshold"] as? NSNumber)?.intValue - ?? (dorsDict["low_battery_threshold"] as? NSNumber)?.intValue - ?? 20)))) - let relayMinBattery = UInt8(min(100, max(0, Int((dorsDict["relayMinBatteryLevel"] as? NSNumber)?.intValue - ?? (dorsDict["relay_min_battery_level"] as? NSNumber)?.intValue - ?? 30)))) - let relayOptimalConnections = UInt8(min(255, max(0, Int((dorsDict["relayOptimalConnectionCount"] as? NSNumber)?.intValue - ?? (dorsDict["relay_optimal_connection_count"] as? NSNumber)?.intValue - ?? 4)))) - - let dorsConfig = DorsConfig( - preferOnline: preferOnline, - switchHysteresis: switchHysteresis, - switchCooldownSecs: switchCooldown, - bleToWifiRetryThreshold: bleRetry, - minSuccessRateBeforeEscalation: minSuccessRateClamped, - minBleSamplesBeforeSuccessRateEscalation: minBleSamples, - rssiSwitchThreshold: rssiThreshold, - congestionQueueThreshold: congestionThreshold, - stabilityWindowSecs: stabilityWindow, - poorSignalDurationSecs: poorSignalDuration, - ttlEscalationThreshold: ttlThreshold, - congestionDurationSecs: congestionDuration, - ttlEscalationHoldSecs: ttlHold, - historyWindowSize: historyWindow, - queueRecoveryRatio: queueRecovery, - lowBatteryThreshold: lowBattery, - relayMinBatteryLevel: relayMinBattery, - relayOptimalConnectionCount: relayOptimalConnections - ) - - do { - try proto.updateDorsConfig(config: dorsConfig) - emitDiagnostic(level: "info", message: "Applied initial DORS config") - } catch { - emitDiagnostic(level: "warning", message: "Failed to apply initial DORS config", context: [ - "error": error.localizedDescription - ]) - } - } - - // The whole relay section, not just the priority: allowRelay and - // minBatteryForRelay used to be parsed by nothing, leaving - // `config.relay` on mobile permanently at its defaults. - if let relayDict = rawConfig["relay"] as? [String: Any] { - do { - let updated = mergedRelayConfig(current: proto.getRelayConfig(), from: relayDict) - try proto.updateRelayConfig(config: updated) - emitDiagnostic(level: "info", message: "Applied initial relay config", context: [ - "priority": relayPriorityName(updated.relayPriority) - ]) - } catch { - emitDiagnostic(level: "warning", message: "Failed to apply initial relay config", context: [ - "error": error.localizedDescription - ]) - } + InitialRuntimeConfigApplier.apply(proto: proto, rawConfig: rawConfig) { [weak self] level, message, context in + // Passed through as optional: emitDiagnostic omits the key for nil, + // and an empty object would change what React Native apps receive. + self?.emitDiagnostic(level: level, message: message, context: context) } } @@ -883,7 +539,7 @@ class OfflineProtocolModule: RCTEventEmitter { print("[OfflineProtocolModule] create() called with config: \(configJson)") do { print("[OfflineProtocolModule] Parsing config...") - let parsed = try parseConfig(configJson) + let parsed = try ProtocolConfigParser.parse(configJson) let config = parsed.config print("[OfflineProtocolModule] Config parsed successfully: \(config)") print("[OfflineProtocolModule] Creating OfflineProtocol instance...") @@ -3552,26 +3208,7 @@ class OfflineProtocolModule: RCTEventEmitter { /// camelCase and snake_case spellings are accepted, matching every other /// config parser on this bridge. private func mergedRelayConfig(current: RelayConfig, from dict: [String: Any]) -> RelayConfig { - func number(_ keys: String...) -> NSNumber? { - for key in keys { - if let value = dict[key] as? NSNumber { return value } - } - return nil - } - func flag(_ keys: String...) -> Bool? { - for key in keys { - if let value = dict[key] as? Bool { return value } - if let value = dict[key] as? NSNumber { return value.boolValue } - } - return nil - } - let priorityRaw = (dict["relayPriority"] as? String) ?? (dict["relay_priority"] as? String) - return RelayConfig( - minBatteryForRelay: number("minBatteryForRelay", "min_battery_for_relay") - .map { UInt8(min(100, max(0, $0.intValue))) } ?? current.minBatteryForRelay, - allowRelay: flag("allowRelay", "allow_relay") ?? current.allowRelay, - relayPriority: normalizeRelayPriority(priorityRaw) ?? current.relayPriority - ) + InitialRuntimeConfigApplier.mergedRelayConfig(current: current, from: dict) } @objc func isRelay(_ resolver: @escaping RCTPromiseResolveBlock, diff --git a/bindings/react-native/ios/ProtocolConfigParser.swift b/bindings/react-native/ios/ProtocolConfigParser.swift new file mode 100644 index 00000000..cce9ce5e --- /dev/null +++ b/bindings/react-native/ios/ProtocolConfigParser.swift @@ -0,0 +1,240 @@ +// +// ProtocolConfigParser.swift +// +// Parses the JSON config string from a host wrapper into UniFFI ProtocolConfig. +// Shared by the React Native module, the Flutter plugin, and the Swift package +// assembly (ADR 0025). Mirrors android/ ProtocolConfigParser.kt: keep read +// order and precedence in sync. +// + +import Foundation + +enum ProtocolConfigParser { + static func parse(_ configJson: String) throws -> (config: ProtocolConfig, raw: [String: Any]) { + guard let jsonData = configJson.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw NSError( + domain: "OfflineProtocol", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"] + ) + } + + // Encryption section (four flags, compact-envelope kill switch, + // pending queue): read by EncryptionConfigReader, Foundation-only so + // the SwiftPM suite covers it; mirrors ProtocolConfigParser.kt on + // Android, keep the read order and precedence in sync. + let encryption = EncryptionConfigReader.read(raw) + let overflowPolicy: OverflowPolicy + switch encryption.overflowPolicyRaw.lowercased() { + case "drop_newest": + overflowPolicy = .dropNewest + default: + overflowPolicy = .dropOldest + } + + // binaryWireEnabled (kill switch, default on): top level only, which + // IS its home in both the JS config and the flat UniFFI dictionary. + // camelCase or snake_case. + let binaryWireEnabled = raw["binaryWireEnabled"] as? Bool + ?? raw["binary_wire_enabled"] as? Bool ?? true + + // Nostr sealing (kill switch, default on). Nested home under + // `transports.nostr.sealingEnabled`, where the rest of the Nostr + // transport settings live, then the flat key, both cases. Same + // nested-wins-over-flat rule as `encryption` and `group`; mirrors + // ProtocolConfigParser.kt, keep in sync. + let nostrRaw = (raw["transports"] as? [String: Any])?["nostr"] as? [String: Any] + let nostrSealingEnabled = nostrRaw?["sealingEnabled"] as? Bool + ?? nostrRaw?["sealing_enabled"] as? Bool + ?? raw["nostrSealingEnabled"] as? Bool + ?? raw["nostr_sealing_enabled"] as? Bool + ?? true + + // Nostr cold contact (key-package publication + peer resolution, kill + // switch, default on). Same nested-then-flat shape as sealing above. + let nostrColdContactEnabled = nostrRaw?["coldContactEnabled"] as? Bool + ?? nostrRaw?["cold_contact_enabled"] as? Bool + ?? raw["nostrColdContactEnabled"] as? Bool + ?? raw["nostr_cold_contact_enabled"] as? Bool + ?? true + + // Nostr username discovery (claim publication + username resolution). + // Same nested-then-flat shape, but the default is FALSE: publishing + // binds a human-readable name to an address in a public place, which + // an app must opt into rather than inherit. + let nostrUsernameDiscoveryEnabled = nostrRaw?["usernameDiscoveryEnabled"] as? Bool + ?? nostrRaw?["username_discovery_enabled"] as? Bool + ?? raw["nostrUsernameDiscoveryEnabled"] as? Bool + ?? raw["nostr_username_discovery_enabled"] as? Bool + ?? false + + // Group section (nested home under `group`, then top level, both + // cases; same shape rules as `encryption`; mirrors + // ProtocolConfigParser.kt, keep in sync). These were UniFFI-only + // until the broadcast default flipped on: with no JS-reachable + // opt-out, an RN app could not force per-member fan-out. + let groupRaw = raw["group"] as? [String: Any] + let maxGroupMembers = groupRaw?["maxGroupMembers"] as? Int + ?? groupRaw?["max_group_members"] as? Int + ?? raw["maxGroupMembers"] as? Int + ?? raw["max_group_members"] as? Int + ?? 256 + let groupRelayEnabled = groupRaw?["relayEnabled"] as? Bool + ?? groupRaw?["relay_enabled"] as? Bool + ?? raw["groupRelayEnabled"] as? Bool + ?? raw["group_relay_enabled"] as? Bool + ?? true + let groupRelayBroadcastEnabled = groupRaw?["relayBroadcastEnabled"] as? Bool + ?? groupRaw?["relay_broadcast_enabled"] as? Bool + ?? raw["groupRelayBroadcastEnabled"] as? Bool + ?? raw["group_relay_broadcast_enabled"] as? Bool + ?? true + // Default false, see GroupConfig::enforce_admin_commits. Enabling it + // makes this device refuse membership commits it cannot authorize, + // which forks it from every member that accepted them. + let groupEnforceAdminCommits = groupRaw?["enforceAdminCommits"] as? Bool + ?? groupRaw?["enforce_admin_commits"] as? Bool + ?? raw["groupEnforceAdminCommits"] as? Bool + ?? raw["group_enforce_admin_commits"] as? Bool + ?? false + + // Mesh forwarding section: read by MeshRelayConfigReader, Foundation-only + // so the SwiftPM suite covers it; mirrors ProtocolConfigParser.kt, keep + // the read order and precedence in sync. Every field stays optional all + // the way to the core, which owns the defaults. + let meshRelay = MeshRelayConfigReader.read(raw).map { values in + MeshRelayConfig( + maxTtl: values.maxTtl, + denseMaxTtl: values.denseMaxTtl, + denseDegree: values.denseDegree, + fanout: values.fanout, + jitterMinMs: values.jitterMinMs, + jitterMaxMs: values.jitterMaxMs, + ratePerSec: values.ratePerSec, + burst: values.burst, + peerRatePerSec: values.peerRatePerSec, + peerBurst: values.peerBurst, + queueCapacity: values.queueCapacity, + biasMinScale: values.biasMinScale, + biasMaxHandicapMs: values.biasMaxHandicapMs, + activityWindowMs: values.activityWindowMs, + activityMinForwards: values.activityMinForwards, + activityIdleWindows: values.activityIdleWindows + ) + } + + // Custody section: read by CustodyConfigReader, Foundation-only like + // the mesh forwarding reader and for the same reason; mirrors + // ProtocolConfigParser.kt, keep the read order in sync. Absent stays + // absent all the way to the core, whose default is off; the reader + // carries the overflow policy as the app spelled it, and an unknown + // spelling stays absent rather than becoming a default written here. + let custody = CustodyConfigReader.read(raw).map { values in + CustodyConfig( + enabled: values.enabled, + holdMs: values.holdMs, + maxEntriesPerDepositor: values.maxEntriesPerDepositor, + maxBytesPerDepositor: values.maxBytesPerDepositor, + maxEntries: values.maxEntries, + maxBytes: values.maxBytes, + strangerMaxEntries: values.strangerMaxEntries, + strangerMaxBytes: values.strangerMaxBytes, + overflowPolicy: values.overflowPolicy.flatMap(custodyOverflowPolicy) + ) + } + + // Data layer section (nested home under `data`, both cases). Same + // rule as meshRelay: absent stays absent, so the Rust default is the + // only default. Kept in step with android/ ProtocolConfigParser: + // read order and precedence must match. + let dataSection = raw["data"] as? [String: Any] + let dataEnabled = dataSection?["enabled"] as? Bool + + // Security section, with a top-level spelling accepted too. Absent + // stays absent, so the Rust default is the only default; see the + // assignment below for why a literal here would be the bug. + // + // Unlike `data`, a flat spelling is honoured: this flag is the lever an + // app reaches for when its fleet's clocks are wrong and its control + // plane has gone quiet, and a lever that silently does nothing because + // it was written one level too high is worse than no lever. + let securitySection = raw["security"] as? [String: Any] + let controlFreshnessEnforced = + securitySection?["controlFreshnessEnforced"] as? Bool + ?? securitySection?["control_freshness_enforced"] as? Bool + ?? raw["controlFreshnessEnforced"] as? Bool + ?? raw["control_freshness_enforced"] as? Bool + + var config = ProtocolConfig( + appId: raw["appId"] as? String ?? raw["app_id"] as? String ?? "", + profile: raw["profile"] as? String ?? "", + bleEnabled: raw["bleEnabled"] as? Bool ?? raw["ble_enabled"] as? Bool ?? true, + wifiDirectEnabled: raw["wifiDirectEnabled"] as? Bool ?? raw["wifi_direct_enabled"] as? Bool ?? true, + internetEnabled: raw["internetEnabled"] as? Bool ?? raw["internet_enabled"] as? Bool ?? true, + reticulumEnabled: raw["reticulumEnabled"] as? Bool ?? raw["reticulum_enabled"] as? Bool ?? false, + nostrEnabled: raw["nostrEnabled"] as? Bool ?? raw["nostr_enabled"] as? Bool ?? false, + preferOnline: raw["preferOnline"] as? Bool ?? raw["prefer_online"] as? Bool ?? false, + // Clamped, not converted: `initialTtl` is a public config field + // of unbounded `number` type in TypeScript, so an application + // passing 300 would trap here and abort inside `create()`. + // Android truncates the same value through `toUByte()`. + initialTtl: UInt8(min(255, max(0, raw["initialTtl"] as? Int ?? raw["initial_ttl"] as? Int ?? 8))), + encryptionEnabled: encryption.enabled, + autoKeyExchange: encryption.autoKeyExchange, + storePending: encryption.storePending, + requireEncryption: encryption.requireEncryption, + maxPendingPerPeer: encryption.maxPendingPerPeer, + maxPendingGlobal: encryption.maxPendingGlobal, + pendingTtlMs: encryption.pendingTtlMs, + overflowPolicy: overflowPolicy, + // `clamping:` and not `UInt32(_:)`: the value is app-supplied JS, + // and a negative or oversized number traps the initializer. + maxGroupMembers: UInt32(clamping: maxGroupMembers), + groupRelayEnabled: groupRelayEnabled, + groupRelayBroadcastEnabled: groupRelayBroadcastEnabled, + groupEnforceAdminCommits: groupEnforceAdminCommits, + binaryWireEnabled: binaryWireEnabled, + nostrSealingEnabled: nostrSealingEnabled, + nostrColdContactEnabled: nostrColdContactEnabled, + nostrUsernameDiscoveryEnabled: nostrUsernameDiscoveryEnabled, + compactEnvelopeEnabled: encryption.compactEnvelopeEnabled, + richPayloadEnabled: encryption.richPayloadEnabled, + cryptoRecoveryEnabled: encryption.cryptoRecoveryEnabled, + meshRelay: meshRelay, + custody: custody + ) + + // Assigned only when the app actually sent it. Writing `?? false` + // above would restate the default as a literal here, and the literal + // is what survives a change to the real default: the release that + // flips it on would keep forcing `false` for every app that omits the + // section, exactly as `config.relay` sat at its defaults for several + // releases. + // Always assigned: the reader resolves it with the same default the + // UDL declares, so there is no literal here to outlive a change to it. + config.encryptServiceMessages = encryption.encryptServiceMessages + if let dataEnabled { + config.dataEnabled = dataEnabled + } + if let controlFreshnessEnforced { + config.controlFreshnessEnforced = controlFreshnessEnforced + } + + return (config, raw) + } + + /// The custody overflow policy as the app spelled it, or nil for a + /// spelling this build does not know: nil reaches the core as "keep the + /// default", never as a default chosen here. + private static func custodyOverflowPolicy(_ raw: String) -> OverflowPolicy? { + switch raw.lowercased() { + case "drop_newest", "dropnewest": + return .dropNewest + case "drop_oldest", "dropoldest": + return .dropOldest + default: + return nil + } + } +} diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 085df7d5..eac560ef 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -8155,6 +8155,7 @@ mod error_mapping_tests { #[cfg(test)] mod tests { mod file_stores; + mod flutter_bridge_guards; use super::*; use std::collections::HashMap; @@ -8300,7 +8301,7 @@ mod tests { false for every app that omits the section" ); - let swift = read(rn.join("ios/OfflineProtocolModule.swift")); + let swift = read(rn.join("ios/ProtocolConfigParser.swift")); assert!( swift.contains("raw[\"data\"]") && swift.contains("config.dataEnabled = dataEnabled"), "the Swift parser must read the data section AND apply it to the \ @@ -8376,7 +8377,7 @@ mod tests { high often enough to be worth honouring" ); - let swift = read(rn.join("ios/OfflineProtocolModule.swift")); + let swift = read(rn.join("ios/ProtocolConfigParser.swift")); assert!( swift.contains("raw[\"security\"]") && swift.contains("config.controlFreshnessEnforced = controlFreshnessEnforced"), @@ -11712,7 +11713,7 @@ mod tests { }; let udl = read(manifest.join("src/offline_protocol.udl")); let swift_reader = read(rn.join("ios/EncryptionConfigReader.swift")); - let swift_module = read(rn.join("ios/OfflineProtocolModule.swift")); + let swift_parser = read(rn.join("ios/ProtocolConfigParser.swift")); let kotlin_parser = read(rn.join("android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt")); let index = read(rn.join("src/index.ts")); @@ -11749,8 +11750,8 @@ mod tests { ); } assert!( - swift_module.contains(&format!("encryption.{camel}")), - "OfflineProtocolModule.swift does not pass {camel} to the ProtocolConfig" + swift_parser.contains(&format!("encryption.{camel}")), + "ProtocolConfigParser.swift does not pass {camel} to the ProtocolConfig" ); assert!( kotlin_parser.contains(&format!("{camel} = {camel}")), @@ -16391,6 +16392,85 @@ mod tests { ); } + const INTERNET_CONFIGURE_MARKERS: &[&str] = &[ + "wss://", + "setAuthToken", + "autoReconnect", + "maxReconnectAttempts", + "manager.configure", + ]; + + // InternetTransportBootstrap is the shared home for Flutter; React Native + // still inlines configureAndStartInternet on Android. Until RN delegates, + // both must keep the same configure knobs. + #[test] + fn internet_transport_bootstrap_keeps_rn_configure_invariants() { + let rn = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native"); + let bootstrap = std::fs::read_to_string( + rn.join("android/src/main/java/com/offlineprotocol/InternetTransportBootstrap.kt"), + ) + .unwrap_or_else(|e| panic!("cannot read InternetTransportBootstrap.kt: {e}")); + let rn_module = std::fs::read_to_string( + rn.join("android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt"), + ) + .unwrap_or_else(|e| panic!("cannot read OfflineProtocolModule.kt: {e}")); + let rn_fn = rn_module + .split("private fun configureAndStartInternet") + .nth(1) + .and_then(|tail| tail.split("\n private fun ").next()) + .unwrap_or(""); + assert!( + !rn_fn.is_empty(), + "OfflineProtocolModule.configureAndStartInternet not found: the guard needs a stable anchor" + ); + for marker in INTERNET_CONFIGURE_MARKERS { + assert!( + bootstrap.contains(marker), + "InternetTransportBootstrap.kt missing `{marker}`" + ); + assert!( + rn_fn.contains(marker), + "OfflineProtocolModule.configureAndStartInternet missing `{marker}`: \ + update InternetTransportBootstrap.kt or restore RN parity" + ); + } + } + + // Same parity pin for the Swift bootstrap and RN iOS module. + #[test] + fn internet_transport_bootstrap_swift_keeps_rn_configure_invariants() { + let rn = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native"); + let bootstrap = std::fs::read_to_string(rn.join("ios/InternetTransportBootstrap.swift")) + .unwrap_or_else(|e| panic!("cannot read InternetTransportBootstrap.swift: {e}")); + let rn_module = std::fs::read_to_string(rn.join("ios/OfflineProtocolModule.swift")) + .unwrap_or_else(|e| panic!("cannot read OfflineProtocolModule.swift: {e}")); + let rn_fn = rn_module + .split("private func configureAndStartInternet") + .nth(1) + .and_then(|tail| { + tail.split("\n private func configureAndStartReticulum") + .next() + }) + .unwrap_or(""); + assert!( + !rn_fn.is_empty(), + "OfflineProtocolModule.configureAndStartInternet (Swift) not found: the guard needs a stable anchor" + ); + for marker in INTERNET_CONFIGURE_MARKERS { + assert!( + bootstrap.contains(marker), + "InternetTransportBootstrap.swift missing `{marker}`" + ); + assert!( + rn_fn.contains(marker), + "OfflineProtocolModule.configureAndStartInternet (Swift) missing `{marker}`: \ + update InternetTransportBootstrap.swift or restore RN parity" + ); + } + } + /// Every method React Native can reach on iOS is spelled the same way in /// both halves of the hand-written bridge, and hands over each argument /// the same way. @@ -16993,7 +17073,18 @@ mod tests { /// and by that guard clause, not by this test. #[test] fn react_native_ios_bridge_bounds_every_byte_it_builds_from_javascript() { - let swift = rn_source_code_only("ios/OfflineProtocolModule.swift"); + // Config parsing moved out of OfflineProtocolModule.swift (ADR 0025); + // every file that still narrows JavaScript numbers must stay bounded. + const SWIFT_JS_NUMERIC_SOURCES: &[&str] = &[ + "ios/OfflineProtocolModule.swift", + "ios/ProtocolConfigParser.swift", + "ios/InitialRuntimeConfigApplier.swift", + ]; + let swift = SWIFT_JS_NUMERIC_SOURCES + .iter() + .map(|rel| rn_source_code_only(rel)) + .collect::>() + .join(" "); /// Markers that bound a conversion at the point it is written. const BOUNDED: &[&str] = &[ @@ -17039,7 +17130,7 @@ mod tests { assert!( found >= 8, - "only found {found} UInt8 conversions in OfflineProtocolModule.swift; \ + "only found {found} UInt8 conversions in {SWIFT_JS_NUMERIC_SOURCES:?}; \ the scanner is broken, not the bridge" ); assert!( diff --git a/crates/offline-protocol-uniffi/src/tests/flutter_bridge_guards.rs b/crates/offline-protocol-uniffi/src/tests/flutter_bridge_guards.rs new file mode 100644 index 00000000..766937ea --- /dev/null +++ b/crates/offline-protocol-uniffi/src/tests/flutter_bridge_guards.rs @@ -0,0 +1,446 @@ +//! Flutter binding compile lists, method-channel parity, docs, and CI wiring. +//! +//! Kept in this file (not `lib.rs`) so CI can run these guards on the fast +//! `flutter_dart` path while any production UniFFI edit in `lib.rs` triggers +//! the native jniLibs + AAR gate. + +/// Parses `OfflineProtocol.nativeMethodChannelMethods` from the Dart source. +fn flutter_dart_method_channel_methods(dart: &str) -> Vec { + const MARKER: &str = "static const List nativeMethodChannelMethods = ["; + let tail = dart + .split(MARKER) + .nth(1) + .unwrap_or_else(|| panic!("{MARKER} not found in offline_protocol.dart")); + let body = tail + .split("];") + .next() + .unwrap_or_else(|| panic!("nativeMethodChannelMethods list not terminated")); + body.lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let inner = line.strip_prefix('\'')?.strip_suffix('\'')?; + Some(inner.to_string()) + }) + .collect() +} + +/// Method names handled in `OfflineProtocolPlugin.kt` `when (call.method)`. +fn flutter_kotlin_method_channel_methods(kt: &str) -> std::collections::BTreeSet { + const ANCHOR: &str = "when (call.method) {"; + let block = kt + .split(ANCHOR) + .nth(1) + .unwrap_or_else(|| panic!("{ANCHOR} not found in OfflineProtocolPlugin.kt")); + let block = block + .split("else ->") + .next() + .unwrap_or_else(|| panic!("when (call.method) missing else arm")); + let mut names = std::collections::BTreeSet::new(); + for line in block.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix('"') else { + continue; + }; + let Some((method, _)) = rest.split_once('"') else { + continue; + }; + if !method.is_empty() { + names.insert(method.to_string()); + } + } + names +} + +/// Method names handled in `OfflineProtocolPlugin.swift` `switch call.method`. +fn flutter_swift_method_channel_methods(swift: &str) -> std::collections::BTreeSet { + const ANCHOR: &str = "switch call.method {"; + let block = swift + .split(ANCHOR) + .nth(1) + .unwrap_or_else(|| panic!("{ANCHOR} not found in OfflineProtocolPlugin.swift")); + let block = block + .split("default:") + .next() + .unwrap_or_else(|| panic!("switch call.method missing default arm")); + let mut names = std::collections::BTreeSet::new(); + for line in block.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("case \"") else { + continue; + }; + let Some((method, _)) = rest.split_once('"') else { + continue; + }; + if method.contains(',') { + continue; + } + names.insert(method.to_string()); + } + names +} + +fn flutter_plugin_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/flutter/offline_protocol") +} + +fn rn_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native") +} + +fn read_text(path: &std::path::Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// Shared bridge sources the plugin must compile: the Internet stack, the +/// local transports, event replay and foreground reconnect. Excluding one +/// builds green and ships a plugin that cannot run the transport. +const FLUTTER_SHARED_SOURCES: &[&str] = &[ + "InternetTransportBootstrap", + "InternetManager", + "PresenceWatchPolicy", + "EventReplayRing", + "ForegroundReconnectPolicy", + "WifiDirectManager", + "ProtocolConfigParser", + "InitialRuntimeConfigApplier", +]; + +/// Every `exclude('com/offlineprotocol/X.kt')` in the plugin's Gradle build. +fn flutter_kotlin_excludes(gradle: &str) -> Vec { + gradle + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("exclude('")?; + Some(rest.split_once('\'')?.0.to_string()) + }) + .collect() +} + +// The Android compile surface is every shared source under the React Native +// tree less the KotlinCompile excludes (ADR 0025 decision 3). A name that +// matches no file is a rename nobody carried over, and the renamed file, which +// may need React, would be compiled in its place. A list of what to take must +// not come back: the Kotlin plugin ignores `java { include }`, so such a list +// drives nothing while looking like the compile surface. +#[test] +fn flutter_android_excludes_name_real_files() { + let gradle = read_text(&flutter_plugin_dir().join("android/build.gradle")); + let excludes = flutter_kotlin_excludes(&gradle); + assert!( + excludes.len() >= 3, + "found only {} KotlinCompile excludes in the Flutter android/build.gradle; \ + the parser is broken, not the build: {excludes:?}", + excludes.len() + ); + let java = rn_dir().join("android/src/main/java"); + for rel in &excludes { + assert!( + java.join(rel).is_file(), + "Flutter android/build.gradle excludes `{rel}`, which does not exist under \ + bindings/react-native/android/src/main/java: carry the rename over" + ); + } + for name in FLUTTER_SHARED_SOURCES { + assert!( + !excludes + .iter() + .any(|rel| rel.ends_with(&format!("/{name}.kt"))), + "Flutter android/build.gradle must not exclude the shared source `{name}.kt`" + ); + } + let code: String = gradle + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + assert!( + !code.contains("include it") && !code.contains("include '"), + "Flutter android/build.gradle must not take sources by an include list: the \ + Kotlin plugin ignores it (ADR 0025 decision 3)" + ); +} + +/// The quoted `'BridgeRN/X'` entries of `rn_only_swift` in the podspec. +fn flutter_podspec_excludes(podspec: &str) -> std::collections::BTreeSet { + let body = podspec + .split("rn_only_swift = [") + .nth(1) + .and_then(|tail| tail.split(']').next()) + .unwrap_or_else(|| panic!("rn_only_swift = [ ... ] not found in offline_protocol.podspec")); + body.lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let inner = line.strip_prefix('\'')?.strip_suffix('\'')?; + Some(inner.strip_prefix("BridgeRN/")?.to_string()) + }) + .collect() +} + +/// The file names `sync_bridge_rn.sh` leaves out of the copy, less the two +/// that are not bridge sources. +fn flutter_sync_excludes(script: &str) -> std::collections::BTreeSet { + script + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("--exclude ")?; + Some(rest.trim_end_matches('\\').trim().to_string()) + }) + .filter(|name| name != "tests/" && name != "Package.swift") + .collect() +} + +// The iOS compile surface is `BridgeRN/**/*.swift` less `rn_only_swift`, and +// BridgeRN is written by sync_bridge_rn.sh, which leaves out its own list. The +// two lists say the same thing in two languages, so they must agree, and each +// name must be a source that exists, for the reason the Android guard gives. +#[test] +fn flutter_ios_exclude_lists_agree_and_name_real_files() { + let ios = flutter_plugin_dir().join("ios"); + let podspec = read_text(&ios.join("offline_protocol.podspec")); + let script = read_text(&ios.join("sync_bridge_rn.sh")); + let from_podspec = flutter_podspec_excludes(&podspec); + let from_script = flutter_sync_excludes(&script); + assert!( + from_podspec.len() >= 2, + "found only {} entries in rn_only_swift; the parser is broken: {from_podspec:?}", + from_podspec.len() + ); + assert_eq!( + from_podspec, from_script, + "offline_protocol.podspec rn_only_swift and sync_bridge_rn.sh --exclude must list \ + the same React-only sources" + ); + let rn_ios = rn_dir().join("ios"); + for name in &from_podspec { + assert!( + rn_ios.join(name).is_file(), + "the Flutter podspec excludes `{name}`, which does not exist under \ + bindings/react-native/ios: carry the rename over" + ); + } + for name in FLUTTER_SHARED_SOURCES { + assert!( + !from_podspec.contains(&format!("{name}.swift")), + "the Flutter podspec must not exclude the shared source `{name}.swift`" + ); + } + assert!( + podspec.contains("'BridgeRN/**/*.swift'"), + "the Flutter podspec must take every shared source by glob (ADR 0025 decision 3)" + ); +} + +// A version, an SDK level or a deployment target written down in the plugin is +// right on the day it is made and drifts with nothing failing (ADR 0025 +// decision 5). The podspec and the Gradle build read them from the React +// Native package; the pubspec cannot, so the release gate checks it. +#[test] +fn flutter_native_builds_read_what_the_react_native_package_declares() { + let plugin = flutter_plugin_dir(); + let podspec = read_text(&plugin.join("ios/offline_protocol.podspec")); + let gradle = read_text(&plugin.join("android/build.gradle")); + for needle in [ + "s.version = rn_package['version']", + "s.platform = :ios, ios_deployment_target", + ] { + assert!( + podspec.contains(needle), + "offline_protocol.podspec must read `{needle}` from the React Native package" + ); + } + for needle in [ + "version rnPackage.version", + "compileSdk sdkLevel('compileSdk')", + "minSdkVersion sdkLevel('minSdkVersion')", + "moduleDependency('net.java.dev.jna:jna')", + "moduleDependency('androidx.security:security-crypto')", + "moduleDependency('com.squareup.okhttp3:okhttp')", + ] { + assert!( + gradle.contains(needle), + "Flutter android/build.gradle must read `{needle}` from the React Native module" + ); + } + let release = read_text( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.github/workflows/release.yml"), + ); + assert!( + release.contains("bindings/flutter/offline_protocol/pubspec.yaml"), + "release.yml must gate the Flutter pubspec version against the tag" + ); +} + +/// The text of `kt` from the companion object onwards, with `//` comments +/// dropped, so a declaration counts only where it is code. +fn kotlin_companion_code(kt: &str) -> String { + kt.split("companion object {") + .nth(1) + .unwrap_or_else(|| panic!("companion object not found in OfflineProtocolPlugin.kt")) + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n") +} + +// Flutter creates a new plugin instance for every engine. With an engine +// thread per instance, a new engine's `create` runs while the previous one's +// queued teardown is still blocked on transport I/O: two cores under one +// identity. One thread per process orders every call behind that teardown +// (docs/bridges/flutter.md, F2). +#[test] +fn flutter_engine_thread_is_shared_by_every_plugin_instance() { + let plugin = flutter_plugin_dir(); + let kt = read_text( + &plugin + .join("android/src/main/kotlin/com/offlineprotocol/flutter/OfflineProtocolPlugin.kt"), + ); + let (instance, _) = kt + .split_once("companion object {") + .unwrap_or_else(|| panic!("companion object not found in OfflineProtocolPlugin.kt")); + assert!( + !instance.contains("val engineExecutor"), + "OfflineProtocolPlugin.kt declares engineExecutor per instance; it must live in \ + the companion object, one per process" + ); + assert!( + kotlin_companion_code(&kt).contains("val engineExecutor"), + "OfflineProtocolPlugin.kt must declare engineExecutor in its companion object" + ); + + let swift = read_text(&plugin.join("ios/Classes/OfflineProtocolPlugin.swift")); + let declarations: Vec<&str> = swift + .lines() + .map(str::trim) + .filter(|line| !line.starts_with("//") && line.contains("engineQueue = DispatchQueue(")) + .collect(); + assert_eq!( + declarations, + ["private static let engineQueue = DispatchQueue("], + "OfflineProtocolPlugin.swift must declare engineQueue once, as a static let" + ); +} + +#[test] +fn flutter_bridges_doc_is_linked_from_the_bridge_index() { + let readme = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/bridges/README.md"), + ) + .unwrap_or_else(|e| panic!("cannot read docs/bridges/README.md: {e}")); + let flutter_doc = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/bridges/flutter.md"); + assert!( + flutter_doc.is_file(), + "docs/bridges/flutter.md must exist: it is the Flutter bridge contract" + ); + assert!( + readme.contains("flutter.md"), + "docs/bridges/README.md must link docs/bridges/flutter.md" + ); + let body = std::fs::read_to_string(&flutter_doc) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", flutter_doc.display())); + for marker in ["F2", "F3", "F4", "measure-plugin-aar-size.sh"] { + assert!( + body.contains(marker), + "docs/bridges/flutter.md must document `{marker}`" + ); + } +} + +#[test] +fn flutter_binding_ci_job_is_wired_in_workflow() { + let ci = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.github/workflows/ci.yml"), + ) + .unwrap_or_else(|e| panic!("cannot read .github/workflows/ci.yml: {e}")); + for needle in [ + "flutter-binding:", + "flutter_dart:", + "flutter_native:", + "flutter_lock:", + "Decide Flutter native", + "flutter_native_gate", + "flutter_bridge_guards.rs", + "bindings/flutter/scripts/ci-build-android-jni-arm64.sh", + "measure-plugin-aar-size.sh --check", + "bindings/flutter/offline_protocol", + "bindings/flutter/offline_protocol/android/**", + "gradle/actions/wrapper-validation@", + "bindings/react-native/android/**", + "Cargo.lock", + "dorny/paths-filter@", + "flutter-version:", + ] { + assert!( + ci.contains(needle), + ".github/workflows/ci.yml Flutter Binding job must reference `{needle}`" + ); + } +} + +#[test] +fn flutter_aar_baseline_json_is_well_formed() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../bindings/flutter/baselines/offline-protocol-release-aar.json"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + let value: serde_json::Value = + serde_json::from_str(&raw).expect("offline-protocol-release-aar.json must be JSON"); + let obj = value.as_object().expect("baseline must be a JSON object"); + for key in ["artifact", "bytes", "tolerance_bytes"] { + assert!( + obj.contains_key(key), + "baseline missing `{key}`; see bindings/flutter/baselines/README.md" + ); + } + let bytes = obj["bytes"] + .as_u64() + .expect("bytes must be a positive integer"); + assert!(bytes > 0, "baseline bytes must be > 0"); + let tol = obj["tolerance_bytes"] + .as_u64() + .expect("tolerance_bytes must be a non-negative integer"); + assert!( + tol > 0, + "tolerance_bytes must be > 0 for regression headroom" + ); +} + +#[test] +fn flutter_method_channel_methods_match_native_plugins() { + let flutter = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../bindings/flutter/offline_protocol"); + let dart = std::fs::read_to_string(flutter.join("lib/src/offline_protocol.dart")) + .unwrap_or_else(|e| panic!("cannot read offline_protocol.dart: {e}")); + let kt = std::fs::read_to_string( + flutter + .join("android/src/main/kotlin/com/offlineprotocol/flutter/OfflineProtocolPlugin.kt"), + ) + .unwrap_or_else(|e| panic!("cannot read OfflineProtocolPlugin.kt: {e}")); + let swift = std::fs::read_to_string(flutter.join("ios/Classes/OfflineProtocolPlugin.swift")) + .unwrap_or_else(|e| panic!("cannot read OfflineProtocolPlugin.swift: {e}")); + + let dart_methods = flutter_dart_method_channel_methods(&dart); + assert!( + !dart_methods.is_empty(), + "nativeMethodChannelMethods must list at least one method" + ); + let dart_set: std::collections::BTreeSet<_> = dart_methods.iter().cloned().collect(); + assert_eq!( + dart_methods.len(), + dart_set.len(), + "nativeMethodChannelMethods must not contain duplicates: {dart_methods:?}" + ); + + let kotlin = flutter_kotlin_method_channel_methods(&kt); + let swift_methods = flutter_swift_method_channel_methods(&swift); + + assert_eq!( + kotlin, dart_set, + "OfflineProtocolPlugin.kt when (call.method) must match Dart nativeMethodChannelMethods" + ); + assert_eq!( + swift_methods, dart_set, + "OfflineProtocolPlugin.swift switch call.method must match Dart nativeMethodChannelMethods" + ); +} diff --git a/docs/bridges/README.md b/docs/bridges/README.md index 7463e617..1bbe0ebf 100644 --- a/docs/bridges/README.md +++ b/docs/bridges/README.md @@ -9,6 +9,7 @@ binding. | This file | The contract every binding shares | | [Swift](swift.md) | iOS native, the Swift package and the React Native iOS bridge | | [Kotlin](kotlin.md) | Android native, the Android library and the React Native Android bridge | +| [Flutter](flutter.md) | Flutter plugin (`bindings/flutter/offline_protocol`) | | [Python](python.md) | Desktop and tooling | | [TypeScript](typescript.md) | The React Native JavaScript surface | | [Local API](local-api.md) | A server fronting one engine for several local clients over a socket, and the reference server | @@ -633,19 +634,27 @@ except the ones named as needing it: | Android | `OfflineProtocolModule.kt`, `OfflineProtocolPackage.kt`, `MeshHeadlessWakeService.kt` | | iOS | `OfflineProtocolModule.swift`, `OfflineProtocolModule.m` | -The Swift package and the Android library are built from the files the React -Native module compiles, where they are, less those five -([ADR 0025](../adr/0025-native-packages-are-assembled-in-place.md)). So a -transport manager that takes a `Promise`, or a policy class that imports -React for one type, still builds in React Native and breaks both native -packages for every application that uses them. +The Swift package, the Android library, and the Flutter plugin are built from +the files the React Native module compiles, where they are, less the React-only +entry points +([ADR 0025](../adr/0025-native-packages-are-assembled-in-place.md)). Flutter +compiles them through a list of what to leave out in `android/build.gradle` +and `offline_protocol.podspec` instead of assembling a separate package tree +([F1](flutter.md#f1-shared-bridge-sources-compiled-less-a-named-list)). So a +transport manager that takes a `Promise`, or a policy class that imports React +for one type, still builds in React Native and breaks the native packages and +the Flutter plugin for every application that uses them. The check is the package build, on every pull request: the `Swift Package` -job builds the package and runs it on a simulator, and the `Android Library` -job compiles the library and its suite. Each list of what to leave out is +job builds the package and runs it on a simulator, the `Android Library` +job compiles the library and its suite, and the `Flutter Binding` job runs +Dart tests, assembles the plugin release AAR (shared Kotlin + jniLibs), and +checks the committed footprint baseline when Flutter-related paths change. +Each list of what to leave out is checked the other way too. A name on it that matches no file is a rename -nobody carried over, and both builds refuse it, because the renamed file -would otherwise be taken. +nobody carried over, and the two native package builds refuse it, because the +renamed file would otherwise be taken. For the Flutter plugin two Rust guards +refuse it instead. **A value the React Native package declares, and that would drift without anything failing, is read and not written again.** The packages read the iOS @@ -665,6 +674,7 @@ found by the first application to update. | Kotlin | Secure storage backed by Keystore; no blocking work on the main looper; awareness that platform callbacks arrive on binder threads; the telemetry session boundary from an `Application.ActivityLifecycleCallbacks` watcher, never `onHostPause` (C12); a peer-stream manager, over a Wi-Fi Direct group and the Wi-Fi network, that announces a peer only under the address its preamble proved, one per address, by the lower-opened rule (K8) | | Python | Nothing platform-specific; it is the thinnest binding and therefore the best place to smoke-test an ABI change; a re-entrant lock on the generated callback handle map, installed at import, because the collector can free a core object inside a callback lookup and the core's drop then asks for that lock again (P10); the host platform for telemetry from `platform`; a BLE peripheral that serves the address and the core-built identity assertion, and a central that verifies before it announces (P8); a peripheral that binds each central to the address its hello proved, and a central that writes its hello before its first write (P15); a central that scans unfiltered on BlueZ, and both roles registering again when bluetoothd restarts or the adapter powers on (P16, P17); a peripheral that resets, once, a controller the kernel lost track of and that refuses every advertisement (P17); a peer-stream manager that announces a host only under the address its preamble proved, and keeps one announced stream per address (P9); a gateway-daemon client that announces a session only once the gateway bound it to this device's address, and settles a frame only on the gateway's verdict, never on the write (P11) | | TypeScript | Config normalization, event typing kept in step with the core, no assumption that a native method exists in an older binary, and no telemetry lifecycle code of its own | +| Flutter | Shared bridge sources compiled less a named list of React-only ones (F1); engine-thread UniFFI with main-thread channel callbacks (F2); method-channel names pinned to Dart (F3); event seq/replay parity with React Native (F4); config via shared parsers (F5); secure storage via shared Kotlin/Swift adapters (F6) | | C | Nothing the library can do for it: the transports, storage and lifecycle are all the host's; callbacks callable from any thread that never call back into the engine and never unwind; every string the library hands out freed with `offline_protocol_string_free`; `os_major` supplied for telemetry, `os` never (K1 to K3 in [c.md](c.md)) | A storage adapter written in any of them owes the same thing: a green diff --git a/docs/bridges/flutter.md b/docs/bridges/flutter.md new file mode 100644 index 00000000..20dedc4b --- /dev/null +++ b/docs/bridges/flutter.md @@ -0,0 +1,213 @@ +# Flutter bridge contract + +Covers the Flutter plugin under `bindings/flutter/offline_protocol`: Dart API, +Android and iOS host plugins, and the shared React Native bridge sources they +compile ([ADR 0025](../adr/0025-native-packages-are-assembled-in-place.md)). + +Read [the shared contract](README.md) first. This document covers what is +specific to Flutter. + +Integration steps live in [bindings/flutter/README.md](../../bindings/flutter/README.md). + +## The generated layer + +UniFFI produces Kotlin and Swift from the interface definition. Flutter does not +ship a fourth bindgen output: it links the same generated artifacts as React +Native (`bindings/react-native/android/.../uniffi`, `ios/Generated/`). See +[C1](README.md#c1-regenerate-every-binding-together). + +Regenerate with `./scripts/generate-bindings.sh` and rebuild mobile libraries +before changing the UDL. + +## F1. Shared bridge sources, compiled less a named list + +The plugin compiles every hand-written source under +`bindings/react-native/{android,ios}` except the ones it names, as ADR 0025 +decision 3 asks: a list of what to leave out, never a list of what to take. +On Android that is the `KotlinCompile` excludes in `android/build.gradle`. On +iOS it is `rn_only_swift` in `ios/offline_protocol.podspec`, which must agree +with the `--exclude` list in `ios/sync_bridge_rn.sh`. What is left out needs +React (the module, its package, the mesh foreground and wake services, the +sticky event dispatcher) or is a transport the plugin does not host yet +(Nostr, Reticulum). + +A new shared source reaches the plugin without being registered. A list of +what to take would not: the Kotlin plugin ignores `java { include }`, so such +a list drives nothing while looking like the compile surface. + +Rust guards in `offline-protocol-uniffi/src/tests/flutter_bridge_guards.rs` +hold this: `flutter_android_excludes_name_real_files` and +`flutter_ios_exclude_lists_agree_and_name_real_files` refuse an excluded name +that matches no file, which is a rename nobody carried over and would let the +renamed file in, and refuse an exclusion of a shared transport. +`flutter_method_channel_methods_match_native_plugins` pins the channel surface +(F3). + +**The iOS copy can be stale.** CocoaPods indexes only files under the pod +root and does not follow a directory symlink, so `pod install` copies the +shared Swift sources into `ios/BridgeRN/`. An edit to a shared source followed +by a Flutter build without `pod install` compiles the old copy and nothing +says so. Run `ios/sync_bridge_rn.sh` or `pod install` after editing one. + +## F1b. Values are read, not written again + +The plugin's version, Android SDK levels, Android dependency versions and iOS +deployment target are read from the React Native package: the podspec reads +`package.json` and the deployment target line of `MeshSdk.podspec`, and the +Gradle build reads `package.json` and the module's `build.gradle`, each from a +line that has to be there exactly once (ADR 0025 decision 5). A copy is right +on the day it is made and drifts with nothing failing. `pubspec.yaml` cannot +read another file, so it carries the version itself and the release gate in +`release.yml` refuses a tag it does not match. +`flutter_native_builds_read_what_the_react_native_package_declares` holds +both. + +## F2. Engine thread and main-thread callbacks (K2 parity) + +UniFFI and transport lifecycle run on a dedicated engine thread +(`OfflineProtocolEngine` on Android, `com.offlineprotocol.flutter.engine` on +iOS). Method-channel results and `EventChannel` deliveries are posted on the +main looper / main queue. + +Nothing blocking runs on the UI thread, detach included: engine stop can wait +on transport I/O, so detach queues the teardown behind the calls already +submitted and returns. + +**The engine thread is one per process, not one per plugin instance.** Flutter +creates a new plugin instance for every engine, for example after an Android +activity is destroyed and relaunched. With a thread per instance, the new +engine's `create` would run while the old engine's teardown is still blocked +on transport I/O, leaving two cores with one identity on BLE and the relay, +and the old one able to flush protocol state over what the new one loaded. +So the Android executor lives in the plugin's companion object and is never +shut down, and the iOS queue is a `static let`: every call from every +instance queues behind the teardown before it. +`flutter_engine_thread_is_shared_by_every_plugin_instance` holds both. On +iOS, `deinit` tears down directly and never syncs onto the engine queue, +where it may itself be running. + +`create` parses and validates its config before it tears down the running +engine, so a config the plugin refuses leaves the current session untouched. +See [K2](kotlin.md#k2-nothing-blocking-on-the-main-looper). + +Every engine-thread failure is caught as `Throwable` on Android. The generated +bindings load the native library on first use, and a missing or mismatched +library throws `UnsatisfiedLinkError`, an `Error`: uncaught on the engine +thread it kills the process and the call is never answered. It is reported as +`NATIVE_LIBRARY_UNAVAILABLE`. + +## F3. Method-channel API is pinned across three languages + +Dart calls `MethodChannel('com.offlineprotocol/offline_protocol')`. The method +names are listed in `OfflineProtocol.nativeMethodChannelMethods` and must match +`OfflineProtocolPlugin.kt` and `OfflineProtocolPlugin.swift` exactly. CI enforces +this via `flutter_method_channel_methods_match_native_plugins`. + +New surface area: add the Dart wrapper, both native handlers, and extend the +const list (tests fail if the list drifts). + +## F4. Events are opaque JSON with optional seq and replay + +Events cross as JSON strings ([C3](README.md#c3-events-cross-as-opaque-json)). +The event channel may attach monotonic `seq` values. Dart `NativeEventPipeline` +implements the same gap-detection and `replayEvents` contract as React Native +`admitNativeEvent`. + +Native code assigns `seq` and records to `EventReplayRing` only while an event +sink is attached. Apps must subscribe to `OfflineProtocol.events` **before** +`createFromConfig` if they need every event or replay recovery. + +## F5. Config parsing + +`createFromConfig` accepts JSON (camelCase or snake_case). Native +`ProtocolConfigParser` and `InitialRuntimeConfigApplier` are shared with React +Native ([C6](README.md#c6-config-parsers-must-not-default-to-literals)). Dart +may auto-enable transports after `start()` mirroring the React Native JS layer +(Internet, Wi‑Fi Direct). + +`create` refuses a config with `reticulumEnabled` or `nostrEnabled` set. The +plugin hosts no manager for either, and accepting the flag would have the core +route messages to a transport nothing serves, with no error anywhere. + +## F5b. iOS BLE and `Info.plist` + +Shared `BleManager` uses CoreBluetooth state restoration. Before `BleManager.start()` +runs, the host app must declare `NSBluetoothAlwaysUsageDescription` and +`UIBackgroundModes` (`bluetooth-central`, `bluetooth-peripheral`). Same keys as +[React Native iOS setup](../../bindings/react-native/README.md#ios-permissions). +The example smoke path enables the Internet transport flag only so it can boot +without starting BLE; enabling BLE in config requires those plist entries. + +## F6. Secure storage + +MLS and protocol state use the same Kotlin/Swift storage adapters as React +Native (`MlsSecureStorage`, `AppContainerProtocolStateStorage` / +`ProtocolStateStorage`). See [K4](kotlin.md#k4-secure-storage) and +[S6](swift.md#s6-secure-storage). + +## F7. Session lifecycle and foreground Internet heal + +`destroy` tears down native resources; Dart cancels and reattaches the event +channel subscription while keeping the broadcast `events` stream. `stop` and +`createFromConfig` reset Dart seq tracking to stay aligned with native numbering. + +Foreground Internet reconnect uses shared `ForegroundReconnectPolicy` (Android +`ActivityLifecycleCallbacks` on the plugin activity, iOS `UIApplication` +notifications). + +## F8. Telemetry + +The Flutter plugin does not yet call `enableTelemetry`. When it does, it must +follow [C12](README.md#c12-telemetry-is-callback-free-and-the-binding-fills-the-platform): +fill platform fields in the binding, own the session boundary off UI pause +granularity, and never run blocking flush on the main thread. + +## F9. Binary footprint + +The plugin links BLE and mesh bridge sources into the AAR and CocoaPod. CI runs +`bindings/flutter/scripts/measure-plugin-aar-size.sh --check` against a +committed baseline (`bindings/flutter/baselines/`). The baseline is the +arm64-v8a plugin with the library built under the `mobile` profile, the one +the Android release ships ([ADR 0029](../adr/0029-mobile-libraries-are-sized-not-aborting.md)). +The script subtracts any other ABI present under the React Native `jniLibs`, +so the local check measures what CI measures. Re-baseline intentionally after +compile-surface or UniFFI artifact changes. + +## What CI runs + +The `Flutter Binding` job in `.github/workflows/ci.yml` (path-filtered on pull +requests; full run on pushes to `main`): + +- **Dart (fast path):** `flutter test` and `flutter analyze` when the plugin, + `docs/bridges/flutter.md`, workflow, or `flutter_bridge_guards.rs` change. +- **UniFFI production (`lib.rs`):** any edit runs the native path (jniLibs + + AAR gate), not the guard-only fast path. +- **Android native (slow path):** arm64 UniFFI build, a check of the + example's committed Gradle wrapper, then `:offline_protocol:assembleRelease` + via `measure-plugin-aar-size.sh --check` when the plugin's own Android tree + or the example's, baselines, Flutter scripts, shared RN Android tree + (Kotlin, jniLibs, ProGuard consumer rules, Gradle), bindgen, UniFFI ABI + inputs, or root `Cargo.lock` / workspace `Cargo.toml` change. Changes + confined to Dart or guard-only tests can skip the NDK/Gradle path; pushes to + `main` always run the full native gate. `flutter analyze` excludes + `android/**`, so the plugin's own Kotlin is compiled only by this path. + +iOS pod compilation is not gated in CI yet (requires XCFramework assembly like +the Swift Package job). Until it is, typecheck the plugin by hand against the +Flutter engine framework and the shared sources, from +`bindings/react-native/ios`: + +```bash +FLUTTER_FRAMEWORK="$(dirname "$(dirname "$(readlink -f "$(command -v flutter)")")")/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" +FILES=() +for f in *.swift; do + case "$f" in Package.swift|OfflineProtocolModule.swift|NostrManager.swift|NostrQueryTracker.swift|ReticulumManager.swift|InboundEventBuffer.swift) ;; + *) FILES+=("$f") ;; + esac +done +xcrun --sdk iphonesimulator swiftc -typecheck -target arm64-apple-ios13.0-simulator \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" -F "$FLUTTER_FRAMEWORK" \ + -Xcc -fmodule-map-file=Generated/offline_protocolFFI.modulemap -Xcc -IGenerated \ + "${FILES[@]}" ble/*.swift mesh/*.swift Generated/offline_protocol.swift \ + ../../flutter/offline_protocol/ios/Classes/*.swift +``` diff --git a/tools/capi-fuzz/Cargo.lock b/tools/capi-fuzz/Cargo.lock index 00561938..51461d80 100644 --- a/tools/capi-fuzz/Cargo.lock +++ b/tools/capi-fuzz/Cargo.lock @@ -2613,9 +2613,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -3492,9 +3492,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ]