From 05668a2bcbbcde87e9cfa7ebc4b9724cabc2abbc Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Mon, 5 Oct 2026 16:19:24 +0200 Subject: [PATCH] Preserve FOTA logs before opening image recovery --- .../fota/logger_screen/logger_screen.dart | 5 +- .../fota/stepper_view/update_view.dart | 26 ++- .../test/widgets/fota_recovery_log_test.dart | 207 ++++++++++++++++++ 3 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 open_wearable/test/widgets/fota_recovery_log_test.dart diff --git a/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart b/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart index ac84ca54a..a326d1872 100644 --- a/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart +++ b/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart @@ -3,8 +3,9 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; class LoggerScreen extends StatelessWidget { - const LoggerScreen({required this.logger, super.key}); + const LoggerScreen({required this.logger, this.logSnapshot, super.key}); final FirmwareUpdateLogger logger; + final Future>? logSnapshot; @override Widget build(BuildContext context) { @@ -18,7 +19,7 @@ class LoggerScreen extends StatelessWidget { Widget _logFutureBuilder() { return FutureBuilder>( - future: logger.readLogs(), + future: logSnapshot ?? logger.readLogs(), builder: (context, snapshot) { if (snapshot.hasData) { final messages = (snapshot.data ?? const []) diff --git a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart index 0e539e129..bef10a7f4 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; import 'package:open_wearable/widgets/app_banner.dart'; @@ -38,6 +39,7 @@ class _UpdateStepViewState extends State { bool _verificationBannerShown = false; bool _isVerificationPending = false; FotaPostUpdateVerificationResult? _verificationResult; + Future>? _recoveryLogSnapshot; bool _loopWarningHandled = false; String? _lastResetValidateStage; StreamSubscription>? _verificationPendingSubscription; @@ -285,7 +287,28 @@ class _UpdateStepViewState extends State { return; } - context.read().add(AbortUpdate()); + final bloc = context.read(); + var state = bloc.state; + if (state is! UpdateFirmwareStateHistory || !state.isComplete) { + final aborted = bloc.stream.firstWhere( + (state) => state is UpdateFirmwareStateHistory && state.isComplete, + ); + bloc.add(AbortUpdate()); + state = await aborted; + } + if (!mounted) return; + + // Slot inspection disposes the native updater for this device. Preserve + // its log after cancellation and before opening the recovery page. + if (state is UpdateFirmwareStateHistory) { + _recoveryLogSnapshot = state.updateManager?.logger.readLogs(); + try { + await _recoveryLogSnapshot; + } catch (_) { + // Keep the failed future so Show Log reports the original read error. + } + } + if (!mounted) return; context.push('/fota/slots', extra: wearable); } @@ -467,6 +490,7 @@ class _UpdateStepViewState extends State { '/view', extra: LoggerScreen( logger: state.updateManager!.logger, + logSnapshot: _recoveryLogSnapshot, ), ); }, diff --git a/open_wearable/test/widgets/fota_recovery_log_test.dart b/open_wearable/test/widgets/fota_recovery_log_test.dart new file mode 100644 index 000000000..5348b39a7 --- /dev/null +++ b/open_wearable/test/widgets/fota_recovery_log_test.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/fota/fota_slots_page.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/update_view.dart'; +import 'package:provider/provider.dart'; + +class _Logger implements FirmwareUpdateLogger { + final snapshot = Completer>(); + bool disposed = false; + int reads = 0; + + @override + Stream get logMessageStream => const Stream.empty(); + + @override + Future> readLogs({bool clearLogs = false}) async { + reads++; + if (disposed) throw StateError('Update manager does not exist'); + return snapshot.future; + } + + @override + Future clearLogs() async {} +} + +class _Manager implements FirmwareUpdateManager { + @override + final _Logger logger = _Logger(); + final cancelCompleted = Completer(); + bool cancelStarted = false; + + @override + Future cancel() { + cancelStarted = true; + return cancelCompleted.future; + } + + @override + Future kill() async { + logger.disposed = true; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Bloc extends Cubit implements UpdateBloc { + final _Manager manager; + _Bloc(this.manager) : super(UpdateInitial()); + + void stage(String stage) => emit( + UpdateFirmwareStateHistory(UpdateFirmware(stage), []), + ); + + @override + void add(UpdateEvent event) { + if (event is AbortUpdate) { + manager.cancel().then((_) { + if (!isClosed) { + emit( + UpdateFirmwareStateHistory( + null, + [UpdateCompleteAborted()], + isComplete: true, + updateManager: manager, + ), + ); + } + }); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Ear extends Wearable implements FotaSlotInfoCapability { + final _Manager manager; + int slotReads = 0; + _Ear(this.manager) + : super(name: 'ear', disconnectNotifier: WearableDisconnectNotifier()); + + @override + String get deviceId => 'ear'; + + @override + Future disconnect() async {} + + @override + Future> readFirmwareSlots() async { + slotReads++; + // The native slot reader shares and disposes this device's update manager. + manager.logger.disposed = true; + return []; + } + + @override + Future eraseFirmwareSlot({int? channel}) async {} +} + +void main() { + for (final readFails in [false, true]) { + testWidgets('recovery preserves the log result (read fails: $readFails)', + (tester) async { + final manager = _Manager(); + final ear = _Ear(manager); + final provider = FirmwareUpdateRequestProvider() + ..setSelectedPeripheral(ear); + final bloc = _Bloc(manager); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Scaffold( + body: SingleChildScrollView( + child: UpdateStepView(autoStart: false), + ), + ), + ), + GoRoute( + path: '/fota/slots', + builder: (_, __) => FotaSlotsPage(device: ear), + ), + GoRoute(path: '/view', builder: (_, state) => state.extra! as Widget), + ], + ); + addTearDown(bloc.close); + addTearDown(() { + if (!manager.cancelCompleted.isCompleted) { + manager.cancelCompleted.complete(); + } + if (!manager.logger.snapshot.isCompleted) { + manager.logger.snapshot.complete([]); + } + }); + addTearDown(provider.dispose); + addTearDown(router.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + BlocProvider.value(value: bloc), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + for (final stage in ['Reset', 'Validate', 'Reset', 'Validate']) { + bloc.stage(stage); + await tester.pump(); + } + await tester.pump(const Duration(seconds: 1)); + await tester.tap(find.text('Open Image Slots')); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 300)); + } + expect(ear.slotReads, 0, reason: 'Wait for cancellation before recovery'); + expect(manager.logger.reads, 0); + expect(manager.cancelStarted, isTrue); + + manager.cancelCompleted.complete(); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 20)); + } + expect(manager.logger.reads, 1); + expect(ear.slotReads, 0, reason: 'Preserve logs before slot inspection'); + if (readFails) { + manager.logger.snapshot.completeError(StateError('Original log error')); + } else { + manager.logger.snapshot.complete([ + McuLogMessage( + 'Update cancelled; diagnostic details', + McuMgrLogCategory.dfu, + McuMgrLogLevel.info, + DateTime(2026), + ), + ]); + } + await tester.pumpAndSettle(); + expect(ear.slotReads, 1); + expect(manager.logger.disposed, isTrue); + router.pop(); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Show Log')); + await tester.tap(find.text('Show Log')); + await tester.pumpAndSettle(); + expect( + find.textContaining( + readFails + ? 'Original log error' + : 'Update cancelled; diagnostic details', + ), + findsOneWidget, + ); + expect( + find.textContaining('Update manager does not exist'), + findsNothing, + ); + expect(manager.logger.reads, 1); + }); + } +}