From aafa9928ac915996243ee85a7b80e0f310aa5e42 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 3 Mar 2026 17:06:54 +0100 Subject: [PATCH] feat(dfu): add firmware file selection and validation --- lib/model/firmware_file_selection.dart | 70 ++++++++ lib/model/shifter_types.dart | 1 + .../firmware_file_selection_service.dart | 154 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 32 ++++ pubspec.yaml | 1 + .../firmware_file_selection_service_test.dart | 132 +++++++++++++++ 7 files changed, 392 insertions(+) create mode 100644 lib/model/firmware_file_selection.dart create mode 100644 lib/service/firmware_file_selection_service.dart create mode 100644 test/service/firmware_file_selection_service_test.dart diff --git a/lib/model/firmware_file_selection.dart b/lib/model/firmware_file_selection.dart new file mode 100644 index 0000000..22e952a --- /dev/null +++ b/lib/model/firmware_file_selection.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +class DfuV1FirmwareMetadata { + const DfuV1FirmwareMetadata({ + required this.totalLength, + required this.crc32, + required this.sessionId, + required this.flags, + }); + + final int totalLength; + final int crc32; + final int sessionId; + final int flags; +} + +class DfuV1PreparedFirmware { + const DfuV1PreparedFirmware({ + required this.fileName, + required this.fileBytes, + required this.metadata, + this.filePath, + }); + + final String fileName; + final String? filePath; + final Uint8List fileBytes; + final DfuV1FirmwareMetadata metadata; +} + +enum FirmwareSelectionFailureReason { + canceled, + malformedSelection, + unsupportedExtension, + emptyFile, + readFailed, +} + +class FirmwareSelectionFailure { + const FirmwareSelectionFailure({ + required this.reason, + required this.message, + }); + + final FirmwareSelectionFailureReason reason; + final String message; +} + +class FirmwareFileSelectionResult { + const FirmwareFileSelectionResult._({ + this.firmware, + this.failure, + }); + + final DfuV1PreparedFirmware? firmware; + final FirmwareSelectionFailure? failure; + + bool get isSuccess => firmware != null; + + bool get isCanceled => + failure?.reason == FirmwareSelectionFailureReason.canceled; + + static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) { + return FirmwareFileSelectionResult._(firmware: firmware); + } + + static FirmwareFileSelectionResult failed(FirmwareSelectionFailure failure) { + return FirmwareFileSelectionResult._(failure: failure); + } +} diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index e51fbce..f0930f6 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -31,6 +31,7 @@ const int universalShifterDfuPreferredMtu = 128; const int universalShifterDfuFlagEncrypted = 0x01; const int universalShifterDfuFlagSigned = 0x02; +const int universalShifterDfuFlagNone = 0x00; const int errorSequence = 1; const int errorFtmsMissing = 2; diff --git a/lib/service/firmware_file_selection_service.dart b/lib/service/firmware_file_selection_service.dart new file mode 100644 index 0000000..1f9be2f --- /dev/null +++ b/lib/service/firmware_file_selection_service.dart @@ -0,0 +1,154 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:abawo_bt_app/model/firmware_file_selection.dart'; +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:abawo_bt_app/service/dfu_protocol.dart'; +import 'package:file_picker/file_picker.dart'; + +typedef SessionIdGenerator = int Function(); + +class FirmwarePickerSelection { + const FirmwarePickerSelection({ + required this.fileName, + required this.fileBytes, + this.filePath, + }); + + final String fileName; + final Uint8List fileBytes; + final String? filePath; +} + +abstract interface class FirmwareFilePicker { + Future pickFirmwareFile(); +} + +class LocalFirmwareFilePicker implements FirmwareFilePicker { + @override + Future pickFirmwareFile() async { + final pickResult = await FilePicker.platform.pickFiles( + allowMultiple: false, + withData: true, + type: FileType.custom, + allowedExtensions: const ['bin'], + ); + if (pickResult == null) { + return null; + } + if (pickResult.files.isEmpty) { + return FirmwarePickerSelection( + fileName: '', + fileBytes: Uint8List(0), + ); + } + + final selected = pickResult.files.first; + final bytes = selected.bytes ?? await _readFromPath(selected.path); + + return FirmwarePickerSelection( + fileName: selected.name, + filePath: selected.path, + fileBytes: bytes, + ); + } + + Future _readFromPath(String? path) async { + if (path == null || path.trim().isEmpty) { + throw const FileSystemException( + 'Selected file did not contain readable bytes or a valid path.', + ); + } + final file = File(path); + return file.readAsBytes(); + } +} + +class FirmwareFileSelectionService { + FirmwareFileSelectionService({ + required FirmwareFilePicker filePicker, + SessionIdGenerator? sessionIdGenerator, + }) : _filePicker = filePicker, + _sessionIdGenerator = sessionIdGenerator ?? _randomSessionId; + + final FirmwareFilePicker _filePicker; + final SessionIdGenerator _sessionIdGenerator; + + Future selectAndPrepareDfuV1() async { + final FirmwarePickerSelection? selection; + try { + selection = await _filePicker.pickFirmwareFile(); + } catch (error) { + return FirmwareFileSelectionResult.failed( + FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.readFailed, + message: 'Could not read selected firmware file: $error', + ), + ); + } + + if (selection == null) { + return FirmwareFileSelectionResult.failed( + const FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.canceled, + message: 'Firmware selection canceled.', + ), + ); + } + + final fileName = selection.fileName.trim(); + if (fileName.isEmpty) { + return FirmwareFileSelectionResult.failed( + const FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.malformedSelection, + message: 'Selected firmware file is missing a valid name.', + ), + ); + } + + if (!_hasBinExtension(fileName)) { + return FirmwareFileSelectionResult.failed( + FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.unsupportedExtension, + message: + 'Unsupported firmware file "$fileName". Please select a .bin file.', + ), + ); + } + + if (selection.fileBytes.isEmpty) { + return FirmwareFileSelectionResult.failed( + FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.emptyFile, + message: + 'Selected firmware file "$fileName" is empty. Choose a non-empty .bin file.', + ), + ); + } + + final metadata = DfuV1FirmwareMetadata( + totalLength: selection.fileBytes.length, + crc32: DfuProtocol.crc32(selection.fileBytes), + sessionId: _sessionIdGenerator() & 0xFF, + flags: universalShifterDfuFlagNone, + ); + + return FirmwareFileSelectionResult.success( + DfuV1PreparedFirmware( + fileName: fileName, + filePath: selection.filePath, + fileBytes: selection.fileBytes, + metadata: metadata, + ), + ); + } + + bool _hasBinExtension(String fileName) { + return fileName.toLowerCase().endsWith('.bin'); + } + + static int _randomSessionId() { + return Random.secure().nextInt(256); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 080d439..451942c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import connectivity_plus +import file_picker import flutter_blue_plus_darwin import nb_utils import path_provider_foundation @@ -15,6 +16,7 @@ import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index c34064e..bce48e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -345,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" fixnum: dependency: transitive description: @@ -427,6 +443,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_reactive_ble: dependency: "direct main" description: @@ -1187,6 +1211,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 43c044a..7461c6c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: flutter_reactive_ble: ^5.4.0 nb_utils: ^7.2.0 cbor: ^6.3.3 + file_picker: ^8.1.7 dev_dependencies: flutter_test: diff --git a/test/service/firmware_file_selection_service_test.dart b/test/service/firmware_file_selection_service_test.dart new file mode 100644 index 0000000..7876b97 --- /dev/null +++ b/test/service/firmware_file_selection_service_test.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import 'package:abawo_bt_app/model/firmware_file_selection.dart'; +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:abawo_bt_app/service/firmware_file_selection_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FirmwareFileSelectionService', () { + test('prepares v1 metadata for selected .bin firmware', () async { + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.BIN', + filePath: '/tmp/firmware.BIN', + fileBytes: Uint8List.fromList([1, 2, 3, 4]), + ), + ), + sessionIdGenerator: () => 0x1AB, + ); + + final result = await service.selectAndPrepareDfuV1(); + expect(result.isSuccess, isTrue); + + final firmware = result.firmware!; + expect(firmware.fileName, 'firmware.BIN'); + expect(firmware.filePath, '/tmp/firmware.BIN'); + expect(firmware.fileBytes, [1, 2, 3, 4]); + expect(firmware.metadata.totalLength, 4); + expect(firmware.metadata.crc32, 0xB63CFBCD); + expect(firmware.metadata.sessionId, 0xAB); + expect(firmware.metadata.flags, universalShifterDfuFlagNone); + }); + + test('returns canceled result when user dismisses picker', () async { + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker(selection: null), + ); + + final result = await service.selectAndPrepareDfuV1(); + + expect(result.isSuccess, isFalse); + expect(result.isCanceled, isTrue); + expect(result.failure?.reason, FirmwareSelectionFailureReason.canceled); + }); + + test('rejects unsupported extension', () async { + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.hex', + fileBytes: Uint8List.fromList([1]), + ), + ), + ); + + final result = await service.selectAndPrepareDfuV1(); + + expect(result.isSuccess, isFalse); + expect(result.failure?.reason, + FirmwareSelectionFailureReason.unsupportedExtension); + }); + + test('rejects empty payload', () async { + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.bin', + fileBytes: Uint8List(0), + ), + ), + ); + + final result = await service.selectAndPrepareDfuV1(); + + expect(result.isSuccess, isFalse); + expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile); + }); + + test('generates session id per run', () async { + var nextSession = 9; + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.bin', + fileBytes: Uint8List.fromList([10]), + ), + ), + sessionIdGenerator: () => nextSession++, + ); + + final first = await service.selectAndPrepareDfuV1(); + final second = await service.selectAndPrepareDfuV1(); + + expect(first.firmware?.metadata.sessionId, 9); + expect(second.firmware?.metadata.sessionId, 10); + }); + + test('maps picker read failure to explicit validation error', () async { + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: null, + error: const FormatException('broken pick payload'), + ), + ); + + final result = await service.selectAndPrepareDfuV1(); + + expect(result.isSuccess, isFalse); + expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed); + expect(result.failure?.message, contains('broken pick payload')); + }); + }); +} + +class _FakeFirmwareFilePicker implements FirmwareFilePicker { + _FakeFirmwareFilePicker({ + required this.selection, + this.error, + }); + + final FirmwarePickerSelection? selection; + final Object? error; + + @override + Future pickFirmwareFile() async { + if (error != null) { + throw error!; + } + return selection; + } +}