From dd8d6ba3e133300ae930826bbc907296eb65b095 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Tue, 6 Oct 2026 09:06:24 +0200 Subject: [PATCH] Restore FOTA verification countdown during reset --- .../models/fota_post_update_verification.dart | 23 ++- .../fota/fota_verification_banner.dart | 58 +++--- .../fota/stepper_view/update_view.dart | 163 +++++++++------ .../fota_post_update_verification_test.dart | 188 +++++++++++++++++- 4 files changed, 336 insertions(+), 96 deletions(-) diff --git a/open_wearable/lib/models/fota_post_update_verification.dart b/open_wearable/lib/models/fota_post_update_verification.dart index 410db7dcb..34e047a9f 100644 --- a/open_wearable/lib/models/fota_post_update_verification.dart +++ b/open_wearable/lib/models/fota_post_update_verification.dart @@ -10,11 +10,13 @@ class ArmedFotaPostUpdateVerification { final String verificationId; final String wearableName; final String? sideLabel; + final DateTime deadline; const ArmedFotaPostUpdateVerification({ required this.verificationId, required this.wearableName, this.sideLabel, + required this.deadline, }); } @@ -81,11 +83,17 @@ class FotaPostUpdateVerificationCoordinator { bool isVerificationPending(String verificationId) => _pendingById.containsKey(verificationId); + void cancel(String verificationId) { + _verificationTimers.remove(verificationId)?.cancel(); + if (_pendingById.remove(verificationId) != null) _publishPendingIds(); + } + Future armFromUpdateRequest({ required FirmwareUpdateRequest request, Wearable? selectedWearable, String? preResolvedWearableName, String? preResolvedSideLabel, + Wearable? connectionBeforeReset, }) async { _cleanupExpired(); @@ -128,6 +136,7 @@ class FotaPostUpdateVerificationCoordinator { expectedSideLabel: expectedSideLabel, expectedFirmwareVersion: expectedFirmwareVersion, armedAt: DateTime.now(), + connectionBeforeReset: connectionBeforeReset, ); _pendingById[verificationId] = pending; _verificationTimers[verificationId] = Timer(_maxPendingAge, () { @@ -139,6 +148,7 @@ class FotaPostUpdateVerificationCoordinator { verificationId: verificationId, wearableName: displayName ?? 'OpenEarable', sideLabel: expectedSideLabel, + deadline: pending.armedAt.add(_maxPendingAge), ); } @@ -163,7 +173,12 @@ class FotaPostUpdateVerificationCoordinator { connectedSideLabel: connectedSideLabel, ); - if (pending == null || !_verifyingIds.add(pending.verificationId)) { + // The app also checks its existing connections when verification is armed. + // A Wearable belongs to one connection; only a new connection can verify + // the reboot, including when reinstalling the same firmware version. + if (pending == null || + identical(pending.connectionBeforeReset, wearable) || + !_verifyingIds.add(pending.verificationId)) { return null; } try { @@ -186,7 +201,9 @@ class FotaPostUpdateVerificationCoordinator { expectedFirmwareVersion != null && detectedFirmwareVersion != null && _firmwareVersionsMatch( - expectedFirmwareVersion, detectedFirmwareVersion,); + expectedFirmwareVersion, + detectedFirmwareVersion, + ); final displayName = pending.displayWearableName ?? 'OpenEarable'; final result = FotaPostUpdateVerificationResult( verificationId: pending.verificationId, @@ -582,6 +599,7 @@ class _PendingPostUpdateVerification { final String? expectedSideLabel; final String? expectedFirmwareVersion; final DateTime armedAt; + final Wearable? connectionBeforeReset; const _PendingPostUpdateVerification({ required this.verificationId, @@ -591,5 +609,6 @@ class _PendingPostUpdateVerification { required this.expectedSideLabel, required this.expectedFirmwareVersion, required this.armedAt, + this.connectionBeforeReset, }); } diff --git a/open_wearable/lib/widgets/fota/fota_verification_banner.dart b/open_wearable/lib/widgets/fota/fota_verification_banner.dart index 4e265394e..a737a0742 100644 --- a/open_wearable/lib/widgets/fota/fota_verification_banner.dart +++ b/open_wearable/lib/widgets/fota/fota_verification_banner.dart @@ -14,6 +14,7 @@ class FotaVerificationBanner extends StatefulWidget { final String wearableName; final String? sideLabel; final VoidCallback onDismiss; + final bool showUploadCompleted; const FotaVerificationBanner({ super.key, @@ -21,6 +22,7 @@ class FotaVerificationBanner extends StatefulWidget { required this.wearableName, this.sideLabel, required this.onDismiss, + this.showUploadCompleted = true, }); @override @@ -93,33 +95,34 @@ class _FotaVerificationBannerState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - AppBanner( - backgroundColor: successBackground, - foregroundColor: successForeground, - leadingIcon: Icons.verified_rounded, - content: Text.rich( - TextSpan( - style: successTextStyle, - children: [ - const TextSpan( - text: 'Firmware upload completed successfully for ', - ), - TextSpan(text: widget.wearableName), - if (widget.sideLabel != null) const TextSpan(text: ' '), - if (widget.sideLabel != null) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _FotaSideBadge( - sideLabel: widget.sideLabel!, - accentColor: successForeground, - ), + if (widget.showUploadCompleted) + AppBanner( + backgroundColor: successBackground, + foregroundColor: successForeground, + leadingIcon: Icons.verified_rounded, + content: Text.rich( + TextSpan( + style: successTextStyle, + children: [ + const TextSpan( + text: 'Firmware upload completed successfully for ', ), - const TextSpan(text: '.'), - ], + TextSpan(text: widget.wearableName), + if (widget.sideLabel != null) const TextSpan(text: ' '), + if (widget.sideLabel != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _FotaSideBadge( + sideLabel: widget.sideLabel!, + accentColor: successForeground, + ), + ), + const TextSpan(text: '.'), + ], + ), ), ), - ), - const SizedBox(height: 8), + if (widget.showUploadCompleted) const SizedBox(height: 8), AppBanner( backgroundColor: warningBackground, foregroundColor: warningForeground, @@ -186,6 +189,7 @@ void showFotaVerificationBanner( required String wearableName, String? sideLabel, Duration duration = const Duration(minutes: 3), + DateTime? deadline, }) { final controller = Provider.of(context, listen: false); _pruneMissingFotaVerificationBannerKeys(controller); @@ -200,9 +204,9 @@ void showFotaVerificationBanner( } } _activeFotaVerificationBannerKeys.remove(verificationId); - final deadline = _fotaVerificationDeadlinesById.putIfAbsent( + final verificationDeadline = _fotaVerificationDeadlinesById.putIfAbsent( verificationId, - () => DateTime.now().add(duration), + () => deadline ?? DateTime.now().add(duration), ); controller.showBanner( @@ -214,7 +218,7 @@ void showFotaVerificationBanner( key: bannerKey, content: FotaVerificationBanner( key: ValueKey('fota_verification_$verificationId'), - deadline: deadline, + deadline: verificationDeadline, wearableName: wearableName, sideLabel: sideLabel, onDismiss: () => _dismissFotaVerificationBanner( 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 bef10a7f4..bd9d3bbf3 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart @@ -7,7 +7,6 @@ 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'; import 'package:open_wearable/widgets/fota/fota_verification_banner.dart'; import '../logger_screen/logger_screen.dart'; @@ -36,7 +35,9 @@ class _UpdateStepViewState extends State { bool _lastReportedRunning = false; bool _startRequested = false; - bool _verificationBannerShown = false; + bool _verificationStarted = false; + bool _verificationCancelled = false; + ArmedFotaPostUpdateVerification? _verification; bool _isVerificationPending = false; FotaPostUpdateVerificationResult? _verificationResult; Future>? _recoveryLogSnapshot; @@ -97,39 +98,11 @@ class _UpdateStepViewState extends State { listener: (context, state) async { _reportRunningState(_isUpdateInProgress(state)); final updateProvider = context.read(); + unawaited(_updateVerification(state, updateProvider)); await _maybeShowLoopWarning( state: state, updateProvider: updateProvider, ); - if (state is UpdateFirmwareStateHistory && - state.isComplete && - state.history.isNotEmpty && - state.history.last is UpdateCompleteSuccess) { - if (_verificationBannerShown) { - return; - } - _verificationBannerShown = true; - final armedVerification = await FotaPostUpdateVerificationCoordinator - .instance - .armFromUpdateRequest( - request: updateProvider.updateParameters, - selectedWearable: updateProvider.selectedWearable, - preResolvedWearableName: widget.preResolvedWearableName, - preResolvedSideLabel: widget.preResolvedSideLabel, - ); - if (!mounted || armedVerification == null) { - return; - } - _bindVerificationLifecycle(armedVerification.verificationId); - if (_isVerificationPending) { - showFotaVerificationBanner( - this.context, - verificationId: armedVerification.verificationId, - wearableName: armedVerification.wearableName, - sideLabel: armedVerification.sideLabel, - ); - } - } }, builder: (context, state) { return switch (state) { @@ -141,18 +114,83 @@ class _UpdateStepViewState extends State { ); } + // The updater can also reset before uploading to clear an old pending image. + // Only the reset after Test/Confirm starts post-update verification. + bool _isVerificationReset(UpdateState state) => + state is UpdateFirmwareStateHistory && + state.currentState?.stage == 'Reset' && + state.history + .any((entry) => entry.stage == 'Test' || entry.stage == 'Confirm'); + + bool _isNativeSuccess(UpdateState state) => + state is UpdateFirmwareStateHistory && + state.isComplete && + state.history.isNotEmpty && + state.history.last is UpdateCompleteSuccess; + + // TEST_ONLY can remain in Reset for the configured 90-second swap estimate. + // A new connection reporting the expected firmware is already verified. + bool _hasVerificationResult(UpdateState state) => + _verificationResult != null && + (_isVerificationReset(state) || _isNativeSuccess(state)); + bool _isUpdateInProgress(UpdateState state) { - if (state is UpdateInitial) { - return false; - } + if (state is UpdateInitial || _hasVerificationResult(state)) return false; if (state is UpdateFirmwareStateHistory) { - return !state.isComplete; + return !state.isComplete || _isVerificationPending; } return true; } - /// Subscribes the page-level success panel to the coordinator entry created - /// for this update so it disappears immediately after reconnect validation. + Future _updateVerification( + UpdateState state, + FirmwareUpdateRequestProvider provider, + ) async { + final coordinator = FotaPostUpdateVerificationCoordinator.instance; + if (state is UpdateFirmwareStateHistory && + state.isComplete && + !_isNativeSuccess(state)) { + _verificationCancelled = true; + final verification = _verification; + if (verification != null) { + coordinator.cancel(verification.verificationId); + dismissFotaVerificationBannerById(context, verification.verificationId); + } + return; + } + if (_verificationStarted || + (!_isVerificationReset(state) && !_isNativeSuccess(state))) { + return; + } + _verificationStarted = true; + final verification = await coordinator.armFromUpdateRequest( + request: provider.updateParameters, + selectedWearable: provider.selectedWearable, + preResolvedWearableName: widget.preResolvedWearableName, + preResolvedSideLabel: widget.preResolvedSideLabel, + connectionBeforeReset: + _isVerificationReset(state) ? provider.selectedWearable : null, + ); + if (verification == null) return; + if (_verificationCancelled) { + coordinator.cancel(verification.verificationId); + return; + } + if (!mounted) return; + _verification = verification; + _bindVerificationLifecycle(verification.verificationId); + if (_isVerificationPending) { + showFotaVerificationBanner( + context, + verificationId: verification.verificationId, + wearableName: verification.wearableName, + sideLabel: verification.sideLabel, + deadline: verification.deadline, + ); + } + } + + /// Keeps the countdown and page completion tied to actual reconnect validation. void _bindVerificationLifecycle(String verificationId) { _verificationPendingSubscription?.cancel(); final coordinator = FotaPostUpdateVerificationCoordinator.instance; @@ -163,6 +201,12 @@ class _UpdateStepViewState extends State { coordinator.isVerificationPending(verificationId); _verificationResult = coordinator.resultFor(verificationId); }); + if (!_isVerificationPending) { + dismissFotaVerificationBannerById(context, verificationId); + } + _reportRunningState( + _isUpdateInProgress(context.read().state), + ); } _verificationPendingSubscription = @@ -447,9 +491,7 @@ class _UpdateStepViewState extends State { ) { final history = state.history; final currentState = state.currentState; - final showSuccessMessage = state.isComplete && - history.isNotEmpty && - history.last is UpdateCompleteSuccess; + final verificationFinished = _hasVerificationResult(state); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -458,24 +500,25 @@ class _UpdateStepViewState extends State { // Only the native upload progress belongs in the completed steps. for (final entry in history.where( (entry) => - entry.stage != 'Upload firmware' || entry is UpdateProgressFirmware, + entry.stage != 'Upload firmware' || + entry is UpdateProgressFirmware, )) ...[ _historyEntry(context, entry), const SizedBox(height: 8), ], - if (currentState != null) ...[ + if (currentState != null && !verificationFinished) ...[ _currentStatePanel(context, state), const SizedBox(height: 10), ], - if (!state.isComplete) ...[ + if (!state.isComplete && !verificationFinished) ...[ _abortButton(context), const SizedBox(height: 10), ], - if (showSuccessMessage && _isVerificationPending) ...[ + if (_isVerificationPending) ...[ _successPanel(context), const SizedBox(height: 10), ], - if (showSuccessMessage && _verificationResult != null) ...[ + if (verificationFinished) ...[ _completedStep( context, _verificationResult!.message, @@ -627,11 +670,21 @@ class _UpdateStepViewState extends State { final core = currentState.imageNumber == 0 ? 'application' : 'network'; return 'Uploading $core core ${currentState.progress}%'; } - return currentState.stage; + return _isVerificationReset(state) + ? 'Reset and verify' + : currentState.stage; } Widget _successPanel(BuildContext context) { - return const _VerificationWarningPanel(); + final verification = _verification!; + return FotaVerificationBanner( + deadline: verification.deadline, + wearableName: verification.wearableName, + sideLabel: verification.sideLabel, + showUploadCompleted: false, + // The coordinator owns the timeout and replaces this with its result. + onDismiss: () {}, + ); } Widget _firmwareInfoCard(BuildContext context, SelectedFirmware firmware) { @@ -698,22 +751,6 @@ class _UpdateStepViewState extends State { } } -class _VerificationWarningPanel extends StatelessWidget { - const _VerificationWarningPanel(); - - @override - Widget build(BuildContext context) { - return const AppBanner( - backgroundColor: Color(0xFFFFECEC), - foregroundColor: Color(0xFF8A1C1C), - leadingIcon: Icons.warning_amber_rounded, - content: - Text('Waiting for the earphone to reconnect and verify its firmware. ' - 'Do not reset or power off the device.'), - ); - } -} - enum _LoopWarningAction { ignore, openSlots, diff --git a/open_wearable/test/models/fota_post_update_verification_test.dart b/open_wearable/test/models/fota_post_update_verification_test.dart index f25d2c30e..2c115528a 100644 --- a/open_wearable/test/models/fota_post_update_verification_test.dart +++ b/open_wearable/test/models/fota_post_update_verification_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:open_wearable/view_models/app_banner_controller.dart'; import 'package:open_wearable/widgets/fota/stepper_view/update_view.dart'; +import 'package:open_wearable/widgets/fota/fota_verification_banner.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; @@ -203,10 +204,13 @@ void main() { addTearDown(subscription.cancel); bloc.finish(); await tester.pumpAndSettle(); - expect(armedIds, isNotNull, - reason: 'Verification must be armed on upload success',); + expect( + armedIds, + isNotNull, + reason: 'Verification must be armed on upload success', + ); final id = armedIds!.single; - expect(find.textContaining('Waiting for the earphone'), findsOneWidget); + expect(find.textContaining('Verification in progress'), findsOneWidget); if (timeout) { await tester.pump(const Duration(minutes: 3)); await tester.pumpAndSettle(); @@ -219,13 +223,189 @@ void main() { expect(find.textContaining('Update verified'), findsOneWidget); } expect(coordinator.isVerificationPending(id), isFalse); - expect(find.textContaining('Waiting for the earphone'), findsNothing); + expect(find.textContaining('Verification in progress'), findsNothing); expect(find.textContaining('00:00'), findsNothing); await tester.pumpWidget(const SizedBox()); provider.dispose(); } }); + for (final outcome in [ + 'verified', + 'mismatch', + 'timeout', + 'abort', + 'failure', + ]) { + testWidgets('reset shows a countdown and resolves on $outcome', + (tester) async { + coordinator = FotaPostUpdateVerificationCoordinator.instance; + final original = + _Ear('reset-$outcome', DevicePosition.right, () async => '2.3.0'); + final provider = FirmwareUpdateRequestProvider() + ..setSelectedPeripheral(original) + ..setFirmware( + RemoteFirmware( + name: '2.2.9', + version: '2.2.9', + url: 'https://example.test/fw.zip', + type: FirmwareType.multiImage, + ), + ); + final bloc = _CompletedBloc(provider.updateParameters); + final banners = AppBannerController(); + final running = []; + addTearDown(bloc.close); + addTearDown(provider.dispose); + addTearDown(banners.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: banners), + BlocProvider.value(value: bloc), + ], + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: UpdateStepView( + autoStart: false, + preResolvedSideLabel: 'R', + onUpdateRunningChanged: running.add, + ), + ), + ), + ), + ), + ); + Set? armedIds; + final sub = coordinator.pendingVerificationIds.listen((ids) { + if (ids.isNotEmpty) armedIds = ids; + }); + addTearDown(sub.cancel); + bloc.show(UpdateFirmwareStateHistory(UpdateFirmware('Validate'), [])); + await tester.pump(); + expect(armedIds, isNull, reason: 'Initial validation is before reboot'); + bloc.show(UpdateFirmwareStateHistory(UpdateFirmware('Reset'), [ + UpdateFirmware('Validate'), + ]),); + await tester.pump(); + expect(armedIds, isNull, + reason: 'Recovery resets before upload must not verify',); + expect(find.textContaining('Verification in progress'), findsNothing); + bloc.show( + UpdateFirmwareStateHistory(UpdateFirmware('Reset'), [ + UpdateProgressFirmware('Upload', 100, 1), + UpdateFirmware('Test'), + ]), + ); + await tester.pump(); + await tester.pump(); + expect( + armedIds, + isNotNull, + reason: 'Verification must start at Reset, before native success', + ); + final id = armedIds!.single; + expect(find.text('Reset and verify'), findsOneWidget); + expect(find.textContaining('Verification in progress'), findsOneWidget); + expect(find.textContaining(RegExp(r'0[23]:[0-5][0-9]')), findsOneWidget); + expect(banners.activeBanners, hasLength(1)); + expect(running.last, isTrue); + // main.dart rechecks already-connected wearables when verification arms. + expect(await coordinator.verifyOnWearableConnected(original), isNull); + expect(coordinator.isVerificationPending(id), isTrue); + final deadline = tester + .widget(find.byType(FotaVerificationBanner)) + .deadline; + bloc.show( + UpdateFirmwareStateHistory(UpdateFirmware('Reset'), [ + UpdateProgressFirmware('Upload', 100, 1), + UpdateFirmware('Test'), + UpdateFirmware('Reset'), + ]), + ); + await tester.pump(const Duration(seconds: 30)); + expect(armedIds, {id}); + expect( + tester + .widget( + find.byType(FotaVerificationBanner), + ) + .deadline, + deadline, + ); + if (outcome == 'timeout') { + await tester.pump(const Duration(seconds: 150)); + } else if (outcome == 'abort' || outcome == 'failure') { + bloc.show( + UpdateFirmwareStateHistory( + null, + [ + outcome == 'abort' + ? UpdateCompleteAborted() + : UpdateCompleteFailure('Transport failed'), + ], + isComplete: true, + ), + ); + } else { + await coordinator.verifyOnWearableConnected( + _Ear( + original.deviceId, + DevicePosition.right, + () async => outcome == 'verified' ? '2.2.9' : '2.3.0', + ), + ); + } + await tester.pumpAndSettle(); + expect(coordinator.isVerificationPending(id), isFalse); + expect(find.textContaining('Verification in progress'), findsNothing); + expect(find.text('Reset and verify'), findsNothing); + expect(find.text('Abort Update'), findsNothing); + expect(banners.activeBanners, isEmpty); + expect( + running.last, + isFalse, + reason: + 'A verified reconnect must release navigation before native success', + ); + if (outcome == 'verified') { + expect( + find.textContaining('Update verified (version 2.2.9)'), + findsOneWidget, + ); + bloc.finish(); + await tester.pumpAndSettle(); + expect(armedIds, {id}, reason: 'Late native success must not rearm'); + expect(find.textContaining('Update verified'), findsOneWidget); + } else if (outcome == 'mismatch') { + expect( + find.textContaining('Expected 2.2.9 but detected 2.3.0'), + findsOneWidget, + ); + expect(find.textContaining('Update verified'), findsNothing); + } else if (outcome == 'timeout') { + expect(find.textContaining('Verification timed out'), findsOneWidget); + expect(find.textContaining('00:00'), findsNothing); + } else { + expect(coordinator.resultFor(id), isNull); + expect( + await coordinator.verifyOnWearableConnected( + _Ear( + original.deviceId, + DevicePosition.right, + () async => '2.2.9', + ), + ), + isNull, + reason: 'Cancelled checks must not verify on a later reconnect', + ); + } + await tester.pumpWidget(const SizedBox()); + }); + } + testWidgets('upload is only marked complete once native upload finishes', (tester) async { final provider = FirmwareUpdateRequestProvider();