@capacitor-community/bluetooth-le 7.2.0 → 7.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,12 @@
1
1
  import Foundation
2
2
  import CoreBluetooth
3
3
 
4
+ enum DeviceListMode {
5
+ case none
6
+ case alert
7
+ case list
8
+ }
9
+
4
10
  class DeviceManager: NSObject, CBCentralManagerDelegate {
5
11
  typealias Callback = (_ success: Bool, _ message: String) -> Void
6
12
  typealias StateReceiver = (_ enabled: Bool) -> Void
@@ -15,12 +21,15 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
15
21
  private var timeoutMap = [String: DispatchWorkItem]()
16
22
  private var stopScanWorkItem: DispatchWorkItem?
17
23
  private var alertController: UIAlertController?
24
+ private var deviceListView: DeviceListView?
25
+ private var popoverController: UIPopoverPresentationController?
18
26
  private var discoveredDevices = [String: Device]()
19
27
  private var deviceNameFilter: String?
20
28
  private var deviceNamePrefixFilter: String?
21
- private var shouldShowDeviceList = false
29
+ private var deviceListMode: DeviceListMode = .none
22
30
  private var allowDuplicates = false
23
31
  private var manufacturerDataFilters: [ManufacturerDataFilter]?
32
+ private var serviceDataFilters: [ServiceDataFilter]?
24
33
 
25
34
  init(_ viewController: UIViewController?, _ displayStrings: [String: String], _ callback: @escaping Callback) {
26
35
  super.init()
@@ -81,8 +90,9 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
81
90
  _ name: String?,
82
91
  _ namePrefix: String?,
83
92
  _ manufacturerDataFilters: [ManufacturerDataFilter]?,
93
+ _ serviceDataFilters: [ServiceDataFilter]?,
84
94
  _ allowDuplicates: Bool,
85
- _ shouldShowDeviceList: Bool,
95
+ _ deviceListMode: DeviceListMode,
86
96
  _ scanDuration: Double?,
87
97
  _ callback: @escaping Callback,
88
98
  _ scanResultCallback: @escaping ScanResultCallback
@@ -92,13 +102,14 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
92
102
 
93
103
  if self.centralManager.isScanning == false {
94
104
  self.discoveredDevices = [String: Device]()
95
- self.shouldShowDeviceList = shouldShowDeviceList
105
+ self.deviceListMode = deviceListMode
96
106
  self.allowDuplicates = allowDuplicates
97
107
  self.deviceNameFilter = name
98
108
  self.deviceNamePrefixFilter = namePrefix
99
109
  self.manufacturerDataFilters = manufacturerDataFilters
110
+ self.serviceDataFilters = serviceDataFilters
100
111
 
101
- if shouldShowDeviceList {
112
+ if deviceListMode != .none {
102
113
  self.showDeviceList()
103
114
  }
104
115
 
@@ -113,7 +124,7 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
113
124
  options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
114
125
  )
115
126
 
116
- if shouldShowDeviceList == false {
127
+ if deviceListMode == .none {
117
128
  self.resolve("startScanning", "Scan started.")
118
129
  }
119
130
  } else {
@@ -128,10 +139,22 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
128
139
  self.stopScanWorkItem?.cancel()
129
140
  self.stopScanWorkItem = nil
130
141
  DispatchQueue.main.async { [weak self] in
131
- if self?.discoveredDevices.count == 0 {
132
- self?.alertController?.title = self?.displayStrings["noDeviceFound"]
133
- } else {
134
- self?.alertController?.title = self?.displayStrings["availableDevices"]
142
+ guard let self = self else { return }
143
+ switch self.deviceListMode {
144
+ case .alert:
145
+ if self.discoveredDevices.count == 0 {
146
+ self.alertController?.title = self.displayStrings["noDeviceFound"]
147
+ } else {
148
+ self.alertController?.title = self.displayStrings["availableDevices"]
149
+ }
150
+ case .list:
151
+ if self.discoveredDevices.count == 0 {
152
+ self.deviceListView?.setTitle(self.displayStrings["noDeviceFound"])
153
+ } else {
154
+ self.deviceListView?.setTitle(self.displayStrings["availableDevices"])
155
+ }
156
+ case .none:
157
+ break
135
158
  }
136
159
  }
137
160
  }
@@ -155,7 +178,8 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
155
178
 
156
179
  guard self.passesNameFilter(peripheralName: peripheral.name) else { return }
157
180
  guard self.passesNamePrefixFilter(peripheralName: peripheral.name) else { return }
158
- guard self.passesManufacturerDataFilter(advertisementData) else { return }
181
+ guard ScanFilterUtils.passesManufacturerDataFilter(advertisementData, filters: self.manufacturerDataFilters) else { return }
182
+ guard ScanFilterUtils.passesServiceDataFilter(advertisementData, filters: self.serviceDataFilters) else { return }
159
183
 
160
184
  let device: Device
161
185
  if self.allowDuplicates, let knownDevice = discoveredDevices.first(where: { $0.key == peripheral.identifier.uuidString })?.value {
@@ -166,7 +190,12 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
166
190
  }
167
191
  log("New device found: ", device.getName() ?? "Unknown")
168
192
 
169
- if shouldShowDeviceList {
193
+ switch deviceListMode {
194
+ case .none:
195
+ if self.scanResultCallback != nil {
196
+ self.scanResultCallback!(device, advertisementData, RSSI)
197
+ }
198
+ case .alert:
170
199
  DispatchQueue.main.async { [weak self] in
171
200
  self?.alertController?.addAction(UIAlertAction(title: device.getName() ?? "Unknown", style: UIAlertAction.Style.default, handler: { (_) in
172
201
  log("Selected device")
@@ -174,14 +203,29 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
174
203
  self?.resolve("startScanning", device.getId())
175
204
  }))
176
205
  }
177
- } else {
178
- if self.scanResultCallback != nil {
179
- self.scanResultCallback!(device, advertisementData, RSSI)
206
+ case .list:
207
+ DispatchQueue.main.async { [weak self] in
208
+ self?.deviceListView?.addItem(device.getName() ?? "Unknown", action: {
209
+ log("Selected device")
210
+ self?.stopScan()
211
+ self?.resolve("startScanning", device.getId())
212
+ })
180
213
  }
181
214
  }
182
215
  }
183
216
 
184
217
  func showDeviceList() {
218
+ switch deviceListMode {
219
+ case .none:
220
+ break
221
+ case .alert:
222
+ showDeviceListAlert()
223
+ case .list:
224
+ showDeviceListView()
225
+ }
226
+ }
227
+
228
+ func showDeviceListAlert() {
185
229
  DispatchQueue.main.async { [weak self] in
186
230
  self?.alertController = UIAlertController(title: self?.displayStrings["scanning"], message: nil, preferredStyle: UIAlertController.Style.alert)
187
231
  self?.alertController?.addAction(UIAlertAction(title: self?.displayStrings["cancel"], style: UIAlertAction.Style.cancel, handler: { (_) in
@@ -193,6 +237,22 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
193
237
  }
194
238
  }
195
239
 
240
+ func showDeviceListView() {
241
+ DispatchQueue.main.async { [weak self] in
242
+ self?.deviceListView = DeviceListView()
243
+ if #available(macCatalyst 15.0, iOS 15.0, *) {
244
+ self?.deviceListView?.sheetPresentationController?.detents = [.medium()]
245
+ }
246
+ self?.viewController?.present((self?.deviceListView)!, animated: true, completion: nil)
247
+ self?.deviceListView?.setTitle(self?.displayStrings["scanning"])
248
+ self?.deviceListView?.setCancelButton(self?.displayStrings["cancel"], action: {
249
+ log("Cancelled request device.")
250
+ self?.stopScan()
251
+ self?.reject("startScanning", "requestDevice cancelled.")
252
+ })
253
+ }
254
+ }
255
+
196
256
  func getDevices(
197
257
  _ deviceUUIDs: [UUID]
198
258
  ) -> [CBPeripheral] {
@@ -300,53 +360,6 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
300
360
  return name.hasPrefix(prefix)
301
361
  }
302
362
 
303
- private func passesManufacturerDataFilter(_ advertisementData: [String: Any]) -> Bool {
304
- guard let filters = self.manufacturerDataFilters, !filters.isEmpty else {
305
- return true // No filters means everything passes
306
- }
307
-
308
- guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
309
- manufacturerData.count >= 2 else {
310
- return false // If there's no valid manufacturer data, fail
311
- }
312
-
313
- let companyIdentifier = manufacturerData.prefix(2).withUnsafeBytes {
314
- $0.load(as: UInt16.self).littleEndian // Manufacturer ID is little-endian
315
- }
316
-
317
- let payload = manufacturerData.dropFirst(2)
318
-
319
- for filter in filters {
320
- if filter.companyIdentifier != companyIdentifier {
321
- continue // Skip if company ID does not match
322
- }
323
-
324
- if let dataPrefix = filter.dataPrefix {
325
- if payload.count < dataPrefix.count {
326
- continue // Payload too short, does not match
327
- }
328
-
329
- if let mask = filter.mask {
330
- var matches = true
331
- for i in 0..<dataPrefix.count {
332
- if (payload[i] & mask[i]) != (dataPrefix[i] & mask[i]) {
333
- matches = false
334
- break
335
- }
336
- }
337
- if matches {
338
- return true
339
- }
340
- } else if payload.starts(with: dataPrefix) {
341
- return true
342
- }
343
- } else {
344
- return true // Company ID matched, and no dataPrefix required
345
- }
346
- }
347
-
348
- return false // If none matched, return false
349
- }
350
363
 
351
364
  private func resolve(_ key: String, _ value: String) {
352
365
  let callback = self.callbackMap[key]
@@ -7,12 +7,6 @@ import CoreBluetooth
7
7
  let CONNECTION_TIMEOUT: Double = 10
8
8
  let DEFAULT_TIMEOUT: Double = 5
9
9
 
10
- struct ManufacturerDataFilter {
11
- let companyIdentifier: UInt16
12
- let dataPrefix: Data?
13
- let mask: Data?
14
- }
15
-
16
10
  @objc(BluetoothLe)
17
11
  public class BluetoothLe: CAPPlugin {
18
12
  typealias BleDevice = [String: Any]
@@ -117,14 +111,23 @@ public class BluetoothLe: CAPPlugin {
117
111
  let name = call.getString("name")
118
112
  let namePrefix = call.getString("namePrefix")
119
113
  let manufacturerDataFilters = self.getManufacturerDataFilters(call)
114
+ let serviceDataFilters = self.getServiceDataFilters(call)
115
+
116
+ let displayModeString = (call.getString("displayMode") ?? "alert").lowercased()
117
+ guard ["alert", "list"].contains(displayModeString) else {
118
+ call.reject("Invalid displayMode '\(call.getString("displayMode") ?? "")'. Use 'alert' or 'list'.")
119
+ return
120
+ }
121
+ let deviceListMode: DeviceListMode = displayModeString == "list" ? .list : .alert
120
122
 
121
123
  deviceManager.startScanning(
122
124
  serviceUUIDs,
123
125
  name,
124
126
  namePrefix,
125
127
  manufacturerDataFilters,
128
+ serviceDataFilters,
126
129
  false,
127
- true,
130
+ deviceListMode,
128
131
  30,
129
132
  {(success, message) in
130
133
  if success {
@@ -151,14 +154,16 @@ public class BluetoothLe: CAPPlugin {
151
154
  let namePrefix = call.getString("namePrefix")
152
155
  let allowDuplicates = call.getBool("allowDuplicates", false)
153
156
  let manufacturerDataFilters = self.getManufacturerDataFilters(call)
157
+ let serviceDataFilters = self.getServiceDataFilters(call)
154
158
 
155
159
  deviceManager.startScanning(
156
160
  serviceUUIDs,
157
161
  name,
158
162
  namePrefix,
159
163
  manufacturerDataFilters,
164
+ serviceDataFilters,
160
165
  allowDuplicates,
161
- false,
166
+ .none,
162
167
  nil,
163
168
  { (success, message) in
164
169
  if success {
@@ -546,13 +551,13 @@ public class BluetoothLe: CAPPlugin {
546
551
  }
547
552
 
548
553
  let dataPrefix: Data? = {
549
- guard let prefixArray = dataObject["dataPrefix"] as? [Int] else { return nil }
550
- return Data(prefixArray.map { UInt8($0 & 0xFF) })
554
+ guard let prefixString = dataObject["dataPrefix"] as? String else { return nil }
555
+ return stringToData(prefixString)
551
556
  }()
552
557
 
553
558
  let mask: Data? = {
554
- guard let maskArray = dataObject["mask"] as? [Int] else { return nil }
555
- return Data(maskArray.map { UInt8($0 & 0xFF) })
559
+ guard let maskString = dataObject["mask"] as? String else { return nil }
560
+ return stringToData(maskString)
556
561
  }()
557
562
 
558
563
  let manufacturerFilter = ManufacturerDataFilter(
@@ -567,6 +572,44 @@ public class BluetoothLe: CAPPlugin {
567
572
  return manufacturerDataFilters
568
573
  }
569
574
 
575
+ private func getServiceDataFilters(_ call: CAPPluginCall) -> [ServiceDataFilter]? {
576
+ guard let serviceDataArray = call.getArray("serviceData") else {
577
+ return nil
578
+ }
579
+
580
+ var serviceDataFilters: [ServiceDataFilter] = []
581
+
582
+ for index in 0..<serviceDataArray.count {
583
+ guard let dataObject = serviceDataArray[index] as? JSObject,
584
+ let serviceUuidString = dataObject["serviceUuid"] as? String else {
585
+ // Invalid or missing service UUID
586
+ return nil
587
+ }
588
+
589
+ let serviceUuid = CBUUID(string: serviceUuidString)
590
+
591
+ let dataPrefix: Data? = {
592
+ guard let prefixString = dataObject["dataPrefix"] as? String else { return nil }
593
+ return stringToData(prefixString)
594
+ }()
595
+
596
+ let mask: Data? = {
597
+ guard let maskString = dataObject["mask"] as? String else { return nil }
598
+ return stringToData(maskString)
599
+ }()
600
+
601
+ let serviceDataFilter = ServiceDataFilter(
602
+ serviceUuid: serviceUuid,
603
+ dataPrefix: dataPrefix,
604
+ mask: mask
605
+ )
606
+
607
+ serviceDataFilters.append(serviceDataFilter)
608
+ }
609
+
610
+ return serviceDataFilters
611
+ }
612
+
570
613
  private func getDevice(_ call: CAPPluginCall, checkConnection: Bool = true) -> Device? {
571
614
  guard let deviceId = call.getString("deviceId") else {
572
615
  call.reject("deviceId required.")
@@ -0,0 +1,114 @@
1
+ import Foundation
2
+ import CoreBluetooth
3
+
4
+ struct ManufacturerDataFilter {
5
+ let companyIdentifier: UInt16
6
+ let dataPrefix: Data?
7
+ let mask: Data?
8
+ }
9
+
10
+ struct ServiceDataFilter {
11
+ let serviceUuid: CBUUID
12
+ let dataPrefix: Data?
13
+ let mask: Data?
14
+ }
15
+
16
+ class ScanFilterUtils {
17
+
18
+ static func passesManufacturerDataFilter(_ advertisementData: [String: Any], filters: [ManufacturerDataFilter]?) -> Bool {
19
+ guard let filters = filters, !filters.isEmpty else {
20
+ return true // No filters means everything passes
21
+ }
22
+
23
+ guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
24
+ manufacturerData.count >= 2 else {
25
+ return false // If there's no valid manufacturer data, fail
26
+ }
27
+
28
+ let companyIdentifier = manufacturerData.prefix(2).withUnsafeBytes {
29
+ $0.load(as: UInt16.self).littleEndian // Manufacturer ID is little-endian
30
+ }
31
+
32
+ let payload = Data(manufacturerData.dropFirst(2))
33
+
34
+ for filter in filters {
35
+ if filter.companyIdentifier != companyIdentifier {
36
+ continue // Skip if company ID does not match
37
+ }
38
+
39
+ if let dataPrefix = filter.dataPrefix {
40
+ if payload.count < dataPrefix.count {
41
+ continue // Payload too short, does not match
42
+ }
43
+
44
+ if let mask = filter.mask {
45
+ // Validate that mask length matches dataPrefix length
46
+ if mask.count != dataPrefix.count {
47
+ continue // Skip this filter if mask length is invalid
48
+ }
49
+ var matches = true
50
+ for i in 0..<dataPrefix.count {
51
+ if (payload[i] & mask[i]) != (dataPrefix[i] & mask[i]) {
52
+ matches = false
53
+ break
54
+ }
55
+ }
56
+ if matches {
57
+ return true
58
+ }
59
+ } else if payload.starts(with: dataPrefix) {
60
+ return true
61
+ }
62
+ } else {
63
+ return true // Company ID matched, and no dataPrefix required
64
+ }
65
+ }
66
+
67
+ return false // If none matched, return false
68
+ }
69
+
70
+ static func passesServiceDataFilter(_ advertisementData: [String: Any], filters: [ServiceDataFilter]?) -> Bool {
71
+ guard let filters = filters, !filters.isEmpty else {
72
+ return true // No filters means everything passes
73
+ }
74
+
75
+ guard let serviceDataDict = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] else {
76
+ return false // If there's no service data, fail
77
+ }
78
+
79
+ for filter in filters {
80
+ guard let serviceData = serviceDataDict[filter.serviceUuid] else {
81
+ continue // Skip if service UUID does not match
82
+ }
83
+
84
+ if let dataPrefix = filter.dataPrefix {
85
+ if serviceData.count < dataPrefix.count {
86
+ continue // Service data too short, does not match
87
+ }
88
+
89
+ if let mask = filter.mask {
90
+ // Validate that mask length matches dataPrefix length
91
+ if mask.count != dataPrefix.count {
92
+ continue // Skip this filter if mask length is invalid
93
+ }
94
+ var matches = true
95
+ for i in 0..<dataPrefix.count {
96
+ if (serviceData[i] & mask[i]) != (dataPrefix[i] & mask[i]) {
97
+ matches = false
98
+ break
99
+ }
100
+ }
101
+ if matches {
102
+ return true
103
+ }
104
+ } else if serviceData.starts(with: dataPrefix) {
105
+ return true
106
+ }
107
+ } else {
108
+ return true // Service UUID matched, and no dataPrefix required
109
+ }
110
+ }
111
+
112
+ return false // If none matched, return false
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacitor-community/bluetooth-le",
3
- "version": "7.2.0",
3
+ "version": "7.3.1",
4
4
  "description": "Capacitor plugin for Bluetooth Low Energy ",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -19,7 +19,7 @@
19
19
  "prettier": "prettier \"**/*.{css,html,ts,js}\"",
20
20
  "swiftlint": "node-swiftlint",
21
21
  "docgen": "docgen --api BleClientInterface --output-readme README.md --output-json dist/docs.json",
22
- "postdocgen": "prettier README.md --write",
22
+ "postdocgen": "node scripts/fix-docgen-types.js && prettier README.md --write",
23
23
  "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
24
24
  "clean": "rimraf ./dist",
25
25
  "watch": "tsc --watch",
@@ -85,7 +85,17 @@
85
85
  "prettier": "@ionic/prettier-config",
86
86
  "swiftlint": "@ionic/swiftlint-config",
87
87
  "eslintConfig": {
88
- "extends": "@ionic/eslint-config/recommended"
88
+ "extends": "@ionic/eslint-config/recommended",
89
+ "rules": {
90
+ "@typescript-eslint/no-unused-vars": [
91
+ "error",
92
+ {
93
+ "argsIgnorePattern": "^_",
94
+ "varsIgnorePattern": "^_",
95
+ "caughtErrorsIgnorePattern": "^_"
96
+ }
97
+ ]
98
+ }
89
99
  },
90
100
  "repository": {
91
101
  "type": "git",