diff --git a/CHANGELOG.md b/CHANGELOG.md index 075604ea4..52fbc0471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Fixed - opening home when launching app as media picker +- removing groups with obsolete albums ## [v1.13.1] - 2025-05-14 diff --git a/lib/model/covers.dart b/lib/model/covers.dart index 8e9066fa5..0dcb9e023 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -40,6 +40,8 @@ class Covers { Set _rows = {}; + // do not subscribe to events from other modules in constructor + // so that modules can subscribe to each other Covers._private(); Future init() async { diff --git a/lib/model/dynamic_albums.dart b/lib/model/dynamic_albums.dart index 488e43307..f943803bb 100644 --- a/lib/model/dynamic_albums.dart +++ b/lib/model/dynamic_albums.dart @@ -21,13 +21,15 @@ class DynamicAlbums with ChangeNotifier { final EventBus eventBus = EventBus(); + // do not subscribe to events from other modules in constructor + // so that modules can subscribe to each other DynamicAlbums._private() { if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this); - _subscriptions.add(albumGrouping.eventBus.on().listen((e) => _onGroupUriChanged(e.oldGroupUri, e.newGroupUri))); } Future init() async { _rows = (await localMediaDb.loadAllDynamicAlbums()).map((v) => DynamicAlbumFilter(v.name, v.filter)).toSet(); + _subscriptions.add(albumGrouping.eventBus.on().listen((e) => _onGroupUriChanged(e.oldGroupUri, e.newGroupUri))); } int get count => _rows.length; @@ -57,6 +59,7 @@ class DynamicAlbums with ChangeNotifier { await _lock.synchronized(() async { await _doRemove(filters.map((filter) => filter.name).toSet()); notifyListeners(); + eventBus.fire(DynamicAlbumChangedEvent(Map.fromEntries(filters.map((v) => MapEntry(v, null))))); }); } @@ -81,13 +84,7 @@ class DynamicAlbums with ChangeNotifier { }); } - Future clear() async { - await _lock.synchronized(() async { - await localMediaDb.clearDynamicAlbums(); - _rows.clear(); - notifyListeners(); - }); - } + Future clear() => remove(all); DynamicAlbumFilter? get(String name) => _rows.firstWhereOrNull((row) => row.name == name); diff --git a/lib/model/grouping/common.dart b/lib/model/grouping/common.dart index 423d86fbd..e91a1c13a 100644 --- a/lib/model/grouping/common.dart +++ b/lib/model/grouping/common.dart @@ -1,10 +1,17 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/filters/container/album_group.dart'; +import 'package:aves/model/filters/container/dynamic_album.dart'; import 'package:aves/model/filters/container/group_base.dart'; import 'package:aves/model/filters/container/set_or.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/grouping/convert.dart'; +import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:collection/collection.dart'; @@ -28,18 +35,53 @@ class FilterGrouping with ChangeNotifier { final String _host; final T Function(Uri uri, SetOrFilter filter) _createGroupFilter; final Map> _groups = {}; + final Set _subscriptions = {}; + final Map> _sourceSubscriptions = {}; + CollectionSource? _source; Map> get allGroups => Map.unmodifiable(_groups); + // do not subscribe to events from other modules in constructor + // so that modules can subscribe to each other FilterGrouping._private(this._host, this._createGroupFilter) { if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this); } - void init(Map> groups) { + void init() { + _subscriptions.add(dynamicAlbums.eventBus.on().listen((e) => _clearObsoleteFilters())); + } + + void setGroups(Map> groups) { _groups.clear(); _groups.addAll(groups); } + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _sourceSubscriptions.keys.toSet().forEach(unregisterSource); + super.dispose(); + } + + void registerSource(CollectionSource source) { + unregisterSource(_source); + final sourceEvents = source.eventBus; + _sourceSubscriptions[source] = { + sourceEvents.on().listen((e) => _clearObsoleteFilters()), + sourceEvents.on().listen((e) => _clearObsoleteFilters()), + sourceEvents.on().listen((e) => _clearObsoleteFilters()), + }; + _source = source; + } + + void unregisterSource(CollectionSource? source) { + _sourceSubscriptions.remove(source) + ?..forEach((sub) => sub.cancel()) + ..clear(); + } + void addToGroup(Set childrenUris, Uri? destinationGroup) { _removeFromGroups(childrenUris); if (destinationGroup != null) { @@ -73,9 +115,9 @@ class FilterGrouping with ChangeNotifier { int countLeaves(Uri? groupUri) { int count = 0; if (groupUri != null) { - final childrenUri = _groups[groupUri]; - if (childrenUri != null) { - childrenUri.map(uriToFilter).nonNulls.forEach((filter) { + final childrenUris = _groups[groupUri]; + if (childrenUris != null) { + childrenUris.map(uriToFilter).nonNulls.forEach((filter) { if (filter is GroupBaseFilter) { count += countLeaves(filter.uri); } else { @@ -93,15 +135,15 @@ class FilterGrouping with ChangeNotifier { if (currentGroupUri == null) { return _groups.entries.where((kv) => getParentGroup(kv.key) == currentGroupUri).map((kv) { final groupUri = kv.key; - final childrenUri = kv.value; - final childrenFilters = childrenUri.map(uriToFilter).nonNulls.toSet(); + final childrenUris = kv.value; + final childrenFilters = childrenUris.map(uriToFilter).nonNulls.toSet(); return _createGroupFilter(groupUri, SetOrFilter(childrenFilters)); }).toSet(); } - final childrenUri = _groups.entries.firstWhereOrNull((kv) => kv.key == currentGroupUri)?.value; - if (childrenUri != null) { - return childrenUri.map(uriToFilter).nonNulls.toSet(); + final childrenUris = _groups.entries.firstWhereOrNull((kv) => kv.key == currentGroupUri)?.value; + if (childrenUris != null) { + return childrenUris.map(uriToFilter).nonNulls.toSet(); } return {}; @@ -172,6 +214,46 @@ class FilterGrouping with ChangeNotifier { } } + void _clearObsoleteFilters() { + final source = _source; + if (source == null || source.targetScope != CollectionSource.fullScope || !source.isReady) return; + + _groups.entries.forEach((kv) { + final groupUri = kv.key; + final childrenUris = kv.value; + + final rawAlbums = source.rawAlbums; + final allEntries = source.allEntries; + + childrenUris.toSet().forEach((childUri) { + final filter = uriToFilter(childUri); + var valid = false; + if (filter != null) { + switch (filter) { + case GroupBaseFilter _: + valid = true; + case StoredAlbumFilter _: + // check album itself + final isVisibleAlbum = rawAlbums.contains(filter.album); + if (isVisibleAlbum) { + valid = true; + } else { + // check non-visible content (hidden, trash, etc.) + valid = allEntries.any(filter.test); + } + case DynamicAlbumFilter _: + valid = dynamicAlbums.contains(filter.name); + } + } + if (!valid) { + childrenUris.remove(childUri); + debugPrint('Removed obsolete childUri=$childUri from group=$groupUri'); + } + }); + }); + _cleanEmptyGroups(); + } + // group uri / filter conversion static String? getGroupPath(Uri? uri) => uri?.queryParameters[_groupPathParamKey]; diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index eb53c729e..7d1f1f131 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -60,7 +60,9 @@ class MediaStoreSource extends CollectionSource { await localMediaDb.init(); await vaults.init(); await favourites.init(); - albumGrouping.init(settings.albumGroups); + albumGrouping.init(); + albumGrouping.setGroups(settings.albumGroups); + albumGrouping.registerSource(this); await covers.init(); await dynamicAlbums.init(); diff --git a/lib/widgets/settings/app_export/items.dart b/lib/widgets/settings/app_export/items.dart index 2a69b82c9..a77f690c9 100644 --- a/lib/widgets/settings/app_export/items.dart +++ b/lib/widgets/settings/app_export/items.dart @@ -39,7 +39,7 @@ extension ExtraAppExportItem on AppExportItem { favourites.import(jsonMap, source); case AppExportItem.settings: await settings.import(jsonMap); - albumGrouping.init(settings.albumGroups); + albumGrouping.setGroups(settings.albumGroups); } } } diff --git a/test/fake/db.dart b/test/fake/db.dart index a0a979e82..3df10863c 100644 --- a/test/fake/db.dart +++ b/test/fake/db.dart @@ -125,6 +125,9 @@ class FakeAvesDb extends Fake implements LocalMediaDb { @override Future addDynamicAlbums(Set rows) => SynchronousFuture(null); + @override + Future removeDynamicAlbums(Set names) => SynchronousFuture(null); + // video playback @override diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 9e922806f..f0e562bde 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -8,6 +8,8 @@ import 'package:aves/model/entry/extensions/favourites.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/covered/tag.dart'; +import 'package:aves/model/grouping/common.dart'; +import 'package:aves/model/grouping/convert.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/settings/settings.dart'; @@ -32,10 +34,10 @@ import 'package:shared_preferences_platform_interface/shared_preferences_platfor import '../fake/android_app_service.dart'; import '../fake/availability.dart'; +import '../fake/db.dart'; import '../fake/device_service.dart'; import '../fake/media_fetch_service.dart'; import '../fake/media_store_service.dart'; -import '../fake/db.dart'; import '../fake/metadata_fetch_service.dart'; import '../fake/report_service.dart'; import '../fake/storage_service.dart'; @@ -73,12 +75,17 @@ void main() { await settings.init(monitorPlatformSettings: false); settings.canUseAnalysisService = false; await androidFileUtils.init(); + albumGrouping.init(); }); setUp(() async { (getIt() as FakeMediaStoreService).reset(); }); + tearDown(() async { + albumGrouping.setGroups({}); + }); + tearDownAll(() async { await getIt.reset(); }); @@ -397,4 +404,28 @@ void main() { ), ); }); + + test('groups are cleared when removing entries', () async { + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final albumFilter = StoredAlbumFilter(image1.directory!, 'whatever'); + + final source = await _initSource(); + final groupUri = albumGrouping.buildGroupUri(null, 'some group name'); + final childUri = GroupingConversion.filterToUri(albumFilter); + albumGrouping.addToGroup({childUri}.nonNulls.toSet(), groupUri); + expect(source.rawAlbums.length, 1); + expect(albumGrouping.exists(groupUri), true); + + await source.removeEntries({image1.uri}, includeTrash: true); + + // waiting for microtask to make sure event bus listeners executed + await Future.microtask(() {}); + + expect(source.rawAlbums.length, 0); + expect(albumGrouping.exists(groupUri), false); + }); } diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index a793f3005..eefc6edb7 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -29,13 +29,17 @@ import '../fake/media_store_service.dart'; import '../fake/storage_service.dart'; void main() { + setUpAll(() async { + albumGrouping.init(); + }); + setUp(() async { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); }); tearDown(() async { - albumGrouping.init({}); + albumGrouping.setGroups({}); await getIt.reset(); }); diff --git a/test/model/grouping/common_test.dart b/test/model/grouping/common_test.dart index 1a3b24763..28efd6ae9 100644 --- a/test/model/grouping/common_test.dart +++ b/test/model/grouping/common_test.dart @@ -16,6 +16,10 @@ void main() { const groupName = 'some group name'; const storedAlbumPath = '/path/to/album'; + setUpAll(() async { + albumGrouping.init(); + }); + setUp(() async { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); @@ -23,7 +27,7 @@ void main() { }); tearDown(() async { - albumGrouping.init({}); + albumGrouping.setGroups({}); await dynamicAlbums.clear(); await getIt.reset(); });