From d76b4b7f812c277ba9c458388c9e0f39811e8ec7 Mon Sep 17 00:00:00 2001 From: Sheehan Date: Sun, 4 Oct 2026 22:37:12 -0400 Subject: [PATCH 1/5] fix(ios): settle each GATT promise with its own result 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) --- ios/HybridMunimBluetooth.swift | 72 +++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/ios/HybridMunimBluetooth.swift b/ios/HybridMunimBluetooth.swift index 596dc5f..254b22a 100755 --- a/ios/HybridMunimBluetooth.swift +++ b/ios/HybridMunimBluetooth.swift @@ -2930,9 +2930,10 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { pendingCharacteristicDiscoveryCounts.removeValue(forKey: deviceId) devicesNeedingServiceRediscovery.remove(deviceId) + let promise = pendingServiceDiscoveryPromises.removeValue(forKey: deviceId) completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) let services = buildGATTServices(from: peripheral.services ?? []) - pendingServiceDiscoveryPromises.removeValue(forKey: deviceId)?.resolve(withResult: services) + promise?.resolve(withResult: services) emit("servicesDiscovered", body: [ "deviceId": deviceId, "services": servicePayload(services) @@ -3771,16 +3772,18 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { let deviceId = peripheral.identifier.uuidString if let error = error { - completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) - pendingServiceDiscoveryPromises.removeValue(forKey: deviceId)?.reject(withError: error) + let promise = pendingServiceDiscoveryPromises.removeValue(forKey: deviceId) pendingCharacteristicDiscoveryCounts.removeValue(forKey: deviceId) + completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) + promise?.reject(withError: error) return } guard let services = peripheral.services, !services.isEmpty else { - completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) - pendingServiceDiscoveryPromises.removeValue(forKey: deviceId)?.resolve(withResult: []) + let promise = pendingServiceDiscoveryPromises.removeValue(forKey: deviceId) pendingCharacteristicDiscoveryCounts.removeValue(forKey: deviceId) + completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) + promise?.resolve(withResult: []) return } @@ -3797,9 +3800,10 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { let deviceId = peripheral.identifier.uuidString if let error = error { - completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) - pendingServiceDiscoveryPromises.removeValue(forKey: deviceId)?.reject(withError: error) + let promise = pendingServiceDiscoveryPromises.removeValue(forKey: deviceId) pendingCharacteristicDiscoveryCounts.removeValue(forKey: deviceId) + completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) + promise?.reject(withError: error) return } @@ -3820,9 +3824,10 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { let deviceId = peripheral.identifier.uuidString if let error = error { - completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) - pendingServiceDiscoveryPromises.removeValue(forKey: deviceId)?.reject(withError: error) + let promise = pendingServiceDiscoveryPromises.removeValue(forKey: deviceId) pendingCharacteristicDiscoveryCounts.removeValue(forKey: deviceId) + completeGattOperation(deviceId: deviceId, kinds: ["discoverServices"], target: deviceId) + promise?.reject(withError: error) return } @@ -3836,13 +3841,15 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { if let error = error { for key in Array(pendingDescriptorReadPromises.keys) where key.hasPrefix(prefix) { + let promise = pendingDescriptorReadPromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["readDescriptor"], target: key) - pendingDescriptorReadPromises.removeValue(forKey: key)?.reject(withError: error) + promise?.reject(withError: error) } for key in Array(pendingDescriptorWritePromises.keys) where key.hasPrefix(prefix) { - completeGattOperation(deviceId: deviceId, kinds: ["writeDescriptor"], target: key) - pendingDescriptorWritePromises.removeValue(forKey: key)?.reject(withError: error) + let promise = pendingDescriptorWritePromises.removeValue(forKey: key) pendingDescriptorWriteValues.removeValue(forKey: key) + completeGattOperation(deviceId: deviceId, kinds: ["writeDescriptor"], target: key) + promise?.reject(withError: error) } return } @@ -3852,8 +3859,9 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { if let descriptor = findDescriptor(characteristic: characteristic, descriptorUUID: descriptorUUID) { peripheral.readValue(for: descriptor) } else { + let promise = pendingDescriptorReadPromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["readDescriptor"], target: key) - pendingDescriptorReadPromises.removeValue(forKey: key)?.reject(withError: NSError( + promise?.reject(withError: NSError( domain: "MunimBluetooth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Descriptor not found: \(descriptorUUID)"] @@ -3866,9 +3874,10 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { let value = pendingDescriptorWriteValues[key] { peripheral.writeValue(value, for: descriptor) } else { - completeGattOperation(deviceId: deviceId, kinds: ["writeDescriptor"], target: key) + let promise = pendingDescriptorWritePromises.removeValue(forKey: key) pendingDescriptorWriteValues.removeValue(forKey: key) - pendingDescriptorWritePromises.removeValue(forKey: key)?.reject(withError: NSError( + completeGattOperation(deviceId: deviceId, kinds: ["writeDescriptor"], target: key) + promise?.reject(withError: NSError( domain: "MunimBluetooth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Descriptor not found: \(descriptorUUID)"] @@ -3887,8 +3896,11 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { serviceUUID: serviceUUID, characteristicUUID: characteristic.uuid.uuidString ) + // Take the promise before completing: completing starts the next queued + // operation, which may store its own promise under the same key. + let promise = pendingReadPromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["readCharacteristic"], target: key) - pendingReadPromises.removeValue(forKey: key)?.reject(withError: error) + promise?.reject(withError: error) return } @@ -3900,13 +3912,14 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { serviceUUID: serviceUUID, characteristicUUID: characteristic.uuid.uuidString ) + let promise = pendingReadPromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["readCharacteristic"], target: key) let value = CharacteristicValue( value: hexString, serviceUUID: serviceUUID, characteristicUUID: characteristic.uuid.uuidString ) - pendingReadPromises.removeValue(forKey: key)?.resolve(withResult: value) + promise?.resolve(withResult: value) emit("characteristicValueChanged", body: [ "deviceId": deviceId, @@ -3931,13 +3944,14 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { descriptorUUID: descriptor.uuid.uuidString ) + let promise = pendingDescriptorReadPromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["readDescriptor"], target: key) if let error = error { - pendingDescriptorReadPromises.removeValue(forKey: key)?.reject(withError: error) + promise?.reject(withError: error) return } - pendingDescriptorReadPromises.removeValue(forKey: key)?.resolve(withResult: DescriptorValue( + promise?.resolve(withResult: DescriptorValue( value: descriptor.value.flatMap { descriptorValueToHex($0) } ?? "", serviceUUID: serviceUUID, characteristicUUID: characteristic.uuid.uuidString, @@ -3954,12 +3968,13 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { characteristicUUID: characteristic.uuid.uuidString ) + let promise = pendingWritePromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["writeCharacteristic"], target: key) if let error = error { - pendingWritePromises.removeValue(forKey: key)?.reject(withError: error) + promise?.reject(withError: error) NSLog("Bluetooth: writeError") } else { - pendingWritePromises.removeValue(forKey: key)?.resolve(withResult: ()) + promise?.resolve(withResult: ()) debugLog("write succeeded characteristic=\(characteristic.uuid.uuidString)") } } @@ -3976,11 +3991,12 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { descriptorUUID: descriptor.uuid.uuidString ) pendingDescriptorWriteValues.removeValue(forKey: key) + let promise = pendingDescriptorWritePromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["writeDescriptor"], target: key) if let error = error { - pendingDescriptorWritePromises.removeValue(forKey: key)?.reject(withError: error) + promise?.reject(withError: error) } else { - pendingDescriptorWritePromises.removeValue(forKey: key)?.resolve(withResult: ()) + promise?.resolve(withResult: ()) } } @@ -3991,13 +4007,14 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { serviceUUID: characteristic.service?.uuid.uuidString ?? "", characteristicUUID: characteristic.uuid.uuidString ) + let promise = pendingNotificationStatePromises.removeValue(forKey: key) completeGattOperation(deviceId: deviceId, kinds: ["subscribe", "unsubscribe"], target: key) if let error = error { - pendingNotificationStatePromises.removeValue(forKey: key)?.reject(withError: error) + promise?.reject(withError: error) NSLog("Bluetooth: notification state error - %@", error.localizedDescription) } else { - pendingNotificationStatePromises.removeValue(forKey: key)?.resolve(withResult: ()) + promise?.resolve(withResult: ()) NSLog( "Bluetooth: notification state updated characteristic=%@ notifying=%@", characteristic.uuid.uuidString, @@ -4037,15 +4054,16 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { func handlePeripheralDidReadRSSI(_ peripheral: CBPeripheral, rssi RSSI: NSNumber, error: Error?) { let deviceId = peripheral.identifier.uuidString + let promise = pendingRSSIPromises.removeValue(forKey: deviceId) completeGattOperation(deviceId: deviceId, kinds: ["readRSSI"], target: deviceId) if let error = error { - pendingRSSIPromises.removeValue(forKey: deviceId)?.reject(withError: error) + promise?.reject(withError: error) NSLog("Bluetooth: RSSI error peripheral=%@ error=%@", deviceId, error.localizedDescription) return } - pendingRSSIPromises.removeValue(forKey: deviceId)?.resolve(withResult: RSSI.doubleValue) + promise?.resolve(withResult: RSSI.doubleValue) emit("rssiUpdated", body: ["deviceId": deviceId, "rssi": RSSI.doubleValue]) debugLog("RSSI peripheral=\(deviceId) value=\(RSSI)") } From 21647fa2c1229e40293efe71efca4c975007df9e Mon Sep 17 00:00:00 2001 From: Sheehan Date: Sun, 4 Oct 2026 22:37:24 -0400 Subject: [PATCH 2/5] fix(ios): reject invalid UUIDs, duplicate services and CCCD writes instead 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) --- ios/HybridMunimBluetooth.swift | 61 ++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/ios/HybridMunimBluetooth.swift b/ios/HybridMunimBluetooth.swift index 254b22a..7536e2f 100755 --- a/ios/HybridMunimBluetooth.swift +++ b/ios/HybridMunimBluetooth.swift @@ -487,7 +487,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { // Service UUIDs - ALLOWED if !options.serviceUUIDs.isEmpty { - let uuids = options.serviceUUIDs.compactMap { CBUUID(string: $0) } + let uuids = try options.serviceUUIDs.map { try makeCBUUID($0) } advertisingData[CBAdvertisementDataServiceUUIDsKey] = uuids #if DEBUG NSLog("[MunimBluetooth] Advertising service UUIDs: %@", options.serviceUUIDs) @@ -514,7 +514,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { // Advertising data types - Most are NOT ALLOWED if let advertisingDataTypes = options.advertisingData { // Only process allowed fields - processAdvertisingData(advertisingDataTypes, into: &advertisingData) + try processAdvertisingData(advertisingDataTypes, into: &advertisingData) if let completeLocalName = advertisingDataTypes.completeLocalName { advertisingData[CBAdvertisementDataLocalNameKey] = completeLocalName #if DEBUG @@ -558,7 +558,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { peripheralManager.stopAdvertising() var newAdvertisingData: [String: Any] = [:] - processAdvertisingData(advertisingData, into: &newAdvertisingData) + try processAdvertisingData(advertisingData, into: &newAdvertisingData) currentAdvertisingData = normalizeAdvertisingData(advertisingData, serviceUUIDs: nil, localName: nil) peripheralManager.startAdvertising(newAdvertisingData) @@ -613,7 +613,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { var createdServices: [(GATTService, CBMutableService)] = [] for service in services { - let serviceUUID = CBUUID(string: service.uuid) + let serviceUUID = try makeCBUUID(service.uuid) let mutableService = CBMutableService(type: serviceUUID, primary: true) var characteristics: [CBMutableCharacteristic] = [] @@ -621,7 +621,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { NSLog("[MunimBluetooth] Service %@: %d characteristics", service.uuid, service.characteristics.count) for characteristic in service.characteristics { - let charUUID = CBUUID(string: characteristic.uuid) + let charUUID = try makeCBUUID(characteristic.uuid) var properties: CBCharacteristicProperties = [] for prop in characteristic.properties { @@ -665,8 +665,8 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { value: nil, permissions: permissions ) - mutableChar.descriptors = characteristic.descriptors?.compactMap { - makeMutableDescriptor(from: $0) + mutableChar.descriptors = try characteristic.descriptors?.compactMap { + try makeMutableDescriptor(from: $0) } characteristics.append(mutableChar) @@ -678,8 +678,11 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { createdServices.append((service, mutableService)) } + // GATT allows several instances of one service UUID, which + // `uniqueKeysWithValues` would trap on; includes resolve to the first. let servicesByUUID = Dictionary( - uniqueKeysWithValues: createdServices.map { ($0.0.uuid.lowercased(), $0.1) } + createdServices.map { ($0.0.uuid.lowercased(), $0.1) }, + uniquingKeysWith: { first, _ in first } ) for (service, mutableService) in createdServices { @@ -918,6 +921,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { } let manufacturerFilter = try parseManufacturerScanFilter(options) + let serviceUUIDs = try options?.serviceUUIDs?.map { try makeCBUUID($0) } scanOptions = options scanDeviceNameFilter = options?.deviceName.flatMap { $0.isEmpty ? nil : $0 } scanManufacturerFilter = manufacturerFilter @@ -928,7 +932,6 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { scanOptions[CBCentralManagerScanOptionAllowDuplicatesKey] = options.allowDuplicates ?? false } - let serviceUUIDs = options?.serviceUUIDs?.map { CBUUID(string: $0) } centralManager.scanForPeripherals( withServices: serviceUUIDs?.isEmpty == false ? serviceUUIDs : nil, options: scanOptions as [String : Any] @@ -1243,6 +1246,12 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { promise.reject(withError: NSError(domain: "MunimBluetooth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid hex string for descriptor write"])) return promise } + // CoreBluetooth raises an uncatchable exception when the Client + // Characteristic Configuration descriptor is written directly. + guard !isClientCharacteristicConfiguration(descriptorUUID) else { + promise.reject(withError: NSError(domain: "MunimBluetooth", code: 1, userInfo: [NSLocalizedDescriptionKey: "iOS does not allow writing the Client Characteristic Configuration descriptor (0x2902); use subscribeToCharacteristic() or unsubscribeFromCharacteristic()"])) + return promise + } let key = descriptorKey(deviceId: deviceId, serviceUUID: serviceUUID, characteristicUUID: characteristicUUID, descriptorUUID: descriptorUUID) enqueueGattOperation( @@ -2290,6 +2299,32 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { } } + /// `CBUUID(string:)` raises an Objective-C exception (which Swift cannot + /// catch) for anything that is not a 16/32-bit hex UUID or a full UUID, so + /// strings coming from JS are checked first. + private func makeCBUUID(_ value: String) throws -> CBUUID { + let isShortForm = value.range( + of: "^(0[xX])?[0-9A-Fa-f]{4}([0-9A-Fa-f]{4})?$", + options: .regularExpression + ) != nil + guard isShortForm || UUID(uuidString: value) != nil else { + throw NSError( + domain: "MunimBluetooth", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Invalid Bluetooth UUID: '\(value)'. Use a 4 or 8 digit hex UUID or a full 128-bit UUID."] + ) + } + return CBUUID(string: value) + } + + /// The Client Characteristic Configuration descriptor (0x2902) in any of + /// the forms JS may pass it. + private func isClientCharacteristicConfiguration(_ uuid: String) -> Bool { + let normalized = uuid.lowercased() + return normalized == "2902" || normalized == "00002902" || + normalized == "00002902-0000-1000-8000-00805f9b34fb" + } + private func hexStringToData(_ hex: String) -> Data? { var data = Data() var hex = hex.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2499,7 +2534,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { } } - private func processAdvertisingData(_ data: AdvertisingDataTypes, into advertisingData: inout [String: Any]) { + private func processAdvertisingData(_ data: AdvertisingDataTypes, into advertisingData: inout [String: Any]) throws { if let localName = data.completeLocalName ?? data.shortenedLocalName { advertisingData[CBAdvertisementDataLocalNameKey] = localName } @@ -2513,7 +2548,7 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { serviceUUIDs.append(contentsOf: data.completeServiceUUIDs128 ?? []) if !serviceUUIDs.isEmpty { - advertisingData[CBAdvertisementDataServiceUUIDsKey] = serviceUUIDs.map { CBUUID(string: $0) } + advertisingData[CBAdvertisementDataServiceUUIDsKey] = try serviceUUIDs.map { try makeCBUUID($0) } } } @@ -2549,8 +2584,8 @@ class HybridMunimBluetooth: HybridMunimBluetoothSpec { ) } - private func makeMutableDescriptor(from descriptor: GATTDescriptor) -> CBMutableDescriptor? { - let descriptorUUID = CBUUID(string: descriptor.uuid) + private func makeMutableDescriptor(from descriptor: GATTDescriptor) throws -> CBMutableDescriptor? { + let descriptorUUID = try makeCBUUID(descriptor.uuid) let normalizedUUID = descriptorUUID.uuidString.lowercased() let userDescriptionUUID = CBUUID(string: CBUUIDCharacteristicUserDescriptionString).uuidString.lowercased() let formatUUID = CBUUID(string: CBUUIDCharacteristicFormatString).uuidString.lowercased() From abddce16a7d14e2fd794ace520bd441ab80671ed Mon Sep 17 00:00:00 2001 From: Sheehan Date: Sun, 4 Oct 2026 22:37:27 -0400 Subject: [PATCH 3/5] fix(ios): list deviceLost in supportedEvents 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) --- ios/MunimBluetoothEventEmitter.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/MunimBluetoothEventEmitter.swift b/ios/MunimBluetoothEventEmitter.swift index 5c605c5..3603501 100644 --- a/ios/MunimBluetoothEventEmitter.swift +++ b/ios/MunimBluetoothEventEmitter.swift @@ -37,6 +37,8 @@ class MunimBluetoothEventEmitter: RCTEventEmitter { return [ "deviceFound", "onDeviceFound", + // Android only; listed so subscribing on iOS is not an RCTLogError. + "deviceLost", "scanResult", "scanFailed", "advertisingStartFailed", From f66132c0459d2c9295a5be56a0f38b971f17d3a5 Mon Sep 17 00:00:00 2001 From: Sheehan Date: Sun, 4 Oct 2026 22:37:40 -0400 Subject: [PATCH 4/5] fix(android): accept 16/32-bit UUIDs and stop advertising from crashing 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) --- .../main/java/com/munimbluetooth/BleUuid.kt | 34 ++++++++++++ .../munimbluetooth/HybridMunimBluetooth.kt | 55 +++++++++++-------- .../MunimBluetoothBackgroundService.kt | 10 ++-- 3 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 android/src/main/java/com/munimbluetooth/BleUuid.kt diff --git a/android/src/main/java/com/munimbluetooth/BleUuid.kt b/android/src/main/java/com/munimbluetooth/BleUuid.kt new file mode 100644 index 0000000..f31f630 --- /dev/null +++ b/android/src/main/java/com/munimbluetooth/BleUuid.kt @@ -0,0 +1,34 @@ +package com.munimbluetooth + +import android.os.ParcelUuid +import java.util.UUID + +private const val BLUETOOTH_BASE_UUID_SUFFIX = "-0000-1000-8000-00805f9b34fb" + +/** + * Parses a Bluetooth UUID passed from JS. Besides full 128-bit UUIDs this + * accepts the 16- and 32-bit short forms ("180D", "0x180D", "0000180D") that + * iOS accepts and the README uses, expanding them onto the Bluetooth base UUID. + * `UUID.fromString` alone rejects short forms. + * + * @throws IllegalArgumentException when [value] is not a Bluetooth UUID. + */ +internal fun parseBleUuid(value: String): UUID { + val hex = if (value.startsWith("0x", ignoreCase = true)) value.substring(2) else value + val isShortForm = (hex.length == 4 || hex.length == 8) && + hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + if (isShortForm) { + return UUID.fromString(hex.padStart(8, '0') + BLUETOOTH_BASE_UUID_SUFFIX) + } + return UUID.fromString(value) +} + +internal fun parseBleParcelUuid(value: String): ParcelUuid = ParcelUuid(parseBleUuid(value)) + +/** True when [uuid] is the Bluetooth UUID that [value] names, in any accepted form. */ +internal fun isSameBleUuid(uuid: UUID, value: String): Boolean = + try { + uuid == parseBleUuid(value) + } catch (_: IllegalArgumentException) { + false + } diff --git a/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt b/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt index f5104f3..88bda49 100755 --- a/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt +++ b/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt @@ -474,13 +474,13 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { for (serviceData in services) { val service = BluetoothGattService( - UUID.fromString(serviceData.uuid), + parseBleUuid(serviceData.uuid), BluetoothGattService.SERVICE_TYPE_PRIMARY ) for (characteristicData in serviceData.characteristics) { val characteristic = BluetoothGattCharacteristic( - UUID.fromString(characteristicData.uuid), + parseBleUuid(characteristicData.uuid), propertiesFromArray(characteristicData.properties), characteristicPermissionsFromArray( characteristicData.permissions, @@ -493,7 +493,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { } characteristicData.descriptors?.forEach { descriptorData -> val descriptor = BluetoothGattDescriptor( - UUID.fromString(descriptorData.uuid), + parseBleUuid(descriptorData.uuid), descriptorPermissionsFromArray(descriptorData.permissions) ) descriptorData.value?.let { value -> @@ -973,7 +973,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { fun filter(serviceUUID: String?): ScanFilter { val builder = ScanFilter.Builder() - serviceUUID?.let { builder.setServiceUuid(ParcelUuid.fromString(it)) } + serviceUUID?.let { builder.setServiceUuid(parseBleParcelUuid(it)) } deviceName?.let { builder.setDeviceName(it) } deviceAddress?.let { builder.setDeviceAddress(it) } if (manufacturerId != null) { @@ -1929,7 +1929,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { val promise = Promise() val dataBuilder = AdvertiseData.Builder() options.serviceUUIDs?.forEach { uuid -> - dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid)) + dataBuilder.addServiceUuid(parseBleParcelUuid(uuid)) } normalizeAdvertisingData( options.advertisingData, @@ -2501,17 +2501,28 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { } val dataBuilder = AdvertiseData.Builder() - currentServiceUUIDs.forEach { uuid -> - dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid)) - } - val scanResponseBuilder = AdvertiseData.Builder() - currentAdvertisingData?.let { - processAdvertisingData( - data = it, - dataBuilder = scanResponseBuilder, - includeServiceUuids = false + try { + currentServiceUUIDs.forEach { uuid -> + dataBuilder.addServiceUuid(parseBleParcelUuid(uuid)) + } + currentAdvertisingData?.let { + processAdvertisingData( + data = it, + dataBuilder = scanResponseBuilder, + includeServiceUuids = false + ) + } + } catch (error: IllegalArgumentException) { + // This coroutine has no exception handler: an invalid UUID from + // JS would otherwise crash the app instead of failing the start. + val message = error.message ?: "Invalid advertising data" + Log.e(TAG, "Advertising failed: $message", error) + eventEmitter.emit( + "advertisingStartFailed", + mapOf("error" to message, "message" to message) ) + return@launch } val settings = AdvertiseSettings.Builder() @@ -3918,10 +3929,10 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { serviceUUID: String, characteristicUUID: String ): BluetoothGattCharacteristic? { - val service = gatt.services.firstOrNull { it.uuid.toString().equals(serviceUUID, ignoreCase = true) } + val service = gatt.services.firstOrNull { isSameBleUuid(it.uuid, serviceUUID) } ?: return null return service.characteristics.firstOrNull { - it.uuid.toString().equals(characteristicUUID, ignoreCase = true) + isSameBleUuid(it.uuid, characteristicUUID) } } @@ -3933,7 +3944,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { ): BluetoothGattDescriptor? { val characteristic = findCharacteristic(gatt, serviceUUID, characteristicUUID) ?: return null return characteristic.descriptors.firstOrNull { - it.uuid.toString().equals(descriptorUUID, ignoreCase = true) + isSameBleUuid(it.uuid, descriptorUUID) } } @@ -4026,8 +4037,8 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { characteristicUUID: String ): BluetoothGattCharacteristic? { return try { - val service = gattServer?.getService(UUID.fromString(serviceUUID)) - service?.getCharacteristic(UUID.fromString(characteristicUUID)) + val service = gattServer?.getService(parseBleUuid(serviceUUID)) + service?.getCharacteristic(parseBleUuid(characteristicUUID)) } catch (_: IllegalArgumentException) { null } @@ -4727,7 +4738,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { private fun addServiceUUIDs(uuids: Array?, dataBuilder: AdvertiseData.Builder) { uuids?.forEach { uuid -> - dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid)) + dataBuilder.addServiceUuid(parseBleParcelUuid(uuid)) } } @@ -4737,7 +4748,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { ) { serviceDataEntries?.forEach { entry -> hexStringToByteArray(entry.data)?.let { dataBytes -> - dataBuilder.addServiceData(ParcelUuid.fromString(entry.uuid), dataBytes) + dataBuilder.addServiceData(parseBleParcelUuid(entry.uuid), dataBytes) } } } @@ -4800,7 +4811,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() { serviceUUIDs.forEach { uuid -> val service = BluetoothGattService( - UUID.fromString(uuid), + parseBleUuid(uuid), BluetoothGattService.SERVICE_TYPE_PRIMARY ) gattServer?.addService(service) diff --git a/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt b/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt index b332e79..18b8625 100644 --- a/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt +++ b/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt @@ -257,7 +257,7 @@ class MunimBluetoothBackgroundService : Service() { val data = AdvertiseData.Builder() serviceUUIDs.forEach { uuid -> - runCatching { ParcelUuid.fromString(uuid) }.getOrNull()?.let(data::addServiceUuid) + runCatching { parseBleParcelUuid(uuid) }.getOrNull()?.let(data::addServiceUuid) } val scanResponse = AdvertiseData.Builder() @@ -312,14 +312,14 @@ class MunimBluetoothBackgroundService : Service() { for (index in 0 until services.length()) { val serviceJson = services.getJSONObject(index) val service = BluetoothGattService( - UUID.fromString(serviceJson.getString("uuid")), + parseBleUuid(serviceJson.getString("uuid")), BluetoothGattService.SERVICE_TYPE_PRIMARY ) val characteristics = serviceJson.optJSONArray("characteristics") ?: JSONArray() for (characteristicIndex in 0 until characteristics.length()) { val characteristicJson = characteristics.getJSONObject(characteristicIndex) val characteristic = BluetoothGattCharacteristic( - UUID.fromString(characteristicJson.getString("uuid")), + parseBleUuid(characteristicJson.getString("uuid")), propertiesFromJson(characteristicJson.optJSONArray("properties")), characteristicPermissionsFromJson( characteristicJson.optJSONArray("permissions"), @@ -342,7 +342,7 @@ class MunimBluetoothBackgroundService : Service() { for (descriptorIndex in 0 until descriptors.length()) { val descriptorJson = descriptors.getJSONObject(descriptorIndex) val descriptor = BluetoothGattDescriptor( - UUID.fromString(descriptorJson.getString("uuid")), + parseBleUuid(descriptorJson.getString("uuid")), descriptorPermissionsFromJson(descriptorJson.optJSONArray("permissions")) ) optionalString(descriptorJson, "value")?.let { value -> @@ -581,7 +581,7 @@ class MunimBluetoothBackgroundService : Service() { val filters = serviceUUIDs.mapNotNull { uuid -> runCatching { ScanFilter.Builder() - .setServiceUuid(ParcelUuid.fromString(uuid)) + .setServiceUuid(parseBleParcelUuid(uuid)) .build() }.getOrNull() } From 75fcd102458d2b0e0d6e337136f239bf5c793b23 Mon Sep 17 00:00:00 2001 From: Sheehan Date: Sun, 4 Oct 2026 22:37:43 -0400 Subject: [PATCH 5/5] fix(plugin): declare BLUETOOTH_SCAN with neverForLocation 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) --- app.plugin.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app.plugin.js b/app.plugin.js index 8de2b0c..527eac2 100644 --- a/app.plugin.js +++ b/app.plugin.js @@ -6,7 +6,12 @@ const ANDROID_BLUETOOTH_PERMISSIONS = { { name: 'android.permission.BLUETOOTH', maxSdkVersion: '30' }, { name: 'android.permission.BLUETOOTH_ADMIN', maxSdkVersion: '30' }, { name: 'android.permission.ACCESS_FINE_LOCATION', maxSdkVersion: '30' }, - { name: 'android.permission.BLUETOOTH_SCAN' }, + // Location is capped at API 30, so on Android 12+ scan results are only + // delivered when the scan permission disavows location (see README). + { + name: 'android.permission.BLUETOOTH_SCAN', + usesPermissionFlags: 'neverForLocation', + }, { name: 'android.permission.BLUETOOTH_CONNECT' }, ], connect: [ @@ -48,6 +53,9 @@ function ensureAndroidPermission(manifest, permission) { if (permission.maxSdkVersion) { attributes['android:maxSdkVersion'] = permission.maxSdkVersion; } + if (permission.usesPermissionFlags) { + attributes['android:usesPermissionFlags'] = permission.usesPermissionFlags; + } permissions.push({ $: attributes }); } }