Skip to content

Commit ae9dc91

Browse files
committed
fix(ios): Use proper indices when comparing payload in manufacturer data (#797)
Elements in a slice returned by `.dropFirst(n)` are not 0-indexed. This needs to be wrapped in a Data to create a new copy that can be indexed via a 0-base. Fixes #797
1 parent 1691971 commit ae9dc91

4 files changed

Lines changed: 269 additions & 102 deletions

File tree

ios/Sources/BluetoothLe/DeviceManager.swift

Lines changed: 2 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
179179

180180
guard self.passesNameFilter(peripheralName: peripheral.name) else { return }
181181
guard self.passesNamePrefixFilter(peripheralName: peripheral.name) else { return }
182-
guard self.passesManufacturerDataFilter(advertisementData) else { return }
183-
guard self.passesServiceDataFilter(advertisementData) else { return }
182+
guard ScanFilterUtils.passesManufacturerDataFilter(advertisementData, filters: self.manufacturerDataFilters) else { return }
183+
guard ScanFilterUtils.passesServiceDataFilter(advertisementData, filters: self.serviceDataFilters) else { return }
184184

185185
let device: Device
186186
if self.allowDuplicates, let knownDevice = discoveredDevices.first(where: { $0.key == peripheral.identifier.uuidString })?.value {
@@ -361,94 +361,6 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
361361
return name.hasPrefix(prefix)
362362
}
363363

364-
private func passesManufacturerDataFilter(_ advertisementData: [String: Any]) -> Bool {
365-
guard let filters = self.manufacturerDataFilters, !filters.isEmpty else {
366-
return true // No filters means everything passes
367-
}
368-
369-
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
370-
manufacturerData.count >= 2 else {
371-
return false // If there's no valid manufacturer data, fail
372-
}
373-
374-
let companyIdentifier = manufacturerData.prefix(2).withUnsafeBytes {
375-
$0.load(as: UInt16.self).littleEndian // Manufacturer ID is little-endian
376-
}
377-
378-
let payload = manufacturerData.dropFirst(2)
379-
380-
for filter in filters {
381-
if filter.companyIdentifier != companyIdentifier {
382-
continue // Skip if company ID does not match
383-
}
384-
385-
if let dataPrefix = filter.dataPrefix {
386-
if payload.count < dataPrefix.count {
387-
continue // Payload too short, does not match
388-
}
389-
390-
if let mask = filter.mask {
391-
var matches = true
392-
for i in 0..<dataPrefix.count {
393-
if (payload[i] & mask[i]) != (dataPrefix[i] & mask[i]) {
394-
matches = false
395-
break
396-
}
397-
}
398-
if matches {
399-
return true
400-
}
401-
} else if payload.starts(with: dataPrefix) {
402-
return true
403-
}
404-
} else {
405-
return true // Company ID matched, and no dataPrefix required
406-
}
407-
}
408-
409-
return false // If none matched, return false
410-
}
411-
412-
private func passesServiceDataFilter(_ advertisementData: [String: Any]) -> Bool {
413-
guard let filters = self.serviceDataFilters, !filters.isEmpty else {
414-
return true // No filters means everything passes
415-
}
416-
417-
guard let serviceDataDict = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] else {
418-
return false // If there's no service data, fail
419-
}
420-
421-
for filter in filters {
422-
guard let serviceData = serviceDataDict[filter.serviceUuid] else {
423-
continue // Skip if service UUID does not match
424-
}
425-
426-
if let dataPrefix = filter.dataPrefix {
427-
if serviceData.count < dataPrefix.count {
428-
continue // Service data too short, does not match
429-
}
430-
431-
if let mask = filter.mask {
432-
var matches = true
433-
for i in 0..<dataPrefix.count {
434-
if (serviceData[i] & mask[i]) != (dataPrefix[i] & mask[i]) {
435-
matches = false
436-
break
437-
}
438-
}
439-
if matches {
440-
return true
441-
}
442-
} else if serviceData.starts(with: dataPrefix) {
443-
return true
444-
}
445-
} else {
446-
return true // Service UUID matched, and no dataPrefix required
447-
}
448-
}
449-
450-
return false // If none matched, return false
451-
}
452364

453365
private func resolve(_ key: String, _ value: String) {
454366
let callback = self.callbackMap[key]

ios/Sources/BluetoothLe/Plugin.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,6 @@ import CoreBluetooth
77
let CONNECTION_TIMEOUT: Double = 10
88
let DEFAULT_TIMEOUT: Double = 5
99

10-
struct ManufacturerDataFilter {
11-
let companyIdentifier: UInt16
12-
let dataPrefix: Data?
13-
let mask: Data?
14-
}
15-
16-
struct ServiceDataFilter {
17-
let serviceUuid: CBUUID
18-
let dataPrefix: Data?
19-
let mask: Data?
20-
}
21-
2210
@objc(BluetoothLe)
2311
public class BluetoothLe: CAPPlugin, CAPBridgedPlugin {
2412
public let identifier = "BluetoothLe"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import XCTest
2+
import CoreBluetooth
3+
@testable import Plugin
4+
5+
class ScanFiltersTests: XCTestCase {
6+
7+
// MARK: - Manufacturer Data Filter Tests
8+
9+
func testManufacturerDataFilter_InvalidMaskLength() {
10+
// Test that when mask.count != dataPrefix.count, the filter is skipped
11+
// and returns false (no match)
12+
13+
// Create manufacturer data: 2 bytes company ID + 4 bytes payload
14+
var manufacturerData = Data()
15+
manufacturerData.append(contentsOf: [0x4C, 0x00]) // Apple company ID (0x004C in little-endian)
16+
manufacturerData.append(contentsOf: [0x01, 0x02, 0x03, 0x04]) // 4 bytes payload
17+
18+
let advertisementData: [String: Any] = [
19+
CBAdvertisementDataManufacturerDataKey: manufacturerData
20+
]
21+
22+
// Create filter with dataPrefix of 4 bytes but mask of only 2 bytes
23+
// This should be skipped due to invalid mask length
24+
let dataPrefix = Data([0x01, 0x02, 0x03, 0x04]) // 4 bytes
25+
let mask = Data([0xFF, 0xFF]) // Only 2 bytes - invalid!
26+
27+
let filter = ManufacturerDataFilter(
28+
companyIdentifier: 0x004C,
29+
dataPrefix: dataPrefix,
30+
mask: mask
31+
)
32+
33+
// Should return false because the filter is skipped due to invalid mask
34+
let result = ScanFilterUtils.passesManufacturerDataFilter(advertisementData, filters: [filter])
35+
XCTAssertFalse(result, "Should return false when mask length doesn't match dataPrefix length")
36+
}
37+
38+
func testManufacturerDataFilter_ValidMaskLength() {
39+
// Test the valid case where mask and dataPrefix have the same length
40+
var manufacturerData = Data()
41+
manufacturerData.append(contentsOf: [0x4C, 0x00]) // Apple company ID
42+
manufacturerData.append(contentsOf: [0x01, 0x02, 0x03, 0x04])
43+
44+
let advertisementData: [String: Any] = [
45+
CBAdvertisementDataManufacturerDataKey: manufacturerData
46+
]
47+
48+
// Mask and dataPrefix have the same length - should work correctly
49+
let dataPrefix = Data([0x01, 0x02, 0x03, 0x04])
50+
let mask = Data([0xFF, 0xFF, 0xFF, 0xFF]) // Same length as dataPrefix
51+
52+
let filter = ManufacturerDataFilter(
53+
companyIdentifier: 0x004C,
54+
dataPrefix: dataPrefix,
55+
mask: mask
56+
)
57+
58+
let result = ScanFilterUtils.passesManufacturerDataFilter(advertisementData, filters: [filter])
59+
XCTAssertTrue(result, "Should match when mask and dataPrefix have same length")
60+
}
61+
62+
func testManufacturerDataFilter_NoMask() {
63+
// Test without a mask - should use simple prefix matching
64+
var manufacturerData = Data()
65+
manufacturerData.append(contentsOf: [0x4C, 0x00])
66+
manufacturerData.append(contentsOf: [0x01, 0x02, 0x03, 0x04])
67+
68+
let advertisementData: [String: Any] = [
69+
CBAdvertisementDataManufacturerDataKey: manufacturerData
70+
]
71+
72+
let dataPrefix = Data([0x01, 0x02])
73+
let filter = ManufacturerDataFilter(
74+
companyIdentifier: 0x004C,
75+
dataPrefix: dataPrefix,
76+
mask: nil // No mask
77+
)
78+
79+
let result = ScanFilterUtils.passesManufacturerDataFilter(advertisementData, filters: [filter])
80+
XCTAssertTrue(result, "Should match with prefix matching when no mask is provided")
81+
}
82+
83+
// MARK: - Service Data Filter Tests
84+
85+
func testServiceDataFilter_InvalidMaskLength() {
86+
// Test that when mask.count != dataPrefix.count, the filter is skipped
87+
// and returns false (no match)
88+
89+
let serviceUUID = CBUUID(string: "1234")
90+
let serviceData = Data([0x01, 0x02, 0x03, 0x04]) // 4 bytes
91+
92+
let advertisementData: [String: Any] = [
93+
CBAdvertisementDataServiceDataKey: [serviceUUID: serviceData]
94+
]
95+
96+
// Create filter with dataPrefix of 4 bytes but mask of only 2 bytes
97+
// This should be skipped due to invalid mask length
98+
let dataPrefix = Data([0x01, 0x02, 0x03, 0x04]) // 4 bytes
99+
let mask = Data([0xFF, 0xFF]) // Only 2 bytes - invalid!
100+
101+
let filter = ServiceDataFilter(
102+
serviceUuid: serviceUUID,
103+
dataPrefix: dataPrefix,
104+
mask: mask
105+
)
106+
107+
// Should return false because the filter is skipped due to invalid mask
108+
let result = ScanFilterUtils.passesServiceDataFilter(advertisementData, filters: [filter])
109+
XCTAssertFalse(result, "Should return false when mask length doesn't match dataPrefix length")
110+
}
111+
112+
func testServiceDataFilter_ValidMaskLength() {
113+
// Test the valid case where mask and dataPrefix have the same length
114+
let serviceUUID = CBUUID(string: "1234")
115+
let serviceData = Data([0x01, 0x02, 0x03, 0x04])
116+
117+
let advertisementData: [String: Any] = [
118+
CBAdvertisementDataServiceDataKey: [serviceUUID: serviceData]
119+
]
120+
121+
let dataPrefix = Data([0x01, 0x02, 0x03, 0x04])
122+
let mask = Data([0xFF, 0xFF, 0xFF, 0xFF]) // Same length
123+
124+
let filter = ServiceDataFilter(
125+
serviceUuid: serviceUUID,
126+
dataPrefix: dataPrefix,
127+
mask: mask
128+
)
129+
130+
let result = ScanFilterUtils.passesServiceDataFilter(advertisementData, filters: [filter])
131+
XCTAssertTrue(result, "Should match when mask and dataPrefix have same length")
132+
}
133+
134+
func testServiceDataFilter_NoMask() {
135+
// Test without a mask
136+
let serviceUUID = CBUUID(string: "1234")
137+
let serviceData = Data([0x01, 0x02, 0x03, 0x04])
138+
139+
let advertisementData: [String: Any] = [
140+
CBAdvertisementDataServiceDataKey: [serviceUUID: serviceData]
141+
]
142+
143+
let dataPrefix = Data([0x01, 0x02])
144+
let filter = ServiceDataFilter(
145+
serviceUuid: serviceUUID,
146+
dataPrefix: dataPrefix,
147+
mask: nil
148+
)
149+
150+
let result = ScanFilterUtils.passesServiceDataFilter(advertisementData, filters: [filter])
151+
XCTAssertTrue(result, "Should match with prefix matching when no mask is provided")
152+
}
153+
}

0 commit comments

Comments
 (0)