From 8828d343f2eb47220052c0b4cc6b242590185a0d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 18:49:41 -0500 Subject: [PATCH 1/2] style: dart format the files touched by auto backup triggers These six files predate the current formatter. CI's "Check formatting of changed files" step runs dart format over every .dart file a PR touches, so reformat them up front instead of mixing the reflow into a feature diff. --- .../backup_frequency_type_select_sheet.dart | 39 ++--- .../transaction_views/edit_note_view.dart | 139 +++++++++--------- .../global/address_book_service_provider.dart | 5 +- .../global/auto_swb_service_provider.dart | 5 +- .../enums/backup_frequency_type.dart | 6 +- lib/utilities/format.dart | 5 +- 6 files changed, 88 insertions(+), 111 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart index d61c93c626..d03b631e32 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart @@ -18,9 +18,7 @@ import '../../../../../utilities/enums/backup_frequency_type.dart'; import '../../../../../utilities/text_styles.dart'; class BackupFrequencyTypeSelectSheet extends ConsumerWidget { - const BackupFrequencyTypeSelectSheet({ - super.key, - }); + const BackupFrequencyTypeSelectSheet({super.key}); String prettyFrequencyType(BackupFrequencyType type) { switch (type) { @@ -37,16 +35,15 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return WillPopScope( onWillPop: () async { - Navigator.of(context) - .pop(ref.read(prefsChangeNotifierProvider).backupFrequencyType); + Navigator.of( + context, + ).pop(ref.read(prefsChangeNotifierProvider).backupFrequencyType); return false; }, child: Container( decoration: BoxDecoration( color: Theme.of(context).extension()!.popupBG, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Padding( padding: const EdgeInsets.only( @@ -62,9 +59,9 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { Center( child: Container( decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -73,9 +70,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { height: 4, ), ), - const SizedBox( - height: 36, - ), + const SizedBox(height: 36), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -84,9 +79,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { style: STextStyles.pageTitleH2(context), textAlign: TextAlign.left, ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), for (int i = 0; i < BackupFrequencyType.values.length; i++) Column( children: [ @@ -131,9 +124,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { }, ), ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Flexible( child: Column( children: [ @@ -151,14 +142,10 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { ), ), ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), ], ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), ], ), ], diff --git a/lib/pages/wallet_view/transaction_views/edit_note_view.dart b/lib/pages/wallet_view/transaction_views/edit_note_view.dart index bcb6202ec3..2589cc7a17 100644 --- a/lib/pages/wallet_view/transaction_views/edit_note_view.dart +++ b/lib/pages/wallet_view/transaction_views/edit_note_view.dart @@ -71,34 +71,33 @@ class _EditNoteViewState extends ConsumerState { condition: !isDesktop, builder: (child) => Background(child: child), child: Scaffold( - backgroundColor: - isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.background, - appBar: - isDesktop - ? null - : AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Edit note", - style: STextStyles.navBarTitle(context), - ), + backgroundColor: isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.background, + appBar: isDesktop + ? null + : AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, ), + title: Text( + "Edit note", + style: STextStyles.navBarTitle(context), + ), + ), body: MobileEditNoteScaffold( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -115,10 +114,9 @@ class _EditNoteViewState extends ConsumerState { ), ), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -127,55 +125,50 @@ class _EditNoteViewState extends ConsumerState { autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: _noteController, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), focusNode: noteFieldFocusNode, - decoration: standardInputDecoration( - "Note", - noteFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Note", + noteFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: - _noteController.text.isNotEmpty + suffixIcon: _noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _noteController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _noteController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), diff --git a/lib/providers/global/address_book_service_provider.dart b/lib/providers/global/address_book_service_provider.dart index e61f6960ae..09d9fb3adc 100644 --- a/lib/providers/global/address_book_service_provider.dart +++ b/lib/providers/global/address_book_service_provider.dart @@ -11,5 +11,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/address_book_service.dart'; -final addressBookServiceProvider = - ChangeNotifierProvider((ref) => AddressBookService()); +final addressBookServiceProvider = ChangeNotifierProvider( + (ref) => AddressBookService(), +); diff --git a/lib/providers/global/auto_swb_service_provider.dart b/lib/providers/global/auto_swb_service_provider.dart index ba67fef945..ecd8be161f 100644 --- a/lib/providers/global/auto_swb_service_provider.dart +++ b/lib/providers/global/auto_swb_service_provider.dart @@ -13,7 +13,6 @@ import 'secure_store_provider.dart'; import '../../services/auto_swb_service.dart'; final autoSWBServiceProvider = ChangeNotifierProvider( - (ref) => AutoSWBService( - secureStorageInterface: ref.read(secureStoreProvider), - ), + (ref) => + AutoSWBService(secureStorageInterface: ref.read(secureStoreProvider)), ); diff --git a/lib/utilities/enums/backup_frequency_type.dart b/lib/utilities/enums/backup_frequency_type.dart index daec5df4e6..6a0992ff36 100644 --- a/lib/utilities/enums/backup_frequency_type.dart +++ b/lib/utilities/enums/backup_frequency_type.dart @@ -8,8 +8,4 @@ * */ -enum BackupFrequencyType { - everyTenMinutes, - everyAppStart, - afterClosingAWallet, -} +enum BackupFrequencyType { everyTenMinutes, everyAppStart, afterClosingAWallet } diff --git a/lib/utilities/format.dart b/lib/utilities/format.dart index 7a9fe1b69f..79ec8aecb6 100644 --- a/lib/utilities/format.dart +++ b/lib/utilities/format.dart @@ -68,8 +68,9 @@ abstract class Format { return dayAndYear; } - final minutes = - date.minute < 10 ? "0${date.minute}" : date.minute.toString(); + final minutes = date.minute < 10 + ? "0${date.minute}" + : date.minute.toString(); return "$dayAndYear, ${date.hour}:$minutes"; } From 07d6fecf0284420601e30bf64b5b3a4dedbdd7f4 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 18:49:48 -0500 Subject: [PATCH 2/2] feat: add event-driven auto backup triggers after wallet changes Fire a debounced auto-backup when a transaction note is saved or an address book contact is created, edited, or deleted, under a new "After editing a note or contact" backup frequency. AutoSWBService becomes provider-owned and takes its enabled/frequency gate as a predicate that is re-evaluated when the debounce fires, so turning auto backup off inside the debounce window cancels the pending backup and an SWB restore cannot trigger a backup storm. Changes arriving during an active backup queue a single follow-up instead of being dropped, and timers and notifications are inert after disposal. Rewriting the "default" self contact with identical data, which both address book views do on every open, no longer counts as an edit. closes #306 --- lib/main.dart | 3 + .../edit_auto_backup_view.dart | 4 + .../backup_frequency_type_select_sheet.dart | 2 + .../transaction_views/edit_note_view.dart | 14 +- .../create_auto_backup.dart | 4 + .../global/address_book_service_provider.dart | 8 +- .../global/auto_swb_service_provider.dart | 18 +- lib/services/address_book_service.dart | 21 +++ lib/services/auto_swb_service.dart | 176 +++++++++++------- .../enums/backup_frequency_type.dart | 7 +- lib/utilities/format.dart | 2 + lib/utilities/prefs.dart | 9 + test/services/auto_swb_service_test.dart | 163 ++++++++++++++++ 13 files changed, 351 insertions(+), 80 deletions(-) create mode 100644 test/services/auto_swb_service_test.dart diff --git a/lib/main.dart b/lib/main.dart index 89d7decb85..0ce75fe5a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -498,6 +498,9 @@ class _MaterialAppWithThemeState extends ConsumerState case BackupFrequencyType.afterClosingAWallet: // ignore this case here break; + case BackupFrequencyType.afterChanges: + ref.read(autoSWBServiceProvider); + break; } } diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart index 077ff21c51..64d4003880 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart @@ -73,6 +73,7 @@ class _EditAutoBackupViewState extends ConsumerState { BackupFrequencyType.everyTenMinutes, BackupFrequencyType.everyAppStart, BackupFrequencyType.afterClosingAWallet, + BackupFrequencyType.afterChanges, ]; String passwordFeedback = @@ -572,6 +573,9 @@ class _EditAutoBackupViewState extends ConsumerState { case BackupFrequencyType.afterClosingAWallet: message = "After closing a cryptocurrency wallet"; break; + case BackupFrequencyType.afterChanges: + message = "After editing a note or contact"; + break; } return DropdownMenuItem( diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart index d03b631e32..c814ee6cb7 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/backup_frequency_type_select_sheet.dart @@ -28,6 +28,8 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { return "Every app start"; case BackupFrequencyType.afterClosingAWallet: return "After closing a cryptocurrency wallet"; + case BackupFrequencyType.afterChanges: + return "After editing a note or contact"; } } diff --git a/lib/pages/wallet_view/transaction_views/edit_note_view.dart b/lib/pages/wallet_view/transaction_views/edit_note_view.dart index 2589cc7a17..02dd158d40 100644 --- a/lib/pages/wallet_view/transaction_views/edit_note_view.dart +++ b/lib/pages/wallet_view/transaction_views/edit_note_view.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/isar/models/transaction_note.dart'; +import '../../../providers/global/auto_swb_service_provider.dart'; import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/constants.dart'; @@ -88,7 +89,7 @@ class _EditNoteViewState extends ConsumerState { const Duration(milliseconds: 75), ); } - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -191,7 +192,11 @@ class _EditNoteViewState extends ConsumerState { ), ); - if (mounted) { + ref + .read(autoSWBServiceProvider) + .requestBackupAfterChange(); + + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -210,7 +215,10 @@ class _EditNoteViewState extends ConsumerState { value: _noteController.text, ), ); - if (mounted) { + + ref.read(autoSWBServiceProvider).requestBackupAfterChange(); + + if (context.mounted) { Navigator.of(context).pop(); } }, diff --git a/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart b/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart index 1a0fc67533..ed8d741007 100644 --- a/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart +++ b/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart @@ -86,6 +86,7 @@ class _CreateAutoBackup extends ConsumerState { BackupFrequencyType.everyTenMinutes, BackupFrequencyType.everyAppStart, BackupFrequencyType.afterClosingAWallet, + BackupFrequencyType.afterChanges, ]; Future _enableAutoBackup() async { @@ -616,6 +617,9 @@ class _CreateAutoBackup extends ConsumerState { case BackupFrequencyType.afterClosingAWallet: message = "After closing a cryptocurrency wallet"; break; + case BackupFrequencyType.afterChanges: + message = "After editing a note or contact"; + break; } return DropdownMenuItem( diff --git a/lib/providers/global/address_book_service_provider.dart b/lib/providers/global/address_book_service_provider.dart index 09d9fb3adc..442464ddf3 100644 --- a/lib/providers/global/address_book_service_provider.dart +++ b/lib/providers/global/address_book_service_provider.dart @@ -9,8 +9,14 @@ */ import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../services/address_book_service.dart'; +import 'auto_swb_service_provider.dart'; final addressBookServiceProvider = ChangeNotifierProvider( - (ref) => AddressBookService(), + (ref) => AddressBookService( + requestAutoBackup: ref + .read(autoSWBServiceProvider) + .requestBackupAfterChange, + ), ); diff --git a/lib/providers/global/auto_swb_service_provider.dart b/lib/providers/global/auto_swb_service_provider.dart index ecd8be161f..0b3d1f6699 100644 --- a/lib/providers/global/auto_swb_service_provider.dart +++ b/lib/providers/global/auto_swb_service_provider.dart @@ -9,10 +9,18 @@ */ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'secure_store_provider.dart'; + import '../../services/auto_swb_service.dart'; +import '../../utilities/enums/backup_frequency_type.dart'; +import 'prefs_provider.dart'; +import 'secure_store_provider.dart'; -final autoSWBServiceProvider = ChangeNotifierProvider( - (ref) => - AutoSWBService(secureStorageInterface: ref.read(secureStoreProvider)), -); +final autoSWBServiceProvider = ChangeNotifierProvider((ref) { + final prefs = ref.read(prefsChangeNotifierProvider); + return AutoSWBService( + secureStorageInterface: ref.read(secureStoreProvider), + shouldBackupAfterChange: () => + prefs.isAutoBackupEnabled && + prefs.backupFrequencyType == BackupFrequencyType.afterChanges, + ); +}); diff --git a/lib/services/address_book_service.dart b/lib/services/address_book_service.dart index f49d9e6649..0ccf3ef25f 100644 --- a/lib/services/address_book_service.dart +++ b/lib/services/address_book_service.dart @@ -8,12 +8,19 @@ * */ +import 'dart:convert'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; + import '../db/isar/main_db.dart'; import '../models/isar/models/contact_entry.dart'; class AddressBookService extends ChangeNotifier { + AddressBookService({this.requestAutoBackup}); + + final void Function()? requestAutoBackup; + ContactEntry getContactById(String id) { final ContactEntry? contactEntry = MainDB.instance.getContactEntry(id: id); if (contactEntry == null) { @@ -66,15 +73,28 @@ class AddressBookService extends ChangeNotifier { } else { await MainDB.instance.putContactEntry(contactEntry: contact); notifyListeners(); + requestAutoBackup?.call(); return true; } } /// Edit contact Future editContact(ContactEntry editedContact) async { + // The address book views rewrite the "default" self contact on every open. + // An identical write is not a user edit, so it must not notify listeners or + // trigger an auto backup. + final existing = MainDB.instance.getContactEntry( + id: editedContact.customId, + ); + if (existing != null && + jsonEncode(existing.toMap()) == jsonEncode(editedContact.toMap())) { + return true; + } + // over write the contact with edited version await MainDB.instance.putContactEntry(contactEntry: editedContact); notifyListeners(); + requestAutoBackup?.call(); return true; } @@ -82,5 +102,6 @@ class AddressBookService extends ChangeNotifier { Future removeContact(String id) async { await MainDB.instance.deleteContactEntry(id: id); notifyListeners(); + requestAutoBackup?.call(); } } diff --git a/lib/services/auto_swb_service.dart b/lib/services/auto_swb_service.dart index 419e7a0804..2db12ebdd1 100644 --- a/lib/services/auto_swb_service.dart +++ b/lib/services/auto_swb_service.dart @@ -25,6 +25,9 @@ enum AutoSWBStatus { idle, backingUp, error } class AutoSWBService extends ChangeNotifier { Timer? _timer; + Timer? _debounceTimer; + bool _backupPending = false; + bool _isDisposed = false; AutoSWBStatus _status = AutoSWBStatus.idle; AutoSWBStatus get status => _status; @@ -33,95 +36,124 @@ class AutoSWBService extends ChangeNotifier { bool get isActivePeriodicTimer => _isActiveTimer; final SecureStorageInterface secureStorageInterface; + final bool Function() shouldBackupAfterChange; + @visibleForTesting + final Future Function()? backupRunner; + final Duration debounceDuration; + + AutoSWBService({ + required this.secureStorageInterface, + required this.shouldBackupAfterChange, + this.backupRunner, + this.debounceDuration = const Duration(seconds: 5), + }); + + void requestBackupAfterChange() { + if (_isDisposed || !shouldBackupAfterChange()) return; + + Logging.instance.d("AutoSWBService.requestBackupAfterChange() triggered"); + // Re-checked when the timer fires: a backup writes wallet seed material, + // and the user can turn auto backup off inside the debounce window. + requestBackup(onlyIf: shouldBackupAfterChange); + } - AutoSWBService({required this.secureStorageInterface}); + void requestBackup({Duration? debounceDuration, bool Function()? onlyIf}) { + if (_isDisposed) return; + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounceDuration ?? this.debounceDuration, () { + _debounceTimer = null; + if (_isDisposed || (onlyIf != null && !onlyIf())) { + Logging.instance.d( + "AutoSWBService.requestBackup() debounce fired but the trigger " + "condition no longer holds; skipping backup", + ); + return; + } + Logging.instance.d( + "AutoSWBService.requestBackup() debounce fired, running doBackup()", + ); + unawaited(doBackup()); + }); + } /// Attempt a backup. Future doBackup() async { + if (_isDisposed) return; if (_status == AutoSWBStatus.backingUp) { - Logging.instance.w( - "AutoSWBService attempted to run doBackup() while a backup is in progress!", - ); + _backupPending = true; return; } - Logging.instance.d("AutoSWBService.doBackup() started..."); - - // set running backup status and notify listeners - _status = AutoSWBStatus.backingUp; - notifyListeners(); - - try { - if (!Prefs.instance.isInitialized) { - await Prefs.instance.init(); - } - final autoBackupDirectoryPath = Prefs.instance.autoBackupLocation; - if (autoBackupDirectoryPath == null) { - Logging.instance.e( - "AutoSWBService attempted to run doBackup() when no auto backup directory was set!", - ); - // set error backup status and notify listeners - _status = AutoSWBStatus.error; - notifyListeners(); - return; - } - - final json = await SWB.createStackWalletJSON( - secureStorage: secureStorageInterface, - ); - final jsonString = jsonEncode(json); - - final adkString = await secureStorageInterface.read( - key: "auto_adk_string", - ); - - final adkVersionString = await secureStorageInterface.read( - key: "auto_adk_version_string", - ); - final int adkVersion = int.parse(adkVersionString!); - - final DateTime now = DateTime.now(); - final String fileToSave = createAutoBackupFilename( - autoBackupDirectoryPath, - now, - ); - - final content = await SWB.encryptStackWalletWithADK( - adkString!, - jsonString, - adkVersion, - ); - - await FS.writeStringToFile( - content, - autoBackupDirectoryPath, - fileToSave.split("/").last, - ); - - Prefs.instance.lastAutoBackup = now; + do { + _backupPending = false; + await _doBackupOnce(); + } while (_backupPending && !_isDisposed); + } - // delete all but the latest 3 auto backups - trimBackups(autoBackupDirectoryPath, 3); + Future _doBackupOnce() async { + Logging.instance.d("AutoSWBService.doBackup() started..."); + _setStatus(AutoSWBStatus.backingUp); + try { + await (backupRunner?.call() ?? _writeBackup()); Logging.instance.d("AutoSWBService.doBackup() succeeded"); } on Exception catch (e, s) { final String err = getErrorMessageFromSWBException(e); Logging.instance.e("$err\n$s", error: e, stackTrace: s); - // set error backup status and notify listeners - _status = AutoSWBStatus.error; - notifyListeners(); + _setStatus(AutoSWBStatus.error); return; } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - // set error backup status and notify listeners - _status = AutoSWBStatus.error; - notifyListeners(); + _setStatus(AutoSWBStatus.error); return; } - // set done/idle backup status and notify listeners - _status = AutoSWBStatus.idle; - notifyListeners(); + _setStatus(AutoSWBStatus.idle); + } + + Future _writeBackup() async { + if (!Prefs.instance.isInitialized) { + await Prefs.instance.init(); + } + + final autoBackupDirectoryPath = Prefs.instance.autoBackupLocation; + if (autoBackupDirectoryPath == null) { + throw StateError("No auto backup directory is set"); + } + + final json = await SWB.createStackWalletJSON( + secureStorage: secureStorageInterface, + ); + final jsonString = jsonEncode(json); + + final adkString = await secureStorageInterface.read(key: "auto_adk_string"); + final adkVersionString = await secureStorageInterface.read( + key: "auto_adk_version_string", + ); + final adkVersion = int.parse(adkVersionString!); + final now = DateTime.now(); + final fileToSave = createAutoBackupFilename(autoBackupDirectoryPath, now); + final content = await SWB.encryptStackWalletWithADK( + adkString!, + jsonString, + adkVersion, + ); + + await FS.writeStringToFile( + content, + autoBackupDirectoryPath, + fileToSave.split("/").last, + ); + + Prefs.instance.lastAutoBackup = now; + trimBackups(autoBackupDirectoryPath, 3); + } + + void _setStatus(AutoSWBStatus status) { + _status = status; + if (!_isDisposed) { + notifyListeners(); + } } /// Trim the number of auto backup files based on age @@ -165,7 +197,7 @@ class AutoSWBService extends ChangeNotifier { (a, b) => b.item1.millisecondsSinceEpoch - a.item1.millisecondsSinceEpoch, ); - // delete any older backups if there are more than the number we want to keep + // Delete backups beyond the retention limit. while (files.length > numberToKeep) { final fileToDelete = files.removeLast().item2; fileToDelete.deleteSync(); @@ -199,6 +231,10 @@ class AutoSWBService extends ChangeNotifier { @override void dispose() { + _isDisposed = true; + _backupPending = false; + _debounceTimer?.cancel(); + _debounceTimer = null; stopPeriodicBackupTimer(shouldNotifyListeners: false); super.dispose(); } diff --git a/lib/utilities/enums/backup_frequency_type.dart b/lib/utilities/enums/backup_frequency_type.dart index 6a0992ff36..2c85ebde7d 100644 --- a/lib/utilities/enums/backup_frequency_type.dart +++ b/lib/utilities/enums/backup_frequency_type.dart @@ -8,4 +8,9 @@ * */ -enum BackupFrequencyType { everyTenMinutes, everyAppStart, afterClosingAWallet } +enum BackupFrequencyType { + everyTenMinutes, + everyAppStart, + afterClosingAWallet, + afterChanges, +} diff --git a/lib/utilities/format.dart b/lib/utilities/format.dart index 79ec8aecb6..808f8692ae 100644 --- a/lib/utilities/format.dart +++ b/lib/utilities/format.dart @@ -142,6 +142,8 @@ abstract class Format { return "Every app start"; case BackupFrequencyType.afterClosingAWallet: return "After closing a cryptocurrency wallet"; + case BackupFrequencyType.afterChanges: + return "After editing a note or contact"; } } } diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart index 56013d64d7..d0824d273e 100644 --- a/lib/utilities/prefs.dart +++ b/lib/utilities/prefs.dart @@ -688,6 +688,13 @@ class Prefs extends ChangeNotifier { value: "onWalletClose", ); break; + case BackupFrequencyType.afterChanges: + DB.instance.put( + boxName: DB.boxNamePrefs, + key: "backupFrequencyType", + value: "afterChanges", + ); + break; } _backupFrequencyType = backupFrequencyType; notifyListeners(); @@ -709,6 +716,8 @@ class Prefs extends ChangeNotifier { return BackupFrequencyType.everyAppStart; case "onWalletClose": return BackupFrequencyType.afterClosingAWallet; + case "afterChanges": + return BackupFrequencyType.afterChanges; default: throw Exception("Invalid Backup Frequency type found in prefs!"); } diff --git a/test/services/auto_swb_service_test.dart b/test/services/auto_swb_service_test.dart new file mode 100644 index 0000000000..522b94b27f --- /dev/null +++ b/test/services/auto_swb_service_test.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/auto_swb_service.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; + +void main() { + late AutoSWBService service; + late Future Function() backupRunner; + var shouldBackup = true; + var isDisposed = false; + + setUp(() { + shouldBackup = true; + isDisposed = false; + backupRunner = () async {}; + service = AutoSWBService( + secureStorageInterface: FakeSecureStorage(), + shouldBackupAfterChange: () => shouldBackup, + backupRunner: () => backupRunner(), + debounceDuration: Duration.zero, + ); + }); + + tearDown(() { + if (!isDisposed) { + service.dispose(); + } + }); + + test('only schedules change backups when enabled', () async { + var backups = 0; + backupRunner = () async { + backups++; + }; + + shouldBackup = false; + service.requestBackupAfterChange(); + await _flushTimers(); + expect(backups, 0); + + shouldBackup = true; + service.requestBackupAfterChange(); + await _flushTimers(); + expect(backups, 1); + }); + + test( + 'does not back up if the gate closes inside the debounce window', + () async { + var backups = 0; + backupRunner = () async { + backups++; + }; + + service.requestBackupAfterChange(); + // User turns auto backup off (or switches frequency) before the timer fires. + shouldBackup = false; + await _flushTimers(); + + expect(backups, 0); + }, + ); + + test('coalesces a burst into one backup', () async { + var backups = 0; + backupRunner = () async { + backups++; + }; + + service.requestBackupAfterChange(); + service.requestBackupAfterChange(); + service.requestBackupAfterChange(); + await _flushTimers(); + + expect(backups, 1); + expect(service.status, AutoSWBStatus.idle); + }); + + test('runs one follow-up for changes during an active backup', () async { + final firstBackup = Completer(); + var backups = 0; + backupRunner = () async { + backups++; + if (backups == 1) { + await firstBackup.future; + } + }; + + final runningBackup = service.doBackup(); + await _flushTimers(); + expect(service.status, AutoSWBStatus.backingUp); + + service.requestBackupAfterChange(); + service.requestBackupAfterChange(); + await _flushTimers(); + expect(backups, 1); + + firstBackup.complete(); + await runningBackup; + + expect(backups, 2); + expect(service.status, AutoSWBStatus.idle); + }); + + test('recovers after a backup failure', () async { + var backups = 0; + backupRunner = () async { + backups++; + if (backups == 1) { + throw StateError('failed'); + } + }; + + await service.doBackup(); + expect(service.status, AutoSWBStatus.error); + + await service.doBackup(); + expect(backups, 2); + expect(service.status, AutoSWBStatus.idle); + }); + + test('cancels a debounced request on dispose', () async { + var backups = 0; + backupRunner = () async { + backups++; + }; + service.requestBackup(debounceDuration: const Duration(milliseconds: 20)); + + service.dispose(); + isDisposed = true; + await Future.delayed(const Duration(milliseconds: 30)); + + expect(backups, 0); + }); + + test('finishes an active backup safely after dispose', () async { + final backup = Completer(); + var backups = 0; + backupRunner = () async { + backups++; + await backup.future; + }; + + final runningBackup = service.doBackup(); + await _flushTimers(); + service.dispose(); + isDisposed = true; + backup.complete(); + + await expectLater(runningBackup, completes); + service.requestBackupAfterChange(); + await _flushTimers(); + + expect(backups, 1); + expect(service.status, AutoSWBStatus.idle); + }); +} + +Future _flushTimers() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +}