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
This commit is contained in:
Rohit Sangwan
2025-11-14 20:20:22 +05:30
committed by GitHub
parent 970db165dd
commit 09bb22750a
9 changed files with 181 additions and 89 deletions
+2
View File
@@ -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
@@ -98,7 +98,10 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
)
}
override fun requestPermissions(withAndroidFineLocation: Boolean, callback: (Result<Unit>) -> Unit) {
override fun requestPermissions(
withAndroidFineLocation: Boolean,
callback: (Result<Unit>) -> 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
}
}
+8 -3
View File
@@ -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
}
}
@@ -260,8 +260,16 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
PlatformButton(
text: 'Disconnect',
enabled: isConnected,
onPressed: () {
bleDevice.disconnect();
onPressed: () async {
try {
await bleDevice.disconnect();
_addLog("DisconnectResult", true);
} catch (e) {
_addLog(
'DisconnectError (${e.runtimeType})',
e,
);
}
},
),
],
+95 -39
View File
@@ -120,42 +120,18 @@ class UniversalBle {
Duration? timeout,
}) async {
timeout ??= const Duration(seconds: 60);
StreamSubscription? connectionSubscription;
Completer<bool> completer = Completer();
Completer<bool> 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<bool> 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<bool> _connectionEventCompleter(
String deviceId, {
Duration? timeout,
}) {
timeout ??= const Duration(seconds: 60);
StreamSubscription? connectionSubscription;
Completer<bool> 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<void> _sendBleInputPropertyCommand(
String deviceId,
String service,
@@ -133,10 +133,7 @@ class UniversalBleLinux extends UniversalBlePlatform {
@override
Future<BleConnectionState> getConnectionState(String deviceId) async {
BlueZDevice? device = _devices[deviceId] ??
_client.devices.cast<BlueZDevice?>().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<void> 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<BlueZDevice?>().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<BlueZDevice?>().firstWhere(
(device) => device?.address == deviceId,
orElse: () => null);
}
Future<void> _ensureInitialized() async {
if (isInitialized) return;
@@ -58,8 +58,6 @@ class UniversalBleWeb extends UniversalBlePlatform {
@override
Future<void> disconnect(String deviceId) async {
_cleanConnection(deviceId);
updateConnection(deviceId, false);
_getDeviceById(deviceId)?.disconnect();
}
+23 -17
View File
@@ -255,14 +255,16 @@ UniversalBlePlugin::Connect(const std::string &device_id) {
std::optional<FlutterError>
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<BluetoothDeviceAgent> &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(
+1
View File
@@ -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<BluetoothDeviceAgent> &device_agent);
void GattCharacteristicValueChanged(const GattCharacteristic& sender, const GattValueChangedEventArgs& args);