Skip to content

V4 turbomodule rewrite#1331

Draft
iotashan wants to merge 36 commits intodotintent:masterfrom
iotashan:v4-turbomodule-rewrite
Draft

V4 turbomodule rewrite#1331
iotashan wants to merge 36 commits intodotintent:masterfrom
iotashan:v4-turbomodule-rewrite

Conversation

@iotashan
Copy link

What is this?

A complete rewrite of react-native-ble-plx for React Native's New Architecture (TurboModules + Fabric). This isn't a patch — it's a ground-up reimplementation of the native modules, JS API layer, example apps, documentation, and test infrastructure.

I started this because v3 had 25 documented issues (thread safety, memory leaks, broken APIs) that couldn't be fixed incrementally, and the library had no path to New Architecture support. Rather than file 25 separate issues, I built the fix. Oh, and the bigger "why"? I have a project I'm starting that needs BLE, and Claude is giving away extra usage on weekends.

I know, I know... v4 drops support for everything but the latest Expo SDK and newer RN versions. I felt that v3 could continue to support old versions, but if someone really wants the latest & greatest, they need to bring their app up to the latest & greatest also.

How this was built

Full transparency: this rewrite was primarily built using Claude Code (Opus 4.6), with peer review assistance from OpenAI Codex (GPT-5.4) and Google Gemini. I directed the architecture, made design decisions, tested on physical hardware, and validated all changes — but the AI agents wrote the vast majority of the code, tests, documentation, and even most of this PR description on my behalf. I believe in full disclosure for OSS contributions.

What changed

Native modules — completely rewritten:

  • Android: Kotlin + Nordic BLE Library (replacing the custom RxAndroidBle adapter)
  • iOS: Swift actors + CoreBluetooth (replacing the Obj-C bridge)
  • Both implemented as TurboModules with Codegen

JS API — mostly compatible, some breaking changes:

  • Same method names and signatures where possible
  • enable()/disable() removed (broken on Android 12+, never worked reliably)
  • setLogLevel() removed
  • Enums changed from TypeScript enums to as const objects
  • New: monitorL2CAPChannel(), requestPhy()/readPhy(), onBondStateChange(), onConnectionEvent()
  • Full migration guide: MIGRATION_V3_TO_V4.md

Testing — new hardware E2E suite:

  • 17 Maestro test flows running on physical devices (iPhone 15 Pro Max + Pixel 6)
  • Custom BLE test peripheral apps (Android + iOS) that expose 7 characteristics + L2CAP echo server
  • Orchestration script (run-e2e.sh) that manages both devices
  • 32 unit tests, all passing

Example apps:

  • Expo SDK 55 example (the recommended path for new projects)
  • Bare React Native 0.84 example
  • Both verified on physical iOS and Android devices

Documentation — complete rewrite:

Breaking changes

  • Requires React Native >= 0.82.0 (New Architecture only)
  • Requires iOS 17.0+, Android API 23+
  • enable()/disable() removed
  • setLogLevel() removed
  • TypeScript enums → as const objects
  • BleManager is now a true singleton (was already trending this way in v3.4.0)
  • createClient() must be called explicitly before using any API

What I'd like from maintainers

This is a large PR and I don't expect an immediate merge. I'm opening it as a draft so you can:

  1. Review the architecture decisions
  2. Run the test suite against your own hardware if you'd like
  3. Provide feedback on API changes
  4. Decide how you want to handle the v3 → v4 transition (alpha release, separate branch, etc.)

I'm happy to split this into smaller PRs if that's preferred, though the native modules are interdependent so it may not decompose cleanly.

How to test

# Unit tests
yarn install && yarn test

# Build verification
yarn prepack

# Hardware E2E (requires two physical BLE devices)
cd integration-tests/hardware/maestro
./run-e2e.sh android   # or ios

CI note

The existing CI matrix tests against RN 0.77.0 and 0.76.6, which are below v4's minimum of 0.82.0. Those matrix jobs will fail by design. The CI config will need updating for v4.

iotashan added 30 commits March 15, 2026 11:15
Fixes from Codex review:
- Use Codegen typed events, not RCTEventEmitter (legacy)
- iOS: @objc adapter over Swift actors (actors not TurboModule surface)
- Background BLE with state restoration is supported (not dropped)
- Rich error model with platform diagnostics (gattStatus, attErrorCode, isRetryable)
- Event batching: per-stream rules, bypass for timing-sensitive, ordering contract
- Android mock testing: custom MockBleManager (Nordic mock utils not in v2.x)
- Support floor: RN 0.82+ (legacy arch removed)
- Additional test cases: permission revoke, BT off, bond failure, restoration
…earch

Research-driven fixes:
- Codegen spec rewritten: NativeBlePlx.ts with inline Readonly<{}> types,
  CodegenTypes.EventEmitter<T> for all streaming events, no Object escape hatches
- codegenConfig added for package.json
- TurboModuleRegistry.get (nullable) instead of getEnforcing
- iOS: custom actor executor on CB DispatchQueue (Swift 5.9+) for non-Sendable CB objects
- iOS: ObjC++ .mm entry point (pure Swift TurboModules not possible)
- iOS: @preconcurrency import CoreBluetooth for Swift 6
- iOS: GATT operation queue (CB does NOT serialize operations)
- requestConnectionParameters → requestConnectionPriority (coarse, Android-only)
- Scanner Compat does NOT handle throttling — built-in debouncing required
- Auto-MTU 517 on Android connect (fixes dotintent#1 user-reported issue)
- L2CAP expanded: open/write/close lifecycle, dynamic PSMs only
- Device identifier incompatibility documented (MAC vs opaque UUID)
- onStateChange as EventEmitter (not callback — callbacks are single-fire)
- Background scan on iOS: must specify service UUID filters
- Fix filename inconsistency: NativeBlePlx.ts throughout (was NativeBleModule.ts in header)
- Replace optionsJson string with typed Readonly<{}> for scan and connect options
- Android 14 auto-MTU caveat: skip requestMtu if system already negotiated >= 517
- Add neverForLocation manifest flag requirement
- Note for impl: transactionId→Nordic Request mapping, bonding vs encryption race,
  L2CAP StreamDelegate bridge, concurrent write+indicate stress test needed
…ignature, L2CAP wiring, connection events, task ordering
…en spec

Add subscriptionType parameter to monitorCharacteristic to allow explicit
indicate vs notify selection (null = auto-detect, preferring notify).
Add getMtu(deviceId) method to read current negotiated MTU.
Add onBondStateChange event emitter for bond state transitions
(none/bonding/bonded).
Add src/specs/NativeBlePlx.ts — the Codegen contract for the v4
TurboModule rewrite. Defines all typed interfaces (DeviceInfo,
CharacteristicInfo, ScanResult, etc.), method signatures, and ten
typed CodegenTypes.EventEmitter declarations. Uses
TurboModuleRegistry.get (nullable) per spec.

Add codegenConfig to package.json pointing at src/specs with
Android javaPackageName com.bleplx.
add types.ts with State, LogLevel, ConnectionPriority, ConnectionState
const objects and ScanOptions/ConnectOptions interfaces

add BleError.ts with BleErrorCode const object and BleError class
for unified cross-platform error handling

add __tests__/BleError.test.ts with constructor and code value tests

fix tooling for typescript support in src/ and __tests__/ directories
…cation flag

- AndroidManifestNew.xml was empty (bare `<manifest></manifest>`), breaking AGP
  namespace mode manifest merging; add xmlns:android and xmlns:tools declarations
- Default neverForLocation to true so BLUETOOTH_SCAN gets
  android:usesPermissionFlags="neverForLocation" out of the box, matching the
  library's own AndroidManifest.xml; apps that derive location from BLE can opt
  out by setting neverForLocation: false in their Expo config
Replace the v3 Java/RxJava implementation with a Kotlin TurboModule
architecture using Nordic Android-BLE-Library 2.11.0 for GATT operations
and Scanner Compat 1.6.0 for scanning.

- BlePlxModule extends Codegen-generated NativeBlePlxSpec
- BleManagerWrapper: per-device Nordic BleManager with coroutine suspend APIs
- ScanManager: Scanner Compat with 5-starts/30s throttle protection
- ErrorConverter: GATT status codes mapped to unified BleErrorCode
- EventSerializer: native objects serialized to Codegen event types
- PermissionHelper: runtime checks for API 31+ permission model
- Fixed AndroidManifestNew.xml (was empty in v3)
- Removed all v3 Java source files under android/src/main/java/
…on cleanup, missing APIs

- EventBatcher.dispose() now discards buffered events instead of flushing,
  preventing callbacks from firing during teardown
- Use Platform.OS instead of hardcoded 'ios' in onDeviceDisconnected
- Track consumer subscriptions (onStateChange, onDeviceDisconnected, etc.)
  and clean them up in destroyClient()
- Clean up existing monitor when duplicate transactionId is provided
- Guard onStateChange callback with removed flag to prevent post-remove firing
- Clean up previous errorSubscription in createClient() to prevent leaks
- Add missing API methods: getMtu, requestPhy, readPhy, getBondedDevices,
  getAuthorizationStatus, L2CAP channel ops, onRestoreState, onBondStateChange,
  onConnectionEvent
- Add BleManagerOptions with scanBatchIntervalMs constructor option
- Add subscriptionType to MonitorOptions
- Update tests to cover all new behavior
…t scaffolding

- __tests__/protocol.test.ts: 32 tests covering base64 round-trip, DeviceInfo
  and CharacteristicInfo shape parsing, BleError construction from native error
  events, BleErrorCode distinctness, and State/ConnectionState/ConnectionPriority
  const object values
- integration-tests/simulated/js/fullSync.test.ts: full JS-layer flow tests
  (mocked TurboModule) covering createClient->scan->connect->write->monitor->
  disconnect, audit dotintent#3 subscription.remove() fix verification, and error
  scenarios (permission denied, timeout, GATT error)
- integration-tests/hardware/maestro/: three Maestro flow scaffolds
  (scan-pair-sync, disconnect-recovery, indicate-stress) with inline comments
- integration-tests/hardware/peripheral-firmware/README.md: GATT profile
  requirements for the BlePlxTest peripheral
- integration-tests/hardware/test-app/README.md: test app setup and testID
  conventions
- android/src/test/kotlin/com/bleplx/README.md: JUnit 5 + MockK test plan
- ios/Tests/README.md: XCTest + CoreBluetoothMock test plan
- Delete 13 v3 JS source files (replaced by TypeScript in v4)
- Delete 9 v3 JS test files (replaced by v4 .test.ts files)
- Delete .flowconfig
- Remove Flow/hermes devDependencies from package.json
- Remove Flow/hermes ESLint override from .eslintrc.json
- Clean up .eslintignore (no more v3 JS files to ignore)
Adds Arduino sketch for Seeed XIAO nRF52840 that exposes a five-characteristic
GATT test service covering read, authenticated write, notify, indicate, and MTU
negotiation — the core BLE operations exercised by react-native-ble-plx v4.
…uilds

- Wire react-native-ble-plx (file:..) into example app with navigation deps
- Create ScanScreen, DeviceScreen, CharacteristicScreen test screens
- Update Metro config to resolve library from parent directory
- Fix Android: BleManagerWrapper notification callback (DataReceivedCallback
  via setNotificationCallback, not enableIndications.with())
- Fix iOS: migrate from bridging header to module imports for framework target
- Fix iOS: replace DispatchQueue.asUnownedSerialExecutor() with
  DispatchSerialQueue (required for Swift 6.2 / Xcode 26)
- Fix iOS: refactor BLEModuleImpl to use typed CodeGen emit methods instead of
  RCTEventEmitter.sendEvent pattern
- Bump iOS deployment target to 17.0 (required for DispatchSerialQueue)
- Downgrade Gradle to 8.13 for JDK 21 compatibility
- Both Android assembleDebug and iOS xcodebuild succeed
Replace placeholder element IDs in all three existing Maestro flow files
with the actual testID values from the example app screens (ScanScreen,
DeviceScreen, CharacteristicScreen). Add the missing write-read-roundtrip
flow and a README documenting prerequisites, permission pre-grant commands,
and the testID reference table.

Key changes:
- scan-start-btn / scan-stop-btn replace start-scan-button
- disconnect-btn replaces disconnect-button
- discover-btn replaces implicit service-discovery step
- monitor-toggle (Switch) replaces stop-monitor-button
- value-display replaces char-value / indicate-count-label
- char-fff1/fff2/fff3 used for known firmware characteristic UUIDs
- disconnect-recovery updated to match app navigation (goBack to ScanScreen)
- indicate-stress updated to use CharacteristicScreen monitor-toggle pattern
Add JUnit 5 + MockK tests for ScanManager throttle logic, ErrorConverter GATT
mapping, PermissionHelper SDK gating, and EventSerializer serialization on
Android; add XCTest suites for GATTOperationQueue serial/cancel/timeout
behaviour, ErrorConverter CB/ATT error mapping, and EventSerializer snapshot
dictionaries on iOS.  Wire JUnit 5 platform into android/build.gradle.
Add ios.modules config to codegenConfig in package.json so the codegen
generates the NativeBlePlx -> BlePlx mapping in RCTModuleProviders.mm.
Without this, TurboModuleRegistry.get('NativeBlePlx') returns null at
runtime even though the native code compiles fine.

Also exclude ios/Tests/ and ios/BlePlx.xcodeproj/ from the podspec
source_files to prevent XCTest import failures in app builds.
… flag

- ios/BlePlx.mm: change startDeviceScan/connectToDevice option params from
  NSDictionary* to Codegen C++ struct types, fix nullability annotations
- example/ios/Podfile: add -ObjC to OTHER_LDFLAGS to prevent dead-stripping
- example/src/App.tsx: restore real test screens
…versions

The BleManager constructor eagerly called TurboModuleRegistry.get() which
used the library's devDependency react-native (0.77.0) instead of the
example app's react-native (0.84.1). The older TurboModuleRegistry had
different bridgeless checks that prevented the module from being found at
runtime.

Two fixes:
- metro.config.js: add resolveRequest to force react/react-native to
  always resolve from the example app's node_modules, preventing version
  mismatches between JS and native runtime
- BleManager.ts: lazy-initialize the native module via a getter instead
  of in the constructor, so the TurboModule lookup happens on first use
  rather than at instantiation time
The library root had react-native@0.77.0 in devDependencies/node_modules,
causing Metro to resolve the wrong TurboModuleRegistry for library code.
Remove library's node_modules from nodeModulesPaths — only the example
app's node_modules should be in the search path.
BREAKING CHANGE: Requires React Native >= 0.82.0 (New Architecture only).
Removes enable()/disable(), setLogLevel(), and the old NativeModule bridge.

Library:
- TurboModule (Fabric) implementation replacing the old bridge
- Android: Kotlin + Nordic BLE Library
- iOS: Swift actors + CoreBluetooth
- New monitorL2CAPChannel() API with iOS openL2CAPChannel fix
- All 25 audit issues from v3 addressed
- yarn prepack builds clean (68 files), lint passes, 32/32 tests pass

E2E Tests (17 flows, both platforms):
- Tests 01-11: Core BLE operations (scan, connect, read, write,
  notify, indicate, MTU, disconnect, UUID filter, background, L2CAP)
- Tests 12-17: requestMTU, monitor unsubscribe, invalid write,
  L2CAP echo, payload boundaries, permission denied

Example Apps:
- Bare RN example (React Native 0.84, React Navigation)
- Expo SDK 55 example (expo-router, verified on iPhone + Pixel 6)

Documentation:
- README: Expo-first quickstart
- API reference, migration guide, troubleshooting, testing guide
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant