#268 groups: listening to source/dynamics to remove groups with obsolete content
This commit is contained in:
parent
7b0f72d6ee
commit
1119fa1407
10 changed files with 148 additions and 22 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue