From f287dd4c045dbc62e1c61c734009fc0c7bb11fe5 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 24 Feb 2024 01:01:10 +0100 Subject: [PATCH] #900 check media store changes on app resume --- .../aves/channel/calls/MediaStoreHandler.kt | 27 +++++++- .../streams/MediaStoreStreamHandler.kt | 5 +- .../model/provider/MediaStoreImageProvider.kt | 67 ++++++++++++++----- lib/model/source/media_store_source.dart | 34 ++++++++++ lib/services/media/media_store_service.dart | 27 ++++++++ lib/widgets/aves_app.dart | 24 +------ 6 files changed, 140 insertions(+), 44 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt index 34db16413..cc787ef7d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt @@ -3,6 +3,8 @@ package deckers.thibault.aves.channel.calls import android.content.Context import android.media.MediaScannerConnection import android.net.Uri +import android.os.Build +import android.provider.MediaStore import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.model.provider.MediaStoreImageProvider import io.flutter.plugin.common.MethodCall @@ -20,13 +22,15 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler { when (call.method) { "checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) } "checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) } + "getChangedUris" -> ioScope.launch { safe(call, result, ::getChangedUris) } + "getGeneration" -> ioScope.launch { safe(call, result, ::getGeneration) } "scanFile" -> ioScope.launch { safe(call, result, ::scanFile) } else -> result.notImplemented() } } private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) { - val knownContentIds = call.argument>("knownContentIds") + val knownContentIds = call.argument>("knownContentIds")?.map { it?.toLong() } if (knownContentIds == null) { result.error("checkObsoleteContentIds-args", "missing arguments", null) return @@ -35,7 +39,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler { } private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { - val knownPathById = call.argument>("knownPathById") + val knownPathById = call.argument>("knownPathById")?.mapKeys { it.key?.toLong() } if (knownPathById == null) { result.error("checkObsoletePaths-args", "missing arguments", null) return @@ -43,6 +47,25 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler { result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById)) } + private fun getChangedUris(call: MethodCall, result: MethodChannel.Result) { + val sinceGeneration = call.argument("sinceGeneration") + if (sinceGeneration == null) { + result.error("getChangedUris-args", "missing arguments", null) + return + } + val uris = MediaStoreImageProvider().getChangedUris(context, sinceGeneration) + result.success(uris) + } + + private fun getGeneration(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + val generation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + MediaStore.getGeneration(context, MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + null + } + result.success(generation) + } + private fun scanFile(call: MethodCall, result: MethodChannel.Result) { val path = call.argument("path") val mimeType = call.argument("mimeType") 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 54b79630b..d87f6076f 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 @@ -19,13 +19,12 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E private lateinit var eventSink: EventSink private lateinit var handler: Handler - private var knownEntries: Map? = null + private var knownEntries: Map? = null private var directory: String? = null init { if (arguments is Map<*, *>) { - @Suppress("unchecked_cast") - knownEntries = arguments["knownEntries"] as Map? + knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap() directory = arguments["directory"] as String? } } 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 4754a6e26..87c166c28 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 @@ -3,7 +3,11 @@ package deckers.thibault.aves.model.provider import android.annotation.SuppressLint import android.app.Activity import android.app.RecoverableSecurityException -import android.content.* +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper import android.graphics.BitmapFactory import android.media.MediaScannerConnection import android.net.Uri @@ -35,7 +39,7 @@ import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.io.SyncFailedException -import java.util.* +import java.util.Locale import java.util.concurrent.CompletableFuture import kotlin.coroutines.Continuation import kotlin.coroutines.resume @@ -45,11 +49,11 @@ import kotlin.coroutines.suspendCoroutine class MediaStoreImageProvider : ImageProvider() { fun fetchAll( context: Context, - knownEntries: Map, + knownEntries: Map, directory: String?, handleNewEntry: NewEntryHandler, ) { - val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean { + val isModified = fun(contentId: Long, dateModifiedSecs: Int): Boolean { val knownDate = knownEntries[contentId] return knownDate == null || knownDate < dateModifiedSecs } @@ -89,7 +93,7 @@ class MediaStoreImageProvider : ImageProvider() { var found = false val fetched = arrayListOf() val id = uri.tryParseId() - val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true + val alwaysValid: NewEntryChecker = fun(_: Long, _: Int): Boolean = true val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) } if (id != null) { if (sourceMimeType == null || isImage(sourceMimeType)) { @@ -119,8 +123,8 @@ class MediaStoreImageProvider : ImageProvider() { } } - fun checkObsoleteContentIds(context: Context, knownContentIds: List): List { - val foundContentIds = HashSet() + fun checkObsoleteContentIds(context: Context, knownContentIds: List): List { + val foundContentIds = HashSet() fun check(context: Context, contentUri: Uri) { val projection = arrayOf(MediaStore.MediaColumns._ID) try { @@ -128,7 +132,7 @@ class MediaStoreImageProvider : ImageProvider() { if (cursor != null) { val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) while (cursor.moveToNext()) { - foundContentIds.add(cursor.getInt(idColumn)) + foundContentIds.add(cursor.getLong(idColumn)) } cursor.close() } @@ -141,8 +145,8 @@ class MediaStoreImageProvider : ImageProvider() { return knownContentIds.subtract(foundContentIds).filterNotNull().toList() } - fun checkObsoletePaths(context: Context, knownPathById: Map): List { - val obsoleteIds = ArrayList() + fun checkObsoletePaths(context: Context, knownPathById: Map): List { + val obsoleteIds = ArrayList() fun check(context: Context, contentUri: Uri) { val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA) try { @@ -151,7 +155,7 @@ class MediaStoreImageProvider : ImageProvider() { val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) while (cursor.moveToNext()) { - val id = cursor.getInt(idColumn) + val id = cursor.getLong(idColumn) val path = cursor.getString(pathColumn) if (knownPathById.containsKey(id) && knownPathById[id] != path) { obsoleteIds.add(id) @@ -168,6 +172,31 @@ class MediaStoreImageProvider : ImageProvider() { return obsoleteIds } + fun getChangedUris(context: Context, sinceGeneration: Int): List { + val changedUris = ArrayList() + fun check(context: Context, contentUri: Uri) { + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = "${MediaStore.MediaColumns.GENERATION_MODIFIED} > ?" + val selectionArgs = arrayOf(sinceGeneration.toString()) + try { + val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null) + if (cursor != null) { + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + changedUris.add(ContentUris.withAppendedId(contentUri, id).toString()) + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e) + } + } + check(context, IMAGE_CONTENT_URI) + check(context, VIDEO_CONTENT_URI) + return changedUris + } + private fun fetchFrom( context: Context, isValidEntry: NewEntryChecker, @@ -207,12 +236,12 @@ class MediaStoreImageProvider : ImageProvider() { val needDuration = projection.contentEquals(VIDEO_PROJECTION) while (cursor.moveToNext()) { - val contentId = cursor.getInt(idColumn) + val id = cursor.getLong(idColumn) val dateModifiedSecs = cursor.getInt(dateModifiedColumn) - if (isValidEntry(contentId, dateModifiedSecs)) { + if (isValidEntry(id, dateModifiedSecs)) { // for multiple items, `contentUri` is the root without ID, // but for single items, `contentUri` already contains the ID - val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, contentId.toLong()) + val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, id) // `mimeType` can be registered as null for file media URIs with unsupported media types (e.g. TIFF on old devices) // in that case we try to use the MIME type provided along the URI val mimeType: String? = cursor.getString(mimeTypeColumn) ?: fileMimeType @@ -237,7 +266,7 @@ class MediaStoreImageProvider : ImageProvider() { "sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, "durationMillis" to durationMillis, // only for map export - "contentId" to contentId, + "contentId" to id, ) if (MimeTypes.isHeic(mimeType)) { @@ -930,8 +959,10 @@ class MediaStoreImageProvider : ImageProvider() { try { val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null) if (cursor != null && cursor.moveToFirst()) { - cursor.getColumnIndex(MediaStore.MediaColumns._ID).let { - if (it != -1) mediaContentUri = ContentUris.withAppendedId(contentUri, cursor.getLong(it)) + val idColumn = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idColumn != -1) { + val id = cursor.getLong(idColumn) + mediaContentUri = ContentUris.withAppendedId(contentUri, id) } cursor.close() } @@ -994,4 +1025,4 @@ object MediaColumns { typealias NewEntryHandler = (entry: FieldMap) -> Unit -private typealias NewEntryChecker = (contentId: Int, dateModifiedSecs: Int) -> Boolean \ No newline at end of file +private typealias NewEntryChecker = (contentId: Long, dateModifiedSecs: Int) -> Boolean \ No newline at end of file diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index d118dd101..1b1e4c318 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -11,12 +11,17 @@ import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; class MediaStoreSource extends CollectionSource { + final Debouncer _changeDebouncer = Debouncer(delay: ADurations.mediaContentChangeDebounceDelay); + final Set _changedUris = {}; + int? _lastGeneration; SourceInitializationState _initState = SourceInitializationState.none; @override @@ -36,6 +41,7 @@ class MediaStoreSource extends CollectionSource { _initState = directory != null ? SourceInitializationState.directory : SourceInitializationState.full; } addDirectories(albums: settings.pinnedFilters.whereType().map((v) => v.album).toSet()); + await updateGeneration(); unawaited(_loadEntries( analysisController: analysisController, directory: directory, @@ -305,6 +311,34 @@ class MediaStoreSource extends CollectionSource { return tempUris; } + void onStoreChanged(String? uri) { + if (uri != null) _changedUris.add(uri); + if (_changedUris.isNotEmpty) { + _changeDebouncer(() async { + final todo = _changedUris.toSet(); + _changedUris.clear(); + final tempUris = await refreshUris(todo); + if (tempUris.isNotEmpty) { + _changedUris.addAll(tempUris); + onStoreChanged(null); + } + }); + } + } + + Future checkForChanges() async { + final sinceGeneration = _lastGeneration; + if (sinceGeneration != null) { + _changedUris.addAll(await mediaStoreService.getChangedUris(sinceGeneration)); + onStoreChanged(null); + } + await updateGeneration(); + } + + Future updateGeneration() async { + _lastGeneration = await mediaStoreService.getGeneration(); + } + // vault Future _loadVaultEntries(String? directory) async { diff --git a/lib/services/media/media_store_service.dart b/lib/services/media/media_store_service.dart index ab762d13a..72a7296f7 100644 --- a/lib/services/media/media_store_service.dart +++ b/lib/services/media/media_store_service.dart @@ -10,6 +10,10 @@ abstract class MediaStoreService { Future> checkObsoletePaths(Map knownPathById); + Future> getChangedUris(int sinceGeneration); + + Future getGeneration(); + // knownEntries: map of contentId -> dateModifiedSecs Stream getEntries(Map knownEntries, {String? directory}); @@ -47,6 +51,29 @@ class PlatformMediaStoreService implements MediaStoreService { return []; } + @override + Future> getChangedUris(int sinceGeneration) async { + try { + final result = await _platform.invokeMethod('getChangedUris', { + 'sinceGeneration': sinceGeneration, + }); + return (result as List).cast(); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return []; + } + + @override + Future getGeneration() async { + try { + return await _platform.invokeMethod('getGeneration'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return null; + } + @override Stream getEntries(Map knownEntries, {String? directory}) { try { diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 1839eb521..b929eac08 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -19,11 +19,9 @@ import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/services/accessibility_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/styles.dart'; import 'package:aves/theme/themes.dart'; -import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/basic/scaffold.dart'; @@ -154,9 +152,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { late final Future _appSetup; late final Future _shouldUseBoldFontLoader; final TvRailController _tvRailController = TvRailController(); - final CollectionSource _mediaStoreSource = MediaStoreSource(); - final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: ADurations.mediaContentChangeDebounceDelay); - final Set _changedUris = {}; + final MediaStoreSource _mediaStoreSource = MediaStoreSource(); Size? _screenSize; final ValueNotifier _pageTransitionsBuilderNotifier = ValueNotifier(defaultPageTransitionsBuilder); @@ -184,7 +180,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { EquatableConfig.stringify = true; _appSetup = _setup(); _shouldUseBoldFontLoader = AccessibilityService.shouldUseBoldFont(); - _subscriptions.add(_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChanged(event as String?))); + _subscriptions.add(_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _mediaStoreSource.onStoreChanged(event as String?))); _subscriptions.add(_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?))); _subscriptions.add(_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion())); _subscriptions.add(_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?))); @@ -399,6 +395,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { } case AppLifecycleState.resumed: RecentlyAddedFilter.updateNow(); + _mediaStoreSource.checkForChanges(); break; default: break; @@ -614,21 +611,6 @@ class _AvesAppState extends State with WidgetsBindingObserver { _mediaStoreSource.updateDerivedFilters(); } - void _onMediaStoreChanged(String? uri) { - if (uri != null) _changedUris.add(uri); - if (_changedUris.isNotEmpty) { - _mediaStoreChangeDebouncer(() async { - final todo = _changedUris.toSet(); - _changedUris.clear(); - final tempUris = await _mediaStoreSource.refreshUris(todo); - if (tempUris.isNotEmpty) { - _changedUris.addAll(tempUris); - _onMediaStoreChanged(null); - } - }); - } - } - void _onError(String? error) => reportService.recordError(error, null); }