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();
});