Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 85 additions & 21 deletions lib/src/universal_ble_web/universal_ble_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class UniversalBleWeb extends UniversalBlePlatform {
final Map<String, StreamSubscription> _connectedDeviceStreamList = {};
final Map<String, StreamSubscription> _characteristicStreamList = {};
final Map<String, List<_UniversalWebBluetoothService>> _serviceCache = {};
final Set<String> _connectionAttemptedDevices = {};
bool _isScanning = false;

@override
Expand Down Expand Up @@ -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);
Expand All @@ -61,7 +76,25 @@ class UniversalBleWeb extends UniversalBlePlatform {

@override
Future<void> 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
Expand Down Expand Up @@ -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")) {
Expand All @@ -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,
Expand All @@ -159,7 +190,7 @@ class UniversalBleWeb extends UniversalBlePlatform {

@override
Future<void> stopScan() async {
_disposeAdvertisementWatcher();
await _stopAdvertisementWatcher();
}

@override
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -476,6 +507,32 @@ class UniversalBleWeb extends UniversalBlePlatform {

BluetoothDevice? _getDeviceById(String id) => _bluetoothDeviceList[id];

Future<void> _waitForFreshAdvertisement(BluetoothDevice device) async {
if (!device.hasWatchAdvertisements()) return;

final ready = Completer<void>();
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<void>.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.
Expand All @@ -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<void> _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
Expand Down
Loading