diff --git a/lib/main.dart b/lib/main.dart index 04ed3daa6d..ff31239834 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -478,6 +478,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..5fdece0fed 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 = @@ -170,9 +171,8 @@ class _EditAutoBackupViewState extends ConsumerState { passwordRepeatController.text = ""; if (!Util.isDesktop) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(AutoBackupView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(AutoBackupView.routeName)); } } } else { @@ -243,9 +243,9 @@ class _EditAutoBackupViewState extends ConsumerState { condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -325,9 +325,9 @@ class _EditAutoBackupViewState 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, ), @@ -394,9 +394,9 @@ class _EditAutoBackupViewState 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, ), @@ -473,15 +473,15 @@ class _EditAutoBackupViewState extends ConsumerState { fillColor: passwordStrength < 0.51 ? 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, ), ), @@ -524,9 +524,9 @@ class _EditAutoBackupViewState 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, ), @@ -547,9 +547,9 @@ class _EditAutoBackupViewState extends ConsumerState { "Auto Backup frequency", style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ) : STextStyles.smallMed12(context), ), @@ -572,6 +572,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( @@ -604,18 +607,18 @@ class _EditAutoBackupViewState extends ConsumerState { Assets.svg.chevronDown, width: 10, height: 5, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ), ), dropdownStyleData: DropdownStyleData( offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -637,9 +640,9 @@ class _EditAutoBackupViewState extends ConsumerState { ), Positioned.fill( child: RawMaterialButton( - splashColor: Theme.of( - context, - ).extension()!.highlight, + splashColor: Theme.of(context) + .extension()! + .highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -676,9 +679,9 @@ class _EditAutoBackupViewState extends ConsumerState { padding: const EdgeInsets.only(right: 4.0), child: SvgPicture.asset( Assets.svg.chevronDown, - color: Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of(context) + .extension()! + .textSubtitle2, width: 12, height: 6, ), 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..ab75955516 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) { @@ -30,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"; } } @@ -44,9 +44,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { 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( @@ -73,9 +71,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { height: 4, ), ), - const SizedBox( - height: 36, - ), + const SizedBox(height: 36), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -84,9 +80,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 +125,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { }, ), ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Flexible( child: Column( children: [ @@ -151,14 +143,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 480ddc66eb..72e7f869e7 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'; @@ -195,6 +196,10 @@ class _EditNoteViewState extends ConsumerState { ), ); + ref + .read(autoSWBServiceProvider) + .requestBackupAfterChange(); + if (context.mounted) { Navigator.of(context).pop(); } @@ -214,6 +219,9 @@ class _EditNoteViewState extends ConsumerState { value: _noteController.text, ), ); + + 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..49c04356d2 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 { @@ -349,9 +350,9 @@ class _CreateAutoBackup 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, ), @@ -383,9 +384,9 @@ class _CreateAutoBackup extends ConsumerState { "Create a passphrase", style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ), textAlign: TextAlign.left, ), @@ -428,9 +429,9 @@ class _CreateAutoBackup 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, ), @@ -507,19 +508,19 @@ class _CreateAutoBackup extends ConsumerState { width: 512, 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, @@ -562,9 +563,9 @@ class _CreateAutoBackup 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, ), @@ -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( @@ -650,18 +654,18 @@ class _CreateAutoBackup extends ConsumerState { Assets.svg.chevronDown, width: 10, height: 5, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ), ), dropdownStyleData: DropdownStyleData( offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), diff --git a/lib/providers/global/address_book_service_provider.dart b/lib/providers/global/address_book_service_provider.dart index e61f6960ae..442464ddf3 100644 --- a/lib/providers/global/address_book_service_provider.dart +++ b/lib/providers/global/address_book_service_provider.dart @@ -9,7 +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()); +final addressBookServiceProvider = ChangeNotifierProvider( + (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 ba67fef945..0b3d1f6699 100644 --- a/lib/providers/global/auto_swb_service_provider.dart +++ b/lib/providers/global/auto_swb_service_provider.dart @@ -9,11 +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( +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..03e63a2959 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; + final _pendingBackups = {}; + bool _isDisposed = false; AutoSWBStatus _status = AutoSWBStatus.idle; AutoSWBStatus get status => _status; @@ -33,95 +36,125 @@ 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}); - - /// Attempt a backup. - Future doBackup() async { - if (_status == AutoSWBStatus.backingUp) { - Logging.instance.w( - "AutoSWBService attempted to run doBackup() while a backup is in progress!", - ); - 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!", + 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", ); - // 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, + Logging.instance.d( + "AutoSWBService.requestBackup() debounce fired, running doBackup()", ); + unawaited(doBackup(onlyIf: onlyIf)); + }); + } - await FS.writeStringToFile( - content, - autoBackupDirectoryPath, - fileToSave.split("/").last, - ); + /// Attempt a backup. + Future doBackup({bool Function()? onlyIf}) async { + if (_isDisposed || !(onlyIf?.call() ?? true)) return; + if (_status == AutoSWBStatus.backingUp) { + _pendingBackups.add(onlyIf); + return; + } - Prefs.instance.lastAutoBackup = now; + do { + _pendingBackups.clear(); + await _doBackupOnce(); + } while (!_isDisposed && + _pendingBackups.any((condition) => condition?.call() ?? true)); + } - // 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 +198,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 +232,10 @@ class AutoSWBService extends ChangeNotifier { @override void dispose() { + _isDisposed = true; + _pendingBackups.clear(); + _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 daec5df4e6..2c85ebde7d 100644 --- a/lib/utilities/enums/backup_frequency_type.dart +++ b/lib/utilities/enums/backup_frequency_type.dart @@ -12,4 +12,5 @@ enum BackupFrequencyType { everyTenMinutes, everyAppStart, afterClosingAWallet, + afterChanges, } diff --git a/lib/utilities/format.dart b/lib/utilities/format.dart index 7a9fe1b69f..808f8692ae 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"; } @@ -141,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..59c89fdc6a 100644 --- a/lib/utilities/prefs.dart +++ b/lib/utilities/prefs.dart @@ -108,10 +108,9 @@ class Prefs extends ChangeNotifier { Future _getLastUnlockedTimeout() async { return (DB.instance.get( - boxName: DB.boxNamePrefs, - key: "lastUnlockedTimeout", - )) - as int? ?? + boxName: DB.boxNamePrefs, + key: "lastUnlockedTimeout", + )) as int? ?? 60; } @@ -135,10 +134,9 @@ class Prefs extends ChangeNotifier { Future _getLastUnlocked() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "lastUnlocked", - ) - as int? ?? + boxName: DB.boxNamePrefs, + key: "lastUnlocked", + ) as int? ?? 0; } @@ -171,10 +169,9 @@ class Prefs extends ChangeNotifier { Future _getCurrentNotificationIndex() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "currentNotificationId", - ) - as int? ?? + boxName: DB.boxNamePrefs, + key: "currentNotificationId", + ) as int? ?? 0; } @@ -199,10 +196,9 @@ class Prefs extends ChangeNotifier { Future> _getWalletIdsSyncOnStartup() async { final list = await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "walletIdsSyncOnStartup", - ) - as List? ?? + boxName: DB.boxNamePrefs, + key: "walletIdsSyncOnStartup", + ) as List? ?? []; return List.from(list); } @@ -228,10 +224,9 @@ class Prefs extends ChangeNotifier { Future _getSyncType() async { final int index = await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "syncTypeIndex", - ) - as int? ?? + boxName: DB.boxNamePrefs, + key: "syncTypeIndex", + ) as int? ?? SyncingType.allWalletsOnStartup.index; return SyncingType.values[index]; } @@ -256,10 +251,9 @@ class Prefs extends ChangeNotifier { Future _getUseWifiOnly() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "wifiOnly", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "wifiOnly", + ) as bool? ?? false; } @@ -283,10 +277,9 @@ class Prefs extends ChangeNotifier { Future _getShowFavoriteWallets() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "showFavoriteWallets", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "showFavoriteWallets", + ) as bool? ?? true; } @@ -310,10 +303,9 @@ class Prefs extends ChangeNotifier { Future _getPreferredLanguage() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "language", - ) - as String? ?? + boxName: DB.boxNamePrefs, + key: "language", + ) as String? ?? Language.englishUS.description; } @@ -337,10 +329,9 @@ class Prefs extends ChangeNotifier { Future _getPreferredCurrency() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "currency", - ) - as String? ?? + boxName: DB.boxNamePrefs, + key: "currency", + ) as String? ?? "USD"; } @@ -405,10 +396,9 @@ class Prefs extends ChangeNotifier { Future _getRandomizePIN() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "randomizePIN", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "randomizePIN", + ) as bool? ?? false; } @@ -432,10 +422,9 @@ class Prefs extends ChangeNotifier { Future _getUseBiometrics() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "useBiometrics", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "useBiometrics", + ) as bool? ?? false; } @@ -459,10 +448,9 @@ class Prefs extends ChangeNotifier { Future _getHasPin() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "hasPin", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "hasPin", + ) as bool? ?? false; } @@ -486,10 +474,9 @@ class Prefs extends ChangeNotifier { Future _getHasDuressPin() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "hasDuressPin", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "hasDuressPin", + ) as bool? ?? false; } @@ -513,10 +500,9 @@ class Prefs extends ChangeNotifier { Future _getBiometricsDuress() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "biometricsDuress", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "biometricsDuress", + ) as bool? ?? false; } @@ -540,10 +526,9 @@ class Prefs extends ChangeNotifier { Future _getHasFamiliarity() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "familiarity", - ) - as int? ?? + boxName: DB.boxNamePrefs, + key: "familiarity", + ) as int? ?? 0; } @@ -567,10 +552,9 @@ class Prefs extends ChangeNotifier { Future _getTorKillswitch() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "torKillswitch", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "torKillswitch", + ) as bool? ?? true; } @@ -594,10 +578,9 @@ class Prefs extends ChangeNotifier { Future _getShowTestNetCoins() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "showTestNetCoins", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "showTestNetCoins", + ) as bool? ?? false; } @@ -624,10 +607,9 @@ class Prefs extends ChangeNotifier { Future _getIsAutoBackupEnabled() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "isAutoBackupEnabled", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "isAutoBackupEnabled", + ) as bool? ?? false; } @@ -651,10 +633,9 @@ class Prefs extends ChangeNotifier { Future _getAutoBackupLocation() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "autoBackupLocation", - ) - as String?; + boxName: DB.boxNamePrefs, + key: "autoBackupLocation", + ) as String?; } // auto backup frequency type @@ -688,6 +669,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(); @@ -695,12 +683,10 @@ class Prefs extends ChangeNotifier { } Future _getBackupFrequencyType() async { - String? rate = - await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "backupFrequencyType", - ) - as String?; + String? rate = await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "backupFrequencyType", + ) as String?; rate ??= "10Min"; switch (rate) { case "10Min": @@ -709,6 +695,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!"); } @@ -734,10 +722,9 @@ class Prefs extends ChangeNotifier { Future _getLastAutoBackup() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "lastAutoBackup", - ) - as DateTime?; + boxName: DB.boxNamePrefs, + key: "lastAutoBackup", + ) as DateTime?; } // auto backup @@ -763,10 +750,9 @@ class Prefs extends ChangeNotifier { Future _getHideBlockExplorerWarning() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "hideBlockExplorerWarning", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "hideBlockExplorerWarning", + ) as bool? ?? false; } @@ -793,10 +779,9 @@ class Prefs extends ChangeNotifier { Future _getGotoWalletOnStartup() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "gotoWalletOnStartup", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "gotoWalletOnStartup", + ) as bool? ?? false; } @@ -820,10 +805,9 @@ class Prefs extends ChangeNotifier { Future _getStartupWalletId() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "startupWalletId", - ) - as String?; + boxName: DB.boxNamePrefs, + key: "startupWalletId", + ) as String?; } // incognito mode off by default @@ -849,10 +833,9 @@ class Prefs extends ChangeNotifier { Future _getHasExternalCalls() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "externalCalls", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "externalCalls", + ) as bool? ?? true; } @@ -871,9 +854,10 @@ class Prefs extends ChangeNotifier { String? get userID => _userId; Future _getUserId() async { - String? userID = - await DB.instance.get(boxName: DB.boxNamePrefs, key: "userID") - as String?; + String? userID = await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "userID", + ) as String?; if (userID == null) { userID = const Uuid().v4(); await saveUserID(userID); @@ -895,12 +879,10 @@ class Prefs extends ChangeNotifier { int? get signupEpoch => _signupEpoch; Future _getSignupEpoch() async { - int? signupEpoch = - await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "signupEpoch", - ) - as int?; + int? signupEpoch = await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "signupEpoch", + ) as int?; if (signupEpoch == null) { signupEpoch = DateTime.now().millisecondsSinceEpoch ~/ @@ -940,10 +922,9 @@ class Prefs extends ChangeNotifier { Future _getEnableCoinControl() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "enableCoinControl", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "enableCoinControl", + ) as bool? ?? false; } @@ -967,10 +948,9 @@ class Prefs extends ChangeNotifier { Future _getEnableSystemBrightness() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "enableSystemBrightness", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "enableSystemBrightness", + ) as bool? ?? false; } @@ -994,10 +974,9 @@ class Prefs extends ChangeNotifier { Future _getThemeId() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "themeId", - ) - as String? ?? + boxName: DB.boxNamePrefs, + key: "themeId", + ) as String? ?? "light"; } @@ -1021,10 +1000,9 @@ class Prefs extends ChangeNotifier { Future _getSystemBrightnessLightThemeId() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "systemBrightnessLightThemeId", - ) - as String? ?? + boxName: DB.boxNamePrefs, + key: "systemBrightnessLightThemeId", + ) as String? ?? "light"; } @@ -1048,10 +1026,9 @@ class Prefs extends ChangeNotifier { Future _getSystemBrightnessDarkTheme() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "systemBrightnessDarkThemeId", - ) - as String? ?? + boxName: DB.boxNamePrefs, + key: "systemBrightnessDarkThemeId", + ) as String? ?? "dark"; } @@ -1081,10 +1058,9 @@ class Prefs extends ChangeNotifier { for (final coin in AppConfig.coins) { final unitIndex = await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "amountUnitFor${coin.identifier}", - ) - as int? ?? + boxName: DB.boxNamePrefs, + key: "amountUnitFor${coin.identifier}", + ) as int? ?? 0; // 0 is "normal" _amountUnits[coin] = AmountUnit.values[unitIndex]; } @@ -1116,10 +1092,9 @@ class Prefs extends ChangeNotifier { for (final coin in AppConfig.coins) { final decimals = await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "maxDecimalsFor${coin.identifier}", - ) - as int? ?? + boxName: DB.boxNamePrefs, + key: "maxDecimalsFor${coin.identifier}", + ) as int? ?? (coin.fractionDigits > 18 ? 18 : coin.fractionDigits); // use some sane max rather than up to 30 that nano uses _amountDecimals[coin.identifier] = decimals; @@ -1152,10 +1127,9 @@ class Prefs extends ChangeNotifier { Future _getUseTor() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "useTor", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "useTor", + ) as bool? ?? false; } @@ -1184,30 +1158,25 @@ class Prefs extends ChangeNotifier { } Future> _getFusionServerInfo() async { - final map = - await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "fusionServerInfoMap", - ) - as Map?; + final map = await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "fusionServerInfoMap", + ) as Map?; if (map == null) { return _fusionServerInfo; } - final actualMap = Map.from( - map, - ).map((key, value) => MapEntry(key, FusionInfo.fromJsonString(value))); + final actualMap = Map.from(map) + .map((key, value) => MapEntry(key, FusionInfo.fromJsonString(value))); // legacy bch check if (actualMap["bitcoincash"] == null || actualMap["bitcoincashTestnet"] == null) { - final saved = - await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "fusionServerInfo", - ) - as String?; + final saved = await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "fusionServerInfo", + ) as String?; if (saved != null) { final bchInfo = FusionInfo.fromJsonString(saved); @@ -1248,10 +1217,9 @@ class Prefs extends ChangeNotifier { Future _getAutoPin() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "autoPin", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "autoPin", + ) as bool? ?? false; } @@ -1275,10 +1243,9 @@ class Prefs extends ChangeNotifier { Future _getEnableExchange() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "showExchange", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "showExchange", + ) as bool? ?? true; } @@ -1299,10 +1266,9 @@ class Prefs extends ChangeNotifier { Future _getAdvancedFiroFeatures() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "advancedFiroFeatures", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "advancedFiroFeatures", + ) as bool? ?? false; } @@ -1323,10 +1289,9 @@ class Prefs extends ChangeNotifier { Future _getLogsPath() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "logsPath", - ) - as String?; + boxName: DB.boxNamePrefs, + key: "logsPath", + ) as String?; } // log level pref @@ -1345,12 +1310,10 @@ class Prefs extends ChangeNotifier { } Future _getLogLevel() async { - final value = - await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "logLevel", - ) - as int?; + final value = await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "logLevel", + ) as int?; try { return Level.values.firstWhere((e) => e.value == value); @@ -1384,10 +1347,9 @@ class Prefs extends ChangeNotifier { Future _getAutoLockInfo() async { final map = await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "autoLockInfo", - ) - as Map? ?? + boxName: DB.boxNamePrefs, + key: "autoLockInfo", + ) as Map? ?? {"enabled": false, "minutes": 10}; return (enabled: map["enabled"] as bool, minutes: map["minutes"] as int); @@ -1410,10 +1372,9 @@ class Prefs extends ChangeNotifier { Future _getPrivacyScreen() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "privacyScreen", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "privacyScreen", + ) as bool? ?? false; } @@ -1434,10 +1395,9 @@ class Prefs extends ChangeNotifier { Future _getDisableScreenShots() async { return await DB.instance.get( - boxName: DB.boxNamePrefs, - key: "disableScreenShots", - ) - as bool? ?? + boxName: DB.boxNamePrefs, + key: "disableScreenShots", + ) as bool? ?? false; } } diff --git a/test/services/auto_swb_service_test.dart b/test/services/auto_swb_service_test.dart new file mode 100644 index 0000000000..5660a4aeb8 --- /dev/null +++ b/test/services/auto_swb_service_test.dart @@ -0,0 +1,180 @@ +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('does not run a queued change backup after disabling backups', () async { + final firstBackup = Completer(); + var backups = 0; + backupRunner = () async { + backups++; + if (backups == 1) await firstBackup.future; + }; + final runningBackup = service.doBackup(); + await _flushTimers(); + service.requestBackupAfterChange(); + await _flushTimers(); + shouldBackup = false; + firstBackup.complete(); + await runningBackup; + expect(backups, 1); + }); + + 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); +}