import 'dart:async'; import 'dart:ui'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/catalog.dart'; import 'package:aves/model/entry/extensions/keys.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/sort.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/covered/album_base.dart'; import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location/country.dart'; import 'package:aves/model/source/location/location.dart'; import 'package:aves/model/source/location/place.dart'; import 'package:aves/model/source/location/state.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/trash.dart'; import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/aves_app.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; import 'package:leak_tracker/leak_tracker.dart'; typedef SourceScope = Set?; mixin SourceBase { EventBus get eventBus; Map get entryById; Set get allEntries; Set get visibleEntries; Set get trashedEntries; List get sortedEntriesByDate; 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); void invalidateEntries(); } abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, StateMixin, LocationMixin, TagMixin, TrashMixin { static const fullScope = {}; CollectionSource() { if (kFlutterMemoryAllocationsEnabled) { LeakTracking.dispatchObjectCreated( library: 'aves', className: '$CollectionSource', object: this, ); } settings.updateStream.where((event) => event.key == SettingKeys.localeKey).listen((_) => invalidateStoredAlbumDisplayNames()); settings.updateStream.where((event) => event.key == SettingKeys.hiddenFiltersKey).listen((event) { final oldValue = event.oldValue; if (oldValue is List?) { final oldHiddenFilters = (oldValue ?? []).map(CollectionFilter.fromJson).nonNulls.toSet(); final newlyVisibleFilters = oldHiddenFilters.whereNot(settings.hiddenFilters.contains).toSet(); _onFilterVisibilityChanged(newlyVisibleFilters); } }); vaults.addListener(_onVaultsChanged); } @mustCallSuper void dispose() { if (kFlutterMemoryAllocationsEnabled) { LeakTracking.dispatchObjectDisposed(object: this); } vaults.removeListener(_onVaultsChanged); _rawEntries.forEach((v) => v.dispose()); } set canAnalyze(bool enabled); final EventBus _eventBus = EventBus(); @override EventBus get eventBus => _eventBus; final Map _entryById = {}; @override Map get entryById => Map.unmodifiable(_entryById); final Set _rawEntries = {}; @override Set get allEntries => Set.unmodifiable(_rawEntries); Set? _visibleEntries, _trashedEntries; @override Set get visibleEntries { _visibleEntries ??= Set.unmodifiable(_applyHiddenFilters(_rawEntries)); return _visibleEntries!; } @override Set get trashedEntries { _trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries)); return _trashedEntries!; } List? _sortedEntriesByDate; @override List get sortedEntriesByDate { _sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntrySort.compareByDate)); return _sortedEntriesByDate!; } // known date by entry ID late Map _savedDates; Future loadDates() async { _savedDates = Map.unmodifiable(await localMediaDb.loadDates()); } Set _getAppHiddenFilters() => { ...settings.hiddenFilters, ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)), }; Iterable _applyHiddenFilters(Iterable entries) { final hiddenFilters = { TrashFilter.instance, ..._getAppHiddenFilters(), }; return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); } Iterable _applyTrashFilter(Iterable entries) { final hiddenFilters = _getAppHiddenFilters(); return entries.where(TrashFilter.instance.test).where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); } void _invalidate({Set? entries, bool notify = true}) { invalidateEntries(); invalidateAlbumFilterSummary(entries: entries, notify: notify); invalidateCountryFilterSummary(entries: entries, notify: notify); invalidatePlaceFilterSummary(entries: entries, notify: notify); invalidateStateFilterSummary(entries: entries, notify: notify); invalidateTagFilterSummary(entries: entries, notify: notify); } @override void invalidateEntries() { _visibleEntries = null; _trashedEntries = null; _sortedEntriesByDate = null; } void updateDerivedFilters([Set? entries]) { _invalidate(entries: entries); // it is possible for entries hidden by a filter type, to have an impact on other types // e.g. given a sole entry for country C and tag T, hiding T should make C disappear too updateDirectories(); updateLocations(); updateTags(); } void addEntries(Set entries, {bool notify = true}) { if (entries.isEmpty) return; final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry))); if (_rawEntries.isNotEmpty) { final newIds = newIdMapEntries.keys.toSet(); _rawEntries.removeWhere((entry) => newIds.contains(entry.id)); } entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { entry.catalogDateMillis = _savedDates[entry.id]; }); _entryById.addAll(newIdMapEntries); _rawEntries.addAll(entries); _invalidate(entries: entries, notify: notify); addDirectories(albums: _applyHiddenFilters(entries).map((entry) => entry.directory).toSet(), notify: notify); if (notify) { eventBus.fire(EntryAddedEvent(entries)); } } Future removeEntries(Set uris, {required bool includeTrash}) async { if (uris.isEmpty) return; final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); if (!includeTrash) { entries.removeWhere(TrashFilter.instance.test); } if (entries.isEmpty) return; final ids = entries.map((entry) => entry.id).toSet(); await favourites.removeIds(ids); await covers.removeIds(ids); await localMediaDb.removeIds(ids); ids.forEach((id) => _entryById.remove); _rawEntries.removeAll(entries); updateDerivedFilters(entries); eventBus.fire(EntryRemovedEvent(entries)); } void clearEntries() { _entryById.clear(); _rawEntries.clear(); _invalidate(); // do not update directories/locations/tags here // as it could reset filter dependent settings (pins, bookmarks, etc.) // caller should take care of updating these at the right time } Future _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { newFields.keys.forEach((key) { final newValue = newFields[key]; switch (key) { case EntryFields.contentId: entry.contentId = newValue as int?; case EntryFields.dateModifiedMillis: // `dateModifiedMillis` changes when moving entries to another directory, // but it does not change when renaming the containing directory entry.dateModifiedMillis = newValue as int?; case EntryFields.path: entry.path = newValue as String?; case EntryFields.title: entry.sourceTitle = newValue as String?; case EntryFields.trashed: final trashed = newValue as bool; entry.trashed = trashed; entry.trashDetails = trashed ? TrashDetails( id: entry.id, path: newFields[EntryFields.trashPath] as String, dateMillis: DateTime.now().millisecondsSinceEpoch, ) : null; case EntryFields.uri: entry.uri = newValue as String; case EntryFields.origin: entry.origin = newValue as int; } }); if (entry.trashed) { final trashPath = entry.trashDetails?.path; if (trashPath != null) { entry.contentId = null; entry.uri = Uri.file(trashPath).toString(); } else { debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}'); } } if (persist) { await covers.moveEntry(entry); final id = entry.id; await localMediaDb.updateEntry(id, entry); await localMediaDb.updateCatalogMetadata(id, entry.catalogMetadata); await localMediaDb.updateAddress(id, entry.addressDetails); await localMediaDb.updateTrash(id, entry.trashDetails); } } Future renameStoredAlbum(String sourceAlbum, String destinationAlbum, Set entries, Set movedOps) async { final oldFilter = StoredAlbumFilter(sourceAlbum, null); final newFilter = StoredAlbumFilter(destinationAlbum, null); final pinned = settings.pinnedFilters.contains(oldFilter); if (vaults.isVault(sourceAlbum)) { await vaults.rename(sourceAlbum, destinationAlbum); } final existingCover = covers.of(oldFilter); await covers.set( filter: newFilter, entryId: existingCover?.$1, packageName: existingCover?.$2, color: existingCover?.$3, ); renameNewAlbum(sourceAlbum, destinationAlbum); await updateAfterMove( todoEntries: entries, moveType: MoveType.move, destinationAlbums: {destinationAlbum}, movedOps: movedOps, ); // update bookmark final albumBookmarks = settings.drawerAlbumBookmarks; if (albumBookmarks != null) { final index = albumBookmarks.indexWhere((v) => v is StoredAlbumFilter && v.album == sourceAlbum); if (index >= 0) { albumBookmarks.removeAt(index); albumBookmarks.insert(index, newFilter); settings.drawerAlbumBookmarks = albumBookmarks; } } // restore pin, as the obsolete album got removed and its associated state cleaned if (pinned) { settings.pinnedFilters = settings.pinnedFilters ..remove(oldFilter) ..add(newFilter); } } Future updateAfterMove({ required Set todoEntries, required MoveType moveType, required Set destinationAlbums, required Set movedOps, }) async { if (movedOps.isEmpty) return; final replacedUris = movedOps .map((movedOp) => movedOp.newFields[EntryFields.path] as String?) .map((targetPath) { final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed); return existingEntry?.uri; }) .nonNulls .toSet(); await removeEntries(replacedUris, includeTrash: false); final fromAlbums = {}; final movedEntries = {}; final copy = moveType == MoveType.copy; if (copy) { movedOps.forEach((movedOp) { final sourceUri = movedOp.uri; final newFields = movedOp.newFields; final sourceEntry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); if (sourceEntry != null) { fromAlbums.add(sourceEntry.directory); movedEntries.add(sourceEntry.copyWith( id: localMediaDb.nextId, uri: newFields[EntryFields.uri] as String?, path: newFields[EntryFields.path] as String?, contentId: newFields[EntryFields.contentId] as int?, // title can change when moved files are automatically renamed to avoid conflict title: newFields[EntryFields.title] as String?, dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?, dateModifiedMillis: newFields[EntryFields.dateModifiedMillis] as int?, origin: newFields[EntryFields.origin] as int?, )); } else { debugPrint('failed to find source entry with uri=$sourceUri'); } }); await localMediaDb.insertEntries(movedEntries); await localMediaDb.saveCatalogMetadata(movedEntries.map((entry) => entry.catalogMetadata).nonNulls.toSet()); await localMediaDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).nonNulls.toSet()); } else { await Future.forEach(movedOps, (movedOp) async { final newFields = movedOp.newFields; if (newFields.isNotEmpty) { final sourceUri = movedOp.uri; final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); if (entry != null) { if (moveType == MoveType.fromBin) { newFields[EntryFields.trashed] = false; } else { fromAlbums.add(entry.directory); } movedEntries.add(entry); await _moveEntry(entry, newFields, persist: true); } } }); } switch (moveType) { case MoveType.copy: addEntries(movedEntries); case MoveType.move: case MoveType.export: cleanEmptyAlbums(fromAlbums.nonNulls.toSet()); addDirectories(albums: destinationAlbums); case MoveType.toBin: case MoveType.fromBin: updateDerivedFilters(movedEntries); } invalidateAlbumFilterSummary(directories: fromAlbums); _invalidate(entries: movedEntries); eventBus.fire(EntryMovedEvent(moveType, movedEntries)); } Future updateAfterRename({ required Set todoEntries, required Set movedOps, required bool persist, }) async { if (movedOps.isEmpty) return; final movedEntries = {}; await Future.forEach(movedOps, (movedOp) async { final newFields = movedOp.newFields; if (newFields.isNotEmpty) { final sourceUri = movedOp.uri; final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); if (entry != null) { movedEntries.add(entry); await _moveEntry(entry, newFields, persist: persist); } } }); eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries)); } SourceScope get loadedScope; SourceScope get targetScope; Future init({ required SourceScope scope, AnalysisController? analysisController, bool loadTopEntriesFirst = false, }); Future> refreshUris(Set changedUris, {AnalysisController? analysisController}); Future refreshEntries(Set entries, Set dataTypes) async { const background = false; const persist = true; await Future.forEach(entries, (entry) async { await entry.refresh(background: background, persist: persist, dataTypes: dataTypes); }); if (dataTypes.contains(EntryDataType.aspectRatio)) { onAspectRatioChanged(); } if (dataTypes.contains(EntryDataType.catalog)) { // explicit GC before cataloguing multiple items await deviceService.requestGarbageCollection(); await Future.forEach(entries, (entry) async { await entry.catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist); await localMediaDb.updateCatalogMetadata(entry.id, entry.catalogMetadata); }); onCatalogMetadataChanged(); } if (dataTypes.contains(EntryDataType.address)) { await Future.forEach(entries, (entry) async { await entry.locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: settings.appliedLocale); await localMediaDb.updateAddress(entry.id, entry.addressDetails); }); onAddressMetadataChanged(); } updateDerivedFilters(entries); eventBus.fire(EntryRefreshedEvent(entries)); } Future analyze(AnalysisController? analysisController, {Set? entries}) async { final todoEntries = entries ?? visibleEntries; final defaultAnalysisController = AnalysisController(); final _analysisController = analysisController ?? defaultAnalysisController; final force = _analysisController.force; if (!_analysisController.isStopping) { var startAnalysisService = false; if (_analysisController.canStartService && settings.canUseAnalysisService) { // cataloguing if (!startAnalysisService) { final opCount = (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest)).length; startAnalysisService = opCount > TagMixin.commitCountThreshold; } // ignore locating countries // locating places if (!startAnalysisService && await availability.canLocatePlaces) { final opCount = (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locatePlacesTest)).length; startAnalysisService = opCount > LocationMixin.commitCountThreshold; } } if (startAnalysisService) { final lifecycleState = AvesApp.lifecycleStateNotifier.value; switch (lifecycleState) { case AppLifecycleState.resumed: case AppLifecycleState.inactive: await AnalysisService.startService( force: force, entryIds: entries?.map((entry) => entry.id).toList(), ); default: unawaited(reportService.log('analysis service not started because app is in state=$lifecycleState')); } } else { // explicit GC before cataloguing multiple items await deviceService.requestGarbageCollection(); await catalogEntries(_analysisController, todoEntries); updateDerivedFilters(todoEntries); await locateEntries(_analysisController, todoEntries); updateDerivedFilters(todoEntries); } } defaultAnalysisController.dispose(); state = SourceState.ready; } void onAspectRatioChanged() => eventBus.fire(AspectRatioChangedEvent()); // monitoring bool _canRefresh = true; void pauseMonitoring() => _canRefresh = false; void resumeMonitoring() => _canRefresh = true; bool get canRefresh => _canRefresh; // filter summary int count(CollectionFilter filter) { if (filter is AlbumBaseFilter) { return albumEntryCount(filter); } else if (filter is LocationFilter) { switch (filter.level) { case LocationLevel.country: return countryEntryCount(filter); case LocationLevel.state: return stateEntryCount(filter); case LocationLevel.place: return placeEntryCount(filter); } } else if (filter is TagFilter) { return tagEntryCount(filter); } return 0; } int size(CollectionFilter filter) { if (filter is AlbumBaseFilter) { return albumSize(filter); } else if (filter is LocationFilter) { switch (filter.level) { case LocationLevel.country: return countrySize(filter); case LocationLevel.state: return stateSize(filter); case LocationLevel.place: return placeSize(filter); } } else if (filter is TagFilter) { return tagSize(filter); } return 0; } AvesEntry? recentEntry(CollectionFilter filter) { if (filter is AlbumBaseFilter) { return albumRecentEntry(filter); } else if (filter is LocationFilter) { switch (filter.level) { case LocationLevel.country: return countryRecentEntry(filter); case LocationLevel.state: return stateRecentEntry(filter); case LocationLevel.place: return placeRecentEntry(filter); } } else if (filter is TagFilter) { return tagRecentEntry(filter); } return null; } AvesEntry? coverEntry(CollectionFilter filter) { final id = covers.of(filter)?.$1; if (id != null) { final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id); if (entry != null) return entry; } return recentEntry(filter); } void _onFilterVisibilityChanged(Set newlyVisibleFilters) { updateDerivedFilters(); eventBus.fire(const FilterVisibilityChangedEvent()); if (newlyVisibleFilters.isNotEmpty) { final candidateEntries = visibleEntries.where((entry) => newlyVisibleFilters.any((f) => f.test(entry))).toSet(); analyze(null, entries: candidateEntries); } } void _onVaultsChanged() { final newlyVisibleFilters = vaults.vaultDirectories.whereNot(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)).toSet(); _onFilterVisibilityChanged(newlyVisibleFilters); } } class AspectRatioChangedEvent {}