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 universalShifterDfuFlagSigned = 0x02;
|
||||
const int universalShifterDfuFlagNone = 0x00;
|
||||
|
||||
const int errorSequence = 1;
|
||||
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 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"))
|
||||
|
||||
32
pubspec.lock
32
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:
|
||||
|
||||
@ -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:
|
||||
|
||||
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