Repository navigation
fix: iOS GATT promise mix-ups and crashes, Android short UUIDs, plugin scan permission - #49
Merged
Merged
Conversation
The GATT delegate handlers called completeGattOperation() before taking the pending promise out of its dictionary. Completing starts the next queued operation, whose start closure stores its promise under the same key (same characteristic, descriptor or device). The handler then removed and settled that new promise with the previous operation's result, and the earlier promise was never settled. So two writes to one characteristic without awaiting (for example Promise.all over chunked writes) left the first promise pending forever and resolved the second before its own ATT response arrived. Reads, descriptor reads/writes, subscribe/unsubscribe, readRSSI and discoverServices behaved the same way. Each handler now takes its promise before completing the operation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…stead of crashing
- CBUUID(string:) raises an Objective-C exception for strings that are not
a Bluetooth UUID ("180", "xyz", a trailing space), which Swift cannot
catch, so startScan/startAdvertising/updateAdvertisingData/setServices
crashed the app on such input. UUIDs from JS are now validated and the
call throws instead. Every form CBUUID accepts (16/32-bit, optional 0x,
full 128-bit) is still accepted.
- setServices built its include map with Dictionary(uniqueKeysWithValues:),
which traps when two services share a UUID (valid GATT, e.g. two Battery
services, or "180F" and "180f").
- writeDescriptor on the Client Characteristic Configuration descriptor
(0x2902), the normal way to enable notifications on Android, makes
CoreBluetooth raise an exception. It now rejects and points to
subscribeToCharacteristic().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
deviceLost is part of the typed event map, but subscribing to it on iOS made RCTEventEmitter log "is not a supported event type" (a red box in development builds). iOS never emits it; it is listed so subscribing is harmless, as on Android. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng the app
The README uses short UUIDs throughout (startScan({ serviceUUIDs: ['180D'] }),
readCharacteristic(id, '180D', '2A37'), setServices([{ uuid: '180D', ... }])),
and iOS accepts them, but Android passed them to UUID.fromString /
ParcelUuid.fromString, which only accept full 128-bit UUIDs:
- reads, writes, subscribes and descriptor calls rejected with
"Characteristic not found" because lookups compared strings;
- startScan, setServices and the background session threw;
- startAdvertising / updateAdvertisingData build their payload inside a
Main-dispatcher coroutine with no exception handler, so a short (or any
invalid) UUID there crashed the whole app.
UUIDs from JS now go through parseBleUuid(), which expands 16/32-bit forms
onto the Bluetooth base UUID, lookups compare parsed UUIDs, and an invalid
UUID in the advertising coroutine emits advertisingStartFailed instead of
crashing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Expo plugin's default "scan" capability added BLUETOOTH_SCAN without usesPermissionFlags while capping ACCESS_FINE_LOCATION at API 30. On Android 12+ the Bluetooth stack only delivers scan results to apps that hold fine location or disavow it, so Expo apps using the plugin defaults started scans that never reported a device. The flag matches the README's manual setup and the example app. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sheehanmunim
pushed a commit
that referenced
this pull request
Oct 8, 2026
## [0.9.1](v0.9.0...v0.9.1) (2026-10-08) ### 🐛 Bug Fixes * iOS GATT promise mix-ups and crashes, Android short UUIDs, plugin scan permission ([#49](#49)) ([943959d](943959d)) * populate manufacturer data entries in scan results ([#50](#50)) ([480d6d8](480d6d8)) ### 📚 Documentation * move Installation up and list Expo first ([b31f5fd](b31f5fd))
Member
Author
|
🎉 This PR is included in version 0.9.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit fixes (2026-10-04). Each fix is a separate commit.
Bugs fixed
completeGattOperation()before taking its promise out of the pending dictionary. Completing an operation starts the next queued one, and itsstartclosure stores a new promise under the same key. The handler then settled that new promise with the old result, and the original promise was dropped.Promise.all(chunks.map(c => writeCharacteristic(id, s, ch, c)))never settles.readRSSIanddiscoverServiceshad the same problem.CBUUID(string:)raises an Objective-C exception, which Swift cannot catch, for anything that isn't a Bluetooth UUID ("180","xyz", a trailing space).startScan,startAdvertising,updateAdvertisingDataandsetServicesnow validate UUIDs from JS and throw instead.setServiceswith two services sharing a UUID trapped inDictionary(uniqueKeysWithValues:).writeDescriptoron the CCCD (0x2902) makes CoreBluetooth raise an exception. It now rejects and points tosubscribeToCharacteristic().deviceLostcaused anRCTLogError(a red box in dev builds), because the event is in the TS event map but missing fromsupportedEvents.'180D'/'2A37'everywhere, but Android passed them toUUID.fromString/ParcelUuid.fromString.setServicesand the background session threw.startAdvertising/updateAdvertisingDatabuild their payload in a Main-dispatcher coroutine with no exception handler, so the whole app crashed.parseBleUuid()expands 16- and 32-bit forms onto the Bluetooth base UUID. Lookups compare parsed UUIDs. The advertising coroutine emitsadvertisingStartFailedinstead of crashing.scancapability declaredBLUETOOTH_SCANwithoutneverForLocationwhile capping fine location at API 30, so the Bluetooth stack filtered out every scan result. It now matches the README's manual setup and the example manifest.How each fix was verified
@expo/config-pluginsbefore and after.BLUETOOTH_SCANnow carriesandroid:usesPermissionFlags="neverForLocation".CBUUID(string:)(uncaughtNSException).0x-prefixed and 128-bit.parseBleUuid: compiled with kotlinc 2.2.20 and tested (short,0x-prefixed, 32-bit, full and invalid inputs, plus the equality helper). The other Kotlin edits are type-identical substitutions.swiftc -parsepasses. The GATT ordering fix was verified by reading the code.npm ci,tscandnpm run buildpass, andnpm pack --dry-runcontents are correct.Found, not fixed
Each of these needs a design decision or broader change:
CBUUID.uuidString, so full-form base UUIDs (0000180f-…) don't match the short form CoreBluetooth reports.stopAdvertising()also removes all GATT services, unlike Android.hexStringToDatarejects""and left-pads odd-length input, while Android rejects odd-length input.deviceFoundpayload differs from Android (serviceDataand the manufacturer-data prefix).connect()twice for the same device orphans the first promise. On Android it also leaks aBluetoothGatt.onExecuteWrite).isScanningstays true after Bluetooth is toggled off, so the nextstartScan()silently does nothing.stopAdvertising.initare never unregistered, so they leak across JS reloads.appearance/solicitation advertising fields are encoded as service data/UUIDs.@expo/config-pluginswithout declaring it, which can fail under strict (pnpm/isolated) installs.munim-ffmpegusesexpo/config-plugins.🤖 Generated with Claude Code