#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 ### Fixed
- opening home when launching app as media picker - 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 ## <a id="v1.13.1"></a>[v1.13.1] - 2025-05-14

View file

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

View file

@ -21,13 +21,15 @@ class DynamicAlbums with ChangeNotifier {
final EventBus eventBus = EventBus(); final EventBus eventBus = EventBus();
// do not subscribe to events from other modules in constructor
// so that modules can subscribe to each other
DynamicAlbums._private() { DynamicAlbums._private() {
if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this); if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this);
_subscriptions.add(albumGrouping.eventBus.on<GroupUriChangedEvent>().listen((e) => _onGroupUriChanged(e.oldGroupUri, e.newGroupUri)));
} }
Future<void> init() async { Future<void> init() async {
_rows = (await localMediaDb.loadAllDynamicAlbums()).map((v) => DynamicAlbumFilter(v.name, v.filter)).toSet(); _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; int get count => _rows.length;
@ -57,6 +59,7 @@ class DynamicAlbums with ChangeNotifier {
await _lock.synchronized(() async { await _lock.synchronized(() async {
await _doRemove(filters.map((filter) => filter.name).toSet()); await _doRemove(filters.map((filter) => filter.name).toSet());
notifyListeners(); notifyListeners();
eventBus.fire(DynamicAlbumChangedEvent(Map.fromEntries(filters.map((v) => MapEntry(v, null)))));
}); });
} }
@ -81,13 +84,7 @@ class DynamicAlbums with ChangeNotifier {
}); });
} }
Future<void> clear() async { Future<void> clear() => remove(all);
await _lock.synchronized(() async {
await localMediaDb.clearDynamicAlbums();
_rows.clear();
notifyListeners();
});
}
DynamicAlbumFilter? get(String name) => _rows.firstWhereOrNull((row) => row.name == name); DynamicAlbumFilter? get(String name) => _rows.firstWhereOrNull((row) => row.name == name);

View file

@ -1,10 +1,17 @@
import 'dart:async';
import 'dart:convert'; 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/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/group_base.dart';
import 'package:aves/model/filters/container/set_or.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/filters/filters.dart';
import 'package:aves/model/grouping/convert.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/services/common/services.dart';
import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -28,18 +35,53 @@ class FilterGrouping<T extends GroupBaseFilter> with ChangeNotifier {
final String _host; final String _host;
final T Function(Uri uri, SetOrFilter filter) _createGroupFilter; final T Function(Uri uri, SetOrFilter filter) _createGroupFilter;
final Map<Uri, Set<Uri>> _groups = {}; 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); 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) { FilterGrouping._private(this._host, this._createGroupFilter) {
if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this); 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.clear();
_groups.addAll(groups); _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) { void addToGroup(Set<Uri> childrenUris, Uri? destinationGroup) {
_removeFromGroups(childrenUris); _removeFromGroups(childrenUris);
if (destinationGroup != null) { if (destinationGroup != null) {
@ -73,9 +115,9 @@ class FilterGrouping<T extends GroupBaseFilter> with ChangeNotifier {
int countLeaves(Uri? groupUri) { int countLeaves(Uri? groupUri) {
int count = 0; int count = 0;
if (groupUri != null) { if (groupUri != null) {
final childrenUri = _groups[groupUri]; final childrenUris = _groups[groupUri];
if (childrenUri != null) { if (childrenUris != null) {
childrenUri.map(uriToFilter).nonNulls.forEach((filter) { childrenUris.map(uriToFilter).nonNulls.forEach((filter) {
if (filter is GroupBaseFilter) { if (filter is GroupBaseFilter) {
count += countLeaves(filter.uri); count += countLeaves(filter.uri);
} else { } else {
@ -93,15 +135,15 @@ class FilterGrouping<T extends GroupBaseFilter> with ChangeNotifier {
if (currentGroupUri == null) { if (currentGroupUri == null) {
return _groups.entries.where((kv) => getParentGroup(kv.key) == currentGroupUri).map((kv) { return _groups.entries.where((kv) => getParentGroup(kv.key) == currentGroupUri).map((kv) {
final groupUri = kv.key; final groupUri = kv.key;
final childrenUri = kv.value; final childrenUris = kv.value;
final childrenFilters = childrenUri.map(uriToFilter).nonNulls.toSet(); final childrenFilters = childrenUris.map(uriToFilter).nonNulls.toSet();
return _createGroupFilter(groupUri, SetOrFilter(childrenFilters)); return _createGroupFilter(groupUri, SetOrFilter(childrenFilters));
}).toSet(); }).toSet();
} }
final childrenUri = _groups.entries.firstWhereOrNull((kv) => kv.key == currentGroupUri)?.value; final childrenUris = _groups.entries.firstWhereOrNull((kv) => kv.key == currentGroupUri)?.value;
if (childrenUri != null) { if (childrenUris != null) {
return childrenUri.map(uriToFilter).nonNulls.toSet(); return childrenUris.map(uriToFilter).nonNulls.toSet();
} }
return {}; 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 // group uri / filter conversion
static String? getGroupPath(Uri? uri) => uri?.queryParameters[_groupPathParamKey]; static String? getGroupPath(Uri? uri) => uri?.queryParameters[_groupPathParamKey];

View file

@ -60,7 +60,9 @@ class MediaStoreSource extends CollectionSource {
await localMediaDb.init(); await localMediaDb.init();
await vaults.init(); await vaults.init();
await favourites.init(); await favourites.init();
albumGrouping.init(settings.albumGroups); albumGrouping.init();
albumGrouping.setGroups(settings.albumGroups);
albumGrouping.registerSource(this);
await covers.init(); await covers.init();
await dynamicAlbums.init(); await dynamicAlbums.init();

View file

@ -39,7 +39,7 @@ extension ExtraAppExportItem on AppExportItem {
favourites.import(jsonMap, source); favourites.import(jsonMap, source);
case AppExportItem.settings: case AppExportItem.settings:
await settings.import(jsonMap); 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 @override
Future<void> addDynamicAlbums(Set<DynamicAlbumRow> rows) => SynchronousFuture(null); Future<void> addDynamicAlbums(Set<DynamicAlbumRow> rows) => SynchronousFuture(null);
@override
Future<void> removeDynamicAlbums(Set<String> names) => SynchronousFuture(null);
// video playback // video playback
@override @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/favourites.dart';
import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/covered/stored_album.dart';
import 'package:aves/model/filters/covered/tag.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/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/settings/settings.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/android_app_service.dart';
import '../fake/availability.dart'; import '../fake/availability.dart';
import '../fake/db.dart';
import '../fake/device_service.dart'; import '../fake/device_service.dart';
import '../fake/media_fetch_service.dart'; import '../fake/media_fetch_service.dart';
import '../fake/media_store_service.dart'; import '../fake/media_store_service.dart';
import '../fake/db.dart';
import '../fake/metadata_fetch_service.dart'; import '../fake/metadata_fetch_service.dart';
import '../fake/report_service.dart'; import '../fake/report_service.dart';
import '../fake/storage_service.dart'; import '../fake/storage_service.dart';
@ -73,12 +75,17 @@ void main() {
await settings.init(monitorPlatformSettings: false); await settings.init(monitorPlatformSettings: false);
settings.canUseAnalysisService = false; settings.canUseAnalysisService = false;
await androidFileUtils.init(); await androidFileUtils.init();
albumGrouping.init();
}); });
setUp(() async { setUp(() async {
(getIt<MediaStoreService>() as FakeMediaStoreService).reset(); (getIt<MediaStoreService>() as FakeMediaStoreService).reset();
}); });
tearDown(() async {
albumGrouping.setGroups({});
});
tearDownAll(() async { tearDownAll(() async {
await getIt.reset(); 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'; import '../fake/storage_service.dart';
void main() { void main() {
setUpAll(() async {
albumGrouping.init();
});
setUp(() async { setUp(() async {
// specify Posix style path context for consistent behaviour when running tests on Windows // specify Posix style path context for consistent behaviour when running tests on Windows
getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix)); getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix));
}); });
tearDown(() async { tearDown(() async {
albumGrouping.init({}); albumGrouping.setGroups({});
await getIt.reset(); await getIt.reset();
}); });

View file

@ -16,6 +16,10 @@ void main() {
const groupName = 'some group name'; const groupName = 'some group name';
const storedAlbumPath = '/path/to/album'; const storedAlbumPath = '/path/to/album';
setUpAll(() async {
albumGrouping.init();
});
setUp(() async { setUp(() async {
// specify Posix style path context for consistent behaviour when running tests on Windows // specify Posix style path context for consistent behaviour when running tests on Windows
getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix)); getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix));
@ -23,7 +27,7 @@ void main() {
}); });
tearDown(() async { tearDown(() async {
albumGrouping.init({}); albumGrouping.setGroups({});
await dynamicAlbums.clear(); await dynamicAlbums.clear();
await getIt.reset(); await getIt.reset();
}); });