Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 36 additions & 13 deletions lib/src/fota/bloc/update_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,28 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
_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(
Expand Down Expand Up @@ -104,14 +122,17 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
);
});
on<DownloadStarted>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
_state = _updatedState(UpdateFirmware('Download firmware'));
emit(_state!);
});
on<UnpackStarted>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
_state = _updatedState(UpdateFirmware('Unpack firmware'));
emit(_state!);
});
on<UploadState>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
if (event is UploadProgress) {
_state = _updatedState(
UpdateProgressFirmware(
Expand All @@ -127,6 +148,7 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
}
});
on<UploadFinished>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
_state = _updatedState(
UpdateCompleteSuccess(),
updateManager: _firmwareUpdateManager,
Expand Down Expand Up @@ -165,6 +187,16 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
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,
Expand All @@ -191,15 +223,6 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
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,
Expand Down
73 changes: 37 additions & 36 deletions lib/src/fota/handlers/firmware_update_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> manifestJson = json.decode(manifestString);
Manifest manifest;
// read manifest.json
final manifestFile = File('${destinationDir.path}/manifest.json');
final manifestString = await manifestFile.readAsString();
Map<String, dynamic> 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);
}
}
Expand Down
31 changes: 22 additions & 9 deletions lib/src/fota/repository/firmware_image_repository.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<int> 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;
}
}
46 changes: 46 additions & 0 deletions test/firmware_version_comparison_test.dart
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
86 changes: 86 additions & 0 deletions test/fota_preparation_failure_test.dart
Original file line number Diff line number Diff line change
@@ -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<FirmwareUpdateManager> Function(FirmwareUpdateCallback?) run;
_Handler(this.run);
@override
Future<FirmwareUpdateManager> 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<UpdateFirmwareStateHistory> completed(UpdateBloc bloc) => bloc.stream
.where((s) => s is UpdateFirmwareStateHistory && s.isComplete)
.cast<UpdateFirmwareStateHistory>()
.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<UpdateCompleteFailure>());
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<FirmwareUpdateManager>().future));
addTearDown(bloc.close);
final result = completed(bloc);
bloc.add(AbortUpdate());
expect((await result).history.last, isA<UpdateCompleteAborted>());
});

test('abort during preparation stops the next upload stage', () async {
final resume = Completer<void>();
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<UpdateCompleteAborted>());
resume.complete();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(uploadStarted, isFalse);
expect((bloc.state as UpdateFirmwareStateHistory).isComplete, isTrue);
expect((bloc.state as UpdateFirmwareStateHistory).history.last,
isA<UpdateCompleteAborted>(),);
});
}
Loading
Loading