From dbd0e3a7fcb557a242b04297a9a4fbce377c68fa Mon Sep 17 00:00:00 2001 From: Usman Mehmood Date: Tue, 22 Sep 2026 12:18:04 +0200 Subject: [PATCH 1/2] fix(web): stop advertisement watching before GATT connect - Await advertisement subscription cancellation and browser unwatching before connection attempts. - Preserve caller-provided connection timeouts and Web advertisement support. - Clear stale service state after failed Web GATT connections. --- CHANGELOG.md | 1 + .../universal_ble_web/universal_ble_web.dart | 49 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7239be07..ad6408f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * **Breaking:** Add `QueueType.auto` which auto-selects the best queueing strategy per platform: Android uses a per-device queue, all other platforms run commands in parallel. It is now the default for both `UniversalBle` and `UniversalBlePeripheral`, replacing the previous `QueueType.global` default. * iOS/macOS: Handle write-without-response transmit buffer backpressure * iOS/macOS: complete concurrent reads, descriptor operations, notification changes, and RSSI reads one callback at a time. +* Web: wait for advertisement watching to stop before starting a GATT connection. ## 2.3.0 * Windows: support connectionless manufacturer-data advertising without a GATT service, including state/error reporting and cleanup on stop/disposal. diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index b415caa7..274c6587 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -46,7 +46,18 @@ class UniversalBleWeb extends UniversalBlePlatform { message: "$deviceId Not Found", ); } - await device.connect(timeout: connectionTimeout); + + // Advertisement watching is independent from requestDevice and can remain + // active after the chooser closes. Wait for it to stop before starting the + // GATT handshake so the two browser operations cannot overlap. + await _stopAdvertisementWatcher(deviceId); + try { + await device.connect(timeout: connectionTimeout); + } catch (_) { + _serviceCache.remove(deviceId); + device.disconnect(); + rethrow; + } // Subscribe to Connection Stream if (_connectedDeviceStreamList[deviceId] != null) { @@ -103,7 +114,7 @@ class UniversalBleWeb extends UniversalBlePlatform { // Update Scan Result updateScanResult(device.toBleScanResult()); - _watchDeviceAdvertisements(device); + await _watchDeviceAdvertisements(device); } catch (e) { String error = e.toString().replaceAll("DeviceNotFoundError:", "").trim(); if (error.toLowerCase().contains("api globally disabled")) { @@ -130,10 +141,7 @@ class UniversalBleWeb extends UniversalBlePlatform { try { if (!device.hasWatchAdvertisements()) return; - if (_deviceAdvertisementStreamList[device.id] != null) { - _deviceAdvertisementStreamList[device.id]?.cancel(); - await device.unwatchAdvertisements(); - } + await _stopAdvertisementWatcher(device.id); _deviceAdvertisementStreamList[device.id] = device.advertisements.listen(( event, @@ -159,7 +167,7 @@ class UniversalBleWeb extends UniversalBlePlatform { @override Future stopScan() async { - _disposeAdvertisementWatcher(); + await _stopAdvertisementWatcher(); } @override @@ -429,7 +437,7 @@ class UniversalBleWeb extends UniversalBlePlatform { if (key.contains(deviceId)) value.cancel(); return key.contains(deviceId); }); - _disposeAdvertisementWatcher(deviceId); + unawaited(_stopAdvertisementWatcher(deviceId)); _serviceCache.remove(deviceId); // _bluetoothDeviceList.removeWhere((element) => element.id == deviceId); } @@ -493,15 +501,22 @@ class UniversalBleWeb extends UniversalBlePlatform { return services; } - void _disposeAdvertisementWatcher([String? deviceId]) { - _deviceAdvertisementStreamList.removeWhere((key, value) { - if (deviceId != null && key != deviceId) return false; - value.cancel(); - _getDeviceById( - deviceId ?? key, - )?.unwatchAdvertisements().onError((_, stackTrace) {}); - return true; - }); + Future _stopAdvertisementWatcher([String? deviceId]) async { + final deviceIds = _deviceAdvertisementStreamList.keys + .where((key) => deviceId == null || key == deviceId) + .toList(growable: false); + for (final id in deviceIds) { + final subscription = _deviceAdvertisementStreamList.remove(id); + await subscription?.cancel(); + + final device = _getDeviceById(id); + if (device == null || !device.watchingAdvertisements) continue; + try { + await device.unwatchAdvertisements(); + } catch (error) { + UniversalLogger.logError("WebUnwatchAdvertisementError: $error"); + } + } } @override From b4ccc336039d39c1c5bb6f84efa74c3ccd0aa381 Mon Sep 17 00:00:00 2001 From: Usman Mehmood Date: Tue, 22 Sep 2026 15:01:47 +0200 Subject: [PATCH 2/2] fix(web): stabilize BLE reconnect lifecycle - Wait for a fresh advertisement before reconnecting to a previously used device. - Fall back to the normal GATT connection when advertisement watching is unavailable or times out. - Reset reconnect tracking after the browser returns a freshly selected device. - Cancel stale connection and characteristic subscriptions before disconnecting. - Clear cached services and stop advertisement watching during teardown. - Emit the disconnected state explicitly after native disconnection. --- .../universal_ble_web/universal_ble_web.dart | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index 274c6587..09b57d81 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -19,6 +19,7 @@ class UniversalBleWeb extends UniversalBlePlatform { final Map _connectedDeviceStreamList = {}; final Map _characteristicStreamList = {}; final Map> _serviceCache = {}; + final Set _connectionAttemptedDevices = {}; bool _isScanning = false; @override @@ -47,10 +48,15 @@ class UniversalBleWeb extends UniversalBlePlatform { ); } + final reconnecting = !_connectionAttemptedDevices.add(deviceId); + // Advertisement watching is independent from requestDevice and can remain // active after the chooser closes. Wait for it to stop before starting the // GATT handshake so the two browser operations cannot overlap. await _stopAdvertisementWatcher(deviceId); + if (reconnecting) { + await _waitForFreshAdvertisement(device); + } try { await device.connect(timeout: connectionTimeout); } catch (_) { @@ -60,9 +66,7 @@ class UniversalBleWeb extends UniversalBlePlatform { } // Subscribe to Connection Stream - if (_connectedDeviceStreamList[deviceId] != null) { - _connectedDeviceStreamList[deviceId]?.cancel(); - } + await _connectedDeviceStreamList.remove(deviceId)?.cancel(); _connectedDeviceStreamList[deviceId] = device.connected.listen((event) { if (!event) _cleanConnection(deviceId); @@ -72,7 +76,25 @@ class UniversalBleWeb extends UniversalBlePlatform { @override Future disconnect(String deviceId) async { - _getDeviceById(deviceId)?.disconnect(); + final device = _getDeviceById(deviceId); + if (device == null) return; + + // Remove listeners and cached GATT objects before disconnecting. This + // prevents a late event from the closing session being observed by a + // listener installed for an immediate reconnect. + await _connectedDeviceStreamList.remove(deviceId)?.cancel(); + final characteristicPrefix = '${deviceId}_'; + final characteristicKeys = _characteristicStreamList.keys + .where((key) => key.startsWith(characteristicPrefix)) + .toList(growable: false); + for (final key in characteristicKeys) { + await _characteristicStreamList.remove(key)?.cancel(); + } + await _stopAdvertisementWatcher(deviceId); + _serviceCache.remove(deviceId); + + device.disconnect(); + updateConnection(deviceId, false); } @override @@ -110,6 +132,7 @@ class UniversalBleWeb extends UniversalBlePlatform { // Update local device list _bluetoothDeviceList[device.id] = device; + _connectionAttemptedDevices.remove(device.id); // Update Scan Result updateScanResult(device.toBleScanResult()); @@ -484,6 +507,32 @@ class UniversalBleWeb extends UniversalBlePlatform { BluetoothDevice? _getDeviceById(String id) => _bluetoothDeviceList[id]; + Future _waitForFreshAdvertisement(BluetoothDevice device) async { + if (!device.hasWatchAdvertisements()) return; + + final ready = Completer(); + var acceptAdvertisements = false; + final subscription = device.advertisements.listen((_) { + if (acceptAdvertisements && !ready.isCompleted) ready.complete(); + }); + try { + // The dependency's advertisement stream replays its last value. Give + // that cached event a turn before accepting packets from the new watch. + await Future.delayed(Duration.zero); + await device.watchAdvertisements(); + acceptAdvertisements = true; + await ready.future.timeout(const Duration(seconds: 3)); + } catch (_) { + // Watching advertisements is optional. If it is unsupported or no + // packet arrives promptly, fall back to the normal GATT connection. + } finally { + await subscription.cancel(); + try { + await device.unwatchAdvertisements(); + } catch (_) {} + } + } + /// Get services and their characteristics. /// Services and characteristics are cached. /// Clears cache on disconnection.