#268 groups: listening to source/dynamics to remove groups with obsolete content

This commit is contained in:
Thibault Deckers 2025-05-27 00:28:41 +02:00
parent 7b0f72d6ee
commit 1119fa1407
10 changed files with 148 additions and 22 deletions

View file

@ -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
## <a id="v1.13.1"></a>[v1.13.1] - 2025-05-14

View file

@ -40,6 +40,8 @@ class Covers {
Set<CoverRow> _rows = {};
// do not subscribe to events from other modules in constructor
// so that modules can subscribe to each other
Covers._private();
Future<void> init() async {

View file

@ -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<GroupUriChangedEvent>().listen((e) => _onGroupUriChanged(e.oldGroupUri, e.newGroupUri)));
}
Future<void> init() async {
_rows = (await localMediaDb.loadAllDynamicAlbums()).map((v) => DynamicAlbumFilter(v.name, v.filter)).toSet();
_subscriptions.add(albumGrouping.eventBus.on<GroupUriChangedEvent>().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<void> clear() async {
await _lock.synchronized(() async {
await localMediaDb.clearDynamicAlbums();
_rows.clear();
notifyListeners();
});
}
Future<void> clear() => remove(all);
DynamicAlbumFilter? get(String name) => _rows.firstWhereOrNull((row) => row.name == name);

View file

@ -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<T extends GroupBaseFilter> with ChangeNotifier {
final String _host;
final T Function(Uri uri, SetOrFilter filter) _createGroupFilter;
final Map<Uri, Set<Uri>> _groups = {};
final Set<StreamSubscription> _subscriptions = {};
final Map<CollectionSource, Set<StreamSubscription>> _sourceSubscriptions = {};
CollectionSource? _source;
Map<Uri, Set<Uri>> 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<Uri, Set<Uri>> groups) {
void init() {
_subscriptions.add(dynamicAlbums.eventBus.on<DynamicAlbumChangedEvent>().listen((e) => _clearObsoleteFilters()));
}
void setGroups(Map<Uri, Set<Uri>> 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<EntryMovedEvent>().listen((e) => _clearObsoleteFilters()),
sourceEvents.on<EntryRemovedEvent>().listen((e) => _clearObsoleteFilters()),
sourceEvents.on<AlbumsChangedEvent>().listen((e) => _clearObsoleteFilters()),
};
_source = source;
}
void unregisterSource(CollectionSource? source) {
_sourceSubscriptions.remove(source)
?..forEach((sub) => sub.cancel())
..clear();
}
void addToGroup(Set<Uri> childrenUris, Uri? destinationGroup) {
_removeFromGroups(childrenUris);
if (destinationGroup != null) {
@ -73,9 +115,9 @@ class FilterGrouping<T extends GroupBaseFilter> 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<T extends GroupBaseFilter> 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<T extends GroupBaseFilter> 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];

View file

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

View file

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

View file

@ -125,6 +125,9 @@ class FakeAvesDb extends Fake implements LocalMediaDb {
@override
Future<void> addDynamicAlbums(Set<DynamicAlbumRow> rows) => SynchronousFuture(null);
@override
Future<void> removeDynamicAlbums(Set<String> names) => SynchronousFuture(null);
// video playback
@override

View file

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

View file

@ -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>(() => p.Context(style: p.Style.posix));
});
tearDown(() async {
albumGrouping.init({});
albumGrouping.setGroups({});
await getIt.reset();
});

View file

@ -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>(() => p.Context(style: p.Style.posix));
@ -23,7 +27,7 @@ void main() {
});
tearDown(() async {
albumGrouping.init({});
albumGrouping.setGroups({});
await dynamicAlbums.clear();
await getIt.reset();
});