From ce69587d2c30a2f4cff97caf7d17b2a9e5b7961b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 12 Jun 2020 15:10:48 +0900 Subject: [PATCH] save known entries in sqlite and only fetch from mediastore new/modified entries --- .../deckers/thibault/aves/MainActivity.java | 2 +- .../aves/channel/calls/ImageFileHandler.java | 15 ++ .../channel/streams/ImageOpStreamHandler.java | 2 +- .../streams/MediaStoreStreamHandler.java | 10 +- .../aves/model/provider/ImageProvider.java | 4 +- .../provider/MediaStoreImageProvider.java | 145 +++++++++++------- lib/model/metadata_db.dart | 76 ++++++++- lib/model/source/collection_source.dart | 6 +- lib/model/source/location.dart | 12 +- lib/model/source/tag.dart | 10 +- lib/services/image_file_service.dart | 19 ++- .../album/grid/list_section_layout.dart | 2 +- .../media_store_collection_provider.dart | 53 ++++--- lib/widgets/debug_page.dart | 31 +++- lib/widgets/fullscreen/fullscreen_body.dart | 10 +- lib/widgets/home_page.dart | 7 +- 16 files changed, 294 insertions(+), 110 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index 56520b9a4..a47af8e50 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -48,7 +48,7 @@ public class MainActivity extends FlutterActivity { new StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new ImageByteStreamHandler(this, args)); new StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new ImageOpStreamHandler(this, args)); - new StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new MediaStoreStreamHandler(this)); + new StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new MediaStoreStreamHandler(this, args)); new StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new StorageAccessStreamHandler(this, args)); new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler( diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java index 3dbca6909..23fa8023c 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java @@ -9,11 +9,13 @@ import androidx.annotation.NonNull; import com.bumptech.glide.Glide; +import java.util.List; import java.util.Map; import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.provider.ImageProvider; import deckers.thibault.aves.model.provider.ImageProviderFactory; +import deckers.thibault.aves.model.provider.MediaStoreImageProvider; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -37,6 +39,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { @Override public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { switch (call.method) { + case "getObsoleteEntries": + new Thread(() -> getObsoleteEntries(call, new MethodResultWrapper(result))).start(); + break; case "getImageEntry": new Thread(() -> getImageEntry(call, new MethodResultWrapper(result))).start(); break; @@ -79,6 +84,16 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result)); } + private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + List known = call.argument("knownContentIds"); + if (known == null) { + result.error("getObsoleteEntries-args", "failed because of missing arguments", null); + return; + } + List obsolete = new MediaStoreImageProvider().getObsoleteContentIds(activity, known); + result.success(obsolete); + } + private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { String uriString = call.argument("uri"); String mimeType = call.argument("mimeType"); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java index a198d4cdf..1399bc3d5 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java @@ -94,7 +94,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { String destinationDir = (String) argMap.get("destinationPath"); if (copy == null || destinationDir == null) return; - ArrayList entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toCollection(ArrayList::new)); + List entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toList()); provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { @Override public void onSuccess(Map fields) { diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java index 1990c568c..2d0dfe633 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java @@ -15,9 +15,15 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler { private Activity activity; private EventChannel.EventSink eventSink; private Handler handler; + private Map knownEntries; - public MediaStoreStreamHandler(Activity activity) { + @SuppressWarnings("unchecked") + public MediaStoreStreamHandler(Activity activity, Object arguments) { this.activity = activity; + if (arguments instanceof Map) { + Map argMap = (Map) arguments; + this.knownEntries = (Map) argMap.get("knownEntries"); + } } @Override @@ -41,7 +47,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler { } void fetchAll() { - new MediaStoreImageProvider().fetchAll(activity, this::success); // 350ms + new MediaStoreImageProvider().fetchAll(activity, knownEntries, this::success); // 350ms endOfStream(); } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 66c59debb..b213556e5 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -33,8 +33,8 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import deckers.thibault.aves.model.ImageEntry; @@ -65,7 +65,7 @@ public abstract class ImageProvider { return Futures.immediateFailedFuture(new UnsupportedOperationException()); } - public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList entries, @NonNull ImageOpCallback callback) { + public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, @NonNull ImageOpCallback callback) { callback.onFailure(new UnsupportedOperationException()); } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 01e31429b..c6899fa5d 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -25,8 +25,10 @@ import java.io.File; import java.io.FileNotFoundException; import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; @@ -68,16 +70,13 @@ public class MediaStoreImageProvider extends ImageProvider { MediaStore.Video.Media.ORIENTATION, } : new String[0]).flatMap(Stream::of).toArray(String[]::new); - public void fetchAll(Activity activity, NewEntryHandler newEntryHandler) { - String orderBy; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC"; - } else { - orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; - } - - fetchFrom(activity, newEntryHandler, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, orderBy); - fetchFrom(activity, newEntryHandler, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION, orderBy); + public void fetchAll(Context context, Map knownEntries, NewEntryHandler newEntryHandler) { + NewEntryChecker isModified = (contentId, dateModifiedSecs) -> { + final Integer knownDate = knownEntries.get(contentId); + return knownDate == null || knownDate < dateModifiedSecs; + }; + fetchFrom(context, isModified, newEntryHandler, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION); + fetchFrom(context, isModified, newEntryHandler, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION); } @Override @@ -88,23 +87,48 @@ public class MediaStoreImageProvider extends ImageProvider { entry.put("uri", uri.toString()); callback.onSuccess(entry); }; + NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true; if (mimeType.startsWith(MimeTypes.IMAGE)) { Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); - entryCount = fetchFrom(context, onSuccess, contentUri, IMAGE_PROJECTION, null); + entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION); } else if (mimeType.startsWith(MimeTypes.VIDEO)) { Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); - entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION, null); + entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION); } if (entryCount == 0) { callback.onFailure(new Exception("failed to fetch entry at uri=" + uri)); } } - @SuppressLint("InlinedApi") - private int fetchFrom(final Context context, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection, String orderBy) { - int entryCount = 0; + public List getObsoleteContentIds(Context context, List knownContentIds) { + final ArrayList current = new ArrayList<>(); + current.addAll(getContentIdList(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)); + current.addAll(getContentIdList(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)); + return knownContentIds.stream().filter(id -> !current.contains(id)).collect(Collectors.toList()); + } + private List getContentIdList(Context context, Uri contentUri) { + final ArrayList foundContentIds = new ArrayList<>(); + try { + Cursor cursor = context.getContentResolver().query(contentUri, new String[]{MediaStore.MediaColumns._ID}, null, null, null); + if (cursor != null) { + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); + while (cursor.moveToNext()) { + foundContentIds.add(cursor.getInt(idColumn)); + } + cursor.close(); + } + } catch (Exception e) { + Log.e(LOG_TAG, "failed to get content IDs for contentUri=" + contentUri, e); + } + return foundContentIds; + } + + @SuppressLint("InlinedApi") + private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) { + int newEntryCount = 0; final boolean needDuration = projection == VIDEO_PROJECTION; + final String orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; try { Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy); @@ -127,49 +151,52 @@ public class MediaStoreImageProvider extends ImageProvider { int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION); while (cursor.moveToNext()) { - final long contentId = cursor.getLong(idColumn); - // this is fine if `contentUri` does not already contain the ID - final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId); - final String path = cursor.getString(pathColumn); - final String mimeType = cursor.getString(mimeTypeColumn); - int width = cursor.getInt(widthColumn); - int height = cursor.getInt(heightColumn); - long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0; + final int contentId = cursor.getInt(idColumn); + final int dateModifiedSecs = cursor.getInt(dateModifiedColumn); + if (newEntryChecker.where(contentId, dateModifiedSecs)) { + // this is fine if `contentUri` does not already contain the ID + final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId); + final String path = cursor.getString(pathColumn); + final String mimeType = cursor.getString(mimeTypeColumn); + int width = cursor.getInt(widthColumn); + int height = cursor.getInt(heightColumn); + final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0; - Map entryMap = new HashMap() {{ - put("uri", itemUri.toString()); - put("path", path); - put("mimeType", mimeType); - put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); - put("sizeBytes", cursor.getLong(sizeColumn)); - put("title", cursor.getString(titleColumn)); - put("dateModifiedSecs", cursor.getLong(dateModifiedColumn)); - put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn)); - // only for map export - put("contentId", contentId); - }}; - entryMap.put("width", width); - entryMap.put("height", height); - entryMap.put("durationMillis", durationMillis); + Map entryMap = new HashMap() {{ + put("uri", itemUri.toString()); + put("path", path); + put("mimeType", mimeType); + put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); + put("sizeBytes", cursor.getLong(sizeColumn)); + put("title", cursor.getString(titleColumn)); + put("dateModifiedSecs", dateModifiedSecs); + put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn)); + // only for map export + put("contentId", contentId); + }}; + entryMap.put("width", width); + entryMap.put("height", height); + entryMap.put("durationMillis", durationMillis); - if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) { - // some images are incorrectly registered in the Media Store, - // they are valid but miss some attributes, such as width, height, orientation - ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context); - entryMap = entry.toMap(); - width = entry.width != null ? entry.width : 0; - height = entry.height != null ? entry.height : 0; - } - - if ((width <= 0 || height <= 0) && needSize(mimeType)) { - // this is probably not a real image, like "/storage/emulated/0", so we skip it - Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path + ", mimeType=" + mimeType); - } else { - newEntryHandler.handleEntry(entryMap); - if (entryCount % 30 == 0) { - Thread.sleep(10); + if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) { + // some images are incorrectly registered in the Media Store, + // they are valid but miss some attributes, such as width, height, orientation + ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context); + entryMap = entry.toMap(); + width = entry.width != null ? entry.width : 0; + height = entry.height != null ? entry.height : 0; + } + + if ((width <= 0 || height <= 0) && needSize(mimeType)) { + // this is probably not a real image, like "/storage/emulated/0", so we skip it + Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path + ", mimeType=" + mimeType); + } else { + newEntryHandler.handleEntry(entryMap); + if (newEntryCount % 30 == 0) { + Thread.sleep(10); + } + newEntryCount++; } - entryCount++; } } cursor.close(); @@ -177,7 +204,7 @@ public class MediaStoreImageProvider extends ImageProvider { } catch (Exception e) { Log.e(LOG_TAG, "failed to get entries", e); } - return entryCount; + return newEntryCount; } private boolean needSize(String mimeType) { @@ -231,7 +258,7 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList entries, ImageOpCallback callback) { + public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, ImageOpCallback callback) { String volumeName = "external"; StorageManager sm = activity.getSystemService(StorageManager.class); if (sm != null) { @@ -323,4 +350,8 @@ public class MediaStoreImageProvider extends ImageProvider { public interface NewEntryHandler { void handleEntry(Map entry); } + + public interface NewEntryChecker { + boolean where(int contentId, int dateModifiedSecs); + } } \ No newline at end of file diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 5068e046d..b6119f301 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; @@ -12,6 +13,7 @@ class MetadataDb { Future get path async => join(await getDatabasesPath(), 'metadata.db'); + static const entryTable = 'entry'; static const dateTakenTable = 'dateTaken'; static const metadataTable = 'metadata'; static const addressTable = 'address'; @@ -24,6 +26,20 @@ class MetadataDb { _database = openDatabase( await path, onCreate: (db, version) async { + await db.execute('CREATE TABLE $entryTable(' + 'contentId INTEGER PRIMARY KEY' + ', uri TEXT' + ', path TEXT' + ', mimeType TEXT' + ', width INTEGER' + ', height INTEGER' + ', orientationDegrees INTEGER' + ', sizeBytes INTEGER' + ', title TEXT' + ', dateModifiedSecs INTEGER' + ', sourceDateTakenMillis INTEGER' + ', durationMillis INTEGER' + ')'); await db.execute('CREATE TABLE $dateTakenTable(' 'contentId INTEGER PRIMARY KEY' ', dateMillis INTEGER' @@ -67,6 +83,62 @@ class MetadataDb { await init(); } + void removeIds(List contentIds) async { + if (contentIds == null || contentIds.isEmpty) return; + + final stopwatch = Stopwatch()..start(); + final db = await _database; + // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead + final batch = db.batch(); + const where = 'contentId = ?'; + contentIds.forEach((id) { + final whereArgs = [id]; + batch.delete(entryTable, where: where, whereArgs: whereArgs); + batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); + batch.delete(metadataTable, where: where, whereArgs: whereArgs); + batch.delete(addressTable, where: where, whereArgs: whereArgs); + batch.delete(favouriteTable, where: where, whereArgs: whereArgs); + }); + await batch.commit(noResult: true); + debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries'); + } + + // entries + + Future clearEntries() async { + final db = await _database; + final count = await db.delete(entryTable, where: '1'); + debugPrint('$runtimeType clearEntries deleted $count entries'); + } + + Future> loadEntries() async { + final stopwatch = Stopwatch()..start(); + final db = await _database; + final maps = await db.query(entryTable); + final entries = maps.map((map) => ImageEntry.fromMap(map)).toList(); + debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); + return entries; + } + + Future saveEntries(Iterable entries) async { + if (entries == null || entries.isEmpty) return; + final stopwatch = Stopwatch()..start(); + final db = await _database; + final batch = db.batch(); + entries.forEach((entry) => _batchInsertEntry(batch, entry)); + await batch.commit(noResult: true); + debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); + } + + void _batchInsertEntry(Batch batch, ImageEntry entry) { + if (entry == null) return; + batch.insert( + entryTable, + entry.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + // date taken Future clearDates() async { @@ -229,12 +301,10 @@ class MetadataDb { final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId); if (ids.isEmpty) return; - // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead -// final stopwatch = Stopwatch()..start(); final db = await _database; + // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); await batch.commit(noResult: true); -// debugPrint('$runtimeType removeFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries'); } } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 47e52a317..2870492f3 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -40,10 +40,14 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { Future loadDates() async { final stopwatch = Stopwatch()..start(); _savedDates = List.unmodifiable(await metadataDb.loadDates()); - debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} saved entries'); + debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries'); } void addAll(Iterable entries) { + if (_rawEntries.isNotEmpty) { + final newContentIds = entries.map((entry) => entry.contentId).toList(); + _rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); + } entries.forEach((entry) { final contentId = entry.contentId; entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis; diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 87ab77b6a..b239ef9a2 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -6,6 +6,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; mixin LocationMixin on SourceBase { + static const _commitCountThreshold = 50; + List sortedCountries = List.unmodifiable([]); List sortedPlaces = List.unmodifiable([]); @@ -16,19 +18,21 @@ mixin LocationMixin on SourceBase { final contentId = entry.contentId; entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null); }); - debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} saved entries'); + debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); onAddressMetadataChanged(); } Future locateEntries() async { - final stopwatch = Stopwatch()..start(); +// final stopwatch = Stopwatch()..start(); final unlocatedEntries = rawEntries.where((entry) => entry.hasGps && !entry.isLocated).toList(); + if (unlocatedEntries.isEmpty) return; + final newAddresses = []; await Future.forEach(unlocatedEntries, (entry) async { await entry.locate(); if (entry.isLocated) { newAddresses.add(entry.addressDetails); - if (newAddresses.length >= 50) { + if (newAddresses.length >= _commitCountThreshold) { await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); newAddresses.clear(); } @@ -36,7 +40,7 @@ mixin LocationMixin on SourceBase { }); await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); onAddressMetadataChanged(); - debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inMilliseconds}ms'); +// debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inSeconds}s'); } void onAddressMetadataChanged() { diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index ede78d9af..c563efcb3 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -6,6 +6,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; mixin TagMixin on SourceBase { + static const _commitCountThreshold = 300; + List sortedTags = List.unmodifiable([]); Future loadCatalogMetadata() async { @@ -15,12 +17,12 @@ mixin TagMixin on SourceBase { final contentId = entry.contentId; entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null); }); - debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} saved entries'); + debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); onCatalogMetadataChanged(); } Future catalogEntries() async { - final stopwatch = Stopwatch()..start(); +// final stopwatch = Stopwatch()..start(); final uncataloguedEntries = rawEntries.where((entry) => !entry.isCatalogued).toList(); if (uncataloguedEntries.isEmpty) return; @@ -29,7 +31,7 @@ mixin TagMixin on SourceBase { await entry.catalog(); if (entry.isCatalogued) { newMetadata.add(entry.catalogMetadata); - if (newMetadata.length >= 500) { + if (newMetadata.length >= _commitCountThreshold) { await metadataDb.saveMetadata(List.unmodifiable(newMetadata)); newMetadata.clear(); } @@ -37,7 +39,7 @@ mixin TagMixin on SourceBase { }); await metadataDb.saveMetadata(List.unmodifiable(newMetadata)); onCatalogMetadataChanged(); - debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s with ${newMetadata.length} new entries'); +// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s'); } void onCatalogMetadataChanged() { diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 3b94c201e..d5045fba9 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -16,15 +16,30 @@ class ImageFileService { static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static const double thumbnailDefaultSize = 64.0; - static Stream getImageEntries() { + // knownEntries: map of contentId -> dateModifiedSecs + static Stream getImageEntries(Map knownEntries) { try { - return mediaStoreChannel.receiveBroadcastStream().map((event) => ImageEntry.fromMap(event)); + return mediaStoreChannel.receiveBroadcastStream({ + 'knownEntries': knownEntries, + }).map((event) => ImageEntry.fromMap(event)); } on PlatformException catch (e) { debugPrint('getImageEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); return Stream.error(e); } } + static Future getObsoleteEntries(List knownContentIds) async { + try { + final result = await platform.invokeMethod('getObsoleteEntries', { + 'knownContentIds': knownContentIds, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getObsoleteEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } + static Future getImageEntry(String uri, String mimeType) async { debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType'); try { diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/album/grid/list_section_layout.dart index 93f99fbb8..f9eec6a7a 100644 --- a/lib/widgets/album/grid/list_section_layout.dart +++ b/lib/widgets/album/grid/list_section_layout.dart @@ -34,7 +34,7 @@ class SectionedListLayoutProvider extends StatelessWidget { } SectionedListLayout _updateLayouts(BuildContext context) { - debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent'); +// debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent'); final sectionLayouts = []; final showHeaders = collection.showHeaders; final source = collection.source; diff --git a/lib/widgets/common/data_providers/media_store_collection_provider.dart b/lib/widgets/common/data_providers/media_store_collection_provider.dart index e32bf0a1d..7ce1a5520 100644 --- a/lib/widgets/common/data_providers/media_store_collection_provider.dart +++ b/lib/widgets/common/data_providers/media_store_collection_provider.dart @@ -13,9 +13,9 @@ class MediaStoreSource extends CollectionSource { Future init() async { final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; - await metadataDb.init(); // <20ms + await metadataDb.init(); await favourites.init(); - final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone(); // <20ms + final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone(); final catalogTimeZone = settings.catalogTimeZone; if (currentTimeZone != catalogTimeZone) { // clear catalog metadata to get correct date/times when moving to a different time zone @@ -29,35 +29,50 @@ class MediaStoreSource extends CollectionSource { } Future refresh() async { + debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; + final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries + final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); + final obsoleteEntries = await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList()); + oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId)); + + // show known entries + addAll(oldEntries); + await loadCatalogMetadata(); // 600ms for 5500 entries + await loadAddresses(); // 200ms for 3000 entries + debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); + + // clean up obsolete entries + metadataDb.removeIds(obsoleteEntries); + + // fetch new entries var refreshCount = 10; const refreshCountMax = 1000; - final allEntries = []; - - // TODO split image fetch AND/OR cache fetch across sessions - ImageFileService.getImageEntries().listen( + final allNewEntries = [], pendingNewEntries = []; + final addPendingEntries = () { + allNewEntries.addAll(pendingNewEntries); + addAll(pendingNewEntries); + pendingNewEntries.clear(); + }; + ImageFileService.getImageEntries(knownEntryMap).listen( (entry) { - allEntries.add(entry); - if (allEntries.length >= refreshCount) { + pendingNewEntries.add(entry); + if (pendingNewEntries.length >= refreshCount) { refreshCount = min(refreshCount * 10, refreshCountMax); - addAll(allEntries); - allEntries.clear(); -// debugPrint('$runtimeType streamed ${entries.length} entries at ${stopwatch.elapsed.inMilliseconds}ms'); + addPendingEntries(); } }, onDone: () async { - debugPrint('$runtimeType stream done, elapsed=${stopwatch.elapsed}'); - addAll(allEntries); - // TODO reduce setup time until here - updateAlbums(); // <50ms + addPendingEntries(); + debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}'); + await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries + updateAlbums(); stateNotifier.value = SourceState.cataloguing; - await loadCatalogMetadata(); // 400ms for 5400 entries - await catalogEntries(); // <50ms + await catalogEntries(); stateNotifier.value = SourceState.locating; - await loadAddresses(); // 350ms - await locateEntries(); // <50ms + await locateEntries(); stateNotifier.value = SourceState.ready; debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}'); }, diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index f9f441739..d20a06fda 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -1,11 +1,11 @@ import 'dart:collection'; -import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/image_file_service.dart'; @@ -30,6 +30,7 @@ class DebugPage extends StatefulWidget { class DebugPageState extends State { Future _dbFileSizeLoader; + Future> _dbEntryLoader; Future> _dbDateLoader; Future> _dbMetadataLoader; Future> _dbAddressLoader; @@ -169,9 +170,28 @@ class DebugPageState extends State { ); }, ), + FutureBuilder( + future: _dbEntryLoader, + builder: (context, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + return Row( + children: [ + Expanded( + child: Text('DB entry rows: ${snapshot.data.length}'), + ), + const SizedBox(width: 8), + RaisedButton( + onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()), + child: const Text('Clear'), + ), + ], + ); + }, + ), FutureBuilder( future: _dbDateLoader, - builder: (context, AsyncSnapshot> snapshot) { + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -190,7 +210,7 @@ class DebugPageState extends State { ), FutureBuilder( future: _dbMetadataLoader, - builder: (context, AsyncSnapshot> snapshot) { + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -209,7 +229,7 @@ class DebugPageState extends State { ), FutureBuilder( future: _dbAddressLoader, - builder: (context, AsyncSnapshot> snapshot) { + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -228,7 +248,7 @@ class DebugPageState extends State { ), FutureBuilder( future: _dbFavouritesLoader, - builder: (context, AsyncSnapshot> snapshot) { + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -327,6 +347,7 @@ class DebugPageState extends State { void _startDbReport() { _dbFileSizeLoader = metadataDb.dbFileSize(); + _dbEntryLoader = metadataDb.loadEntries(); _dbDateLoader = metadataDb.loadDates(); _dbMetadataLoader = metadataDb.loadMetadataEntries(); _dbAddressLoader = metadataDb.loadAddresses(); diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index e1a8470d3..0bbeaa942 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'dart:math'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/album/collection_page.dart'; @@ -451,6 +451,7 @@ class _FullscreenVerticalPageViewState extends State void _unregisterWidget(FullscreenVerticalPageView widget) { widget.verticalPager.removeListener(_onVerticalPageControllerChanged); widget.entryNotifier.removeListener(_onEntryChanged); + _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); } @override @@ -517,7 +518,12 @@ class _FullscreenVerticalPageViewState extends State // when the entry image itself changed (e.g. after rotation) void _onImageChanged() async { await UriImage(uri: entry.uri, mimeType: entry.mimeType).evict(); - // TODO TLAD also evict `ThumbnailProvider` with specified extents + // evict low quality thumbnail (without specified extents) + await ThumbnailProvider(entry: entry).evict(); + // evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents) + final extents = List.generate(6, (index) => pow(2, index + 5).toDouble()); + await Future.forEach(extents, (extent) => ThumbnailProvider(entry: entry, extent: extent).evict()); + await ThumbnailProvider(entry: entry).evict(); if (entry.path != null) await FileImage(File(entry.path)).evict(); // rebuild to refresh the Image inside ImagePage diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index bd513f6f0..985a43454 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -36,20 +36,16 @@ class _HomePageState extends State { } Future _setup() async { - debugPrint('$runtimeType _setup'); - - // TODO reduce permission check time final permissions = await [ Permission.storage, // to access media with unredacted metadata with scoped storage (Android 10+) Permission.accessMediaLocation, - ].request(); // 350ms + ].request(); if (permissions[Permission.storage] != PermissionStatus.granted) { unawaited(SystemNavigator.pop()); return; } - // TODO notify when icons are ready for drawer and section header refresh await androidFileUtils.init(); // 170ms final intentData = await ViewerService.getIntentData(); @@ -97,7 +93,6 @@ class _HomePageState extends State { builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return const Icon(AIcons.error); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); - debugPrint('$runtimeType app setup future complete'); if (AvesApp.mode == AppMode.view) { return SingleFullscreenPage(entry: _viewerEntry); }