Skip to content

feat: add GATT server support - #29

Open
mvilliott-cardware wants to merge 1 commit into
zykeco:mainfrom
mvilliott-cardware:feature/gatt-server-support
Open

feat: add GATT server support#29
mvilliott-cardware wants to merge 1 commit into
zykeco:mainfrom
mvilliott-cardware:feature/gatt-server-support

Conversation

@mvilliott-cardware

Copy link
Copy Markdown
Contributor

Summary

Adds GATT server support so React Native apps can operate as BLE peripherals on both Android and iOS.

  • Add APIs for defining services and characteristics
  • Support reads, writes, notifications, indications, and subscriptions
  • Add advertising configuration, local name support, and connected-central tracking
  • Add Android advertising permissions and GATT server implementation
  • Add iOS CoreBluetooth peripheral implementation
  • Preserve the existing central-role requestMTU API
  • Add comprehensive unit tests and public API documentation
  • Update the example app with separate Central and Peripheral tabs
  • Add reusable test-peripheral configuration and manual characteristic testing
  • Fix Android codegen scope to prevent duplicate generated view-manager classes

Platform notes

  • iOS may place custom service UUIDs in its overflow advertising area. Android devices may need an unfiltered scan followed by service discovery.
  • CoreBluetooth does not expose physical peripheral connection callbacks. On iOS, centrals are tracked when they read, write, or subscribe.
  • The example’s native projects can be regenerated from app.json with npx expo prebuild --clean.

Some Example app screen shots

screenshot-1784680158727 screenshot-1784680265264 screenshot-1784680224552 screenshot-1784680214959

@ps73 ps73 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pendingStart handling for the .unknown power state, iOS notify backpressure via peripheralManagerIsReady re-queuing in order.
  • Android per-device notify FIFO with a settled-once NotificationOperation is the right shape (modulo the race flagged inline).
  • Permissions API (checkPermissions/requestPermissions) correctly couples advertise → connect and is tested; the Expo plugin already gates BLUETOOTH_ADVERTISE.
  • Generated Nitro code stays gitignored; the android/build.gradle codegen-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? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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):

  • didReceiveRead looks up "180f:2a19", misses the stored "0000180f-…:00002a19-…" entry, and responds with empty data.
  • didSubscribeTo stores subscriptions under the short-form key, which send() (using the full-form key from JS) never matches — so notifyGattServerCharacteristicChanged resolves "success" with empty queuedDeviceIds and silently sends nothing.
  • didReceiveWrite stores values under the short-form key, splitting state from setCharacteristicValue/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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/manager.ts
return createPermissionStatus(permissions, missing);
}

private waitForResolvedBluetoothPermissionState(): Promise<BLEState> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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).

Comment thread src/manager.ts
this.cancelPendingGattServerStart(
'GATT server start was superseded by a new start request'
);
const sessionId = ++this._gattServerSessionId;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

Comment thread src/manager.ts
return this.Instance.getGattServerConnectedDevices();
}

public getGattServerDeviceMTU(deviceId: string): number {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

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.

2 participants