diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index 8edd93670..54b79630b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -20,11 +20,13 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E private lateinit var handler: Handler private var knownEntries: Map? = null + private var directory: String? = null init { if (arguments is Map<*, *>) { @Suppress("unchecked_cast") knownEntries = arguments["knownEntries"] as Map? + directory = arguments["directory"] as String? } } @@ -58,7 +60,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E } private fun fetchAll() { - MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) } + MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory) { success(it) } endOfStream() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 322672824..ef7abef99 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -37,13 +37,24 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class MediaStoreImageProvider : ImageProvider() { - fun fetchAll(context: Context, knownEntries: Map, handleNewEntry: NewEntryHandler) { + fun fetchAll( + context: Context, + knownEntries: Map, + directory: String?, + handleNewEntry: NewEntryHandler, + ) { val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean { val knownDate = knownEntries[contentId] return knownDate == null || knownDate < dateModifiedSecs } - fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION) - fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) + var selection: String? = null + var selectionArgs: Array? = null + if (directory != null) { + selection = "${MediaColumns.PATH} LIKE ?" + selectionArgs = arrayOf("${StorageUtils.ensureTrailingSeparator(directory)}%") + } + fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection = selection, selectionArgs = selectionArgs) + fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection = selection, selectionArgs = selectionArgs) } // the provided URI can point to the wrong media collection, @@ -138,12 +149,14 @@ class MediaStoreImageProvider : ImageProvider() { handleNewEntry: NewEntryHandler, contentUri: Uri, projection: Array, + selection: String? = null, + selectionArgs: Array? = null, fileMimeType: String? = null, ): Boolean { var found = false val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC" try { - val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy) + val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, orderBy) if (cursor != null) { val contentUriContainsId = when (contentUri) { IMAGE_CONTENT_URI, VIDEO_CONTENT_URI -> false diff --git a/lib/model/db/db_metadata.dart b/lib/model/db/db_metadata.dart index db200ef32..df051677a 100644 --- a/lib/model/db/db_metadata.dart +++ b/lib/model/db/db_metadata.dart @@ -16,13 +16,15 @@ abstract class MetadataDb { Future reset(); - Future removeIds(Set ids, {Set? dataTypes}); + Future removeIds(Iterable ids, {Set? dataTypes}); // entries Future clearEntries(); - Future> loadAllEntries(); + Future> loadEntries({String? directory}); + + Future> loadEntriesById(Iterable ids); Future saveEntries(Iterable entries); @@ -30,8 +32,6 @@ abstract class MetadataDb { Future> searchEntries(String query, {int? limit}); - Future> loadEntries(List ids); - // date taken Future clearDates(); @@ -40,19 +40,23 @@ abstract class MetadataDb { // catalog metadata - Future clearMetadataEntries(); + Future clearCatalogMetadata(); - Future> loadAllMetadataEntries(); + Future> loadCatalogMetadata(); - Future saveMetadata(Set metadataEntries); + Future> loadCatalogMetadataById(Iterable ids); - Future updateMetadata(int id, CatalogMetadata? metadata); + Future saveCatalogMetadata(Set metadataEntries); + + Future updateCatalogMetadata(int id, CatalogMetadata? metadata); // address Future clearAddresses(); - Future> loadAllAddresses(); + Future> loadAddresses(); + + Future> loadAddressesById(Iterable ids); Future saveAddresses(Set addresses); @@ -100,5 +104,5 @@ abstract class MetadataDb { Future addVideoPlayback(Set rows); - Future removeVideoPlayback(Set ids); + Future removeVideoPlayback(Iterable ids); } diff --git a/lib/model/db/db_metadata_sqflite.dart b/lib/model/db/db_metadata_sqflite.dart index 1e2fdec33..9c21e96e1 100644 --- a/lib/model/db/db_metadata_sqflite.dart +++ b/lib/model/db/db_metadata_sqflite.dart @@ -119,7 +119,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeIds(Set ids, {Set? dataTypes}) async { + Future removeIds(Iterable ids, {Set? dataTypes}) async { if (ids.isEmpty) return; final _dataTypes = dataTypes ?? EntryDataType.values.toSet(); @@ -159,27 +159,19 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future> loadAllEntries() async { - final rows = await _db.query(entryTable); + Future> loadEntries({String? directory}) async { + String? where; + List? whereArgs; + if (directory != null) { + where = 'path LIKE ?'; + whereArgs = ['$directory%']; + } + final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs); return rows.map(AvesEntry.fromMap).toSet(); } @override - Future> loadEntries(List ids) async { - if (ids.isEmpty) return {}; - final entries = {}; - await Future.forEach(ids, (id) async { - final rows = await _db.query( - entryTable, - where: 'id = ?', - whereArgs: [id], - ); - if (rows.isNotEmpty) { - entries.add(AvesEntry.fromMap(rows.first)); - } - }); - return entries; - } + Future> loadEntriesById(Iterable ids) => _getByIds(ids, entryTable, AvesEntry.fromMap); @override Future saveEntries(Iterable entries) async { @@ -236,19 +228,22 @@ class SqfliteMetadataDb implements MetadataDb { // catalog metadata @override - Future clearMetadataEntries() async { + Future clearCatalogMetadata() async { final count = await _db.delete(metadataTable, where: '1'); debugPrint('$runtimeType clearMetadataEntries deleted $count rows'); } @override - Future> loadAllMetadataEntries() async { + Future> loadCatalogMetadata() async { final rows = await _db.query(metadataTable); - return rows.map(CatalogMetadata.fromMap).toList(); + return rows.map(CatalogMetadata.fromMap).toSet(); } @override - Future saveMetadata(Set metadataEntries) async { + Future> loadCatalogMetadataById(Iterable ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap); + + @override + Future saveCatalogMetadata(Set metadataEntries) async { if (metadataEntries.isEmpty) return; final stopwatch = Stopwatch()..start(); try { @@ -262,7 +257,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future updateMetadata(int id, CatalogMetadata? metadata) async { + Future updateCatalogMetadata(int id, CatalogMetadata? metadata) async { final batch = _db.batch(); batch.delete(dateTakenTable, where: 'id = ?', whereArgs: [id]); batch.delete(metadataTable, where: 'id = ?', whereArgs: [id]); @@ -298,11 +293,14 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future> loadAllAddresses() async { + Future> loadAddresses() async { final rows = await _db.query(addressTable); return rows.map(AddressDetails.fromMap).toSet(); } + @override + Future> loadAddressesById(Iterable ids) => _getByIds(ids, addressTable, AddressDetails.fromMap); + @override Future saveAddresses(Set addresses) async { if (addresses.isEmpty) return; @@ -502,7 +500,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeVideoPlayback(Set ids) async { + Future removeVideoPlayback(Iterable ids) async { if (ids.isEmpty) return; // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead @@ -510,4 +508,15 @@ class SqfliteMetadataDb implements MetadataDb { ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id])); await batch.commit(noResult: true); } + + // convenience methods + + Future> _getByIds(Iterable ids, String table, T Function(Map row) mapRow) async { + if (ids.isEmpty) return {}; + final rows = await _db.query( + table, + where: 'id IN (${ids.join(',')})', + ); + return rows.map(mapRow).toSet(); + } } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index f0d2b9070..2e5dedd16 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -643,7 +643,7 @@ class AvesEntry { if (persist) { await metadataDb.saveEntries({this}); - if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); + if (catalogMetadata != null) await metadataDb.saveCatalogMetadata({catalogMetadata!}); } await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index 65707f3ea..2b2ee6054 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -259,7 +259,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { ); } - // convenience + // convenience methods // This method checks whether the item already has a metadata date, // and adds a date (the file modified date) via Exif if possible. diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index ece584e15..83cc962d8 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -32,7 +32,7 @@ class CollectionLens with ChangeNotifier { final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier(); final List _subscriptions = []; int? id; - bool listenToSource; + bool listenToSource, groupBursts; List? fixedSelection; List _filteredSortedEntries = []; @@ -44,6 +44,7 @@ class CollectionLens with ChangeNotifier { Set? filters, this.id, this.listenToSource = true, + this.groupBursts = true, this.fixedSelection, }) : filters = (filters ?? {}).whereNotNull().toSet(), sectionFactor = settings.collectionSectionFactor, @@ -174,8 +175,6 @@ class CollectionLens with ChangeNotifier { filterChangeNotifier.notifyListeners(); } - final bool groupBursts = true; - void _applyFilters() { final entries = fixedSelection ?? (filters.contains(TrashFilter.instance) ? source.trashedEntries : source.visibleEntries); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 6a391eb55..eade5b709 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -25,6 +25,8 @@ import 'package:collection/collection.dart'; import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; +enum SourceInitializationState { none, directory, full } + mixin SourceBase { EventBus get eventBus; @@ -222,7 +224,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM if (persist) { final id = entry.id; await metadataDb.updateEntry(id, entry); - await metadataDb.updateMetadata(id, entry.catalogMetadata); + await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata); await metadataDb.updateAddress(id, entry.addressDetails); await metadataDb.updateTrash(id, entry.trashDetails); } @@ -315,7 +317,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } }); await metadataDb.saveEntries(movedEntries); - await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata).whereNotNull().toSet()); + await metadataDb.saveCatalogMetadata(movedEntries.map((entry) => entry.catalogMetadata).whereNotNull().toSet()); await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).whereNotNull().toSet()); } else { await Future.forEach(movedOps, (movedOp) async { @@ -349,11 +351,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM eventBus.fire(EntryMovedEvent(moveType, movedEntries)); } - bool get initialized => false; + SourceInitializationState get initState => SourceInitializationState.none; - Future init(); - - Future refresh({AnalysisController? analysisController}); + Future init({ + AnalysisController? analysisController, + String? directory, + bool loadTopEntriesFirst = false, + }); Future> refreshUris(Set changedUris, {AnalysisController? analysisController}); @@ -363,7 +367,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM // update/delete in DB final id = entry.id; if (dataTypes.contains(EntryDataType.catalog)) { - await metadataDb.updateMetadata(id, entry.catalogMetadata); + await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata); onCatalogMetadataChanged(); } if (dataTypes.contains(EntryDataType.address)) { diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index ffffd7af0..deca24b85 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -20,8 +20,8 @@ mixin LocationMixin on SourceBase { List sortedCountries = List.unmodifiable([]); List sortedPlaces = List.unmodifiable([]); - Future loadAddresses() async { - final saved = await metadataDb.loadAllAddresses(); + Future loadAddresses({Set? ids}) async { + final saved = await (ids != null ? metadataDb.loadAddressesById(ids) : metadataDb.loadAddresses()); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.id]?.addressDetails = metadata); onAddressMetadataChanged(); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 3f7262589..cbe86146b 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -15,13 +14,31 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class MediaStoreSource extends CollectionSource { - bool _initialized = false; + SourceInitializationState _initState = SourceInitializationState.none; @override - bool get initialized => _initialized; + SourceInitializationState get initState => _initState; @override - Future init() async { + Future init({ + AnalysisController? analysisController, + String? directory, + bool loadTopEntriesFirst = false, + }) async { + if (_initState == SourceInitializationState.none) { + await _loadEssentials(); + } + if (_initState != SourceInitializationState.full) { + _initState = directory != null ? SourceInitializationState.directory : SourceInitializationState.full; + } + unawaited(_loadEntries( + analysisController: analysisController, + directory: directory, + loadTopEntriesFirst: loadTopEntriesFirst, + )); + } + + Future _loadEssentials() async { final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; await metadataDb.init(); @@ -34,35 +51,36 @@ class MediaStoreSource extends CollectionSource { // clear catalog metadata to get correct date/times when moving to a different time zone debugPrint('$runtimeType clear catalog metadata to get correct date/times'); await metadataDb.clearDates(); - await metadataDb.clearMetadataEntries(); + await metadataDb.clearCatalogMetadata(); settings.catalogTimeZone = currentTimeZone; } } await loadDates(); - _initialized = true; - debugPrint('$runtimeType init complete in ${stopwatch.elapsed.inMilliseconds}ms'); + debugPrint('$runtimeType load essentials complete in ${stopwatch.elapsed.inMilliseconds}ms'); } - @override - Future refresh({AnalysisController? analysisController}) async { - assert(_initialized); + Future _loadEntries({ + AnalysisController? analysisController, + String? directory, + required bool loadTopEntriesFirst, + }) async { debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; clearEntries(); final Set topEntries = {}; - if (settings.homePage == HomePageSetting.collection) { + if (loadTopEntriesFirst) { final topIds = settings.topEntryIds; if (topIds != null) { debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries'); - topEntries.addAll(await metadataDb.loadEntries(topIds)); + topEntries.addAll(await metadataDb.loadEntriesById(topIds)); addEntries(topEntries); } } debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries'); - final knownEntries = await metadataDb.loadAllEntries(); + final knownEntries = await metadataDb.loadEntries(directory: directory); final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet(); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries'); @@ -79,25 +97,33 @@ class MediaStoreSource extends CollectionSource { addEntries(knownEntries); debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); - await loadCatalogMetadata(); - await loadAddresses(); - updateDerivedFilters(); + if (directory != null) { + final ids = knownLiveEntries.map((entry) => entry.id).toSet(); + await loadCatalogMetadata(ids: ids); + await loadAddresses(ids: ids); + } else { + await loadCatalogMetadata(); + await loadAddresses(); + updateDerivedFilters(); + } // clean up obsolete entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); await metadataDb.removeIds(obsoleteContentIds); - // trash - await loadTrashDetails(); - unawaited(deleteExpiredTrash().then( - (deletedUris) { - if (deletedUris.isNotEmpty) { - debugPrint('evicted ${deletedUris.length} expired items from the trash'); - removeEntries(deletedUris, includeTrash: true); - } - }, - onError: (error) => debugPrint('failed to evict expired trash error=$error'), - )); + if (directory != null) { + // trash + await loadTrashDetails(); + unawaited(deleteExpiredTrash().then( + (deletedUris) { + if (deletedUris.isNotEmpty) { + debugPrint('evicted ${deletedUris.length} expired items from the trash'); + removeEntries(deletedUris, includeTrash: true); + } + }, + onError: (error) => debugPrint('failed to evict expired trash error=$error'), + )); + } // verify paths because some apps move files without updating their `last modified date` debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths'); @@ -120,7 +146,7 @@ class MediaStoreSource extends CollectionSource { pendingNewEntries.clear(); } - mediaStoreService.getEntries(knownDateByContentId).listen( + mediaStoreService.getEntries(knownDateByContentId, directory: directory).listen( (entry) { entry.id = metadataDb.nextId; pendingNewEntries.add(entry); @@ -162,7 +188,7 @@ class MediaStoreSource extends CollectionSource { // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` @override Future> refreshUris(Set changedUris, {AnalysisController? analysisController}) async { - if (!_initialized || !isMonitoring) return changedUris; + if (_initState == SourceInitializationState.none || !isMonitoring) return changedUris; debugPrint('$runtimeType refreshUris ${changedUris.length} uris'); final uriByContentId = Map.fromEntries(changedUris.map((uri) { diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index c1f7b5602..b2fa61907 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -14,8 +14,8 @@ mixin TagMixin on SourceBase { List sortedTags = List.unmodifiable([]); - Future loadCatalogMetadata() async { - final saved = await metadataDb.loadAllMetadataEntries(); + Future loadCatalogMetadata({Set? ids}) async { + final saved = await (ids != null ? metadataDb.loadCatalogMetadataById(ids) : metadataDb.loadCatalogMetadata()); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.id]?.catalogMetadata = metadata); onCatalogMetadataChanged(); @@ -42,7 +42,7 @@ mixin TagMixin on SourceBase { if (entry.isCatalogued) { newMetadata.add(entry.catalogMetadata!); if (newMetadata.length >= commitCountThreshold) { - await metadataDb.saveMetadata(Set.unmodifiable(newMetadata)); + await metadataDb.saveCatalogMetadata(Set.unmodifiable(newMetadata)); onCatalogMetadataChanged(); newMetadata.clear(); } @@ -53,7 +53,7 @@ mixin TagMixin on SourceBase { } setProgress(done: ++progressDone, total: progressTotal); } - await metadataDb.saveMetadata(Set.unmodifiable(newMetadata)); + await metadataDb.saveCatalogMetadata(Set.unmodifiable(newMetadata)); onCatalogMetadataChanged(); } diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart index c048d08a5..95b8eb752 100644 --- a/lib/services/analysis_service.dart +++ b/lib/services/analysis_service.dart @@ -115,8 +115,7 @@ class Analyzer { settings.systemLocalesFallback = await deviceService.getLocales(); _l10n = await AppLocalizations.delegate.load(settings.appliedLocale); _serviceStateNotifier.value = AnalyzerState.running; - await _source.init(); - unawaited(_source.refresh(analysisController: _controller)); + await _source.init(analysisController: _controller); _notificationUpdateTimer = Timer.periodic(notificationUpdateInterval, (_) async { if (!isRunning) return; diff --git a/lib/services/media/media_store_service.dart b/lib/services/media/media_store_service.dart index e22b7d2bc..8a6647485 100644 --- a/lib/services/media/media_store_service.dart +++ b/lib/services/media/media_store_service.dart @@ -11,7 +11,7 @@ abstract class MediaStoreService { Future> checkObsoletePaths(Map knownPathById); // knownEntries: map of contentId -> dateModifiedSecs - Stream getEntries(Map knownEntries); + Stream getEntries(Map knownEntries, {String? directory}); // returns media URI Future scanFile(String path, String mimeType); @@ -48,11 +48,12 @@ class PlatformMediaStoreService implements MediaStoreService { } @override - Stream getEntries(Map knownEntries) { + Stream getEntries(Map knownEntries, {String? directory}) { try { return _streamChannel .receiveBroadcastStream({ 'knownEntries': knownEntries, + 'directory': directory, }) .where((event) => event is Map) .map((event) => AvesEntry.fromMap(event as Map)); diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 3d34f4f6a..db11d41c1 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -168,7 +168,16 @@ class _AvesAppState extends State with WidgetsBindingObserver { debugPrint('$runtimeType lifecycle ${state.name}'); switch (state) { case AppLifecycleState.inactive: - _saveTopEntries(); + switch (appModeNotifier.value) { + case AppMode.main: + case AppMode.pickMediaExternal: + _saveTopEntries(); + break; + case AppMode.pickMediaInternal: + case AppMode.pickFilterInternal: + case AppMode.view: + break; + } break; case AppLifecycleState.paused: case AppLifecycleState.detached: diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 2b2be1150..8f85f6995 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -96,35 +96,46 @@ class _CollectionGridContent extends StatelessWidget { final scrollableWidth = c.item1; final columnCount = c.item2; final tileSpacing = c.item3; - // do not listen for animation delay change - final target = context.read().staggeredAnimationPageTarget; - final tileAnimationDelay = context.read().getTileAnimationDelay(target); return GridTheme( extent: thumbnailExtent, child: EntryListDetailsTheme( extent: thumbnailExtent, - child: SectionedEntryListLayoutProvider( - collection: collection, - scrollableWidth: scrollableWidth, - tileLayout: tileLayout, - columnCount: columnCount, - spacing: tileSpacing, - tileExtent: thumbnailExtent, - tileBuilder: (entry) => AnimatedBuilder( - animation: favourites, - builder: (context, child) { - return InteractiveTile( - key: ValueKey(entry.id), - collection: collection, - entry: entry, - thumbnailExtent: thumbnailExtent, - tileLayout: tileLayout, - isScrollingNotifier: _isScrollingNotifier, - ); - }, - ), - tileAnimationDelay: tileAnimationDelay, - child: child!, + child: ValueListenableBuilder( + valueListenable: collection.source.stateNotifier, + builder: (context, sourceState, child) { + late final Duration tileAnimationDelay; + if (sourceState == SourceState.ready) { + // do not listen for animation delay change + final target = context.read().staggeredAnimationPageTarget; + tileAnimationDelay = context.read().getTileAnimationDelay(target); + } else { + tileAnimationDelay = Duration.zero; + } + return SectionedEntryListLayoutProvider( + collection: collection, + scrollableWidth: scrollableWidth, + tileLayout: tileLayout, + columnCount: columnCount, + spacing: tileSpacing, + tileExtent: thumbnailExtent, + tileBuilder: (entry) => AnimatedBuilder( + animation: favourites, + builder: (context, child) { + return InteractiveTile( + key: ValueKey(entry.id), + collection: collection, + entry: entry, + thumbnailExtent: thumbnailExtent, + tileLayout: tileLayout, + isScrollingNotifier: _isScrollingNotifier, + ); + }, + ), + tileAnimationDelay: tileAnimationDelay, + child: child!, + ); + }, + child: child, ), ), ); diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index c53ab8247..4c458f894 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -51,10 +51,9 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { } final source = context.read(); - if (!source.initialized) { + if (source.initState != SourceInitializationState.full) { // source may be uninitialized in viewer mode await source.init(); - unawaited(source.refresh()); } final entriesByDestination = >{}; diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index e2da8b4b6..5509fe2c4 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -8,6 +8,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/analysis_service.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -131,11 +132,16 @@ class _AppDebugPageState extends State { title: const Text('Show tasks overlay'), ), ElevatedButton( - onPressed: () async { - await source.init(); - await source.refresh(); - }, - child: const Text('Source full refresh'), + onPressed: () => source.init(loadTopEntriesFirst: false), + child: const Text('Source refresh (top off)'), + ), + ElevatedButton( + onPressed: () => source.init(loadTopEntriesFirst: true), + child: const Text('Source refresh (top on)'), + ), + ElevatedButton( + onPressed: () => source.init(directory: '${androidFileUtils.dcimPath}/Camera'), + child: const Text('Source refresh (camera)'), ), ElevatedButton( onPressed: () => AnalysisService.startService(force: false), diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index 5fd835ed7..f2bf3386b 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -21,7 +21,7 @@ class _DebugAppDatabaseSectionState extends State with late Future _dbFileSizeLoader; late Future> _dbEntryLoader; late Future> _dbDateLoader; - late Future> _dbMetadataLoader; + late Future> _dbMetadataLoader; late Future> _dbAddressLoader; late Future> _dbTrashLoader; late Future> _dbFavouritesLoader; @@ -108,7 +108,7 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), - FutureBuilder( + FutureBuilder( future: _dbMetadataLoader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); @@ -122,7 +122,7 @@ class _DebugAppDatabaseSectionState extends State with ), const SizedBox(width: 8), ElevatedButton( - onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()), + onPressed: () => metadataDb.clearCatalogMetadata().then((_) => _startDbReport()), child: const Text('Clear'), ), ], @@ -243,10 +243,10 @@ class _DebugAppDatabaseSectionState extends State with void _startDbReport() { _dbFileSizeLoader = metadataDb.dbFileSize(); - _dbEntryLoader = metadataDb.loadAllEntries(); + _dbEntryLoader = metadataDb.loadEntries(); _dbDateLoader = metadataDb.loadDates(); - _dbMetadataLoader = metadataDb.loadAllMetadataEntries(); - _dbAddressLoader = metadataDb.loadAllAddresses(); + _dbMetadataLoader = metadataDb.loadCatalogMetadata(); + _dbAddressLoader = metadataDb.loadAddresses(); _dbTrashLoader = metadataDb.loadAllTrashDetails(); _dbFavouritesLoader = metadataDb.loadAllFavourites(); _dbCoversLoader = metadataDb.loadAllCovers(); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 8e0e1dcfe..a94900d38 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -4,6 +4,7 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/home_page.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -116,14 +117,33 @@ class _HomePageState extends State { } context.read>().value = appMode; unawaited(reportService.setCustomKey('app_mode', appMode.toString())); + debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); - if (appMode != AppMode.view || _isViewerSourceable(_viewerEntry!)) { - debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); - unawaited(GlobalSearch.registerCallback()); - unawaited(AnalysisService.registerCallback()); - final source = context.read(); - await source.init(); - unawaited(source.refresh()); + switch (appMode) { + case AppMode.main: + case AppMode.pickMediaExternal: + unawaited(GlobalSearch.registerCallback()); + unawaited(AnalysisService.registerCallback()); + final source = context.read(); + await source.init( + loadTopEntriesFirst: settings.homePage == HomePageSetting.collection, + ); + break; + case AppMode.view: + if (_isViewerSourceable(_viewerEntry)) { + final directory = _viewerEntry?.directory; + if (directory != null) { + unawaited(AnalysisService.registerCallback()); + final source = context.read(); + await source.init( + directory: directory, + ); + } + } + break; + case AppMode.pickMediaInternal: + case AppMode.pickFilterInternal: + break; } // `pushReplacement` is not enough in some edge cases @@ -135,7 +155,9 @@ class _HomePageState extends State { )); } - bool _isViewerSourceable(AvesEntry viewerEntry) => viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); + bool _isViewerSourceable(AvesEntry? viewerEntry) { + return viewerEntry != null && viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); + } Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { @@ -156,7 +178,7 @@ class _HomePageState extends State { CollectionLens? collection; final source = context.read(); - if (source.initialized) { + if (source.initState != SourceInitializationState.none) { final album = viewerEntry.directory; if (album != null) { // wait for collection to pass the `loading` state @@ -174,6 +196,11 @@ class _HomePageState extends State { collection = CollectionLens( source: source, filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, + listenToSource: false, + // if we group bursts, opening a burst sub-entry should: + // - identify and select the containing main entry, + // - select the sub-entry in the Viewer page. + groupBursts: false, ); final viewerEntryPath = viewerEntry.path; final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index bc962480b..29a2db70d 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/filters.dart'; @@ -371,6 +372,9 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin } void _goToCollection(CollectionFilter filter) { + final isMainMode = context.read>().value == AppMode.main; + if (!isMainMode) return; + Navigator.pushAndRemoveUntil( context, MaterialPageRoute( diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 48ef7bd9f..ab151bec2 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -188,7 +188,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix showFeedback(context, l10n.genericFailureFeedback); } else { final source = context.read(); - if (source.initialized) { + if (source.initState != SourceInitializationState.none) { await source.removeEntries({entry.uri}, includeTrash: true); } EntryRemovedNotification(entry).dispatch(context); @@ -203,9 +203,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (options == null) return; final source = context.read(); - if (!source.initialized) { + if (source.initState != SourceInitializationState.full) { await source.init(); - unawaited(source.refresh()); } final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); if (destinationAlbum == null) return; diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index 5b38fab6c..d94af86ee 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -39,9 +39,9 @@ class _DbTabState extends State { void _loadDatabase() { final id = entry.id; _dbDateLoader = metadataDb.loadDates().then((values) => values[id]); - _dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.id == id)); - _dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.id == id)); - _dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbMetadataLoader = metadataDb.loadCatalogMetadata().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.id == id)); _dbTrashDetailsLoader = metadataDb.loadAllTrashDetails().then((values) => values.firstWhereOrNull((row) => row.id == id)); _dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(id); setState(() {}); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 7ce90aa63..d1d4e28f6 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; @@ -399,8 +400,12 @@ class _EntryViewerStackState extends State with FeedbackMixin, } void _goToCollection(CollectionFilter filter) { + final isMainMode = context.read>().value == AppMode.main; + if (!isMainMode) return; + final baseCollection = collection; if (baseCollection == null) return; + _onLeave(); Navigator.pushAndRemoveUntil( context, diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 4db38317f..d6036f936 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; @@ -199,7 +198,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { collection: collection, actionDelegate: _actionDelegate, isEditingMetadataNotifier: _isEditingMetadataNotifier, - onFilter: _goToCollection, + onFilter: _onFilter, ); final locationAtTop = widget.split && entry.hasGps; final locationSection = LocationSection( @@ -207,7 +206,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { entry: entry, showTitle: !locationAtTop, isScrollingNotifier: widget.isScrollingNotifier, - onFilter: _goToCollection, + onFilter: _onFilter, ); final basicAndLocationSliver = locationAtTop ? SliverToBoxAdapter( @@ -265,9 +264,5 @@ class _InfoPageContentState extends State<_InfoPageContent> { }); } - void _goToCollection(CollectionFilter filter) { - final isMainMode = context.read>().value == AppMode.main; - if (!isMainMode || collection == null) return; - FilterSelectedNotification(filter).dispatch(context); - } + void _onFilter(CollectionFilter filter) => FilterSelectedNotification(filter).dispatch(context); } diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index 50e37144f..c3ed0d756 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -15,7 +15,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { Future> checkObsoletePaths(Map knownPathById) => SynchronousFuture([]); @override - Stream getEntries(Map knownEntries) => Stream.fromIterable(entries); + Stream getEntries(Map knownEntries, {String? directory}) => Stream.fromIterable(entries); static var _lastId = 1; diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index a3c62df19..e532bd1aa 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -19,12 +19,12 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future init() => SynchronousFuture(null); @override - Future removeIds(Set ids, {Set? dataTypes}) => SynchronousFuture(null); + Future removeIds(Iterable ids, {Set? dataTypes}) => SynchronousFuture(null); // entries @override - Future> loadAllEntries() => SynchronousFuture({}); + Future> loadEntries({String? directory}) => SynchronousFuture({}); @override Future saveEntries(Iterable entries) => SynchronousFuture(null); @@ -40,18 +40,18 @@ class FakeMetadataDb extends Fake implements MetadataDb { // catalog metadata @override - Future> loadAllMetadataEntries() => SynchronousFuture([]); + Future> loadCatalogMetadata() => SynchronousFuture({}); @override - Future saveMetadata(Set metadataEntries) => SynchronousFuture(null); + Future saveCatalogMetadata(Set metadataEntries) => SynchronousFuture(null); @override - Future updateMetadata(int id, CatalogMetadata? metadata) => SynchronousFuture(null); + Future updateCatalogMetadata(int id, CatalogMetadata? metadata) => SynchronousFuture(null); // address @override - Future> loadAllAddresses() => SynchronousFuture({}); + Future> loadAddresses() => SynchronousFuture({}); @override Future saveAddresses(Set addresses) => SynchronousFuture(null); @@ -101,5 +101,5 @@ class FakeMetadataDb extends Fake implements MetadataDb { // video playback @override - Future removeVideoPlayback(Set ids) => SynchronousFuture(null); + Future removeVideoPlayback(Iterable ids) => SynchronousFuture(null); } diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 8da7aab0c..68a3e9229 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -83,7 +83,6 @@ void main() { } }); await source.init(); - await source.refresh(); await readyCompleter.future; return source; }