feat(dfu): add firmware file selection and validation

This commit is contained in:
2026-03-03 17:06:54 +01:00
parent 8b24084f97
commit aafa9928ac
7 changed files with 392 additions and 0 deletions

View 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);
}
}

View File

@ -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;

View 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);
}
}

View File

@ -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"))

View File

@ -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:

View File

@ -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:

View 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;
}
}