feat: add GATT server support - #29
Conversation
ps73
left a comment
There was a problem hiding this comment.
Code Review: GATT server support
A large, well-structured feature adding BLE peripheral (GATT server) support across the TS API, Android (Kotlin), and iOS (Swift). The TypeScript layer is in very good shape: full validation before crossing the bridge, consistent UUID normalization at the JS boundary, session-ID guards against stale native callbacks, and strong test coverage. The native layers have one critical cross-platform correctness bug (iOS short-form SIG UUID key mismatch) and a few Android robustness issues that should be fixed before merging — see the inline comments on each file.
What looks good
- TypeScript layer: validation (empty services, Notify+Indicate conflict) before native calls, clean enum/flag mapping, 711-line test file covering options mapping, event dispatch, and error paths.
- Lifecycle care: session-ID guards, idempotent stop, iOS
pendingStarthandling for the.unknownpower state, iOS notify backpressure viaperipheralManagerIsReadyre-queuing in order. - Android per-device notify FIFO with a settled-once
NotificationOperationis the right shape (modulo the race flagged inline). - Permissions API (
checkPermissions/requestPermissions) correctly couplesadvertise → connectand is tested; the Expo plugin already gatesBLUETOOTH_ADVERTISE. - Generated Nitro code stays gitignored; the
android/build.gradlecodegen-scope fix is explained; honest platform-limitation notes in docs and PR description.
Verdict
Request changes — primarily for the iOS SIG-UUID key mismatch (silently breaks reads/notifications for standard 16-bit UUIDs), the Android sequential addService requirement, and the isMultipleAdvertisementSupported gate. Everything else is polish on an otherwise high-quality contribution — the JS/TS layer could merge as-is.
| return "\(serviceId.lowercased()):\(characteristicId.lowercased())" | ||
| } | ||
|
|
||
| private func characteristicKey(for characteristic: CBCharacteristic) -> String? { |
There was a problem hiding this comment.
🔴 Critical: SIG (16/32-bit) UUIDs break the iOS server.
The JS layer normalizes every UUID to the full 128-bit lowercase form (0000180f-0000-1000-8000-00805f9b34fb), and characteristicsByKey / characteristicValues / subscriptions are keyed on that string. But this helper builds keys from CBUUID.uuidString, which CoreBluetooth collapses to the short form (180F) for Bluetooth-base UUIDs.
Result for any standard SIG UUID (e.g. Battery 180f/2a19 — exactly what the unit tests use):
didReceiveReadlooks up"180f:2a19", misses the stored"0000180f-…:00002a19-…"entry, and responds with empty data.didSubscribeTostores subscriptions under the short-form key, whichsend()(using the full-form key from JS) never matches — sonotifyGattServerCharacteristicChangedresolves "success" with emptyqueuedDeviceIdsand silently sends nothing.didReceiveWritestores values under the short-form key, splitting state fromsetCharacteristicValue/notify which use the full form.
The example app uses random 128-bit UUIDs (full uuidString round-trips fine), which is why manual testing didn't catch it.
Fix: canonicalize both sides through one representation — e.g. always key on CBUUID(string:)-round-tripped values when building characteristicsByKey, or expand short uuidStrings back to the 128-bit base-UUID form here before keying.
| peripheralManager.startAdvertising(advertisement) | ||
| } | ||
|
|
||
| private func registerCentral(_ central: CBCentral) { |
There was a problem hiding this comment.
🟡 Centrals that only read/write are never removed (CoreBluetooth exposes no disconnect callback, and the unsubscribe-based removal only fires for centrals that subscribed). getGattServerConnectedDevices() can therefore return long-gone centrals indefinitely. The limitation is documented — good — but consider an idle-expiry, or at least pruning on peripheralManagerDidUpdateState power cycles.
There was a problem hiding this comment.
The code calls stop() which clears them when peripheral.state != .poweredOn.
There is no way to tell if these read/write only centrals disconnect.
| } | ||
| } | ||
|
|
||
| private func readGattServerState<T>(_ read: () -> T) -> T { |
There was a problem hiding this comment.
🟡 DispatchQueue.main.sync from the JS thread is a deadlock risk: if the main thread ever blocks waiting on the JS thread (another synchronous Nitro call, etc.), the two threads wait on each other. Consider a dedicated serial queue for GATT server state instead of the main queue — it would also decouple the server from main-thread stalls.
| return | ||
| } | ||
|
|
||
| for (serviceConfig in options.services) { |
There was a problem hiding this comment.
🔴 Services are added in a loop without waiting for onServiceAdded.
Android requires the previous addService to complete (callback fired) before the next one is issued; with concurrent adds, many stacks fail or silently drop services. With more than one service in the config this is a real-device failure waiting to happen.
Fix: add the first service here and chain each subsequent addService from onServiceAdded instead of counting down pendingServiceAdds.
| return | ||
| } | ||
|
|
||
| if (options.advertising.enabled && !adapter.isMultipleAdvertisementSupported) { |
There was a problem hiding this comment.
🟠 isMultipleAdvertisementSupported gates all advertising, but that flag means multiple concurrent advertisement sets. Several devices return false here yet advertise a single set just fine — this check falsely rejects working hardware.
Fix: check adapter.bluetoothLeAdvertiser != null instead, and let onStartFailure(ADVERTISE_FAILED_FEATURE_UNSUPPORTED) report genuine lack of support.
| private var startCallback: ((Boolean, String) -> Unit)? = null | ||
| private var pendingServiceAdds = 0 | ||
| private var advertising = false | ||
| private var running = false |
There was a problem hiding this comment.
🟡 Thread-safety: running, advertising, startCallback, pendingServiceAdds, options, pendingBluetoothName are plain vars mutated from the JS/Nitro thread, binder callback threads, and the main handler with no synchronization or @Volatile — formally racy (e.g. pendingServiceAdds -= 1 happens on a binder thread while start() writes it from the caller thread). Consider funneling all state changes through mainHandler, mirroring how the iOS side funnels everything through the main queue.
| ) | ||
| } | ||
|
|
||
| private fun applyBluetoothName( |
There was a problem hiding this comment.
🟡 Renaming the system-wide Bluetooth adapter is a surprising global side effect (visible in the phone's Settings and to every other app). It's restored on stop(), but not if the app crashes or is killed — the user's phone stays renamed. Worth a prominent README warning in addition to the TS doc comment; the local-name-change receiver + timeout handling itself looks solid.
| return createPermissionStatus(permissions, missing); | ||
| } | ||
|
|
||
| private waitForResolvedBluetoothPermissionState(): Promise<BLEState> { |
There was a problem hiding this comment.
🟡 This polls every 50 ms with no timeout — if the state never leaves Unknown/Resetting, it spins forever and the requestPermissions promise never settles. Consider a cap (e.g. ~30 s → reject or return the unresolved state).
| this.cancelPendingGattServerStart( | ||
| 'GATT server start was superseded by a new start request' | ||
| ); | ||
| const sessionId = ++this._gattServerSessionId; |
There was a problem hiding this comment.
🔵 Note: _gattServerSessionId is bumped before the native side stops the previous server, so a restart filters out the old server's advertisingStopped event — the old options' onAdvertisingStopped never fires. Probably intended (it prevents cross-session event leakage), but worth a doc note since callers may rely on the stop callback for cleanup.
| return this.Instance.getGattServerConnectedDevices(); | ||
| } | ||
|
|
||
| public getGattServerDeviceMTU(deviceId: string): number { |
There was a problem hiding this comment.
🟡 MTU semantics differ per platform: Android reports the ATT MTU (default 23, includes the 3-byte header), iOS reports CBCentral.maximumUpdateValueLength (usable notification payload). The same JS field carries numbers with different meanings. Either document this clearly or subtract the ATT header on Android for parity.
Summary
Adds GATT server support so React Native apps can operate as BLE peripherals on both Android and iOS.
requestMTUAPIPlatform notes
app.jsonwithnpx expo prebuild --clean.Some Example app screen shots