From 5a05d6e2d08a3b93397f5a28055a37a657b58637 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:38:50 -0500 Subject: [PATCH 1/5] style(backup): dart format create_backup_view.dart --- .../create_backup_view.dart | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart index 0d88f525fb..29d214d83b 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart @@ -232,9 +232,9 @@ class _RestoreFromFileViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -286,9 +286,9 @@ class _RestoreFromFileViewState extends ConsumerState { "Choose file location", style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ), ), ), @@ -338,9 +338,9 @@ class _RestoreFromFileViewState extends ConsumerState { const SizedBox(width: 16), SvgPicture.asset( Assets.svg.folder, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, width: 16, height: 16, ), @@ -374,9 +374,9 @@ class _RestoreFromFileViewState extends ConsumerState { "Create a passphrase", style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ), textAlign: TextAlign.left, ), @@ -419,9 +419,9 @@ class _RestoreFromFileViewState extends ConsumerState { hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, width: 16, height: 16, ), @@ -494,19 +494,19 @@ class _RestoreFromFileViewState extends ConsumerState { width: MediaQuery.of(context).size.width - 32 - 24, height: 5, fillColor: passwordStrength < 0.51 - ? Theme.of( - context, - ).extension()!.accentColorRed + ? Theme.of(context) + .extension()! + .accentColorRed : passwordStrength < 1 - ? Theme.of( - context, - ).extension()!.accentColorYellow - : Theme.of( - context, - ).extension()!.accentColorGreen, - backgroundColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + ? Theme.of(context) + .extension()! + .accentColorYellow + : Theme.of(context) + .extension()! + .accentColorGreen, + backgroundColor: Theme.of(context) + .extension()! + .buttonBackSecondary, percent: passwordStrength < 0.25 ? 0.03 : passwordStrength, ), ), @@ -549,9 +549,9 @@ class _RestoreFromFileViewState extends ConsumerState { hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, width: 16, height: 16, ), From 32ab0f39e299353345cc7b9c905b44da2ea87f9d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 18:08:21 -0600 Subject: [PATCH 2/5] fix(backup): stop the failure dialog from popping DesktopHomeView --- .../stack_backup_views/create_backup_view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart index 29d214d83b..d0ba389ceb 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart @@ -180,6 +180,7 @@ class _RestoreFromFileViewState extends ConsumerState { builder: (_) => StackOkDialog( title: "Backup creation failed", message: ex?.toString() ?? "Unexpected error", + desktopPopRootNavigator: true, ), ); } From dc642e68abe6617008b8c0b85ca591ed2fbd3590 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:32:27 -0500 Subject: [PATCH 3/5] fix(show_loading): dismiss its own overlay route instead of the top route The overlay was pushed on the root navigator but popped from the caller's navigator, so callers inside a nested navigator (desktop) popped one of their own pages and left the overlay up. Track the pushed route and remove exactly that one. --- lib/utilities/show_loading.dart | 48 +++++--- test/utilities/show_loading_test.dart | 168 ++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 test/utilities/show_loading_test.dart diff --git a/lib/utilities/show_loading.dart b/lib/utilities/show_loading.dart index 39537e37d1..250d3371d2 100644 --- a/lib/utilities/show_loading.dart +++ b/lib/utilities/show_loading.dart @@ -28,6 +28,7 @@ Future showLoading({ required BuildContext context, required String message, String? subMessage, + // Unused: the overlay always goes on the root navigator. bool rootNavigator = false, bool opaqueBG = false, void Function(Exception)? onException, @@ -39,25 +40,29 @@ Future showLoading({ !(whileFuture == null && whileFutureAlt == null), ); - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(opaqueBG ? 1.0 : 0.6), - child: CustomLoadingOverlay( - message: message, - subMessage: subMessage, - eventBus: null, - ), + // Always shown on the root navigator (as showDialog did). Keep a handle on + // the route so only this overlay is dismissed, whatever is on top of it. + final navigator = Navigator.of(context, rootNavigator: true); + final route = DialogRoute( + context: context, + themes: InheritedTheme.capture(from: context, to: navigator.context), + barrierDismissible: false, + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(opaqueBG ? 1.0 : 0.6), + child: CustomLoadingOverlay( + message: message, + subMessage: subMessage, + eventBus: null, ), ), ), ); + unawaited(navigator.push(route)); Exception? ex; T? result; @@ -76,12 +81,17 @@ Future showLoading({ ex = e is Exception ? e : Exception(e.toString()); } - if (context.mounted) { - Navigator.of(context, rootNavigator: rootNavigator).pop(); - if (ex != null) { - onException?.call(ex); + if (navigator.mounted && route.isActive) { + if (route.isCurrent) { + navigator.pop(); + } else { + navigator.removeRoute(route); } } + if (context.mounted && ex != null) { + onException?.call(ex); + } + return result; } diff --git a/test/utilities/show_loading_test.dart b/test/utilities/show_loading_test.dart new file mode 100644 index 0000000000..00271a49cc --- /dev/null +++ b/test/utilities/show_loading_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_service.dart'; +import 'package:stackwallet/utilities/show_loading.dart'; + +import '../sample_data/theme_json.dart'; + +class _MockThemeService extends Mock implements ThemeService {} + +void main() { + late GlobalKey rootNavigatorKey; + late GlobalKey nestedNavigatorKey; + late BuildContext nestedContext; + + setUp(() { + rootNavigatorKey = GlobalKey(); + nestedNavigatorKey = GlobalKey(); + }); + + Future pumpHarness(WidgetTester tester) { + final stackTheme = StackTheme.fromJson(json: lightThemeJsonMap); + final themeService = _MockThemeService(); + when(themeService.getTheme(themeId: "light")).thenAnswer((_) => stackTheme); + + return tester.pumpWidget( + ProviderScope( + overrides: [pThemeService.overrideWithValue(themeService)], + child: MaterialApp( + navigatorKey: rootNavigatorKey, + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(stackTheme)], + ), + home: Navigator( + key: nestedNavigatorKey, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (context) { + nestedContext = context; + return const Scaffold(body: Text("nested page")); + }, + ), + ), + ), + ), + ); + } + + Future pushNestedPage(WidgetTester tester) async { + unawaited( + nestedNavigatorKey.currentState!.push( + MaterialPageRoute( + builder: (context) { + nestedContext = context; + return const Scaffold(body: Text("second nested page")); + }, + ), + ), + ); + await tester.pumpAndSettle(); + } + + for (final rootNavigator in [false, true]) { + testWidgets("shows on the root navigator and leaves nested routes alone " + "(rootNavigator: $rootNavigator)", (tester) async { + await pumpHarness(tester); + await pushNestedPage(tester); + final work = Completer(); + + final loading = showLoading( + whileFuture: work.future, + context: nestedContext, + message: "Working", + rootNavigator: rootNavigator, + ); + await tester.pump(); + + expect(find.text("Working"), findsOneWidget); + expect(rootNavigatorKey.currentState!.canPop(), isTrue); + + work.complete("done"); + await tester.pumpAndSettle(); + + expect(await loading, "done"); + expect(find.text("Working"), findsNothing); + expect(find.text("second nested page"), findsOneWidget); + expect(rootNavigatorKey.currentState!.canPop(), isFalse); + expect(nestedNavigatorKey.currentState!.canPop(), isTrue); + }); + } + + testWidgets("an already-completed operation keeps the caller route", ( + tester, + ) async { + await pumpHarness(tester); + + final loading = showLoading( + whileFuture: Future.value("done"), + context: nestedContext, + message: "Finishing immediately", + ); + await tester.pumpAndSettle(); + + expect(await loading, "done"); + expect(find.text("nested page"), findsOneWidget); + expect(rootNavigatorKey.currentState!.canPop(), isFalse); + expect(nestedNavigatorKey.currentState!.canPop(), isFalse); + }); + + testWidgets("removes only its own route when another route is above it", ( + tester, + ) async { + await pumpHarness(tester); + final work = Completer(); + + final loading = showLoading( + whileFuture: work.future, + context: nestedContext, + message: "Working", + ); + await tester.pump(); + + unawaited( + rootNavigatorKey.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text("newer route")), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("newer route"), findsOneWidget); + + work.complete("done"); + await tester.pumpAndSettle(); + + expect(await loading, "done"); + expect(find.text("newer route"), findsOneWidget); + expect(find.text("Working"), findsNothing); + + rootNavigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.text("nested page"), findsOneWidget); + expect(rootNavigatorKey.currentState!.canPop(), isFalse); + }); + + testWidgets("reports exceptions after dismissing the overlay", ( + tester, + ) async { + await pumpHarness(tester); + Exception? caught; + + final loading = showLoading( + whileFutureAlt: () async => throw Exception("boom"), + context: nestedContext, + message: "Failing", + onException: (e) => caught = e, + ); + await tester.pumpAndSettle(); + + expect(await loading, isNull); + expect(caught.toString(), "Exception: boom"); + expect(rootNavigatorKey.currentState!.canPop(), isFalse); + }); +} From 688cfd7e73a06d9110df8041e233ecdcec6511af Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:33:00 -0500 Subject: [PATCH 4/5] fix(backup): validate view-only recovery data on backup and restore Back up view-only data straight from secure storage, repair a missing isViewOnly flag in otherData, and refuse to create or restore a backup whose view-only data is missing, unparseable or incomplete instead of silently producing a broken wallet. --- .../helpers/restore_create_backup.dart | 31 +++- .../helpers/swb_view_only_recovery_data.dart | 128 ++++++++++++++ .../swb_view_only_recovery_data_test.dart | 167 ++++++++++++++++++ 3 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_view_only_recovery_data.dart create mode 100644 test/pages/stack_backup/swb_view_only_recovery_data_test.dart diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index b131db8312..2ce2461ea4 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -63,6 +63,7 @@ import '../../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface import '../../../../../wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../../wl_gen/interfaces/frost_interface.dart'; +import 'swb_view_only_recovery_data.dart'; class PreRestoreState { final Set walletIds; @@ -296,9 +297,27 @@ abstract class SWB { backupWallet['isFavorite'] = wallet.info.isFavourite; backupWallet['otherDataJsonString'] = wallet.info.otherDataJsonString; - if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + SWBViewOnlyRecoveryData? viewOnlyRecoveryData; + if (wallet is ViewOnlyOptionInterface) { + final rawViewOnlyData = await _secureStore.read( + key: Wallet.getViewOnlyWalletDataSecStoreKey( + walletId: wallet.walletId, + ), + ); + viewOnlyRecoveryData = normalizeSWBViewOnlyRecoveryData( + operation: "back up", + walletName: wallet.info.name, + walletId: wallet.walletId, + otherData: wallet.info.otherData, + encodedData: rawViewOnlyData, + ); + } + + if (viewOnlyRecoveryData != null) { backupWallet['viewOnlyWalletDataKey'] = - (await wallet.getViewOnlyWalletData()).toJsonEncodedString(); + viewOnlyRecoveryData.encodedData; + backupWallet['otherDataJsonString'] = + viewOnlyRecoveryData.otherDataJsonString; } else if (wallet is MnemonicInterface) { backupWallet['mnemonic'] = await wallet.getMnemonic(); backupWallet['mnemonicPassphrase'] = await wallet @@ -399,7 +418,7 @@ abstract class SWB { String? mnemonic, mnemonicPassphrase, privateKey; ViewOnlyWalletData? viewOnlyData; - if (info.isViewOnly) { + if (walletbackup['viewOnlyWalletDataKey'] is String) { final viewOnlyDataEncoded = walletbackup['viewOnlyWalletDataKey'] as String; @@ -407,6 +426,11 @@ abstract class SWB { viewOnlyDataEncoded, walletId: info.walletId, ); + } else if (info.isViewOnly) { + throw FormatException( + 'Cannot restore view-only wallet "${info.name}": ' + 'recovery data is missing', + ); } else if (walletbackup['mnemonic'] == null) { // probably private key based if (walletbackup['privateKey'] != null) { @@ -724,6 +748,7 @@ abstract class SWB { json.decode(jsonBackup) as Map; final List wallets = validJSON["wallets"] as List; + normalizeSWBViewOnlyWalletBackups(wallets); // check for duplicate walletIds and assign new ones if required for (final wallet in wallets) { diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_view_only_recovery_data.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_view_only_recovery_data.dart new file mode 100644 index 0000000000..35106c9b1f --- /dev/null +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_view_only_recovery_data.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; + +import '../../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../../wallets/isar/models/wallet_info.dart'; + +class SWBViewOnlyRecoveryData { + final String encodedData; + final Map otherData; + + const SWBViewOnlyRecoveryData({ + required this.encodedData, + required this.otherData, + }); + + String get otherDataJsonString => jsonEncode(otherData); +} + +void normalizeSWBViewOnlyWalletBackups(List wallets) { + for (final entry in wallets) { + if (entry is! Map) { + throw const FormatException("Cannot restore invalid wallet record"); + } + final wallet = Map.from(entry); + final walletName = wallet["name"]; + final walletId = wallet["id"]; + if (walletName is! String || walletId is! String) { + throw const FormatException("Cannot restore invalid wallet record"); + } + + Map? otherData; + if (wallet["otherDataJsonString"] is String) { + try { + final decoded = jsonDecode(wallet["otherDataJsonString"] as String); + otherData = Map.from(decoded as Map); + } catch (_) { + if (wallet["viewOnlyWalletDataKey"] == null) { + continue; + } + } + } + + final normalized = normalizeSWBViewOnlyRecoveryData( + operation: "restore", + walletName: walletName, + walletId: walletId, + otherData: otherData, + encodedData: wallet["viewOnlyWalletDataKey"], + ); + if (normalized != null) { + entry["otherDataJsonString"] = normalized.otherDataJsonString; + } + } +} + +SWBViewOnlyRecoveryData? normalizeSWBViewOnlyRecoveryData({ + required String operation, + required String walletName, + required String walletId, + required Map? otherData, + required Object? encodedData, +}) { + final declaresViewOnly = otherData?[WalletInfoKeys.isViewOnlyKey] == true; + + if (encodedData == null) { + if (declaresViewOnly) { + throw FormatException( + 'Cannot $operation view-only wallet "$walletName": ' + 'recovery data is missing', + ); + } + return null; + } + + if (encodedData is! String) { + throw FormatException( + 'Cannot $operation view-only wallet "$walletName": ' + 'recovery data is invalid', + ); + } + + final ViewOnlyWalletData data; + try { + data = ViewOnlyWalletData.fromJsonEncodedString( + encodedData, + walletId: walletId, + ); + } catch (_) { + throw FormatException( + 'Cannot $operation view-only wallet "$walletName": ' + 'recovery data is invalid', + ); + } + + if (!_hasRequiredRecoveryData(data)) { + throw FormatException( + 'Cannot $operation view-only wallet "$walletName": ' + 'recovery data is incomplete', + ); + } + + final storedType = otherData?[WalletInfoKeys.viewOnlyTypeIndexKey]; + if (storedType != null && + (storedType is! int || storedType != data.type.index)) { + throw FormatException( + 'Cannot $operation view-only wallet "$walletName": ' + 'recovery metadata conflicts with its data', + ); + } + + final normalized = Map.from(otherData ?? const {}) + ..[WalletInfoKeys.isViewOnlyKey] = true + ..[WalletInfoKeys.viewOnlyTypeIndexKey] = data.type.index; + + return SWBViewOnlyRecoveryData( + encodedData: encodedData, + otherData: Map.unmodifiable(normalized), + ); +} + +bool _hasRequiredRecoveryData(ViewOnlyWalletData data) => switch (data) { + CryptonoteViewOnlyWalletData() => + data.address.isNotEmpty && data.privateViewKey.isNotEmpty, + AddressViewOnlyWalletData() => data.address.isNotEmpty, + ExtendedKeysViewOnlyWalletData() => + data.xPubs.isNotEmpty && + data.xPubs.every((xPub) => xPub.encoded.isNotEmpty), + SparkViewOnlyWalletData() => data.viewKey.isNotEmpty, +}; diff --git a/test/pages/stack_backup/swb_view_only_recovery_data_test.dart b/test/pages/stack_backup/swb_view_only_recovery_data_test.dart new file mode 100644 index 0000000000..74f0483952 --- /dev/null +++ b/test/pages/stack_backup/swb_view_only_recovery_data_test.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/keys/view_only_wallet_data.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_view_only_recovery_data.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; + +void main() { + const walletId = "wallet-id"; + const walletName = "Watching wallet"; + + final encoded = CryptonoteViewOnlyWalletData( + walletId: walletId, + address: "address", + privateViewKey: "view-key", + ).toJsonEncodedString(); + + test("recovers missing view-only metadata", () { + final result = normalizeSWBViewOnlyRecoveryData( + operation: "back up", + walletName: walletName, + walletId: walletId, + otherData: const {"untouched": true}, + encodedData: encoded, + ); + + expect(result, isNotNull); + expect(result!.encodedData, encoded); + expect(result.otherData, { + "untouched": true, + WalletInfoKeys.isViewOnlyKey: true, + WalletInfoKeys.viewOnlyTypeIndexKey: ViewOnlyWalletType.cryptonote.index, + }); + }); + + test("normalizes wallet records before restore mutation", () { + final wallets = [ + { + "name": walletName, + "id": walletId, + "otherDataJsonString": "invalid legacy metadata", + "viewOnlyWalletDataKey": encoded, + }, + ]; + + normalizeSWBViewOnlyWalletBackups(wallets); + + final otherData = jsonDecode( + wallets.single["otherDataJsonString"] as String, + ) as Map; + expect(otherData[WalletInfoKeys.isViewOnlyKey], isTrue); + expect( + otherData[WalletInfoKeys.viewOnlyTypeIndexKey], + ViewOnlyWalletType.cryptonote.index, + ); + }); + + test("accepts matching view-only metadata", () { + final result = normalizeSWBViewOnlyRecoveryData( + operation: "restore", + walletName: walletName, + walletId: walletId, + otherData: { + WalletInfoKeys.isViewOnlyKey: true, + WalletInfoKeys.viewOnlyTypeIndexKey: + ViewOnlyWalletType.cryptonote.index, + }, + encodedData: encoded, + ); + + expect(result, isNotNull); + }); + + test("ignores absent data for a seed wallet", () { + final result = normalizeSWBViewOnlyRecoveryData( + operation: "back up", + walletName: walletName, + walletId: walletId, + otherData: const {}, + encodedData: null, + ); + + expect(result, isNull); + }); + + test("rejects missing recovery data for a declared view-only wallet", () { + expect( + () => normalizeSWBViewOnlyRecoveryData( + operation: "back up", + walletName: walletName, + walletId: walletId, + otherData: const {WalletInfoKeys.isViewOnlyKey: true}, + encodedData: null, + ), + throwsA( + isA().having( + (error) => error.message, + "message", + contains('Cannot back up view-only wallet "$walletName"'), + ), + ), + ); + }); + + test("rejects malformed and wrongly typed recovery data", () { + for (final data in ["not json", 7]) { + expect( + () => normalizeSWBViewOnlyRecoveryData( + operation: "restore", + walletName: walletName, + walletId: walletId, + otherData: const {}, + encodedData: data, + ), + throwsA(isA()), + ); + } + }); + + test("rejects incomplete recovery data", () { + final incomplete = CryptonoteViewOnlyWalletData( + walletId: walletId, + address: "address", + privateViewKey: "", + ).toJsonEncodedString(); + + expect( + () => normalizeSWBViewOnlyRecoveryData( + operation: "restore", + walletName: walletName, + walletId: walletId, + otherData: const {}, + encodedData: incomplete, + ), + throwsA( + isA().having( + (error) => error.message, + "message", + contains("recovery data is incomplete"), + ), + ), + ); + }); + + test("rejects conflicting recovery metadata", () { + expect( + () => normalizeSWBViewOnlyRecoveryData( + operation: "restore", + walletName: walletName, + walletId: walletId, + otherData: { + WalletInfoKeys.isViewOnlyKey: true, + WalletInfoKeys.viewOnlyTypeIndexKey: + ViewOnlyWalletType.addressOnly.index, + }, + encodedData: encoded, + ), + throwsA( + isA().having( + (error) => error.message, + "message", + contains("recovery metadata conflicts"), + ), + ), + ); + }); +} From 17b946f8c89d44df2e255841d7f405cca0456496 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:33:12 -0500 Subject: [PATCH 5/5] fix(backup): release the wakelock on every restore exit path Cancellation and errors returned or threw before WakelockPlus.disable(), leaving the screen wakelock held after a failed or cancelled restore. --- .../helpers/restore_create_backup.dart | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index 2ce2461ea4..981ec715ae 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -724,8 +724,34 @@ abstract class SWB { SecureStorageInterface secureStorageInterface, ShopInBitService shopinbitService, ) async { - if (!Platform.isLinux) await WakelockPlus.enable(); + if (Platform.isLinux) { + return _restoreStackWalletJSON( + jsonBackup, + uiState, + secureStorageInterface, + shopinbitService, + ); + } + await WakelockPlus.enable(); + try { + return await _restoreStackWalletJSON( + jsonBackup, + uiState, + secureStorageInterface, + shopinbitService, + ); + } finally { + await WakelockPlus.disable(); + } + } + + static Future _restoreStackWalletJSON( + String jsonBackup, + StackRestoringUIState? uiState, + SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, + ) async { Logging.instance.d("SWB creating temp backup"); final preRestoreJSON = await createStackWalletJSON( secureStorage: secureStorageInterface, @@ -937,7 +963,6 @@ abstract class SWB { await status; } - if (!Platform.isLinux) await WakelockPlus.disable(); // check if cancel was requested and restore previous state if (_checkShouldCancel( preRestoreState,