From c7fcb5bc53471973b25224662520e64e8f94b4b6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 26 Jan 2021 18:31:42 +0900 Subject: [PATCH] #39 listen to media store changes --- .../deckers/thibault/aves/MainActivity.kt | 140 ++++++++++-------- .../channel/calls/fetchers/RegionFetcher.kt | 5 - .../streams/ContentChangeStreamHandler.kt | 61 ++++++++ .../channel/streams/IntentStreamHandler.kt | 4 + lib/main.dart | 91 +++++++----- lib/model/entry.dart | 28 ++-- lib/model/source/album.dart | 2 +- lib/model/source/collection_lens.dart | 23 ++- lib/model/source/collection_source.dart | 1 + lib/model/source/media_store_source.dart | 48 +++++- lib/theme/durations.dart | 1 + lib/widgets/collection/grid/thumbnail.dart | 8 +- lib/widgets/viewer/debug_page.dart | 2 +- lib/widgets/viewer/entry_action_delegate.dart | 15 +- lib/widgets/viewer/entry_viewer_page.dart | 15 +- lib/widgets/viewer/entry_viewer_stack.dart | 26 +++- lib/widgets/viewer/info/notifications.dart | 6 + lib/widgets/viewer/printer.dart | 3 +- 18 files changed, 318 insertions(+), 161 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 6ee05d8d1..a6742b22c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -16,24 +16,18 @@ import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { - companion object { - private val LOG_TAG = LogUtils.createTag(MainActivity::class.java) - const val INTENT_CHANNEL = "deckers.thibault/aves/intent" - const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" - } - - private val intentStreamHandler = IntentStreamHandler() + private lateinit var contentStreamHandler: ContentChangeStreamHandler + private lateinit var intentStreamHandler: IntentStreamHandler private lateinit var intentDataMap: MutableMap override fun onCreate(savedInstanceState: Bundle?) { Log.i(LOG_TAG, "onCreate intent=$intent") super.onCreate(savedInstanceState) - intentDataMap = extractIntentData(intent) - val messenger = flutterEngine!!.dartExecutor.binaryMessenger MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) @@ -48,59 +42,34 @@ class MainActivity : FlutterActivity() { StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) } + // Media Store change monitoring + contentStreamHandler = ContentChangeStreamHandler(this).apply { + EventChannel(messenger, ContentChangeStreamHandler.CHANNEL).setStreamHandler(this) + } + + // intent handling + intentStreamHandler = IntentStreamHandler().apply { + EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this) + } + intentDataMap = extractIntentData(intent) MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "getIntentData" -> { result.success(intentDataMap) intentDataMap.clear() } - "pick" -> { - val pickedUri = call.argument("uri") - if (pickedUri != null) { - val intent = Intent().apply { - data = Uri.parse(pickedUri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - setResult(RESULT_OK, intent) - } else { - setResult(RESULT_CANCELED) - } - finish() - } - + "pick" -> pick(call) } } - EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { setupShortcuts() } } - @RequiresApi(Build.VERSION_CODES.N_MR1) - private fun setupShortcuts() { - // do not use 'route' as extra key, as the Flutter framework acts on it - - val search = ShortcutInfoCompat.Builder(this, "search") - .setShortLabel(getString(R.string.search_shortcut_short_label)) - .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search)) - .setIntent( - Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) - .putExtra("page", "/search") - ) - .build() - - val videos = ShortcutInfoCompat.Builder(this, "videos") - .setShortLabel(getString(R.string.videos_shortcut_short_label)) - .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie)) - .setIntent( - Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) - .putExtra("page", "/collection") - .putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}")) - ) - .build() - - ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) + override fun onDestroy() { + contentStreamHandler.dispose() + super.onDestroy() } override fun onNewIntent(intent: Intent) { @@ -109,6 +78,25 @@ class MainActivity : FlutterActivity() { intentStreamHandler.notifyNewIntent(extractIntentData(intent)) } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { + val treeUri = data?.data + if (resultCode != RESULT_OK || treeUri == null) { + PermissionManager.onPermissionResult(requestCode, null) + return + } + + // save access permissions across reboots + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + contentResolver.takePersistableUriPermission(treeUri, takeFlags) + + // resume pending action + PermissionManager.onPermissionResult(requestCode, treeUri) + } + } + private fun extractIntentData(intent: Intent?): MutableMap { when (intent?.action) { Intent.ACTION_MAIN -> { @@ -138,22 +126,48 @@ class MainActivity : FlutterActivity() { return HashMap() } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { - val treeUri = data?.data - if (resultCode != RESULT_OK || treeUri == null) { - PermissionManager.onPermissionResult(requestCode, null) - return + private fun pick(call: MethodCall) { + val pickedUri = call.argument("uri") + if (pickedUri != null) { + val intent = Intent().apply { + data = Uri.parse(pickedUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - - // save access permissions across reboots - val takeFlags = (data.flags - and (Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) - contentResolver.takePersistableUriPermission(treeUri, takeFlags) - - // resume pending action - PermissionManager.onPermissionResult(requestCode, treeUri) + setResult(RESULT_OK, intent) + } else { + setResult(RESULT_CANCELED) } + finish() + } + + @RequiresApi(Build.VERSION_CODES.N_MR1) + private fun setupShortcuts() { + // do not use 'route' as extra key, as the Flutter framework acts on it + + val search = ShortcutInfoCompat.Builder(this, "search") + .setShortLabel(getString(R.string.search_shortcut_short_label)) + .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search)) + .setIntent( + Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) + .putExtra("page", "/search") + ) + .build() + + val videos = ShortcutInfoCompat.Builder(this, "videos") + .setShortLabel(getString(R.string.videos_shortcut_short_label)) + .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie)) + .setIntent( + Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) + .putExtra("page", "/collection") + .putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}")) + ) + .build() + + ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) + } + + companion object { + private val LOG_TAG = LogUtils.createTag(MainActivity::class.java) + const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index e432fac28..9b168e565 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -111,11 +111,6 @@ class RegionFetcher internal constructor( .submit() try { val bitmap = target.get() -// if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { -// bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) -// } - bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId") - val tempFile = File.createTempFile("aves", null, context.cacheDir).apply { deleteOnExit() outputStream().use { outputStream -> diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt new file mode 100644 index 000000000..8142c1bb9 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt @@ -0,0 +1,61 @@ +package deckers.thibault.aves.channel.streams + +import android.content.Context +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.util.Log +import deckers.thibault.aves.utils.LogUtils +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class ContentChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler { + private val contentObserver = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + this.onChange(selfChange, null) + } + + override fun onChange(selfChange: Boolean, uri: Uri?) { + // warning: querying the content resolver right after a change + // sometimes yields obsolete results + success(uri?.toString()) + } + } + private lateinit var eventSink: EventSink + private lateinit var handler: Handler + + init { + context.contentResolver.apply { + registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver) + registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver) + } + } + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + handler = Handler(Looper.getMainLooper()) + } + + override fun onCancel(arguments: Any?) {} + + fun dispose() { + context.contentResolver.unregisterContentObserver(contentObserver) + } + + private fun success(uri: String?) { + handler.post { + try { + eventSink.success(uri) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag(ContentChangeStreamHandler::class.java) + const val CHANNEL = "deckers.thibault/aves/contentchange" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt index abd594c58..c5861f208 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt @@ -18,4 +18,8 @@ class IntentStreamHandler : EventChannel.StreamHandler { fun notifyNewIntent(intentData: MutableMap?) { eventSink?.success(intentData) } + + companion object { + const val CHANNEL = "deckers.thibault/aves/intent" + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 32927e63b..02cb28210 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,9 @@ import 'dart:ui'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/home_page.dart'; @@ -45,10 +47,14 @@ class AvesApp extends StatefulWidget { class _AvesAppState extends State { Future _appSetup; + final _mediaStoreSource = MediaStoreSource(); + final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay); + final List changedUris = []; // observers are not registered when using the same list object with different items // the list itself needs to be reassigned List _navigatorObservers = []; + final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange'); final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent'); final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); @@ -96,53 +102,18 @@ class _AvesAppState extends State { void initState() { super.initState(); _appSetup = _setup(); + _contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map)); } - Future _setup() async { - await Firebase.initializeApp().then((app) { - final crashlytics = FirebaseCrashlytics.instance; - FlutterError.onError = crashlytics.recordFlutterError; - crashlytics.setCustomKey('locales', window.locales.join(', ')); - final now = DateTime.now(); - crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})'); - crashlytics.setCustomKey( - 'build_mode', - kReleaseMode - ? 'release' - : kProfileMode - ? 'profile' - : 'debug'); - }); - await settings.init(); - await settings.initFirebase(); - _navigatorObservers = [ - FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()), - CrashlyticsRouteTracker(), - ]; - } - - void _onNewIntent(Map intentData) { - debugPrint('$runtimeType onNewIntent with intentData=$intentData'); - - // do not reset when relaunching the app - if (AvesApp.mode == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; - - FirebaseCrashlytics.instance.log('New intent'); - _navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute( - settings: RouteSettings(name: HomePage.routeName), - builder: (_) => getFirstPage(intentData: intentData), - )); - } - @override Widget build(BuildContext context) { // place the settings provider above `MaterialApp` // so it can be used during navigation transitions return ChangeNotifierProvider.value( value: settings, - child: Provider( - create: (context) => MediaStoreSource(), + child: Provider.value( + value: _mediaStoreSource, child: OverlaySupport( child: FutureBuilder( future: _appSetup, @@ -181,4 +152,48 @@ class _AvesAppState extends State { ), ); } + + Future _setup() async { + await Firebase.initializeApp().then((app) { + final crashlytics = FirebaseCrashlytics.instance; + FlutterError.onError = crashlytics.recordFlutterError; + crashlytics.setCustomKey('locales', window.locales.join(', ')); + final now = DateTime.now(); + crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})'); + crashlytics.setCustomKey( + 'build_mode', + kReleaseMode + ? 'release' + : kProfileMode + ? 'profile' + : 'debug'); + }); + await settings.init(); + await settings.initFirebase(); + _navigatorObservers = [ + FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()), + CrashlyticsRouteTracker(), + ]; + } + + void _onNewIntent(Map intentData) { + debugPrint('$runtimeType onNewIntent with intentData=$intentData'); + + // do not reset when relaunching the app + if (AvesApp.mode == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; + + FirebaseCrashlytics.instance.log('New intent'); + _navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute( + settings: RouteSettings(name: HomePage.routeName), + builder: (_) => getFirstPage(intentData: intentData), + )); + } + + void _onContentChange(String uri) { + changedUris.add(uri); + _contentChangeDebouncer(() { + _mediaStoreSource.refreshUris(List.of(changedUris)); + changedUris.clear(); + }); + } } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index d2244db43..384b29a4b 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -268,12 +268,13 @@ class AvesEntry { } } - // The additional comparison of width to height is a workaround for badly registered entries. - // e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90, - // but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0 - // Double-checking the width/height during loading or cataloguing is the proper solution, - // but it would take space and time, so a basic workaround will do. - bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); + // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, + // so it should be registered as width=1920, height=1080, orientation=90, + // but is incorrectly registered as width=1080, height=1920, orientation=0. + // Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time. + // Comparing width and height can help with the portrait FHD video example, + // but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90 + bool get isRotated => rotationDegrees % 180 == 90; static const ratioSeparator = '\u2236'; static const resolutionSeparator = ' \u00D7 '; @@ -281,7 +282,7 @@ class AvesEntry { String get resolutionText { final ws = width ?? '?'; final hs = height ?? '?'; - return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; + return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; } String get aspectRatioText { @@ -289,7 +290,7 @@ class AvesEntry { final gcd = width.gcd(height); final w = width ~/ gcd; final h = height ~/ gcd; - return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; + return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; } else { return '?$ratioSeparator?'; } @@ -297,13 +298,13 @@ class AvesEntry { double get displayAspectRatio { if (width == 0 || height == 0) return 1; - return isPortrait ? height / width : width / height; + return isRotated ? height / width : width / height; } Size get displaySize { final w = width.toDouble(); final h = height.toDouble(); - return isPortrait ? Size(h, w) : Size(w, h); + return isRotated ? Size(h, w) : Size(w, h); } int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; @@ -636,7 +637,10 @@ class AvesEntry { // 1) date descending // 2) name descending static int compareByDate(AvesEntry a, AvesEntry b) { - final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch); - return c != 0 ? c : -compareByName(a, b); + var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch); + if (c != 0) return c; + c = (b.dateModifiedSecs ?? 0).compareTo(a.dateModifiedSecs ?? 0); + if (c != 0) return c; + return -compareByName(a, b); } } diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index b50aa3777..44b2789c1 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -53,7 +53,7 @@ mixin AlbumMixin on SourceBase { Map getAlbumEntries() { final entries = sortedEntriesForFilterList; final regularAlbums = [], appAlbums = [], specialAlbums = []; - for (var album in sortedAlbums) { + for (final album in sortedAlbums) { switch (androidFileUtils.getAlbumType(album)) { case AlbumType.regular: regularAlbums.add(album); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index de6e12bf7..a151de471 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -19,6 +19,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel EntryGroupFactor groupFactor; EntrySortFactor sortFactor; final AChangeNotifier filterChangeNotifier = AChangeNotifier(); + bool listenToSource; List _filteredEntries; List _subscriptions = []; @@ -30,13 +31,16 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel Iterable filters, @required EntryGroupFactor groupFactor, @required EntrySortFactor sortFactor, + this.listenToSource = true, }) : filters = {if (filters != null) ...filters.where((f) => f != null)}, groupFactor = groupFactor ?? EntryGroupFactor.month, sortFactor = sortFactor ?? EntrySortFactor.date { - _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); - _subscriptions.add(source.eventBus.on().listen((e) => onEntryRemoved(e.entries))); - _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); - _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + if (listenToSource) { + _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + _subscriptions.add(source.eventBus.on().listen((e) => onEntryRemoved(e.entries))); + _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + } _refresh(); } @@ -49,15 +53,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel super.dispose(); } - CollectionLens derive(CollectionFilter filter) { - return CollectionLens( - source: source, - filters: filters, - groupFactor: groupFactor, - sortFactor: sortFactor, - )..addFilter(filter); - } - bool get isEmpty => _filteredEntries.isEmpty; int get entryCount => _filteredEntries.length; @@ -82,7 +77,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel return true; } - Object heroTag(AvesEntry entry) => '$hashCode${entry.uri}'; + Object heroTag(AvesEntry entry) => entry.uri; void addFilter(CollectionFilter filter) { if (filter == null || filters.contains(filter)) return; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 37140c22a..137ca9786 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -56,6 +56,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } void addAll(Iterable entries) { + if (entries.isEmpty) return; if (_rawEntries.isNotEmpty) { final newContentIds = entries.map((entry) => entry.contentId).toList(); _rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index f21ab63ab..a52e78b80 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.dart'; @@ -40,6 +41,7 @@ class MediaStoreSource extends CollectionSource { @override Future refresh() async { + assert(_initialized); debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; @@ -47,8 +49,8 @@ class MediaStoreSource extends CollectionSource { 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())).toSet(); - oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId)); + final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet(); + oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); // show known entries addAll(oldEntries); @@ -57,9 +59,10 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); // clean up obsolete entries - metadataDb.removeIds(obsoleteEntries, updateFavourites: true); + metadataDb.removeIds(obsoleteContentIds, updateFavourites: true); // fetch new entries + // refresh after the first 10 entries, then after 100 more, then every 1000 entries var refreshCount = 10; const refreshCountMax = 1000; final allNewEntries = [], pendingNewEntries = []; @@ -102,6 +105,45 @@ class MediaStoreSource extends CollectionSource { ); } + Future refreshUris(List changedUris) async { + assert(_initialized); + debugPrint('$runtimeType refreshUris uris=$changedUris'); + + final uriByContentId = Map.fromEntries(changedUris.map((uri) { + if (uri == null) return null; + final idString = Uri.parse(uri).pathSegments.last; + return MapEntry(int.tryParse(idString), uri); + }).where((kv) => kv != null)); + + // clean up obsolete entries + final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet(); + uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId)); + metadataDb.removeIds(obsoleteContentIds, updateFavourites: true); + + // add new entries + final newEntries = []; + for (final kv in uriByContentId.entries) { + final contentId = kv.key; + final uri = kv.value; + final sourceEntry = await ImageFileService.getEntry(uri, null); + final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) { + newEntries.add(sourceEntry); + } + } + addAll(newEntries); + await metadataDb.saveEntries(newEntries); + updateAlbums(); + + stateNotifier.value = SourceState.cataloguing; + await catalogEntries(); + + stateNotifier.value = SourceState.locating; + await locateEntries(); + + stateNotifier.value = SourceState.ready; + } + @override Future refreshMetadata(Set entries) { final contentIds = entries.map((entry) => entry.contentId).toSet(); diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 59a0f2a64..51fa3a18f 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -48,4 +48,5 @@ class Durations { static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const searchDebounceDelay = Duration(milliseconds: 250); + static const contentChangeDebounceDelay = Duration(milliseconds: 1000); } diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 23d31c827..3eabb767c 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -55,7 +55,13 @@ class InteractiveThumbnail extends StatelessWidget { TransparentMaterialPageRoute( settings: RouteSettings(name: EntryViewerPage.routeName), pageBuilder: (c, a, sa) => EntryViewerPage( - collection: collection, + collection: CollectionLens( + source: collection.source, + filters: collection.filters, + groupFactor: collection.groupFactor, + sortFactor: collection.sortFactor, + listenToSource: false, + ), initialEntry: entry, ), ), diff --git a/lib/widgets/viewer/debug_page.dart b/lib/widgets/viewer/debug_page.dart index d26324622..bd1f7ce21 100644 --- a/lib/widgets/viewer/debug_page.dart +++ b/lib/widgets/viewer/debug_page.dart @@ -77,8 +77,8 @@ class ViewerDebugPage extends StatelessWidget { 'height': '${entry.height}', 'sourceRotationDegrees': '${entry.sourceRotationDegrees}', 'rotationDegrees': '${entry.rotationDegrees}', + 'isRotated': '${entry.isRotated}', 'isFlipped': '${entry.isFlipped}', - 'portrait': '${entry.isPortrait}', 'displayAspectRatio': '${entry.displayAspectRatio}', 'displaySize': '${entry.displaySize}', }), diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index d81211a9e..d201c698f 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -16,11 +16,11 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/debug_page.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/printer.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:pedantic/pedantic.dart'; import 'package:provider/provider.dart'; @@ -139,15 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await entry.delete()) { showFeedback(context, 'Failed'); - } else if (hasCollection) { - // update collection - collection.source.removeEntries([entry]); - if (collection.sortedEntries.isEmpty) { - Navigator.pop(context); - } } else { - // leave viewer - unawaited(SystemNavigator.pop()); + if (hasCollection) { + collection.source.removeEntries([entry]); + } + EntryDeletedNotification(entry).dispatch(context); } } @@ -199,7 +195,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } else { showFeedback(context, 'Done!'); } - source.refresh(); }, ); } diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index 8bb27913c..f285d8292 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -20,17 +20,10 @@ class EntryViewerPage extends StatelessWidget { Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( - body: collection != null - ? AnimatedBuilder( - animation: collection, - builder: (context, child) => EntryViewerStack( - collection: collection, - initialEntry: initialEntry, - ), - ) - : EntryViewerStack( - initialEntry: initialEntry, - ), + body: EntryViewerStack( + collection: collection, + initialEntry: initialEntry, + ), backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black, resizeToAvoidBottomInset: false, ), diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index b9867800f..7ede8f873 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -164,6 +164,8 @@ class _EntryViewerStackState extends State with SingleTickerPr _goToCollection(notification.filter); } else if (notification is ViewStateNotification) { _updateViewState(notification.uri, notification.viewState); + } else if (notification is EntryDeletedNotification) { + _onEntryDeleted(context, notification.entry); } return false; }, @@ -324,7 +326,14 @@ class _EntryViewerStackState extends State with SingleTickerPr context, MaterialPageRoute( settings: RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage(collection.derive(filter)), + builder: (context) => CollectionPage( + CollectionLens( + source: collection.source, + filters: collection.filters, + groupFactor: collection.groupFactor, + sortFactor: collection.sortFactor, + )..addFilter(filter), + ), ), (route) => false, ); @@ -356,6 +365,21 @@ class _EntryViewerStackState extends State with SingleTickerPr _updateEntry(); } + void _onEntryDeleted(BuildContext context, AvesEntry entry) { + if (hasCollection) { + final entries = collection.sortedEntries; + entries.remove(entry); + if (entries.isEmpty) { + Navigator.pop(context); + } else { + _onCollectionChange(); + } + } else { + // leave viewer + SystemNavigator.pop(); + } + } + void _updateEntry() { if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) { // as of Flutter v1.22.2, `PageView` does not call `onPageChanged` when the last page is deleted diff --git a/lib/widgets/viewer/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart index 32afe7ae9..ed2da66ce 100644 --- a/lib/widgets/viewer/info/notifications.dart +++ b/lib/widgets/viewer/info/notifications.dart @@ -11,6 +11,12 @@ class FilterNotification extends Notification { const FilterNotification(this.filter); } +class EntryDeletedNotification extends Notification { + final AvesEntry entry; + + const EntryDeletedNotification(this.entry); +} + class OpenTempEntryNotification extends Notification { final AvesEntry entry; diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index bab2dc5a7..d774f5d38 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -32,8 +32,9 @@ class EntryPrinter { void _addPdfPage(pdf.Widget pdfChild) { if (pdfChild == null) return; + final displaySize = entry.displaySize; pages.add(pdf.Page( - orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape, + orientation: displaySize.height > displaySize.width ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape, build: (context) => pdf.FullPage( ignoreMargins: true, child: pdf.Center(