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
+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) {