From 09bb22750a51d158a3da2767a938f5e7f7a4c52b Mon Sep 17 00:00:00 2001 From: Rohit Sangwan Date: Fri, 14 Nov 2025 20:20:22 +0530 Subject: [PATCH] Improve disconnect api (#192) * Improve disconnect api * Get disconnection event on Web from callback * Wait for disconnection result on calling disconnect * Improve linux disconnection * Update Changelog * Fix Ai comments --- CHANGELOG.md | 2 + .../universal_ble/UniversalBlePlugin.kt | 39 +++-- darwin/Classes/UniversalBlePlugin.swift | 11 +- .../peripheral_detail_page.dart | 12 +- lib/src/universal_ble.dart | 134 +++++++++++++----- .../universal_ble_linux.dart | 29 ++-- .../universal_ble_web/universal_ble_web.dart | 2 - windows/src/universal_ble_plugin.cpp | 40 +++--- windows/src/universal_ble_plugin.h | 1 + 9 files changed, 181 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acaf5f..3d7c027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ * Unified error codes for all platforms * Add `isScanning` api * Add `requestPermissions` api and auto ask permission on `startScan` +* `disconnect` now waits for disconnection confirmation before returning +* Improve Windows disconnection event handling and cleanup ## 0.21.1 * Fix device name resolution on Windows diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index 52b531c..2d61b3f 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -98,7 +98,10 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), ) } - override fun requestPermissions(withAndroidFineLocation: Boolean, callback: (Result) -> Unit) { + override fun requestPermissions( + withAndroidFineLocation: Boolean, + callback: (Result) -> Unit, + ) { if (permissionHandler == null) { callback( Result.failure( @@ -243,21 +246,31 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), } override fun disconnect(deviceId: String) { - cleanConnection(deviceId.toBluetoothGatt()) + val gatt = deviceId.findGatt() + if (gatt == null) { + mainThreadHandler?.post { + callbackChannel?.onConnectionChanged(deviceId, false, null) {} + } + } else { + cleanConnection(gatt) + } } override fun getConnectionState(deviceId: String): Long { - val connectionState = bluetoothManager.getConnectionState( - bluetoothManager.adapter.getRemoteDevice(deviceId), - BluetoothProfile.GATT - ) - - return if (deviceId.isKnownGatt() || connectionState == BluetoothGatt.STATE_DISCONNECTED || connectionState == BluetoothGatt.STATE_DISCONNECTING) { - connectionState.toBleConnectionState().value - } else { - // Might be connected with device, but not with app - Log.e(TAG, "Device might be connected but not known to this app") - BleConnectionState.Disconnected.value + try { + val connectionState = bluetoothManager.getConnectionState( + bluetoothManager.adapter.getRemoteDevice(deviceId), + BluetoothProfile.GATT + ) + return if (deviceId.isKnownGatt() || connectionState == BluetoothGatt.STATE_DISCONNECTED || connectionState == BluetoothGatt.STATE_DISCONNECTING) { + connectionState.toBleConnectionState().value + } else { + // Might be connected with device, but not with app + Log.e(TAG, "Device might be connected but not known to this app") + BleConnectionState.Disconnected.value + } + } catch (e: Exception) { + return BleConnectionState.Disconnected.value } } diff --git a/darwin/Classes/UniversalBlePlugin.swift b/darwin/Classes/UniversalBlePlugin.swift index cb2b246..b65c4a5 100644 --- a/darwin/Classes/UniversalBlePlugin.swift +++ b/darwin/Classes/UniversalBlePlugin.swift @@ -139,7 +139,10 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral } func disconnect(deviceId: String) throws { - let peripheral = try deviceId.getPeripheral(manager: manager) + guard let peripheral = deviceId.findPeripheral(manager: manager) else { + callbackChannel.onConnectionChanged(deviceId: deviceId, connected: false, error: nil) { _ in } + return + } if peripheral.state != CBPeripheralState.disconnected { manager.cancelPeripheralConnection(peripheral) } @@ -147,7 +150,9 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral } func getConnectionState(deviceId: String) throws -> Int64 { - let peripheral = try deviceId.getPeripheral(manager: manager) + guard let peripheral = deviceId.findPeripheral(manager: manager) else { + return BlueConnectionState.disconnected.rawValue + } switch peripheral.state { case .connecting: return BlueConnectionState.connecting.rawValue @@ -158,7 +163,7 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral case .disconnected: return BlueConnectionState.disconnected.rawValue @unknown default: - fatalError() + return BlueConnectionState.disconnected.rawValue } } diff --git a/example/lib/peripheral_details/peripheral_detail_page.dart b/example/lib/peripheral_details/peripheral_detail_page.dart index 651415a..3f80806 100644 --- a/example/lib/peripheral_details/peripheral_detail_page.dart +++ b/example/lib/peripheral_details/peripheral_detail_page.dart @@ -260,8 +260,16 @@ class _PeripheralDetailPageState extends State { PlatformButton( text: 'Disconnect', enabled: isConnected, - onPressed: () { - bleDevice.disconnect(); + onPressed: () async { + try { + await bleDevice.disconnect(); + _addLog("DisconnectResult", true); + } catch (e) { + _addLog( + 'DisconnectError (${e.runtimeType})', + e, + ); + } }, ), ], diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 3bb8cd9..0c2a41d 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -120,42 +120,18 @@ class UniversalBle { Duration? timeout, }) async { timeout ??= const Duration(seconds: 60); - StreamSubscription? connectionSubscription; - Completer completer = Completer(); + Completer completer = + _connectionEventCompleter(deviceId, timeout: timeout); - void handleError(dynamic error) { - if (completer.isCompleted) return; - connectionSubscription?.cancel(); - completer.completeError(ConnectionException(error)); - } + _platform.connect(deviceId, connectionTimeout: timeout).catchError( + (error) { + if (completer.isCompleted) return; + completer.completeError(ConnectionException(error)); + }, + ); - try { - connectionSubscription = _platform - .bleConnectionUpdateStreamController.stream - .where((e) => e.deviceId == deviceId) - .listen( - (e) { - if (e.error != null) { - handleError(e.error); - } else { - if (!completer.isCompleted) { - completer.complete(e.isConnected); - } - } - }, - onError: handleError, - cancelOnError: true, - ); - - _platform - .connect(deviceId, connectionTimeout: timeout) - .catchError(handleError); - - if (!await completer.future.timeout(timeout)) { - throw ConnectionException("Failed to connect"); - } - } finally { - connectionSubscription?.cancel(); + if (!await completer.future.timeout(timeout)) { + throw ConnectionException("Failed to connect"); } } @@ -165,11 +141,45 @@ class UniversalBle { String deviceId, { Duration? timeout, }) async { - return await _bleCommandQueue.queueCommand( - () => _platform.disconnect(deviceId), - timeout: timeout, - deviceId: deviceId, - ); + timeout ??= const Duration(seconds: 60); + BleConnectionState? connectionState; + try { + connectionState = await _platform.getConnectionState(deviceId); + } catch (e) { + UniversalLogger.logError("Get connection state failed: $e"); + } + + if (connectionState == BleConnectionState.disconnected || + connectionState == BleConnectionState.disconnecting) { + _platform.updateConnection(deviceId, false); + UniversalLogger.logInfo( + "Device $deviceId already disconnected: $connectionState", + ); + return; + } + + try { + Completer completer = + _connectionEventCompleter(deviceId, timeout: timeout); + + await _bleCommandQueue + .queueCommand(() => _platform.disconnect(deviceId), + timeout: timeout, deviceId: deviceId) + .catchError( + (error) { + if (completer.isCompleted) return; + completer.completeError(ConnectionException(error)); + }, + ); + + if (await completer.future.timeout(timeout)) { + UniversalLogger.logError( + "Device $deviceId is still connected after disconnect attempt", + ); + } + } catch (e) { + UniversalLogger.logError("Disconnect failed: $e"); + } } /// Discover services of a device. @@ -505,6 +515,52 @@ class UniversalBle { return read(deviceId, service, characteristic, timeout: timeout); } + static Completer _connectionEventCompleter( + String deviceId, { + Duration? timeout, + }) { + timeout ??= const Duration(seconds: 60); + StreamSubscription? connectionSubscription; + Completer completer = Completer(); + + void cancelSubscription() { + connectionSubscription?.cancel(); + connectionSubscription = null; + } + + void handleError(dynamic error) { + cancelSubscription(); + if (completer.isCompleted) return; + completer.completeError(ConnectionException(error)); + } + + connectionSubscription = _platform + .bleConnectionUpdateStreamController.stream + .where((e) => e.deviceId == deviceId) + .listen( + (e) { + cancelSubscription(); + if (e.error != null) { + handleError(e.error); + } else { + if (!completer.isCompleted) { + completer.complete(e.isConnected); + } + } + }, + onError: handleError, + cancelOnError: true, + ); + + completer.future.timeout(timeout).then((_) { + cancelSubscription(); + }).catchError((_) { + cancelSubscription(); + }); + + return completer; + } + static Future _sendBleInputPropertyCommand( String deviceId, String service, diff --git a/lib/src/universal_ble_linux/universal_ble_linux.dart b/lib/src/universal_ble_linux/universal_ble_linux.dart index 6a746ad..ffc1dec 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -133,10 +133,7 @@ class UniversalBleLinux extends UniversalBlePlatform { @override Future getConnectionState(String deviceId) async { - BlueZDevice? device = _devices[deviceId] ?? - _client.devices.cast().firstWhere( - (device) => device?.address == deviceId, - orElse: () => null); + BlueZDevice? device = _getDeviceById(deviceId); bool connected = device?.connected ?? false; return connected ? BleConnectionState.connected @@ -155,12 +152,11 @@ class UniversalBleLinux extends UniversalBlePlatform { @override Future disconnect(String deviceId) async { - final device = _findDeviceById(deviceId); - if (!device.connected) { - updateConnection(deviceId, false); - return; + final device = _getDeviceById(deviceId); + if (device?.connected == true) { + await device?.disconnect(); } - await device.disconnect(); + updateConnection(deviceId, false); } @override @@ -414,11 +410,10 @@ class UniversalBleLinux extends UniversalBlePlatform { : AvailabilityState.poweredOff; } + /// Find device by id from cache or from client + /// Throws exception if device not found BlueZDevice _findDeviceById(String deviceId) { - final device = _devices[deviceId] ?? - _client.devices.cast().firstWhere( - (device) => device?.address == deviceId, - orElse: () => null); + final device = _getDeviceById(deviceId); if (device == null) { throw UniversalBleException( code: UniversalBleErrorCode.deviceNotFound, @@ -428,6 +423,14 @@ class UniversalBleLinux extends UniversalBlePlatform { return device; } + /// Get device by id from cache or from client + BlueZDevice? _getDeviceById(String deviceId) { + return _devices[deviceId] ?? + _client.devices.cast().firstWhere( + (device) => device?.address == deviceId, + orElse: () => null); + } + Future _ensureInitialized() async { if (isInitialized) return; diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index 15bb926..a847260 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -58,8 +58,6 @@ class UniversalBleWeb extends UniversalBlePlatform { @override Future disconnect(String deviceId) async { - _cleanConnection(deviceId); - updateConnection(deviceId, false); _getDeviceById(deviceId)?.disconnect(); } diff --git a/windows/src/universal_ble_plugin.cpp b/windows/src/universal_ble_plugin.cpp index fe5d5d7..0fae6b8 100644 --- a/windows/src/universal_ble_plugin.cpp +++ b/windows/src/universal_ble_plugin.cpp @@ -255,14 +255,16 @@ UniversalBlePlugin::Connect(const std::string &device_id) { std::optional UniversalBlePlugin::Disconnect(const std::string &device_id) { auto device_address = str_to_mac_address(device_id); - CleanConnection(device_address); - // TODO: send disconnect event only after disconnect is complete - ui_thread_handler_.Post([device_address] { - callback_channel->OnConnectionChanged(mac_address_to_str(device_address), - false, nullptr, SuccessCallback, - ErrorCallback); - }); - + const auto it = connected_devices_.find(device_address); + if (it != connected_devices_.end()) { + it->second->device.Close(); + DisposeServices(it->second); + } else { + ui_thread_handler_.Post([device_id] { + callback_channel->OnConnectionChanged(device_id, false, nullptr, + SuccessCallback, ErrorCallback); + }); + } return std::nullopt; } @@ -1033,18 +1035,22 @@ void UniversalBlePlugin::CleanConnection(const uint64_t bluetooth_address) { const auto device_agent = std::move(node.mapped()); device_agent->device.ConnectionStatusChanged( device_agent->connection_status_changed_token); - // Clean up all characteristics tokens - for (auto &[service_id, service] : device_agent->gatt_map) { - for (auto &[char_id, characteristic] : service.characteristics) { - if (characteristic.subscription_token.has_value()) { - characteristic.obj.ValueChanged( - characteristic.subscription_token.value()); - characteristic.subscription_token = std::nullopt; + DisposeServices(device_agent); + } +} + +void UniversalBlePlugin::DisposeServices(const std::unique_ptr &device_agent) +{ + for (auto& [service_id, service] : device_agent->gatt_map) { + for (auto& [char_id, characteristic] : service.characteristics) { + if (characteristic.subscription_token.has_value()) { + characteristic.obj.ValueChanged( + characteristic.subscription_token.value()); + characteristic.subscription_token = std::nullopt; + } } - } } device_agent->gatt_map.clear(); - } } fire_and_forget UniversalBlePlugin::GetSystemDevicesAsync( diff --git a/windows/src/universal_ble_plugin.h b/windows/src/universal_ble_plugin.h index af6dccb..3dc688c 100644 --- a/windows/src/universal_ble_plugin.h +++ b/windows/src/universal_ble_plugin.h @@ -147,6 +147,7 @@ namespace universal_ble void OnDeviceInfoReceived(const DeviceInformation& device_info); void BluetoothLeDeviceConnectionStatusChanged(const BluetoothLEDevice& sender, const IInspectable& args); void CleanConnection(uint64_t bluetooth_address); + void DisposeServices(const std::unique_ptr &device_agent); void GattCharacteristicValueChanged(const GattCharacteristic& sender, const GattValueChangedEventArgs& args);