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..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 @@ -46,13 +47,27 @@ class UniversalBleWeb extends UniversalBlePlatform { message: "$deviceId Not Found", ); } - await device.connect(timeout: connectionTimeout); - // Subscribe to Connection Stream - if (_connectedDeviceStreamList[deviceId] != null) { - _connectedDeviceStreamList[deviceId]?.cancel(); + 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 (_) { + _serviceCache.remove(deviceId); + device.disconnect(); + rethrow; } + // Subscribe to Connection Stream + await _connectedDeviceStreamList.remove(deviceId)?.cancel(); + _connectedDeviceStreamList[deviceId] = device.connected.listen((event) { if (!event) _cleanConnection(deviceId); updateConnection(deviceId, event); @@ -61,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 @@ -99,11 +132,12 @@ class UniversalBleWeb extends UniversalBlePlatform { // Update local device list _bluetoothDeviceList[device.id] = device; + _connectionAttemptedDevices.remove(device.id); // 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 +164,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 +190,7 @@ class UniversalBleWeb extends UniversalBlePlatform { @override Future stopScan() async { - _disposeAdvertisementWatcher(); + await _stopAdvertisementWatcher(); } @override @@ -429,7 +460,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); } @@ -476,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. @@ -493,15 +550,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