Implement exclusion filter (#175)

* Implement exclusion filter

* Fix web filters

* Apply Ai Suggestions

* Fix scanFilterTest

* Resolve Ai comments

* Update readme and changelog

* Update CHANGELOG.md

Co-authored-by: Foti Dim <foti@navideck.com>

* Update exclusion filter doc

* Fix wording

---------

Co-authored-by: Foti Dim <foti@navideck.com>
Co-authored-by: Foti Dim <fotios.dimanidis@a2zebra.de>
This commit is contained in:
Rohit Sangwan
2025-09-01 13:01:56 +05:30
committed by GitHub
parent f3d5de250c
commit af873b3153
13 changed files with 300 additions and 78 deletions
+1
View File
@@ -1,5 +1,6 @@
## 0.21.1
* Fix device name resolution on Windows
* Add `exclusionFilters` to filter out devices from scan results
## 0.21.0
* BREAKING CHANGE: `connectionTimeout` argument from `connect`, `isPaired` and `pair` API renamed to `timeout`
+14
View File
@@ -154,6 +154,20 @@ Use the `withNamePrefix` parameter to filter devices by names (case sensitive).
List<String> withNamePrefix;
```
##### Exclusion Filter
Use exclusion filters to exclude specific devices from scan results:
```dart
exclusionFilters: [
ExclusionFilter(
namePrefix: 'EXCLUDED_NAME',
services: ['EXCLUDED_SERVICE_UUID'],
manufacturerDataFilter: [ManufacturerDataFilter(companyIdentifier: 0x004c)],
),
]
```
### Connecting
#### Connect
+1 -1
View File
@@ -1,4 +1,4 @@
platform :osx, '10.14'
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+2 -2
View File
@@ -21,9 +21,9 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3
COCOAPODS: 1.16.2
@@ -553,7 +553,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -632,7 +632,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -679,7 +679,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
+1 -1
View File
@@ -402,7 +402,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.21.0"
version: "0.21.1"
vector_math:
dependency: transitive
description:
+20 -1
View File
@@ -4,15 +4,34 @@ class ScanFilter {
List<String> withServices;
List<ManufacturerDataFilter> withManufacturerData;
List<String> withNamePrefix;
List<ExclusionFilter> exclusionFilters;
ScanFilter({
this.withServices = const [],
this.withManufacturerData = const [],
this.withNamePrefix = const [],
this.exclusionFilters = const [],
});
@override
String toString() {
return 'ScanFilter(withServices: $withServices, withManufacturerData: $withManufacturerData, withNamePrefix: $withNamePrefix)';
return 'ScanFilter(withServices: $withServices, withManufacturerData: $withManufacturerData, withNamePrefix: $withNamePrefix, exclusionFilters: $exclusionFilters)';
}
}
class ExclusionFilter {
List<String> services;
List<ManufacturerDataFilter> manufacturerDataFilter;
String? namePrefix;
ExclusionFilter({
this.services = const [],
this.manufacturerDataFilter = const [],
this.namePrefix,
});
bool get hasValidFilters =>
services.isNotEmpty ||
manufacturerDataFilter.isNotEmpty ||
(namePrefix?.isNotEmpty ?? false);
}
@@ -465,7 +465,7 @@ class UniversalBleLinux extends UniversalBlePlatform {
void _onDeviceAdd(BlueZDevice device) {
BleDevice bleDevice = device.toBleDevice();
if (!_bleFilter.matchesDevice(bleDevice)) {
if (!_bleFilter.shouldAcceptDevice(bleDevice)) {
return;
}
@@ -485,7 +485,7 @@ class UniversalBleLinux extends UniversalBlePlatform {
e.contains(BluezProperty.manufacturerData) ||
e.contains(BluezProperty.uuids))
.listen((_) {
if (_bleFilter.matchesDevice(bleDevice)) {
if (_bleFilter.shouldAcceptDevice(bleDevice)) {
updateScanResult(device.toBleDevice());
}
});
@@ -1,12 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:universal_ble/src/universal_ble_pigeon/universal_ble.g.dart';
import 'package:universal_ble/src/utils/universal_ble_filter_util.dart';
import 'package:universal_ble/universal_ble.dart';
class UniversalBlePigeonChannel extends UniversalBlePlatform {
static UniversalBlePigeonChannel? _instance;
static UniversalBlePigeonChannel get instance =>
_instance ??= UniversalBlePigeonChannel._();
late final UniversalBleFilterUtil _bleFilter = UniversalBleFilterUtil();
UniversalBlePigeonChannel._() {
_setupListeners();
@@ -42,6 +44,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform {
PlatformConfig? platformConfig,
}) async {
await _ensureInitialized();
_bleFilter.scanFilter = scanFilter;
await _channel.startScan(
scanFilter.toUniversalScanFilter(),
);
@@ -136,7 +139,12 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform {
/// To set listeners
void _setupListeners() {
UniversalBleCallbackChannel.setUp(_UniversalBleCallbackHandler(
scanResult: updateScanResult,
scanResult: (bleDevice) {
// Only check for exclusion filter here,
// scan filter handled natively on platform side
if (_bleFilter.matchesExclusionFilter(bleDevice)) return;
updateScanResult(bleDevice);
},
availabilityChange: updateAvailability,
connectionChanged: updateConnection,
valueChanged: updateCharacteristicValue,
@@ -348,6 +348,7 @@ class UniversalBleWeb extends UniversalBlePlatform {
WebOptions? webOptions,
) {
List<RequestFilterBuilder> filters = [];
List<RequestFilterBuilder> exclusionFilters = [];
List<int> optionalManufacturerData = [];
List<String> optionalServices = [];
@@ -389,6 +390,25 @@ class UniversalBleWeb extends UniversalBlePlatform {
for (var name in scanFilter.withNamePrefix) {
filters.add(RequestFilterBuilder(namePrefix: name));
}
// Add exclusion filters
for (var exclusionFilter in scanFilter.exclusionFilters) {
exclusionFilters.add(RequestFilterBuilder(
services: exclusionFilter.services.isEmpty
? null
: exclusionFilter.services.toValidUUIDList(),
namePrefix: exclusionFilter.namePrefix,
manufacturerData: exclusionFilter.manufacturerDataFilter.isEmpty
? null
: exclusionFilter.manufacturerDataFilter.map((e) {
return ManufacturerDataFilterBuilder(
companyIdentifier: e.companyIdentifier,
dataPrefix: e.payloadPrefix,
mask: e.payloadMask,
);
}).toList(),
));
}
}
if (optionalServices.isEmpty) {
@@ -396,8 +416,13 @@ class UniversalBleWeb extends UniversalBlePlatform {
"OptionalServices list is empty on web, you have to specify services in the ScanFilter in order to be able to access those after connecting",
);
}
if (filters.isEmpty && exclusionFilters.isNotEmpty) {
UniversalLogger.logError(
"Web platform requires inclusion filters when using exclusion filters. Please add withServices, withNamePrefix, or withManufacturerData filters.",
);
}
if (filters.isEmpty) {
if (filters.isEmpty && exclusionFilters.isEmpty) {
return RequestOptionsBuilder.acceptAllDevices(
optionalServices: optionalServices,
optionalManufacturerData: optionalManufacturerData,
@@ -407,6 +432,7 @@ class UniversalBleWeb extends UniversalBlePlatform {
filters,
optionalServices: optionalServices,
optionalManufacturerData: optionalManufacturerData,
exclusionFilters: exclusionFilters.isEmpty ? null : exclusionFilters,
);
}
}
+72 -36
View File
@@ -7,7 +7,42 @@ import 'package:universal_ble/universal_ble.dart';
class UniversalBleFilterUtil {
ScanFilter? scanFilter;
bool matchesDevice(BleDevice device) {
bool shouldAcceptDevice(BleDevice device) =>
!matchesExclusionFilter(device) && matchedScanFilter(device);
bool matchesExclusionFilter(BleDevice device) {
final exclusionFilters = scanFilter?.exclusionFilters;
if (exclusionFilters == null || exclusionFilters.isEmpty) return false;
final deviceName = device.name?.toLowerCase() ?? '';
final deviceServices = device.services.toValidUUIDList();
for (var filter in exclusionFilters) {
if (!filter.hasValidFilters) continue;
// Check name prefix
final filterNamePrefix = filter.namePrefix?.toLowerCase();
if (filterNamePrefix != null && filterNamePrefix.isNotEmpty) {
if (!deviceName.startsWith(filterNamePrefix)) continue;
}
// Check services
final filterServices = filter.services.toValidUUIDList();
if (filterServices.isNotEmpty &&
!filterServices.any(deviceServices.contains)) {
continue;
}
// Check manufacturer data
if (!manufacturerDataMatches(filter.manufacturerDataFilter, device)) {
continue;
}
// Device matches an exclusion filter
return true;
}
return false;
}
/// Returns true if the device matches the filter
bool matchedScanFilter(BleDevice device) {
final filter = scanFilter;
if (filter == null) return true;
@@ -23,66 +58,67 @@ class UniversalBleFilterUtil {
}
// Else check one of the filter passes
return hasNamePrefixFilter && nameMatches(filter, device) ||
hasServiceFilter && servicesMatch(filter, device) ||
hasManufacturerDataFilter && manufacturerDataMatches(filter, device);
return (hasNamePrefixFilter && nameMatches(filter, device)) ||
(hasServiceFilter && servicesMatch(filter, device)) ||
(hasManufacturerDataFilter &&
manufacturerDataMatches(filter.withManufacturerData, device));
}
bool nameMatches(ScanFilter scanFilter, BleDevice device) {
var namePrefixFilter = scanFilter.withNamePrefix;
final namePrefixFilter = scanFilter.withNamePrefix;
if (namePrefixFilter.isEmpty) return true;
String? name = device.name;
final name = device.name;
if (name == null || name.isEmpty) return false;
return namePrefixFilter.any(name.startsWith);
}
bool servicesMatch(ScanFilter scanFilter, BleDevice device) {
var serviceFilters = scanFilter.withServices;
final serviceFilters = scanFilter.withServices.toValidUUIDList();
if (serviceFilters.isEmpty) return true;
List<String> serviceUuids = device.services;
final serviceUuids = device.services.toValidUUIDList();
if (serviceUuids.isEmpty) {
return false;
}
return serviceFilters.any(serviceUuids.contains);
}
bool manufacturerDataMatches(ScanFilter scanFilter, BleDevice device) {
final manufacturerDataFilters = scanFilter.withManufacturerData;
if (manufacturerDataFilters.isEmpty) return true;
bool manufacturerDataMatches(
List<ManufacturerDataFilter> filters,
BleDevice device,
) {
if (filters.isEmpty) return true;
List<ManufacturerData> deviceDataList = device.manufacturerDataList;
final deviceDataList = device.manufacturerDataList;
if (deviceDataList.isEmpty) return false;
return deviceDataList
.any((deviceData) => manufacturerDataFilters.any((filter) {
// Early return if company identifiers don't match
if (filter.companyIdentifier != deviceData.companyId) {
return false;
}
return deviceDataList.any((deviceData) {
return filters.any((filter) {
// Early return if company identifiers don't match
if (filter.companyIdentifier != deviceData.companyId) {
return false;
}
final payloadPrefix = filter.payloadPrefix;
final payload = deviceData.payload;
final payloadPrefix = filter.payloadPrefix;
final payload = deviceData.payload;
// Handle cases where payload prefix is null or empty
if (payloadPrefix == null || payloadPrefix.isEmpty) {
return true;
}
// Handle cases where payload prefix is null or empty
if (payloadPrefix == null || payloadPrefix.isEmpty) {
return true;
}
// Validate payload lengths
if (payload.isEmpty || payloadPrefix.length > payload.length) {
return false;
}
if (payload.isEmpty || payloadPrefix.length > payload.length) {
return false;
}
final filterMask = filter.payloadMask;
// Choose comparison strategy based on filter mask
return filterMask != null &&
filterMask.length == payloadPrefix.length
? _compareWithMask(payloadPrefix, payload, filterMask)
: _compareWithoutMask(payloadPrefix, payload);
}));
final filterMask = filter.payloadMask;
// Choose comparison strategy based on filter mask
return filterMask != null && filterMask.length == payloadPrefix.length
? _compareWithMask(payloadPrefix, payload, filterMask)
: _compareWithoutMask(payloadPrefix, payload);
});
});
}
bool _compareWithMask(Uint8List prefix, Uint8List payload, Uint8List mask) {
+28 -7
View File
@@ -27,7 +27,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), true);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
true);
});
test('should not match when device has no manufacturer data', () {
@@ -46,7 +49,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), false);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
false);
});
test(
@@ -69,7 +75,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), true);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
true);
});
group('Payload Prefix Tests', () {
@@ -94,7 +103,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), true);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
true);
});
test('should match with multiple manufacturer data entries', () {
@@ -119,7 +131,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), true);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
true);
});
});
@@ -146,7 +161,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), true);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
true);
});
test('should not match when masked values are different', () {
@@ -171,7 +189,10 @@ void main() {
services: [],
);
expect(filterUtil.manufacturerDataMatches(scanFilter, device), false);
expect(
filterUtil.manufacturerDataMatches(
scanFilter.withManufacturerData, device),
false);
});
});
});
+120 -23
View File
@@ -9,7 +9,7 @@ void main() {
var device1 = BleDevice(
deviceId: '1',
name: '1_device',
services: ['1_ser'],
services: ['20a1'],
manufacturerDataList: [
ManufacturerData(0x01, Uint8List.fromList([1, 2, 3])),
],
@@ -17,7 +17,7 @@ void main() {
var device2 = BleDevice(
deviceId: '2',
name: '2_device',
services: ['2_ser'],
services: ['20a2'],
manufacturerDataList: [
ManufacturerData(0x02, Uint8List.fromList([1, 2, 3]))
],
@@ -25,7 +25,7 @@ void main() {
var device3 = BleDevice(
deviceId: '3',
name: '3_device',
services: ['3_ser'],
services: ['20a3'],
manufacturerDataList: [
ManufacturerData(0x03, Uint8List.fromList([1, 2, 3]))
],
@@ -52,7 +52,7 @@ void main() {
test('Test isServicesMatchingFilters', () {
var scanFilter = ScanFilter(
withServices: ['1_ser', 'random', '3_ser'],
withServices: ['20a1', '20a6', '20a3'],
);
expect(
universalBleFilter.servicesMatch(scanFilter, device1),
@@ -84,21 +84,21 @@ void main() {
]);
expect(
universalBleFilter.manufacturerDataMatches(
scanFilter,
scanFilter.withManufacturerData,
device1,
),
isTrue,
);
expect(
universalBleFilter.manufacturerDataMatches(
scanFilter,
scanFilter.withManufacturerData,
device2,
),
isTrue,
);
expect(
universalBleFilter.manufacturerDataMatches(
scanFilter,
scanFilter.withManufacturerData,
device3,
),
isFalse,
@@ -110,7 +110,7 @@ void main() {
test('Test filterDevice: Have filter for all', () {
universalBleFilter.scanFilter = ScanFilter(
withNamePrefix: ['1'],
withServices: ['3_ser'],
withServices: ['20a3'],
withManufacturerData: [
ManufacturerDataFilter(
companyIdentifier: 0x02,
@@ -118,15 +118,15 @@ void main() {
],
);
expect(
universalBleFilter.matchesDevice(device1),
universalBleFilter.matchedScanFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device2),
universalBleFilter.matchedScanFilter(device2),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device3),
universalBleFilter.matchedScanFilter(device3),
isTrue,
);
});
@@ -135,15 +135,15 @@ void main() {
withNamePrefix: ['1'],
);
expect(
universalBleFilter.matchesDevice(device1),
universalBleFilter.matchedScanFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device2),
universalBleFilter.matchedScanFilter(device2),
isFalse,
);
expect(
universalBleFilter.matchesDevice(device3),
universalBleFilter.matchedScanFilter(device3),
isFalse,
);
});
@@ -152,47 +152,144 @@ void main() {
withNamePrefix: ['1', '2'],
);
expect(
universalBleFilter.matchesDevice(device1),
universalBleFilter.matchedScanFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device2),
universalBleFilter.matchedScanFilter(device2),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device3),
universalBleFilter.matchedScanFilter(device3),
isFalse,
);
});
test('Test filterDevice: Empty Filter', () {
universalBleFilter.scanFilter = ScanFilter();
expect(
universalBleFilter.matchesDevice(device1),
universalBleFilter.matchedScanFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device2),
universalBleFilter.matchedScanFilter(device2),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device3),
universalBleFilter.matchedScanFilter(device3),
isTrue,
);
});
test('Test filterDevice: Null Filter', () {
universalBleFilter.scanFilter = ScanFilter();
expect(
universalBleFilter.matchesDevice(device1),
universalBleFilter.matchedScanFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device2),
universalBleFilter.matchedScanFilter(device2),
isTrue,
);
expect(
universalBleFilter.matchesDevice(device3),
universalBleFilter.matchedScanFilter(device3),
isTrue,
);
});
});
group("Test Exclusion Filter", () {
test('Test exclusionFilter: Have filter for 1', () {
universalBleFilter.scanFilter = ScanFilter(exclusionFilters: [
ExclusionFilter(
namePrefix: '1',
services: ['20a1'],
manufacturerDataFilter: [
ManufacturerDataFilter(
companyIdentifier: 0x01,
)
],
)
]);
expect(
universalBleFilter.matchesExclusionFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesExclusionFilter(device2),
isFalse,
);
expect(
universalBleFilter.matchesExclusionFilter(device3),
isFalse,
);
});
test('Test exclusionFilter: Filter for two', () {
universalBleFilter.scanFilter = ScanFilter(exclusionFilters: [
ExclusionFilter(
namePrefix: '1',
services: ['20a1'],
manufacturerDataFilter: [
ManufacturerDataFilter(
companyIdentifier: 0x01,
)
],
),
ExclusionFilter(
namePrefix: '2',
services: ['20a2'],
manufacturerDataFilter: [
ManufacturerDataFilter(
companyIdentifier: 0x02,
)
],
),
]);
expect(
universalBleFilter.matchesExclusionFilter(device1),
isTrue,
);
expect(
universalBleFilter.matchesExclusionFilter(device2),
isTrue,
);
expect(
universalBleFilter.matchesExclusionFilter(device3),
isFalse,
);
});
test('Test exclusionFilter: Invalid filter for last', () {
universalBleFilter.scanFilter = ScanFilter(exclusionFilters: [
ExclusionFilter(
namePrefix: '3',
services: ['20a2'],
manufacturerDataFilter: [
ManufacturerDataFilter(
companyIdentifier: 0x01,
)
],
),
]);
expect(
universalBleFilter.matchesExclusionFilter(device1),
isFalse,
);
expect(
universalBleFilter.matchesExclusionFilter(device2),
isFalse,
);
expect(
universalBleFilter.matchesExclusionFilter(device3),
isFalse,
);
});
test('Test exclusionFilter: Empty Filter', () {
universalBleFilter.scanFilter = ScanFilter();
expect(
universalBleFilter.matchesExclusionFilter(device1),
isFalse,
);
});
});
}