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/bloc/update_bloc.dart b/lib/src/fota/bloc/update_bloc.dart index 3bf94c59..496d190a 100644 --- a/lib/src/fota/bloc/update_bloc.dart +++ b/lib/src/fota/bloc/update_bloc.dart @@ -32,10 +32,28 @@ class UpdateBloc extends Bloc { _abortRequested = false; await _cancelSubscriptions(); - _firmwareUpdateManager = await handler.handleFirmwareUpdate( - firmwareUpdateRequest, - (FirmwareUpdateState state) => add(_StateConverter.convert(state)), - ); + try { + _firmwareUpdateManager = await handler.handleFirmwareUpdate( + firmwareUpdateRequest, + (FirmwareUpdateState state) { + if (_abortRequested || isClosed) { + throw StateError('Firmware update was aborted'); + } + add(_StateConverter.convert(state)); + }, + ); + } catch (error) { + if (!_abortRequested && !isClosed) { + add(UploadFailed(error.toString())); + } + return; + } + if (_abortRequested || isClosed) { + await _firmwareUpdateManager?.cancel(); + await _firmwareUpdateManager?.kill(); + _firmwareUpdateManager = null; + return; + } final progressStream = _firmwareUpdateManager!.progressStream .map( @@ -104,14 +122,17 @@ class UpdateBloc extends Bloc { ); }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; _state = _updatedState(UpdateFirmware('Download firmware')); emit(_state!); }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; _state = _updatedState(UpdateFirmware('Unpack firmware')); emit(_state!); }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; if (event is UploadProgress) { _state = _updatedState( UpdateProgressFirmware( @@ -127,6 +148,7 @@ class UpdateBloc extends Bloc { } }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; _state = _updatedState( UpdateCompleteSuccess(), updateManager: _firmwareUpdateManager, @@ -165,6 +187,16 @@ class UpdateBloc extends Bloc { UpdateFirmware currentState, { FirmwareUpdateManager? updateManager, }) { + if (currentState is UpdateCompleteSuccess || + currentState is UpdateCompleteFailure || + currentState is UpdateCompleteAborted) { + return UpdateFirmwareStateHistory( + null, + [...?_state?.history, currentState], + isComplete: true, + updateManager: updateManager, + ); + } if (_state == null) { return UpdateFirmwareStateHistory( currentState, @@ -191,15 +223,6 @@ class UpdateBloc extends Bloc { updateManager: updateManager, ); } - } else if (currentState is UpdateCompleteSuccess || - currentState is UpdateCompleteFailure || - currentState is UpdateCompleteAborted) { - return UpdateFirmwareStateHistory( - null, - _state!.history + [currentState], - isComplete: true, - updateManager: updateManager, - ); } else { return UpdateFirmwareStateHistory( currentState, diff --git a/lib/src/fota/handlers/firmware_update_handler.dart b/lib/src/fota/handlers/firmware_update_handler.dart index 6c705123..fc74e1b9 100644 --- a/lib/src/fota/handlers/firmware_update_handler.dart +++ b/lib/src/fota/handlers/firmware_update_handler.dart @@ -97,48 +97,49 @@ class FirmwareUnpacker extends FirmwareUpdateHandler { final tempDir = Directory('${systemTempDir.path}/$prefix'); await tempDir.create(); - final firmware = request as MultiImageFirmwareUpdateRequest; - final firmwareFileData = firmware.zipFile!; - final firmwareFile = File('${tempDir.path}/firmware.zip'); - await firmwareFile.writeAsBytes(firmwareFileData); - - final destinationDir = Directory('${tempDir.path}/firmware'); - await destinationDir.create(); try { - await ZipFile.extractToDirectory( - zipFile: firmwareFile, - destinationDir: destinationDir, - ); - } catch (e) { - throw Exception('Failed to unzip firmware'); - } + final firmware = request as MultiImageFirmwareUpdateRequest; + final firmwareFileData = firmware.zipFile!; + final firmwareFile = File('${tempDir.path}/firmware.zip'); + await firmwareFile.writeAsBytes(firmwareFileData); + + final destinationDir = Directory('${tempDir.path}/firmware'); + await destinationDir.create(); + try { + await ZipFile.extractToDirectory( + zipFile: firmwareFile, + destinationDir: destinationDir, + ); + } catch (e) { + throw Exception('Failed to unzip firmware'); + } - // read manifest.json - final manifestFile = File('${destinationDir.path}/manifest.json'); - final manifestString = await manifestFile.readAsString(); - Map manifestJson = json.decode(manifestString); - Manifest manifest; + // read manifest.json + final manifestFile = File('${destinationDir.path}/manifest.json'); + final manifestString = await manifestFile.readAsString(); + Map manifestJson = json.decode(manifestString); + Manifest manifest; - try { - manifest = Manifest.fromJson(manifestJson); - } catch (e) { - throw Exception('Failed to parse manifest.json'); - } + try { + manifest = Manifest.fromJson(manifestJson); + } catch (e) { + throw Exception('Failed to parse manifest.json'); + } - firmware.firmwareImages = []; - for (final file in manifest.files) { - final firmwareFile = File('${destinationDir.path}/${file.file}'); - final firmwareFileData = await firmwareFile.readAsBytes(); - final image = Image( - image: file.image, - data: firmwareFileData, - ); - firmware.firmwareImages!.add(image); + firmware.firmwareImages = []; + for (final file in manifest.files) { + final firmwareFile = File('${destinationDir.path}/${file.file}'); + final firmwareFileData = await firmwareFile.readAsBytes(); + final image = Image( + image: file.image, + data: firmwareFileData, + ); + firmware.firmwareImages!.add(image); + } + } finally { + await tempDir.delete(recursive: true); } - // delete tempDir - await tempDir.delete(recursive: true); - return await _nextHandler!.handleFirmwareUpdate(request, callback); } } 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); + } + }); +} diff --git a/test/fota_preparation_failure_test.dart b/test/fota_preparation_failure_test.dart new file mode 100644 index 00000000..1396d132 --- /dev/null +++ b/test/fota_preparation_failure_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:open_earable_flutter/src/fota/bloc/update_bloc.dart'; +import 'package:open_earable_flutter/src/fota/handlers/firmware_update_handler.dart'; +import 'package:open_earable_flutter/src/fota/model/firmware_update_request.dart'; + +class _Handler extends FirmwareUpdateHandler { + final Future Function(FirmwareUpdateCallback?) run; + _Handler(this.run); + @override + Future handleFirmwareUpdate( + FirmwareUpdateRequest request, + FirmwareUpdateCallback? callback, + ) => + run(callback); +} + +class _Bloc extends UpdateBloc { + final FirmwareUpdateHandler handler; + _Bloc(this.handler) : super(firmwareUpdateRequest: FirmwareUpdateRequest()); + @override + FirmwareUpdateHandler createFirmwareUpdateHandler() => handler; +} + +Future completed(UpdateBloc bloc) => bloc.stream + .where((s) => s is UpdateFirmwareStateHistory && s.isComplete) + .cast() + .first + .timeout(const Duration(seconds: 1)); + +void main() { + for (final unpackStarted in [false, true]) { + test('preparation failure completes (unpack started: $unpackStarted)', + () async { + final bloc = _Bloc(_Handler((callback) async { + if (unpackStarted) callback?.call(FirmwareUnpackStarted()); + throw const FormatException('Invalid firmware manifest'); + }),); + addTearDown(bloc.close); + final result = completed(bloc); + bloc.add(BeginUpdateProcess()); + final state = await result; + expect(state.currentState, isNull); + expect(state.history.last, isA()); + expect((state.history.last as UpdateCompleteFailure).error, + contains('Invalid firmware manifest'),); + }); + } + + test('one abort completes before any progress history exists', () async { + final bloc = + _Bloc(_Handler((_) => Completer().future)); + addTearDown(bloc.close); + final result = completed(bloc); + bloc.add(AbortUpdate()); + expect((await result).history.last, isA()); + }); + + test('abort during preparation stops the next upload stage', () async { + final resume = Completer(); + var uploadStarted = false; + final bloc = _Bloc(_Handler((callback) async { + callback?.call(FirmwareUnpackStarted()); + await resume.future; + callback?.call(FirmwareUploadStarted()); + uploadStarted = true; + throw StateError('Must not start uploading'); + }),); + addTearDown(bloc.close); + final unpack = + bloc.stream.firstWhere((s) => s is UpdateFirmwareStateHistory); + bloc.add(BeginUpdateProcess()); + await unpack; + final result = completed(bloc); + bloc.add(AbortUpdate()); + expect((await result).history.last, isA()); + resume.complete(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(uploadStarted, isFalse); + expect((bloc.state as UpdateFirmwareStateHistory).isComplete, isTrue); + expect((bloc.state as UpdateFirmwareStateHistory).history.last, + isA(),); + }); +} diff --git a/test/fota_unpacker_test.dart b/test/fota_unpacker_test.dart new file mode 100644 index 00000000..027749c8 --- /dev/null +++ b/test/fota_unpacker_test.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:open_earable_flutter/src/fota/handlers/firmware_update_handler.dart'; +import 'package:open_earable_flutter/src/fota/model/firmware_update_request.dart'; + +class _Manager implements FirmwareUpdateManager { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Upload extends FirmwareUpdateHandler { + int calls = 0; + @override + Future handleFirmwareUpdate( + FirmwareUpdateRequest request, + FirmwareUpdateCallback? callback, + ) async { + calls++; + return _Manager(); + } +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + late Directory temp; + const pathChannel = MethodChannel('plugins.flutter.io/path_provider'); + const archiveChannel = MethodChannel('flutter_archive'); + setUp(() async { + temp = await Directory.systemTemp.createTemp('fota-test-'); + binding.defaultBinaryMessenger + .setMockMethodCallHandler(pathChannel, (_) async => temp.path); + }); + tearDown(() async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(pathChannel, null); + binding.defaultBinaryMessenger + .setMockMethodCallHandler(archiveChannel, null); + await temp.delete(recursive: true); + }); + for (final kind in [ + 'archive', + 'json', + 'manifest', + 'missing image', + 'valid', + ]) { + test('unpacker cleans temporary files after $kind', () async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(archiveChannel, + (call) async { + if (kind == 'archive') throw PlatformException(code: 'invalid zip'); + final dir = call.arguments['destinationDir'] as String; + final manifest = kind == 'json' + ? '{' + : kind == 'manifest' + ? '{"format-version":1,"time":0,"files":"invalid-test-value"}' + : '{"format-version":1,"time":0,"files":[{"file":"app.bin"}]}'; + await File('$dir/manifest.json').writeAsString(manifest); + if (kind == 'valid') await File('$dir/app.bin').writeAsBytes([1, 2, 3]); + return null; + }); + final upload = _Upload(); + final handler = FirmwareUnpacker()..setNextHandler(upload); + final request = MultiImageFirmwareUpdateRequest( + firmware: LocalFirmware( + name: 'test.zip', + data: Uint8List(0), + type: FirmwareType.multiImage,), + zipFile: Uint8List(0), + ); + if (kind == 'valid') { + await handler.handleFirmwareUpdate(request, null); + expect(upload.calls, 1); + expect(request.firmwareImages!.single.data, [1, 2, 3]); + } else { + await expectLater( + handler.handleFirmwareUpdate(request, null), throwsA(anything),); + expect(upload.calls, 0); + } + expect(await temp.list().toList(), isEmpty); + }); + } +}