diff --git a/CHANGELOG.md b/CHANGELOG.md index bb04eec3..d92911fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ - Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. - Accept firmware 2.3.x in the OpenEarable V2 support range. +## 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/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/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index ca81d66b..f5eeb75c 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -33,6 +33,8 @@ class BleManager extends BleGattManager { final Map _disconnectCallbacks = {}; final List _connectedDevicesIds = []; + StreamSubscription? _availabilitySubscription; + final Map> _adapterShutdowns = {}; bool _firstScan = true; @@ -61,6 +63,25 @@ 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); + // 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); + }); + } + } + }); + UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, @@ -72,10 +93,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 +116,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; @@ -219,7 +243,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}"); @@ -232,8 +257,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 +273,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 +492,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..8ebc8a36 --- /dev/null +++ b/test/ble_manager_power_cycle_test.dart @@ -0,0 +1,223 @@ +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']); + expect(platform.disconnects, ['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('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 { + 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; + 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 { + 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 { + 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 {} +} 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); + } + }); +}