#900 check media store changes on app resume

This commit is contained in:
Thibault Deckers 2024-02-24 01:01:10 +01:00
parent fcd2e493da
commit f287dd4c04
6 changed files with 140 additions and 44 deletions

View file

@ -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<List<Int?>>("knownContentIds")
val knownContentIds = call.argument<List<Number?>>("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<Map<Int?, String?>>("knownPathById")
val knownPathById = call.argument<Map<Number?, String?>>("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<Int>("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<String>("path")
val mimeType = call.argument<String>("mimeType")

View file

@ -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<Int?, Int?>? = null
private var knownEntries: Map<Long?, Int?>? = null
private var directory: String? = null
init {
if (arguments is Map<*, *>) {
@Suppress("unchecked_cast")
knownEntries = arguments["knownEntries"] as Map<Int?, Int?>?
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap()
directory = arguments["directory"] as String?
}
}

View file

@ -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<Int?, Int?>,
knownEntries: Map<Long?, Int?>,
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<FieldMap>()
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<Int?>): List<Int> {
val foundContentIds = HashSet<Int>()
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Long?>): List<Long> {
val foundContentIds = HashSet<Long>()
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<Int?, String?>): List<Int> {
val obsoleteIds = ArrayList<Int>()
fun checkObsoletePaths(context: Context, knownPathById: Map<Long?, String?>): List<Long> {
val obsoleteIds = ArrayList<Long>()
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<String> {
val changedUris = ArrayList<String>()
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
private typealias NewEntryChecker = (contentId: Long, dateModifiedSecs: Int) -> Boolean

View file

@ -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<String> _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<AlbumFilter>().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<void> checkForChanges() async {
final sinceGeneration = _lastGeneration;
if (sinceGeneration != null) {
_changedUris.addAll(await mediaStoreService.getChangedUris(sinceGeneration));
onStoreChanged(null);
}
await updateGeneration();
}
Future<void> updateGeneration() async {
_lastGeneration = await mediaStoreService.getGeneration();
}
// vault
Future<void> _loadVaultEntries(String? directory) async {

View file

@ -10,6 +10,10 @@ abstract class MediaStoreService {
Future<List<int>> checkObsoletePaths(Map<int?, String?> knownPathById);
Future<List<String>> getChangedUris(int sinceGeneration);
Future<int?> getGeneration();
// knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries, {String? directory});
@ -47,6 +51,29 @@ class PlatformMediaStoreService implements MediaStoreService {
return [];
}
@override
Future<List<String>> getChangedUris(int sinceGeneration) async {
try {
final result = await _platform.invokeMethod('getChangedUris', <String, dynamic>{
'sinceGeneration': sinceGeneration,
});
return (result as List).cast<String>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}
@override
Future<int?> getGeneration() async {
try {
return await _platform.invokeMethod('getGeneration');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@override
Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries, {String? directory}) {
try {

View file

@ -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<AvesApp> with WidgetsBindingObserver {
late final Future<void> _appSetup;
late final Future<bool> _shouldUseBoldFontLoader;
final TvRailController _tvRailController = TvRailController();
final CollectionSource _mediaStoreSource = MediaStoreSource();
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: ADurations.mediaContentChangeDebounceDelay);
final Set<String> _changedUris = {};
final MediaStoreSource _mediaStoreSource = MediaStoreSource();
Size? _screenSize;
final ValueNotifier<PageTransitionsBuilder> _pageTransitionsBuilderNotifier = ValueNotifier(defaultPageTransitionsBuilder);
@ -184,7 +180,7 @@ class _AvesAppState extends State<AvesApp> 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<AvesApp> with WidgetsBindingObserver {
}
case AppLifecycleState.resumed:
RecentlyAddedFilter.updateNow();
_mediaStoreSource.checkForChanges();
break;
default:
break;
@ -614,21 +611,6 @@ class _AvesAppState extends State<AvesApp> 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);
}