diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index af84abb28..a9fd2886e 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -40,6 +40,12 @@ mixin SourceBase { ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); + set state(SourceState value) => stateNotifier.value = value; + + SourceState get state => stateNotifier.value; + + bool get isReady => state == SourceState.ready; + ValueNotifier progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0)); void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total); @@ -430,7 +436,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM updateDerivedFilters(todoEntries); } } - stateNotifier.value = SourceState.ready; + state = SourceState.ready; } // monitoring diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 144cb683b..1b29dc43c 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -51,7 +51,7 @@ mixin LocationMixin on SourceBase { final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet(); if (todo.isEmpty) return; - stateNotifier.value = SourceState.locatingCountries; + state = SourceState.locatingCountries; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); @@ -106,7 +106,7 @@ mixin LocationMixin on SourceBase { knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails); }); - stateNotifier.value = SourceState.locatingPlaces; + state = SourceState.locatingPlaces; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 481804577..80318c595 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -42,7 +42,7 @@ class MediaStoreSource extends CollectionSource { Future _loadEssentials() async { final stopwatch = Stopwatch()..start(); - stateNotifier.value = SourceState.loading; + state = SourceState.loading; await metadataDb.init(); await favourites.init(); await covers.init(); @@ -69,7 +69,7 @@ class MediaStoreSource extends CollectionSource { }) async { debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); - stateNotifier.value = SourceState.loading; + state = SourceState.loading; clearEntries(); final Set topEntries = {}; @@ -195,7 +195,7 @@ class MediaStoreSource extends CollectionSource { if (canAnalyze) { await analyze(analysisController, entries: analysisEntries); } else { - stateNotifier.value = SourceState.ready; + state = SourceState.ready; } // the home page may not reflect the current derived filters @@ -216,7 +216,7 @@ class MediaStoreSource extends CollectionSource { // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` @override Future> refreshUris(Set changedUris, {AnalysisController? analysisController}) async { - if (_initState == SourceInitializationState.none || !isMonitoring) return changedUris; + if (_initState == SourceInitializationState.none || !isMonitoring || !isReady) return changedUris; debugPrint('$runtimeType refreshUris ${changedUris.length} uris'); final uriByContentId = Map.fromEntries(changedUris.map((uri) { diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 270b1ca05..f7b597a18 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -31,7 +31,7 @@ mixin TagMixin on SourceBase { final todo = force ? candidateEntries : candidateEntries.where(catalogEntriesTest).toSet(); if (todo.isEmpty) return; - stateNotifier.value = SourceState.cataloguing; + state = SourceState.cataloguing; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart index 10f916ad9..1fb764e07 100644 --- a/lib/services/analysis_service.dart +++ b/lib/services/analysis_service.dart @@ -85,7 +85,7 @@ class Analyzer { bool get isRunning => serviceState == AnalyzerState.running; - SourceState get sourceState => _source.stateNotifier.value; + SourceState get sourceState => _source.state; static const notificationUpdateInterval = Duration(seconds: 1); @@ -151,7 +151,7 @@ class Analyzer { } void _onSourceStateChanged() { - if (sourceState == SourceState.ready) { + if (_source.isReady) { _refreshApp(); _serviceStateNotifier.value = AnalyzerState.stopping; } diff --git a/lib/widget_common.dart b/lib/widget_common.dart index 79ad994ef..8a267587d 100644 --- a/lib/widget_common.dart +++ b/lib/widget_common.dart @@ -4,7 +4,6 @@ import 'package:aves/app_flavor.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/home_widget.dart'; @@ -64,7 +63,7 @@ Future _getWidgetEntry(int widgetId, bool reuseEntry) async { final source = MediaStoreSource(); final readyCompleter = Completer(); source.stateNotifier.addListener(() { - if (source.stateNotifier.value == SourceState.ready) { + if (source.isReady) { readyCompleter.complete(); } }); diff --git a/lib/widgets/common/app_bar/app_bar_subtitle.dart b/lib/widgets/common/app_bar/app_bar_subtitle.dart index 2a1df005d..688ca0ab5 100644 --- a/lib/widgets/common/app_bar/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar/app_bar_subtitle.dart @@ -60,7 +60,7 @@ class SourceStateSubtitle extends StatelessWidget { @override Widget build(BuildContext context) { - final sourceState = source.stateNotifier.value; + final sourceState = source.state; final subtitle = sourceState.getName(context.l10n); if (subtitle == null) return const SizedBox(); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 3d3da1c88..e26607508 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -266,7 +266,7 @@ class _HomePageState extends State { // wait for collection to pass the `loading` state final completer = Completer(); void _onSourceStateChanged() { - if (source.stateNotifier.value != SourceState.loading) { + if (source.state != SourceState.loading) { source.stateNotifier.removeListener(_onSourceStateChanged); completer.complete(); } diff --git a/lib/widgets/viewer/screen_saver_page.dart b/lib/widgets/viewer/screen_saver_page.dart index 4351455bc..036f9f179 100644 --- a/lib/widgets/viewer/screen_saver_page.dart +++ b/lib/widgets/viewer/screen_saver_page.dart @@ -4,7 +4,6 @@ import 'package:aves/model/settings/enums/slideshow_interval.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; @@ -112,7 +111,7 @@ class _ScreenSaverPageState extends State with WidgetsBindingOb } void _initSlideshowCollection() { - if (source.stateNotifier.value != SourceState.ready || _slideshowCollection != null) return; + if (!source.isReady || _slideshowCollection != null) return; final originalCollection = CollectionLens( source: source, diff --git a/test/fake/android_app_service.dart b/test/fake/android_app_service.dart index 9ec20dcd7..436d767e9 100644 --- a/test/fake/android_app_service.dart +++ b/test/fake/android_app_service.dart @@ -1,7 +1,7 @@ import 'package:aves/services/android_app_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeAndroidAppService extends Fake implements AndroidAppService { @override diff --git a/test/fake/availability.dart b/test/fake/availability.dart index cf09187e4..b92e630c3 100644 --- a/test/fake/availability.dart +++ b/test/fake/availability.dart @@ -1,6 +1,6 @@ import 'package:aves/model/availability.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeAvesAvailability extends Fake implements AvesAvailability { @override diff --git a/test/fake/device_service.dart b/test/fake/device_service.dart index a2efd54c1..c8afba4ec 100644 --- a/test/fake/device_service.dart +++ b/test/fake/device_service.dart @@ -1,6 +1,6 @@ import 'package:aves/services/device_service.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeDeviceService extends Fake implements DeviceService { @override diff --git a/test/fake/media_fetch_service.dart b/test/fake/media_fetch_service.dart new file mode 100644 index 000000000..ac561a5d6 --- /dev/null +++ b/test/fake/media_fetch_service.dart @@ -0,0 +1,15 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/services/media/media_fetch_service.dart'; +import 'package:collection/collection.dart'; +import 'package:test/fake.dart'; + +class FakeMediaFetchService extends Fake implements MediaFetchService { + Duration latency = Duration.zero; + Set entries = {}; + + @override + Future getEntry(String uri, String? mimeType) async { + await Future.delayed(latency); + return entries.firstWhereOrNull((v) => v.uri == uri); + } +} diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index 1ef2e2bb9..a1097ee12 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -2,17 +2,23 @@ import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/media/media_store_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeMediaStoreService extends Fake implements MediaStoreService { + Duration latency = Duration.zero; Set entries = {}; @override - Future> checkObsoleteContentIds(List knownContentIds) => SynchronousFuture([]); + Future> checkObsoleteContentIds(List knownContentIds) async { + await Future.delayed(latency); + return []; + } @override - Future> checkObsoletePaths(Map knownPathById) => SynchronousFuture([]); + Future> checkObsoletePaths(Map knownPathById) async { + await Future.delayed(latency); + return []; + } @override Stream getEntries(Map knownEntries, {String? directory}) => Stream.fromIterable(entries); @@ -23,14 +29,15 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { static int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000; - static AvesEntry newImage(String album, String filenameWithoutExtension) { - final id = nextId; + static AvesEntry newImage(String album, String filenameWithoutExtension, {int? id, int? contentId}) { + id ??= nextId; + contentId ??= id; final date = dateSecs; return AvesEntry( id: id, - uri: 'content://media/external/images/media/$id', + uri: 'content://media/external/images/media/$contentId', path: '$album/$filenameWithoutExtension.jpg', - contentId: id, + contentId: contentId, pageId: null, sourceMimeType: MimeTypes.jpeg, width: 360, diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index 665b1bb7b..49e36feba 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -7,7 +7,7 @@ import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/trash.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeMetadataDb extends Fake implements MetadataDb { static int _lastId = 0; diff --git a/test/fake/metadata_fetch_service.dart b/test/fake/metadata_fetch_service.dart index 556a75bf1..1891146ea 100644 --- a/test/fake/metadata_fetch_service.dart +++ b/test/fake/metadata_fetch_service.dart @@ -2,7 +2,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/services/metadata/metadata_fetch_service.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeMetadataFetchService extends Fake implements MetadataFetchService { final Map _metaMap = {}; diff --git a/test/fake/report_service.dart b/test/fake/report_service.dart index c4e8d1701..bead6f09b 100644 --- a/test/fake/report_service.dart +++ b/test/fake/report_service.dart @@ -1,6 +1,5 @@ import 'package:aves_report/aves_report.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; class FakeReportService extends ReportService { @override diff --git a/test/fake/storage_service.dart b/test/fake/storage_service.dart index 754860374..49714a4b4 100644 --- a/test/fake/storage_service.dart +++ b/test/fake/storage_service.dart @@ -1,7 +1,7 @@ import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeStorageService extends Fake implements StorageService { static const primaryRootAlbum = '/storage/emulated/0'; diff --git a/test/fake/window_service.dart b/test/fake/window_service.dart index a87e5c2dc..500d275c3 100644 --- a/test/fake/window_service.dart +++ b/test/fake/window_service.dart @@ -1,7 +1,7 @@ import 'package:aves/services/window_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:test/fake.dart'; class FakeWindowService extends Fake implements WindowService { @override diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 029244b25..addce76c7 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -10,11 +10,12 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/device_service.dart'; +import 'package:aves/services/media/media_fetch_service.dart'; import 'package:aves/services/media/media_store_service.dart'; import 'package:aves/services/metadata/metadata_fetch_service.dart'; import 'package:aves/services/storage_service.dart'; @@ -29,6 +30,7 @@ import 'package:path/path.dart' as p; import '../fake/android_app_service.dart'; import '../fake/availability.dart'; import '../fake/device_service.dart'; +import '../fake/media_fetch_service.dart'; import '../fake/media_store_service.dart'; import '../fake/metadata_db.dart'; import '../fake/metadata_fetch_service.dart'; @@ -57,6 +59,7 @@ void main() { getIt.registerLazySingleton(FakeAndroidAppService.new); getIt.registerLazySingleton(FakeDeviceService.new); + getIt.registerLazySingleton(FakeMediaFetchService.new); getIt.registerLazySingleton(FakeMediaStoreService.new); getIt.registerLazySingleton(FakeMetadataFetchService.new); getIt.registerLazySingleton(FakeReportService.new); @@ -65,6 +68,7 @@ void main() { await settings.init(monitorPlatformSettings: false); settings.canUseAnalysisService = false; + await androidFileUtils.init(); }); tearDown(() async { @@ -75,7 +79,7 @@ void main() { final source = MediaStoreSource(); final readyCompleter = Completer(); source.stateNotifier.addListener(() { - if (source.stateNotifier.value == SourceState.ready) { + if (source.isReady) { readyCompleter.complete(); } }); @@ -84,6 +88,26 @@ void main() { return source; } + test('initial load v. refresh race condition', () async { + const latency = Duration(milliseconds: 100); + + final loadEntry = FakeMediaStoreService.newImage(testAlbum, 'image1', id: -1, contentId: 1); + final refreshEntry = FakeMediaStoreService.newImage(testAlbum, 'image1', id: -1, contentId: 1); + (mediaStoreService as FakeMediaStoreService) + ..entries = {loadEntry} + ..latency = latency; + (mediaFetchService as FakeMediaFetchService).entries = {refreshEntry}; + + final source = MediaStoreSource(); + unawaited(source.init()); + await Future.delayed(const Duration(milliseconds: 10)); + expect(source.initState, SourceInitializationState.full); + await source.refreshUris({refreshEntry.uri}); + + await Future.delayed(const Duration(seconds: 1)); + expect(source.allEntries.length, 1); + }); + test('album/country/tag hidden on launch when their items are hidden by entry prop', () async { settings.hiddenFilters = {const AlbumFilter(testAlbum, 'whatever')};