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 d61c93c626..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 @@ -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"; } } @@ -37,16 +37,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 +61,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 +72,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { height: 4, ), ), - const SizedBox( - height: 36, - ), + const SizedBox(height: 36), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -84,9 +81,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 +126,7 @@ class BackupFrequencyTypeSelectSheet extends ConsumerWidget { }, ), ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Flexible( child: Column( children: [ @@ -151,14 +144,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..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'; @@ -71,34 +72,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 (context.mounted) { + Navigator.of(context).pop(); + } + }, ), + title: Text( + "Edit note", + style: STextStyles.navBarTitle(context), + ), + ), body: MobileEditNoteScaffold( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -115,10 +115,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 +126,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, - ), + ), ), ), ), @@ -198,7 +192,11 @@ class _EditNoteViewState extends ConsumerState { ), ); - if (mounted) { + ref + .read(autoSWBServiceProvider) + .requestBackupAfterChange(); + + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -217,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 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..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 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..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); +}