feat(dfu): add firmware file selection and validation
This commit is contained in:
70
lib/model/firmware_file_selection.dart
Normal file
70
lib/model/firmware_file_selection.dart
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ const int universalShifterDfuPreferredMtu = 128;
|
|||||||
|
|
||||||
const int universalShifterDfuFlagEncrypted = 0x01;
|
const int universalShifterDfuFlagEncrypted = 0x01;
|
||||||
const int universalShifterDfuFlagSigned = 0x02;
|
const int universalShifterDfuFlagSigned = 0x02;
|
||||||
|
const int universalShifterDfuFlagNone = 0x00;
|
||||||
|
|
||||||
const int errorSequence = 1;
|
const int errorSequence = 1;
|
||||||
const int errorFtmsMissing = 2;
|
const int errorFtmsMissing = 2;
|
||||||
|
|||||||
154
lib/service/firmware_file_selection_service.dart
Normal file
154
lib/service/firmware_file_selection_service.dart
Normal file
@ -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<FirmwarePickerSelection?> pickFirmwareFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalFirmwareFilePicker implements FirmwareFilePicker {
|
||||||
|
@override
|
||||||
|
Future<FirmwarePickerSelection?> 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<Uint8List> _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<FirmwareFileSelectionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
|
import file_picker
|
||||||
import flutter_blue_plus_darwin
|
import flutter_blue_plus_darwin
|
||||||
import nb_utils
|
import nb_utils
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
@ -15,6 +16,7 @@ import sqlite3_flutter_libs
|
|||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||||
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
|
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@ -233,6 +233,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -345,6 +353,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -427,6 +443,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
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:
|
flutter_reactive_ble:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1187,6 +1211,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.0"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.13.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -54,6 +54,7 @@ dependencies:
|
|||||||
flutter_reactive_ble: ^5.4.0
|
flutter_reactive_ble: ^5.4.0
|
||||||
nb_utils: ^7.2.0
|
nb_utils: ^7.2.0
|
||||||
cbor: ^6.3.3
|
cbor: ^6.3.3
|
||||||
|
file_picker: ^8.1.7
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
132
test/service/firmware_file_selection_service_test.dart
Normal file
132
test/service/firmware_file_selection_service_test.dart
Normal file
@ -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(<int>[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, <int>[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(<int>[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(<int>[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<FirmwarePickerSelection?> pickFirmwareFile() async {
|
||||||
|
if (error != null) {
|
||||||
|
throw error!;
|
||||||
|
}
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user