diff --git a/CHANGELOG.md b/CHANGELOG.md index d49cd4b9..4d96fbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,10 @@ * Android: add `closeGattOnDetach` connection option to release GATT clients when the app is killed * Android: close the GATT client once the disconnect completes instead of right after `disconnect()`, and report the real disconnect status * Android: Fix peripheral `getReadinessState()` to check permissions and adapter power before advertising support, and throttle `startAdvertising` Bluetooth enable prompts to at most one dialog. +* Add `isSubscribed` and `getSubscribedCharacteristics` to check characteristic notification/indication subscription status in Central mode. * readRssi commands are not queued anymore * iOS/macOS: complete only the oldest matching pending write on didWriteValueFor - ## 2.2.0 * Expose microsecond scan timestamps captured before Flutter event dispatch * Lower minimum Dart SDK to 3.3 (Flutter 3.19+) to restore compatibility with older stable Flutter releases diff --git a/README.md b/README.md index 2b5ffa6c..214e5d35 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,23 @@ Unsubscribe from notifications and indications of this characteristic. await characteristic.unsubscribe(); ``` +### Check Subscription Status + +Check if a characteristic is currently subscribed to: + +```dart +// Via BleCharacteristic or CharacteristicSubscription +bool isSubscribed = characteristic.isSubscribed; +// or +bool isSubscribed = characteristic.notifications.isSubscribed; + +// Or globally via UniversalBle +bool isSubscribed = UniversalBle.isSubscribed(deviceId, characteristicId); + +// Get all subscribed characteristic UUIDs for a device +List subscribedChars = UniversalBle.getSubscribedCharacteristics(deviceId); +``` + ### Pairing #### Trigger pairing diff --git a/lib/src/extensions/ble_characteristic_extension.dart b/lib/src/extensions/ble_characteristic_extension.dart index 04c198d7..9addbecf 100644 --- a/lib/src/extensions/ble_characteristic_extension.dart +++ b/lib/src/extensions/ble_characteristic_extension.dart @@ -37,6 +37,11 @@ extension BleCharacteristicExtension on BleCharacteristic { queueId: queueId, ); + /// Returns whether this characteristic is currently subscribed to. + bool get isSubscribed => + metaData?.deviceId != null && + UniversalBle.isSubscribed(metaData!.deviceId, uuid); + /// Reads the current value of the characteristic. Future read({Duration? timeout, String? queueId}) => UniversalBle.read( @@ -187,6 +192,9 @@ class CharacteristicSubscription { ); } + /// Returns whether this characteristic is currently subscribed to. + bool get isSubscribed => _characteristic.isSubscribed; + @override String toString() => "CharacteristicSubscription(property: ${_property.name}, isSupported: $isSupported, characteristic: ${_characteristic.uuid})"; diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index e434c45b..32b938cd 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:universal_ble/src/utils/ble_command_queue.dart'; import 'package:universal_ble/src/universal_ble_linux/universal_ble_linux_instance.dart'; import 'package:universal_ble/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart'; import 'package:universal_ble/src/universal_ble_web/universal_ble_web.dart'; +import 'package:universal_ble/src/utils/ble_command_queue.dart'; +import 'package:universal_ble/src/utils/cache_handler.dart'; import 'package:universal_ble/src/utils/universal_logger.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -312,6 +313,17 @@ class UniversalBle { ); } + /// Returns whether this app is currently subscribed to notifications/indications for [characteristic]. + /// + /// Subscription state is updated when [subscribeNotifications], [subscribeIndications], + /// or [unsubscribe] completes, and is automatically cleared when the device disconnects. + static bool isSubscribed(String deviceId, String characteristic) => + CacheHandler.instance.isSubscribed(deviceId, characteristic); + + /// Returns the list of characteristic UUIDs currently subscribed to on [deviceId]. + static List getSubscribedCharacteristics(String deviceId) => + CacheHandler.instance.getSubscribedCharacteristics(deviceId); + /// Read a characteristic value. /// On iOS and MacOS this command will also trigger [onValueChange] listener. static Future read( @@ -768,7 +780,7 @@ class UniversalBle { Duration? timeout, String? queueId, }) async { - return await _bleCommandQueue.queueCommand( + await _bleCommandQueue.queueCommand( () => _platform.setNotifiable( deviceId, BleUuidParser.string(service), @@ -779,6 +791,11 @@ class UniversalBle { timeout: timeout, queueId: queueId, ); + CacheHandler.instance.updateSubscription( + deviceId, + characteristic, + bleInputProperty != BleInputProperty.disabled, + ); } static Future _connectAndExecuteBleCommand( diff --git a/lib/src/utils/cache_handler.dart b/lib/src/utils/cache_handler.dart index 25bbc3d1..df19ac6a 100644 --- a/lib/src/utils/cache_handler.dart +++ b/lib/src/utils/cache_handler.dart @@ -27,8 +27,45 @@ class CacheHandler { /// Retrieves the cached Bluetooth services for a specific device. List? getServices(String deviceId) => _servicesCache[_key(deviceId)]; - /// Resets the cache for a specific device, removing all stored services. + /// Internal cache to store subscribed characteristic UUIDs for each device. + final Map> _subscriptionsCache = {}; + + /// Updates the subscription state of a characteristic for a specific device. + void updateSubscription( + String deviceId, + String characteristicId, + bool isSubscribed, + ) { + final key = _key(deviceId); + final normalizedCharId = BleUuidParser.string(characteristicId); + if (isSubscribed) { + (_subscriptionsCache[key] ??= {}).add(normalizedCharId); + } else { + _subscriptionsCache[key]?.remove(normalizedCharId); + if (_subscriptionsCache[key]?.isEmpty ?? false) { + _subscriptionsCache.remove(key); + } + } + } + + /// Checks if a characteristic is subscribed to on a specific device. + bool isSubscribed(String deviceId, String characteristicId) { + try { + final normalizedCharId = BleUuidParser.string(characteristicId); + return _subscriptionsCache[_key(deviceId)]?.contains(normalizedCharId) ?? + false; + } catch (_) { + return false; + } + } + + /// Retrieves the list of subscribed characteristic UUIDs for a specific device. + List getSubscribedCharacteristics(String deviceId) => + _subscriptionsCache[_key(deviceId)]?.toList() ?? []; + + /// Resets the cache for a specific device, removing all stored services and subscriptions. void resetDeviceCache(String deviceId) { _servicesCache.remove(_key(deviceId)); + _subscriptionsCache.remove(_key(deviceId)); } } diff --git a/test/ble_characteristic_test.dart b/test/ble_characteristic_test.dart index 353ca6f7..f8088ffa 100644 --- a/test/ble_characteristic_test.dart +++ b/test/ble_characteristic_test.dart @@ -53,9 +53,31 @@ void main() { test("Subscription Test", () async { BleCharacteristic characteristic = mockBleCharacteristic; + expect(characteristic.isSubscribed, false); + expect(characteristic.notifications.isSubscribed, false); + expect(UniversalBle.isSubscribed(mockDeviceId, characteristicId), false); + expect(UniversalBle.getSubscribedCharacteristics(mockDeviceId), isEmpty); + debugPrint("Subscribing to char"); await characteristic.notifications.subscribe(); + expect(characteristic.isSubscribed, true); + expect(characteristic.notifications.isSubscribed, true); + expect(UniversalBle.isSubscribed(mockDeviceId, characteristicId), true); + // Case-insensitivity check for deviceId and normalized UUID check + expect( + UniversalBle.isSubscribed(mockDeviceId.toUpperCase(), characteristicId), + true, + ); + expect( + UniversalBle.isSubscribed(mockDeviceId, characteristic.uuid), + true, + ); + expect( + UniversalBle.getSubscribedCharacteristics(mockDeviceId), + contains(characteristic.uuid), + ); + bool gotEvent = false; var subscription = characteristic.notifications.listen((data) { debugPrint("Received CharValue: $data"); @@ -66,9 +88,34 @@ void main() { await characteristic.notifications.unsubscribe(); debugPrint("Unsubscribed from char"); + expect(characteristic.isSubscribed, false); + expect(characteristic.notifications.isSubscribed, false); + expect(UniversalBle.isSubscribed(mockDeviceId, characteristicId), false); + expect(UniversalBle.getSubscribedCharacteristics(mockDeviceId), isEmpty); + subscription.cancel(); expect(gotEvent, true); }); + + test("isSubscribed resets on device disconnect", () async { + BleCharacteristic characteristic = mockBleCharacteristic; + await characteristic.notifications.subscribe(); + addTearDown(() { + final mock = platform as _UniversalBleMock; + mock.notifierTimer?.cancel(); + mock.notifierTimer = null; + }); + expect(characteristic.isSubscribed, true); + expect(UniversalBle.isSubscribed(mockDeviceId, characteristicId), true); + + // Simulate device disconnect + platform.updateConnection(mockDeviceId, false); + + expect(characteristic.isSubscribed, false); + expect(characteristic.notifications.isSubscribed, false); + expect(UniversalBle.isSubscribed(mockDeviceId, characteristicId), false); + expect(UniversalBle.getSubscribedCharacteristics(mockDeviceId), isEmpty); + }); }); test("Write/Read Value Test", () async {