diff --git a/app/lib/folders/watched_folders.dart b/app/lib/folders/watched_folders.dart index b272d6ed..0996c86a 100644 --- a/app/lib/folders/watched_folders.dart +++ b/app/lib/folders/watched_folders.dart @@ -812,8 +812,13 @@ class WatchedFolders extends ChangeNotifier with WidgetsBindingObserver { final needsObservation = file.modified == 0 || file.modified > now; - if (needsObservation && (previous?['stamp'] != file.stamp || previous?['seen'] == null)) { - await repository.save('files', recordId, {...?previous, 'stamp': file.stamp, 'seen': now}, folderId: folder.id); + if (needsObservation && (previous?['observedStamp'] != file.stamp || previous?['seen'] == null)) { + await repository.save('files', recordId, { + ...?previous, + 'observedStamp': file.stamp, + 'seen': now, + }, folderId: folder.id); + return _FileResult.deferred; } diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart index 6e5a2e0f..45d3c4a1 100644 --- a/app/lib/widgets/profile/storage_sync_settings_section.dart +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -537,18 +537,20 @@ class StorageSyncSettingsSection extends StatelessWidget { final database = context.read(); await folders?.suspend(); - await database.clearGuestLibrary( - deleteFiles: (books) async { - for (final book in books) { - await files.deleteBookFile(book.id); - } - - await files.clearCoverFiles(MediaStorageScope.localGuest); - await folders?.clear(); - }, - ); - - folders?.resume(); + try { + await database.clearGuestLibrary( + deleteFiles: (books) async { + for (final book in books) { + await files.deleteBookFile(book.id); + } + + await files.clearCoverFiles(MediaStorageScope.localGuest); + await folders?.clear(); + }, + ); + } finally { + folders?.resume(); + } messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, diff --git a/app/test/folders/watched_folders_test.dart b/app/test/folders/watched_folders_test.dart index 9730aae9..15e68043 100644 --- a/app/test/folders/watched_folders_test.dart +++ b/app/test/folders/watched_folders_test.dart @@ -455,6 +455,28 @@ void main() { expect(target.books, hasLength(1)); }); + for (final clockCatchesUp in [false, true]) { + test('future-dated changes stay eligible when the clock catches up: $clockCatchesUp', () async { + access.put('book.epub', [1]); + final folder = await connect(); + final original = target.books.single; + access.put('book.epub', [2]); + access.reportedModified = DateTime.now().add(const Duration(seconds: 10)).millisecondsSinceEpoch; + await folders.scanNow(folder.id); + expect(await folders.issues(folder.id), isEmpty); + expect(access.reads, 1); + + if (clockCatchesUp) { + clockOffset = const Duration(seconds: 20); + } + + await folders.scanNow(folder.id); + expect((await folders.issues(folder.id)).single['state'], 'changed'); + expect(target.books.single, same(original)); + expect(access.reads, 2); + }); + } + for (final condition in ['recent', 'missing timestamp', 'future timestamp', 'changed during read']) { test('one-time selections finish files deferred for $condition before pausing', () async { await folders.activate(null); diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index f564773f..8adb6484 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -11,6 +11,7 @@ import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/folders/watched_folders.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_service_stub.dart' @@ -129,6 +130,29 @@ class _Files extends BookImportService { } } +class _Folders extends ChangeNotifier implements WatchedFolders { + bool suspended = false; + int clearCalls = 0; + + @override + Future suspend() async { + suspended = true; + } + + @override + void resume() { + suspended = false; + } + + @override + Future clear() async { + clearCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -162,6 +186,7 @@ void main() { AcquisitionAvailabilityProvider? acquisitionAvailabilityProvider, Widget Function(Widget page)? pageBuilder, BookImportService? files, + WatchedFolders? folders, }) async { final prefs = await SharedPreferences.getInstance(); @@ -179,6 +204,7 @@ void main() { ), Provider.value(value: powerSyncService), if (files != null) Provider.value(value: files), + if (folders != null) ChangeNotifierProvider.value(value: folders), StreamProvider.value(value: powerSyncService.syncStates, initialData: powerSyncService.syncState), ChangeNotifierProvider.value(value: authProvider), ChangeNotifierProvider.value( @@ -210,6 +236,8 @@ void main() { final auth = await buildAuthProvider(guest: true); final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); final files = _Files()..failDeletion = true; + final folders = _Folders(); + addTearDown(folders.dispose); service.guestBooks = [Book(id: 'guest-book', title: 'Guest book', author: 'Reader', addedAt: DateTime.now())]; await tester.pumpWidget( @@ -217,6 +245,7 @@ void main() { authProvider: auth, powerSyncService: service, files: files, + folders: folders, pageBuilder: (_) => const Scaffold(body: StorageSyncSettingsSection()), ), ); @@ -229,6 +258,8 @@ void main() { expect(find.textContaining('Could not clear local library'), findsOneWidget); expect(service.guestBooks.single.id, 'guest-book'); expect(service.clearGuestLibraryCalls, 0); + expect(folders.suspended, isFalse); + expect(folders.clearCalls, 0); files.failDeletion = false; await tester.tap(find.text('Clear local library')); await tester.pumpAndSettle(); @@ -238,6 +269,8 @@ void main() { expect(files.clearedScopes, [MediaStorageScope.localGuest]); expect(service.guestBooks, isEmpty); expect(service.clearGuestLibraryCalls, 1); + expect(folders.suspended, isFalse); + expect(folders.clearCalls, 1); expect(auth.isOfflineMode, isTrue); });