diff --git a/android/src/main/java/it/innove/BleManager.java b/android/src/main/java/it/innove/BleManager.java index d5fd4c25..23059571 100644 --- a/android/src/main/java/it/innove/BleManager.java +++ b/android/src/main/java/it/innove/BleManager.java @@ -344,7 +344,7 @@ public void scan(ReadableMap scanningOptions, @SuppressLint("NewApi") // NOTE: constructor checks the API version. @ReactMethod - public void companionScan(ReadableArray serviceUUIDs, ReadableMap options, Callback callback) { + public void deviceSetupScan(ReadableArray serviceUUIDs, ReadableMap options, Callback callback) { if (this.companionScanner == null) { callback.invoke("not supported"); } else { @@ -353,7 +353,7 @@ public void companionScan(ReadableArray serviceUUIDs, ReadableMap options, Callb } @ReactMethod - public void supportsCompanion(Callback callback) { + public void supportsDeviceSetup(Callback callback) { callback.invoke(companionScanner != null); } @@ -885,7 +885,7 @@ public void requestMTU(String deviceUUID, double mtu, Callback callback) { } @ReactMethod - public void getAssociatedPeripherals(Callback callback) { + public void getAssociatedDevices(Callback callback) { Log.d(LOG_TAG, "Get associated peripherals"); if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { callback.invoke("Not supported"); @@ -901,7 +901,7 @@ public void getAssociatedPeripherals(Callback callback) { } @ReactMethod - public void removeAssociatedPeripheral(String address, Callback callback) { + public void removeAssociatedDevice(String address, Callback callback) { Log.d(LOG_TAG, "Remove associated peripheral: " + address); if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { callback.invoke("Not supported"); diff --git a/android/src/main/java/it/innove/CompanionScanner.java b/android/src/main/java/it/innove/CompanionScanner.java index 747d64e3..c96726a0 100644 --- a/android/src/main/java/it/innove/CompanionScanner.java +++ b/android/src/main/java/it/innove/CompanionScanner.java @@ -31,6 +31,8 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; +import java.util.Objects; + @RequiresApi(api = Build.VERSION_CODES.O) public class CompanionScanner { @@ -71,12 +73,12 @@ public void onActivityResult(Activity activity, int requestCode, int resultCode, if (peripheral != null && scanCallback != null) { scanCallback.invoke(null, peripheral.asWritableMap()); scanCallback = null; - bleManager.emitOnCompanionPeripheral(peripheral.asWritableMap()); + bleManager.emitOnDeviceSetupSelected(peripheral.asWritableMap()); } } else { scanCallback.invoke(null, null); scanCallback = null; - bleManager.emitOnCompanionPeripheral(null); + bleManager.emitOnDeviceSetupSelected(null); } } else { // No device, user cancelled? @@ -88,7 +90,7 @@ public void onActivityResult(Activity activity, int requestCode, int resultCode, scanCallback.invoke(null, peripheral != null ? peripheral.asWritableMap() : null); scanCallback = null; } - bleManager.emitOnCompanionPeripheral(peripheral != null ? peripheral.asWritableMap() : null); + bleManager.emitOnDeviceSetupSelected(peripheral != null ? peripheral.asWritableMap() : null); } }; @@ -110,7 +112,7 @@ public void scan(ReadableArray serviceUUIDs, ReadableMap options, Callback callb .setSingleDevice(options.hasKey("single") && options.getBoolean("single")) ; for (int i = 0; i < serviceUUIDs.size(); i++) { - final ParcelUuid uuid = new ParcelUuid(UUIDHelper.uuidFromString(serviceUUIDs.getString(i))); + final ParcelUuid uuid = new ParcelUuid(UUIDHelper.uuidFromString(Objects.requireNonNull(serviceUUIDs.getString(i)))); Log.d(LOG_TAG, "Filter service: " + uuid); builder = builder @@ -144,15 +146,15 @@ public void onFailure(@Nullable CharSequence charSequence) { } WritableMap map = Arguments.createMap(); - map.putString("error", charSequence.toString()); - bleManager.emitOnCompanionFailure(map); + map.putString("error", charSequence != null ? charSequence.toString() : ""); + bleManager.emitOnDeviceSetupFailure(map); } @Override public void onDeviceFound(@NonNull IntentSender intentSender) { Log.d(LOG_TAG, "companion device found"); try { - reactContext.getCurrentActivity().startIntentSenderForResult( + Objects.requireNonNull(reactContext.getCurrentActivity()).startIntentSenderForResult( intentSender, SELECT_DEVICE_REQUEST_CODE, null, 0, 0, 0 ); } catch (IntentSender.SendIntentException e) { @@ -166,7 +168,7 @@ public void onDeviceFound(@NonNull IntentSender intentSender) { WritableMap map = Arguments.createMap(); map.putString("error", msg); - bleManager.emitOnCompanionFailure(map); + bleManager.emitOnDeviceSetupFailure(map); } } }, null); diff --git a/example/app.config.js b/example/app.config.js index a1635361..6a3f2f33 100644 --- a/example/app.config.js +++ b/example/app.config.js @@ -13,6 +13,12 @@ export default ({ config }) => { }, ios: { bundleIdentifier: 'it.innove.example.ble', + infoPlist: { + NSAccessorySetupKitSupports: ['Bluetooth'], + NSAccessorySetupBluetoothServices: [ + 'C219DA19-A018-405F-AF8E-BC98AD9FFAEC', + ], + }, }, plugins: [ [ diff --git a/example/components/ScanDevicesScreen.tsx b/example/components/ScanDevicesScreen.tsx index 93a1740f..798bc632 100644 --- a/example/components/ScanDevicesScreen.tsx +++ b/example/components/ScanDevicesScreen.tsx @@ -29,8 +29,8 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { RootStackParamList } from '../types/navigation'; -const SECONDS_TO_SCAN_FOR = 3; -const SERVICE_UUIDS: string[] = []; +const SECONDS_TO_SCAN_FOR = 5; +const SERVICE_UUIDS: string[] = ['C219DA19-A018-405F-AF8E-BC98AD9FFAEC']; const ALLOW_DUPLICATES = true; declare module 'react-native-ble-manager' { @@ -54,7 +54,20 @@ const ScanDevicesScreen = () => { //console.debug('peripherals map updated', [...peripherals.entries()]); - const startScan = () => { + const startScan = async () => { + if (!(await BleManager.isStarted())) { + try { + BleManager.start({ showAlert: false }) + .then(() => console.debug('BleManager started.')) + .catch((error: any) => + console.error('BeManager could not be started.', error) + ); + } catch (error) { + console.error('unexpected error starting BleManager.', error); + return; + } + } + if (!isScanning) { // reset found peripherals before scan setPeripherals(new Map()); @@ -82,14 +95,14 @@ const ScanDevicesScreen = () => { } }; - const startCompanionScan = () => { + const deviceSetupScan = () => { setPeripherals(new Map()); try { - console.debug('[startCompanionScan] starting companion scan...'); - BleManager.companionScan(SERVICE_UUIDS, { single: false }) + console.debug('[deviceSetupScan] starting device setup scan...'); + BleManager.deviceSetupScan(SERVICE_UUIDS, { single: false, verboseLogging: true }) .then((peripheral: Peripheral | null) => { console.debug( - '[startCompanionScan] scan promise returned successfully.', + '[deviceSetupScan] scan promise returned successfully.', peripheral ); if (peripheral != null) { @@ -99,10 +112,10 @@ const ScanDevicesScreen = () => { } }) .catch((err: any) => { - console.debug('[startCompanionScan] ble scan cancel', err); + console.debug('[deviceSetupScan] ble scan cancel', err); }); } catch (error) { - console.error('[startCompanionScan] ble scan error thrown', error); + console.error('[deviceSetupScan] ble scan error thrown', error); } }; @@ -254,7 +267,7 @@ const ScanDevicesScreen = () => { const getAssociatedPeripherals = async () => { try { - const associatedPeripherals = await BleManager.getAssociatedPeripherals(); + const associatedPeripherals = await BleManager.getAssociatedDevices(); console.debug( '[getAssociatedPeripherals] associatedPeripherals', associatedPeripherals @@ -399,17 +412,6 @@ const ScanDevicesScreen = () => { } useEffect(() => { - try { - BleManager.start({ showAlert: false }) - .then(() => console.debug('BleManager started.')) - .catch((error: any) => - console.error('BeManager could not be started.', error) - ); - } catch (error) { - console.error('unexpected error starting BleManager.', error); - return; - } - const listeners: any[] = [ BleManager.onDiscoverPeripheral(handleDiscoverPeripheral), BleManager.onStopScan(handleStopScan), @@ -494,6 +496,8 @@ const ScanDevicesScreen = () => { ); }; + const supportEnableBluetooth = Platform.OS === 'android'; + return ( <> @@ -521,37 +525,27 @@ const ScanDevicesScreen = () => { - {Platform.OS === 'android' && ( - <> - - - Scan Companion - - - - - Get Associated Peripherals - - - - - - - - Remove Associated Peripherals - - - - Enable Bluetooth - - - + + + Device Setup Scan + + + + + Get Associated Peripherals + + + + + {supportEnableBluetooth && ( + + + Enable Bluetooth + + )} {Array.from(peripherals.values()).length === 0 && ( diff --git a/ios/BleManager.h b/ios/BleManager.h index 0df90eb1..8f7b7f9a 100644 --- a/ios/BleManager.h +++ b/ios/BleManager.h @@ -16,8 +16,8 @@ - (void)emitOnPeripheralDidBond:(NSDictionary *)value; - (void)emitOnCentralManagerWillRestoreState:(NSDictionary *)value; - (void)emitOnDidUpdateNotificationStateFor:(NSDictionary *)value; -- (void)emitOnCompanionPeripheral:(NSDictionary *)value; -- (void)emitOnCompanionFailure:(NSDictionary *)value; +- (void)emitOnDeviceSetupSelected:(NSDictionary *)value; +- (void)emitOnDeviceSetupFailure:(NSDictionary *)value; + (nullable CBCentralManager *)getCentralManager; + (nullable SwiftBleManager *)getInstance; @end @@ -34,8 +34,8 @@ - (void)emitOnPeripheralDidBond:(NSDictionary *)value; - (void)emitOnCentralManagerWillRestoreState:(NSDictionary *)value; - (void)emitOnDidUpdateNotificationStateFor:(NSDictionary *)value; -- (void)emitOnCompanionPeripheral:(NSDictionary *)value; -- (void)emitOnCompanionFailure:(NSDictionary *)value; +- (void)emitOnDeviceSetupSelected:(NSDictionary *)value; +- (void)emitOnDeviceSetupFailure:(NSDictionary *)value; @end #endif diff --git a/ios/BleManager.mm b/ios/BleManager.mm index 474f2757..8f7c7b54 100644 --- a/ios/BleManager.mm +++ b/ios/BleManager.mm @@ -44,10 +44,10 @@ - (void)checkState:(RCTResponseSenderBlock)callback { [_swBleManager checkState:callback]; } -- (void)companionScan:(NSArray *)serviceUUIDs +- (void)deviceSetupScan:(NSArray *)serviceUUIDs option:(NSDictionary *)option callback:(RCTResponseSenderBlock)callback { - [_swBleManager companionScan:serviceUUIDs option:option callback:callback]; + [_swBleManager deviceSetupScan:serviceUUIDs option:option callback:callback]; } - (void)connect:(NSString *)peripheralUUID @@ -74,8 +74,8 @@ - (void)enableBluetooth:(RCTResponseSenderBlock)callback { [_swBleManager enableBluetooth:callback]; } -- (void)getAssociatedPeripherals:(RCTResponseSenderBlock)callback { - [_swBleManager getAssociatedPeripherals:callback]; +- (void)getAssociatedDevices:(RCTResponseSenderBlock)callback { + [_swBleManager getAssociatedDevices:callback]; } - (void)getBondedPeripherals:(RCTResponseSenderBlock)callback { @@ -147,9 +147,9 @@ - (void)refreshCache:(NSString *)peripheralUUID [_swBleManager refreshCache:peripheralUUID callback:callback]; } -- (void)removeAssociatedPeripheral:(NSString *)peripheralUUID +- (void)removeAssociatedDevice:(NSString *)peripheralUUID callback:(RCTResponseSenderBlock)callback { - [_swBleManager removeAssociatedPeripheral:peripheralUUID callback:callback]; + [_swBleManager removeAssociatedDevice:peripheralUUID callback:callback]; } - (void)removeBond:(NSString *)peripheralUUID @@ -238,8 +238,8 @@ - (void)stopScan:(RCTResponseSenderBlock)callback { [_swBleManager stopScan:callback]; } -- (void)supportsCompanion:(RCTResponseSenderBlock)callback { - [_swBleManager supportsCompanion:callback]; +- (void)supportsDeviceSetup:(RCTResponseSenderBlock)callback { + [_swBleManager supportsDeviceSetup:callback]; } - (void)write:(NSString *)peripheralUUID diff --git a/ios/SwiftBleManager.swift b/ios/SwiftBleManager.swift index ed4a2e40..5339c1c0 100644 --- a/ios/SwiftBleManager.swift +++ b/ios/SwiftBleManager.swift @@ -1,5 +1,7 @@ +import AccessorySetupKit import CoreBluetooth import Foundation +import UIKit @objc public class SwiftBleManager: NSObject, CBCentralManagerDelegate, @@ -36,6 +38,66 @@ public class SwiftBleManager: NSObject, CBCentralManagerDelegate, static var verboseLogging = false + private var accessorySession: AnyObject? + + private func invalidateAccessorySessionIfNeeded() { + if #available(iOS 18.0, *), + let session = accessorySession as? ASAccessorySession + { + session.invalidate() + } + accessorySession = nil + } + + private func teardownCentralManager() { + if let scanTimer = scanTimer { + scanTimer.invalidate() + self.scanTimer = nil + } + + manager?.stopScan() + manager?.delegate = nil + + serialQueue.sync { + for peripheral in peripherals.values { + peripheral.instance.delegate = nil + } + peripherals.removeAll() + } + + manager = nil + SwiftBleManager.sharedManager = nil + } + + @available(iOS 18.0, *) + private func deviceSetupPayload( + from accessory: ASAccessory, + serviceUUIDs: [String] + ) -> [String: Any] { + let identifier = + accessory.bluetoothIdentifier?.uuidString.lowercased() + ?? accessory.ssid + ?? accessory.displayName + + var payload: [String: Any] = [ + "id": identifier, + "name": accessory.displayName, + "rssi": 0, + ] + + var advertising: [String: Any] = [:] + advertising["isConnectable"] = true + advertising["serviceUUIDs"] = serviceUUIDs.map { $0.lowercased() } + advertising["manufacturerData"] = [NSNumber]() + advertising["serviceData"] = [NSNumber]() + advertising["txPowerLevel"] = 0 + advertising["rawData"] = NSNull() + + payload["advertising"] = advertising + + return payload + } + @objc public init(bleManager: BleManager) { peripherals = [:] connectCallbacks = [:] @@ -53,6 +115,7 @@ public class SwiftBleManager: NSObject, CBCentralManagerDelegate, characteristicsLatches = [:] exactAdvertisingName = [] connectedPeripherals = [] + accessorySession = nil self.bleManager = bleManager super.init() @@ -1917,30 +1980,172 @@ public class SwiftBleManager: NSObject, CBCentralManagerDelegate, // Not supported } - @objc public func getAssociatedPeripherals( + @objc public func getAssociatedDevices( _ callback: @escaping RCTResponseSenderBlock ) { callback(["Not supported"]) } - @objc public func removeAssociatedPeripheral( + @objc public func removeAssociatedDevice( _ peripheralUUID: String, callback: @escaping RCTResponseSenderBlock ) { callback(["Not supported"]) } - @objc public func supportsCompanion( + @objc public func supportsDeviceSetup( _ callback: @escaping RCTResponseSenderBlock ) { - callback([NSNull(), false]) + if #available(iOS 18.0, *) { + callback([NSNull(), true]) + } else { + callback([NSNull(), false]) + } + } + + @objc public func stop(_ callback: RCTResponseSenderBlock) { + if let scanTimer = scanTimer { + scanTimer.invalidate() + self.scanTimer = nil + } + manager?.stopScan() + manager?.delegate = nil + + serialQueue.sync { + for p in peripherals.values { p.instance.delegate = nil } + peripherals.removeAll() + } + + manager = nil + SwiftBleManager.sharedManager = nil + invalidateAccessorySessionIfNeeded() + callback([]) } - @objc public func companionScan( + @objc public func deviceSetupScan( _ serviceUUIDs: [Any], option: NSDictionary, - callback: RCTResponseSenderBlock + callback: @escaping RCTResponseSenderBlock ) { - callback(["Not supported"]) + guard #available(iOS 18.0, *) else { + callback(["Device setup requires iOS 18"]) + return + } + + // AccessorySetupKit conflicts with an active CBCentralManager, so + // tear down any existing central before starting the accessory session. + teardownCentralManager() + + let session = ASAccessorySession() + accessorySession = session + + if let verboseLogging = option["verboseLogging"] as? Bool { + SwiftBleManager.verboseLogging = verboseLogging + } + + var normalizedServiceUUIDs: [String] = [] + var items: [ASPickerDisplayItem] = [] + let fallbackImage = + UIImage( + systemName: "antenna.radiowaves.left.and.right" + ) ?? UIImage() + + for case let uuidString as String in serviceUUIDs { + let normalizedUUID = uuidString.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !normalizedUUID.isEmpty else { continue } + + normalizedServiceUUIDs.append(normalizedUUID) + + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = + CBUUID(string: normalizedUUID) + let displayName = + option["pickerTitle"] as? String ?? normalizedUUID + let item = ASPickerDisplayItem( + name: displayName, + productImage: fallbackImage, + descriptor: descriptor + ) + items.append(item) + } + + var didResolveResult = false + var pendingSelection: [String: Any]? + let deliverSelection: ([String: Any]?) -> Void = { [weak self] value in + guard let self = self else { return } + if didResolveResult { + return + } + + didResolveResult = true + if let peripheral = value { + callback([NSNull(), peripheral]) + self.bleManager?.emit(onDeviceSetupSelected: peripheral) + } else { + callback([NSNull(), NSNull()]) + self.bleManager?.emit(onDeviceSetupSelected: nil) + } + self.invalidateAccessorySessionIfNeeded() + } + + let deliverError: (String) -> Void = { [weak self] message in + guard let self = self else { return } + if didResolveResult { + return + } + + didResolveResult = true + callback([message]) + self.bleManager?.emit(onDeviceSetupFailure: ["error": message]) + self.invalidateAccessorySessionIfNeeded() + } + + guard !items.isEmpty else { + deliverError("Error: invalid service UUIDs") + return + } + + session.activate(on: DispatchQueue.main) { [weak self] event in + guard let self = self else { return } + + if SwiftBleManager.verboseLogging { + NSLog("ASAccessorySession event: \(event.eventType)") + } + + switch event.eventType { + case .accessoryAdded, .accessoryChanged: + guard let accessory = event.accessory else { return } + pendingSelection = self.deviceSetupPayload( + from: accessory, + serviceUUIDs: normalizedServiceUUIDs + ) + case .pickerDidDismiss: + deliverSelection(pendingSelection) + case .pickerSetupFailed: + let message = + event.error?.localizedDescription + ?? "Accessory setup failed" + deliverError(message) + default: + break + } + } + + session.showPicker(for: items) { error in + if let error { + if SwiftBleManager.verboseLogging { + NSLog( + "ASPicker showPicker error: \(error.localizedDescription)" + ) + } + deliverError( + "Device setup error: \(error.localizedDescription)" + ) + } else { + // Picker completed without explicit error; do nothing here. + } + } } } diff --git a/src/NativeBleManager.ts b/src/NativeBleManager.ts index 282a9760..012a68b6 100644 --- a/src/NativeBleManager.ts +++ b/src/NativeBleManager.ts @@ -176,18 +176,18 @@ export interface Spec extends TurboModule { setName(name: string): void; - getAssociatedPeripherals( + getAssociatedDevices( callback: (error: CallbackError, peripherals: Peripheral[] | null) => void ): void; - removeAssociatedPeripheral( + removeAssociatedDevice( peripheralUUID: string, callback: (error: CallbackError) => void ): void; - supportsCompanion(callback: (supports: boolean) => void): void; + supportsDeviceSetup(callback: (supports: boolean) => void): void; - companionScan( + deviceSetupScan( serviceUUIDs: string[], option: Object, callback: (error: CallbackError, peripheral: Peripheral | null) => void @@ -205,8 +205,8 @@ export interface Spec extends TurboModule { readonly onPeripheralDidBond: EventEmitter; readonly onCentralManagerWillRestoreState: EventEmitter; readonly onDidUpdateNotificationStateFor: EventEmitter; - readonly onCompanionPeripheral: EventEmitter; - readonly onCompanionFailure: EventEmitter; + readonly onDeviceSetupSelected: EventEmitter; + readonly onDeviceSetupFailure: EventEmitter; } export default TurboModuleRegistry.get('BleManager') as Spec; @@ -360,7 +360,7 @@ export type EventDidUpdateNotificationStateFor = { code?: number | null; }; -export type EventCompanionPeripheral = { +export type EventDeviceSetupSelected = { id: string; name: string; rssi: number; @@ -374,4 +374,4 @@ export type EventCompanionPeripheral = { }; }; -export type EventCompanionFailure = { error: string }; +export type EventDeviceSetupFailure = { error: string }; diff --git a/src/index.ts b/src/index.ts index c75ba7f4..9b3fd94c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,11 @@ import { BleState, ConnectOptions, ConnectionPriority, - CompanionScanOptions, Peripheral, PeripheralInfo, ScanOptions, StartOptions, + DeviceSetupScanOptions, } from './types'; import { CallbackError } from './NativeBleManager'; export * from './types'; @@ -712,9 +712,9 @@ class BleManager { * * @returns */ - getAssociatedPeripherals() { + getAssociatedDevices() { return new Promise((fulfill, reject) => { - BleManagerModule.getAssociatedPeripherals( + BleManagerModule.getAssociatedDevices( (error: string | null, peripherals: Peripheral[] | null) => { if (error) { reject(error); @@ -732,9 +732,9 @@ class BleManager { * @returns Promise that resolves once the peripheral has been removed. Rejects * if no association is found. */ - removeAssociatedPeripheral(peripheralId: string) { + removeAssociatedDevice(peripheralId: string) { return new Promise((fulfill, reject) => { - BleManagerModule.removeAssociatedPeripheral( + BleManagerModule.removeAssociatedDevice( peripheralId, (error: string | null) => { if (error) { @@ -748,28 +748,26 @@ class BleManager { } /** - * [Android only] * - * Check if current device supports companion device manager. + * Check if current device supports device setup. * * @return Promise resolving to a boolean. */ - supportsCompanion() { + supportsDeviceSetup() { return new Promise((fulfill) => { - BleManagerModule.supportsCompanion((supports: boolean) => + BleManagerModule.supportsDeviceSetup((supports: boolean) => fulfill(supports) ); }); } /** - * [Android only, API 26+] * - * Start companion scan. + * Start device setup scan. */ - companionScan(serviceUUIDs: string[], options: CompanionScanOptions = {}) { + deviceSetupScan(serviceUUIDs: string[], options: DeviceSetupScanOptions = {}) { return new Promise((fulfill, reject) => { - BleManagerModule.companionScan( + BleManagerModule.deviceSetupScan( serviceUUIDs, options, (error: string | null, peripheral: Peripheral | null) => { diff --git a/src/types.ts b/src/types.ts index f3db3b69..73a3dba7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,11 +170,15 @@ export interface ScanOptions { useScanIntent?: boolean; } -export interface CompanionScanOptions { +export interface DeviceSetupScanOptions { /** * Scan only for a single peripheral. */ single?: boolean; + /** + * Enable verbose native logging during device setup. + */ + verboseLogging?: boolean; } /**