From badc66756425ce6e0622631fa58de3eeae8858b5 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 27 Sep 2026 12:29:36 +0200 Subject: [PATCH 1/6] Handle development firmware labels in update comparisons --- .../repository/firmware_image_repository.dart | 31 +++++++++---- test/firmware_version_comparison_test.dart | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 test/firmware_version_comparison_test.dart diff --git a/lib/src/fota/repository/firmware_image_repository.dart b/lib/src/fota/repository/firmware_image_repository.dart index 5bf6de56..a4f31d66 100644 --- a/lib/src/fota/repository/firmware_image_repository.dart +++ b/lib/src/fota/repository/firmware_image_repository.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; import '../model/firmware_update_request.dart'; @@ -87,17 +88,29 @@ class FirmwareImageRepository { return (latestRelease['tag_name'] as String).replaceFirst('v', ''); } - /// Compares semantic version strings and returns `true` when [latest] is - /// newer than [current]. + /// Returns whether [latest] is newer, ignoring build metadata. + /// Unrecognized version labels do not trigger an update recommendation. bool isNewerVersion(String latest, String current) { - List parse(String v) => v.split('.').map(int.parse).toList(); - final latestParts = parse(latest); - final currentParts = parse(current); + Version parse(String label) { + // Older firmware included the C string terminator in its GATT value. + final normalized = label + .trim() + .replaceFirst(RegExp(r'\x00+$'), '') + .trim() + .replaceFirst(RegExp(r'^[vV]'), ''); + final version = Version.parse(normalized); + return Version( + version.major, + version.minor, + version.patch, + pre: version.preRelease.join('.'), + ); + } - for (int i = 0; i < latestParts.length; i++) { - if (latestParts[i] > currentParts[i]) return true; - if (latestParts[i] < currentParts[i]) return false; + try { + return parse(latest) > parse(current); + } on FormatException { + return false; } - return false; } } diff --git a/test/firmware_version_comparison_test.dart b/test/firmware_version_comparison_test.dart new file mode 100644 index 00000000..4a27082d --- /dev/null +++ b/test/firmware_version_comparison_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/src/fota/repository/firmware_image_repository.dart'; + +void main() { + final repository = FirmwareImageRepository(); + + test('compares stable release components numerically', () { + expect(repository.isNewerVersion('2.2.10', '2.2.9'), isTrue); + expect(repository.isNewerVersion('2.3.0', '2.2.10'), isTrue); + expect(repository.isNewerVersion('2.2.9', '2.2.9'), isFalse); + expect(repository.isNewerVersion('2.2.8', '2.2.9'), isFalse); + }); + + test('accepts the development label observed on the earable', () { + const current = '2.2.9-dev.100+g94986934.dirty'; + expect(repository.isNewerVersion('2.2.8', current), isFalse); + expect(repository.isNewerVersion('2.2.9', current), isTrue); + expect(repository.isNewerVersion('2.2.10', current), isTrue); + }); + + test('orders prereleases numerically and below the stable release', () { + expect(repository.isNewerVersion('2.2.9-dev.10', '2.2.9-dev.9'), isTrue); + expect(repository.isNewerVersion('2.2.9-pr292', '2.2.9'), isFalse); + expect(repository.isNewerVersion('2.2.9', '2.2.9-pr292'), isTrue); + }); + + test('ignores hashes and other build metadata', () { + expect(repository.isNewerVersion('2.2.9+zzz', '2.2.9+aaa'), isFalse); + expect( + repository.isNewerVersion('2.2.9-dev.1+gabc', '2.2.9-dev.1+gdef.dirty'), + isFalse, + ); + }); + + test('accepts release tag prefixes and legacy trailing NUL bytes', () { + expect(repository.isNewerVersion('v2.2.9', '2.2.8\u0000'), isTrue); + expect(repository.isNewerVersion(' V2.2.9 ', '2.2.9\u0000'), isFalse); + }); + + test('malformed or incomplete versions do not throw or suggest updates', () { + for (final label in ['', 'unknown', '2', '2.2.x', 'garbage2.2.9']) { + expect(repository.isNewerVersion('2.2.9', label), isFalse); + expect(repository.isNewerVersion(label, '2.2.9'), isFalse); + } + }); +} From 04ea68bd143c0caf0cc751d2df9abc99b242f99d Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 30 Sep 2026 18:38:12 +0200 Subject: [PATCH 2/6] chore(release): prepare 2.4.2 --- CHANGELOG.md | 5 +++++ example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b33c9fd..2c436c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.4.2 + +* fixed firmware update version comparisons for development firmware labels, prereleases, build metadata, and release tag prefixes. +* fixed malformed firmware version labels causing errors or suggesting incorrect updates. + ## 2.4.1 * fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic. diff --git a/example/pubspec.lock b/example/pubspec.lock index 4539e62f..1b6d29d8 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,7 +414,7 @@ packages: path: ".." relative: true source: path - version: "2.4.1" + version: "2.4.2" open_earable_protocols: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b81f4058..728afd22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: open_earable_flutter description: This package provides functionality for interacting with OpenEarable devices. Control LED colors, control audio, and access raw sensor data. -version: 2.4.1 +version: 2.4.2 repository: https://github.com/OpenEarable/open_earable_flutter/tree/main platforms: From 75ee28a48f651e9f8dcf7a1a4638add726d1be6b Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:04:26 +0200 Subject: [PATCH 3/6] fix: clear BLE connections when the adapter powers off --- lib/src/managers/ble_manager.dart | 46 +++++-- test/ble_manager_power_cycle_test.dart | 175 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 test/ble_manager_power_cycle_test.dart diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index ca81d66b..b6b2d9c6 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -33,6 +33,7 @@ class BleManager extends BleGattManager { final Map _disconnectCallbacks = {}; final List _connectedDevicesIds = []; + StreamSubscription? _availabilitySubscription; bool _firstScan = true; @@ -61,6 +62,18 @@ class BleManager extends BleGattManager { void _init() { _scanStreamController = StreamController.broadcast(); + _availabilitySubscription = UniversalBle.availabilityStream.listen((state) { + if (state == AvailabilityState.poweredOff) { + // Adapter shutdown does not always emit a disconnect for each device. + for (final deviceId in { + ..._connectedDevicesIds, + ..._disconnectCallbacks.keys, + }) { + _handleDisconnect(deviceId); + } + } + }); + UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, @@ -72,10 +85,7 @@ class BleManager extends BleGattManager { _connectCallbacks[deviceId]?.call(); _connectCallbacks.remove(deviceId); } else { - _connectedDevicesIds.remove(deviceId); - _closeAndRemoveStreamsForDevice(deviceId); - _disconnectCallbacks[deviceId]?.call(); - _disconnectCallbacks.remove(deviceId); + _handleDisconnect(deviceId); } }; @@ -98,6 +108,12 @@ class BleManager extends BleGattManager { }; } + void _handleDisconnect(String deviceId) { + _connectedDevicesIds.removeWhere((id) => id == deviceId); + _closeAndRemoveStreamsForDevice(deviceId); + _disconnectCallbacks.remove(deviceId)?.call(); + } + static Future checkAndRequestPermissions() async { bool permGranted = false; @@ -232,8 +248,11 @@ class BleManager extends BleGattManager { final completer = Completer<(bool, List)>(); _connectionCompleters[device.id] = completer; - final connectionFuture = completer.future.whenComplete(() { - _connectionFutures.remove(device.id); + late final Future<(bool, List)> connectionFuture; + connectionFuture = completer.future.whenComplete(() { + if (identical(_connectionFutures[device.id], connectionFuture)) { + _connectionFutures.remove(device.id); + } }); _connectionFutures[device.id] = connectionFuture; @@ -245,12 +264,18 @@ class BleManager extends BleGattManager { final services = await UniversalBle.discoverServices(device.id); - _connectionCompleters[device.id]?.complete((true, services)); + if (!completer.isCompleted) { + completer.complete((true, services)); + } } catch (error, stack) { - _connectionCompleters[device.id]?.completeError(error, stack); + if (!completer.isCompleted) { + completer.completeError(error, stack); + } } finally { - _connectionCompleters.remove(device.id); - _connectCallbacks.remove(device.id); + if (identical(_connectionCompleters[device.id], completer)) { + _connectionCompleters.remove(device.id); + _connectCallbacks.remove(device.id); + } } }; @@ -458,6 +483,7 @@ class BleManager extends BleGattManager { /// Cancel connection state subscription void dispose() { + _availabilitySubscription?.cancel(); UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart new file mode 100644 index 00000000..6f345bd0 --- /dev/null +++ b/test/ble_manager_power_cycle_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/managers/ble_manager.dart'; +import 'package:universal_ble/universal_ble.dart'; + +DiscoveredDevice device(String id) => DiscoveredDevice( + id: id, + name: 'Test wearable', + manufacturerData: Uint8List(0), + rssi: -40, + serviceUuids: const [], + ); + +Future flushEvents() => Future.delayed(Duration.zero); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('power off disconnects both peers even without native callbacks', + () async { + final platform = _Platform(); + UniversalBle.setInstance(platform); + final manager = BleManager(); + addTearDown(manager.dispose); + final disconnected = []; + for (final id in ['left', 'right']) { + await manager.connectToDevice(device(id), () => disconnected.add(id)); + } + final stream = await manager.subscribe( + deviceId: 'left', + serviceId: '180f', + characteristicId: '2a19', + ); + final closed = Completer(); + final sub = stream.listen((_) {}, onDone: closed.complete); + addTearDown(sub.cancel); + + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect(manager.isConnected('left'), isFalse); + expect(manager.isConnected('right'), isFalse); + expect(disconnected, ['left', 'right']); + await closed.future.timeout(const Duration(seconds: 1)); + + // Some platforms also deliver per-device events, possibly after power-off. + platform.updateConnection('left', false); + platform.updateConnection('right', false); + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect(disconnected, ['left', 'right']); + + platform.updateAvailability(AvailabilityState.poweredOn); + await manager.connectToDevice( + device('left'), () => disconnected.add('left')); + expect(manager.isConnected('left'), isTrue); + platform.updateConnection('left', false); + expect(disconnected, ['left', 'right', 'left']); + }); + + test( + 'an old service discovery cannot finish a new connection after power off', + () async { + UniversalBle.queueType = QueueType.none; + addTearDown(() => UniversalBle.queueType = QueueType.global); + final oldServices = Completer>(); + final newServices = Completer>(); + final platform = _Platform()..discoveryReplies = [oldServices, newServices]; + UniversalBle.setInstance(platform); + final manager = BleManager(); + addTearDown(manager.dispose); + final first = manager.connectToDevice(device('left'), () {}); + await flushEvents(); + expect(platform.discoveryCalls, 1); + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect((await first).$1, isFalse); + + platform.updateAvailability(AvailabilityState.poweredOn); + var completed = false; + final second = + manager.connectToDevice(device('left'), () {}).then((result) { + completed = true; + return result; + }); + await flushEvents(); + expect(platform.discoveryCalls, 2); + oldServices.complete([]); + await flushEvents(); + expect(completed, isFalse); + final expected = [BleService('180f', [])]; + newServices.complete(expected); + expect((await second).$2, expected); + }); + + test('wearable manager permits reconnect after a missing disconnect event', + () async { + final platform = _Platform(); + UniversalBle.setInstance(platform); + final manager = WearableManager(); + manager.clearWearableFactories(); + manager.addWearableFactory(_Factory()); + addTearDown(manager.dispose); + final left = await manager.connectToDevice(device('left')); + final right = await manager.connectToDevice(device('right')); + var disconnects = 0; + left.addDisconnectListener(() => disconnects++); + right.addDisconnectListener(() => disconnects++); + + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect(disconnects, 2); + platform.updateAvailability(AvailabilityState.poweredOn); + final newLeft = await manager.connectToDevice(device('left')); + final newRight = await manager.connectToDevice(device('right')); + expect(identical(newLeft, left), isFalse); + expect(identical(newRight, right), isFalse); + }); +} + +class _Platform extends UniversalBlePlatform { + List>>? discoveryReplies; + int discoveryCalls = 0; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + @override + Future getBluetoothAvailabilityState() async => + AvailabilityState.poweredOn; + @override + Future connect(String deviceId, + {Duration? connectionTimeout, + bool autoConnect = false, + Object? platformConfig}) async { + updateConnection(deviceId, true); + } + + @override + Future requestMtu(String deviceId, int expectedMtu) async => expectedMtu; + @override + Future> discoverServices( + String deviceId, bool withDescriptors) async { + final index = discoveryCalls++; + return discoveryReplies == null + ? [] + : await discoveryReplies![index].future; + } + + @override + Future setNotifiable(String deviceId, String service, + String characteristic, BleInputProperty property) async {} + @override + Future stopScan() async {} +} + +class _Factory extends WearableFactory { + @override + Future matches( + DiscoveredDevice device, List services) async => + true; + @override + Future createFromDevice(DiscoveredDevice device, + {Set options = const {}}) async => + _Wearable(device.id, disconnectNotifier!); +} + +class _Wearable extends Wearable { + _Wearable(this.deviceId, WearableDisconnectNotifier notifier) + : super(name: 'Test wearable', disconnectNotifier: notifier); + @override + final String deviceId; + @override + Future disconnect() async {} +} From fa73f0992a10f8cdc283cc266873377c79085de4 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:04:58 +0200 Subject: [PATCH 4/6] style: satisfy regression test formatting --- test/ble_manager_power_cycle_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart index 6f345bd0..3607b01d 100644 --- a/test/ble_manager_power_cycle_test.dart +++ b/test/ble_manager_power_cycle_test.dart @@ -54,7 +54,7 @@ void main() { platform.updateAvailability(AvailabilityState.poweredOn); await manager.connectToDevice( - device('left'), () => disconnected.add('left')); + device('left'), () => disconnected.add('left'),); expect(manager.isConnected('left'), isTrue); platform.updateConnection('left', false); expect(disconnected, ['left', 'right', 'left']); @@ -132,7 +132,7 @@ class _Platform extends UniversalBlePlatform { Future connect(String deviceId, {Duration? connectionTimeout, bool autoConnect = false, - Object? platformConfig}) async { + Object? platformConfig,}) async { updateConnection(deviceId, true); } @@ -140,7 +140,7 @@ class _Platform extends UniversalBlePlatform { Future requestMtu(String deviceId, int expectedMtu) async => expectedMtu; @override Future> discoverServices( - String deviceId, bool withDescriptors) async { + String deviceId, bool withDescriptors,) async { final index = discoveryCalls++; return discoveryReplies == null ? [] @@ -149,7 +149,7 @@ class _Platform extends UniversalBlePlatform { @override Future setNotifiable(String deviceId, String service, - String characteristic, BleInputProperty property) async {} + String characteristic, BleInputProperty property,) async {} @override Future stopScan() async {} } @@ -157,11 +157,11 @@ class _Platform extends UniversalBlePlatform { class _Factory extends WearableFactory { @override Future matches( - DiscoveredDevice device, List services) async => + DiscoveredDevice device, List services,) async => true; @override Future createFromDevice(DiscoveredDevice device, - {Set options = const {}}) async => + {Set options = const {},}) async => _Wearable(device.id, disconnectNotifier!); } From 7c442040fd4d44c2e5f51b9ed1cb0f361d8063f6 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:15:11 +0200 Subject: [PATCH 5/6] fix: release native GATT handles before reconnecting --- lib/src/managers/ble_manager.dart | 9 +++- test/ble_manager_power_cycle_test.dart | 70 ++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index b6b2d9c6..60030f5d 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -34,6 +34,7 @@ class BleManager extends BleGattManager { final List _connectedDevicesIds = []; StreamSubscription? _availabilitySubscription; + final Map> _adapterShutdowns = {}; bool _firstScan = true; @@ -70,6 +71,11 @@ class BleManager extends BleGattManager { ..._disconnectCallbacks.keys, }) { _handleDisconnect(deviceId); + // Android may retain a GATT handle tied to the stopped BT service. + _adapterShutdowns[deviceId] ??= + UniversalBle.disconnect(deviceId).catchError((Object error) { + logger.w('Bluetooth shutdown cleanup failed for $deviceId: $error'); + }).whenComplete(() => _adapterShutdowns.remove(deviceId)); } } }); @@ -235,7 +241,8 @@ class BleManager extends BleGattManager { Future<(bool, List)> connectToDevice( DiscoveredDevice device, VoidCallback onDisconnect, - ) { + ) async { + await _adapterShutdowns[device.id]; final pendingConnection = _connectionFutures[device.id]; if (pendingConnection != null) { logger.d("Reusing pending connection for ${device.id}"); diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart index 3607b01d..8ebc8a36 100644 --- a/test/ble_manager_power_cycle_test.dart +++ b/test/ble_manager_power_cycle_test.dart @@ -43,6 +43,7 @@ void main() { expect(manager.isConnected('left'), isFalse); expect(manager.isConnected('right'), isFalse); expect(disconnected, ['left', 'right']); + expect(platform.disconnects, ['left', 'right']); await closed.future.timeout(const Duration(seconds: 1)); // Some platforms also deliver per-device events, possibly after power-off. @@ -54,12 +55,33 @@ void main() { platform.updateAvailability(AvailabilityState.poweredOn); await manager.connectToDevice( - device('left'), () => disconnected.add('left'),); + device('left'), + () => disconnected.add('left'), + ); expect(manager.isConnected('left'), isTrue); platform.updateConnection('left', false); expect(disconnected, ['left', 'right', 'left']); }); + test('reconnect waits until the old native handle is released', () async { + final platform = _Platform(); + UniversalBle.setInstance(platform); + final manager = BleManager(); + addTearDown(manager.dispose); + await manager.connectToDevice(device('left'), () {}); + final cleanup = Completer(); + platform.disconnectGate = cleanup.future; + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + platform.updateAvailability(AvailabilityState.poweredOn); + final reconnected = manager.connectToDevice(device('left'), () {}); + await flushEvents(); + expect(platform.connectCalls, 1); + cleanup.complete(); + expect((await reconnected).$1, isTrue); + expect(platform.connectCalls, 2); + }); + test( 'an old service discovery cannot finish a new connection after power off', () async { @@ -123,24 +145,42 @@ void main() { class _Platform extends UniversalBlePlatform { List>>? discoveryReplies; int discoveryCalls = 0; + int connectCalls = 0; + final disconnects = []; + Future? disconnectGate; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @override Future getBluetoothAvailabilityState() async => AvailabilityState.poweredOn; @override - Future connect(String deviceId, - {Duration? connectionTimeout, - bool autoConnect = false, - Object? platformConfig,}) async { + Future connect( + String deviceId, { + Duration? connectionTimeout, + bool autoConnect = false, + Object? platformConfig, + }) async { + connectCalls++; updateConnection(deviceId, true); } + @override + Future getConnectionState(String deviceId) async => + BleConnectionState.disconnected; + @override + Future disconnect(String deviceId) async { + disconnects.add(deviceId); + await disconnectGate; + updateConnection(deviceId, false); + } + @override Future requestMtu(String deviceId, int expectedMtu) async => expectedMtu; @override Future> discoverServices( - String deviceId, bool withDescriptors,) async { + String deviceId, + bool withDescriptors, + ) async { final index = discoveryCalls++; return discoveryReplies == null ? [] @@ -148,8 +188,12 @@ class _Platform extends UniversalBlePlatform { } @override - Future setNotifiable(String deviceId, String service, - String characteristic, BleInputProperty property,) async {} + Future setNotifiable( + String deviceId, + String service, + String characteristic, + BleInputProperty property, + ) async {} @override Future stopScan() async {} } @@ -157,11 +201,15 @@ class _Platform extends UniversalBlePlatform { class _Factory extends WearableFactory { @override Future matches( - DiscoveredDevice device, List services,) async => + DiscoveredDevice device, + List services, + ) async => true; @override - Future createFromDevice(DiscoveredDevice device, - {Set options = const {},}) async => + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async => _Wearable(device.id, disconnectNotifier!); } From 34f45873ef046a92acb37382a9e855be922445f8 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:16:15 +0200 Subject: [PATCH 6/6] fix: complete native shutdown cleanup without chaining itself --- lib/src/managers/ble_manager.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index 60030f5d..f5eeb75c 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -75,7 +75,9 @@ class BleManager extends BleGattManager { _adapterShutdowns[deviceId] ??= UniversalBle.disconnect(deviceId).catchError((Object error) { logger.w('Bluetooth shutdown cleanup failed for $deviceId: $error'); - }).whenComplete(() => _adapterShutdowns.remove(deviceId)); + }).whenComplete(() { + _adapterShutdowns.remove(deviceId); + }); } } });