#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.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri 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.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -20,13 +22,15 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
when (call.method) { when (call.method) {
"checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) } "checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) } "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) } "scanFile" -> ioScope.launch { safe(call, result, ::scanFile) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) { 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) { if (knownContentIds == null) {
result.error("checkObsoleteContentIds-args", "missing arguments", null) result.error("checkObsoleteContentIds-args", "missing arguments", null)
return return
@ -35,7 +39,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
} }
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { 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) { if (knownPathById == null) {
result.error("checkObsoletePaths-args", "missing arguments", null) result.error("checkObsoletePaths-args", "missing arguments", null)
return return
@ -43,6 +47,25 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById)) 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) { private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path") val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType") 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 eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
private var knownEntries: Map<Int?, Int?>? = null private var knownEntries: Map<Long?, Int?>? = null
private var directory: String? = null private var directory: String? = null
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
@Suppress("unchecked_cast") knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap()
knownEntries = arguments["knownEntries"] as Map<Int?, Int?>?
directory = arguments["directory"] as String? directory = arguments["directory"] as String?
} }
} }

View file

@ -3,7 +3,11 @@ package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.RecoverableSecurityException 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.graphics.BitmapFactory
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
@ -35,7 +39,7 @@ import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.io.SyncFailedException import java.io.SyncFailedException
import java.util.* import java.util.Locale
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -45,11 +49,11 @@ import kotlin.coroutines.suspendCoroutine
class MediaStoreImageProvider : ImageProvider() { class MediaStoreImageProvider : ImageProvider() {
fun fetchAll( fun fetchAll(
context: Context, context: Context,
knownEntries: Map<Int?, Int?>, knownEntries: Map<Long?, Int?>,
directory: String?, directory: String?,
handleNewEntry: NewEntryHandler, handleNewEntry: NewEntryHandler,
) { ) {
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean { val isModified = fun(contentId: Long, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId] val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedSecs return knownDate == null || knownDate < dateModifiedSecs
} }
@ -89,7 +93,7 @@ class MediaStoreImageProvider : ImageProvider() {
var found = false var found = false
val fetched = arrayListOf<FieldMap>() val fetched = arrayListOf<FieldMap>()
val id = uri.tryParseId() 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) } val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
if (id != null) { if (id != null) {
if (sourceMimeType == null || isImage(sourceMimeType)) { if (sourceMimeType == null || isImage(sourceMimeType)) {
@ -119,8 +123,8 @@ class MediaStoreImageProvider : ImageProvider() {
} }
} }
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int?>): List<Int> { fun checkObsoleteContentIds(context: Context, knownContentIds: List<Long?>): List<Long> {
val foundContentIds = HashSet<Int>() val foundContentIds = HashSet<Long>()
fun check(context: Context, contentUri: Uri) { fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID) val projection = arrayOf(MediaStore.MediaColumns._ID)
try { try {
@ -128,7 +132,7 @@ class MediaStoreImageProvider : ImageProvider() {
if (cursor != null) { if (cursor != null) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
foundContentIds.add(cursor.getInt(idColumn)) foundContentIds.add(cursor.getLong(idColumn))
} }
cursor.close() cursor.close()
} }
@ -141,8 +145,8 @@ class MediaStoreImageProvider : ImageProvider() {
return knownContentIds.subtract(foundContentIds).filterNotNull().toList() return knownContentIds.subtract(foundContentIds).filterNotNull().toList()
} }
fun checkObsoletePaths(context: Context, knownPathById: Map<Int?, String?>): List<Int> { fun checkObsoletePaths(context: Context, knownPathById: Map<Long?, String?>): List<Long> {
val obsoleteIds = ArrayList<Int>() val obsoleteIds = ArrayList<Long>()
fun check(context: Context, contentUri: Uri) { fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA) val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA)
try { try {
@ -151,7 +155,7 @@ class MediaStoreImageProvider : ImageProvider() {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getInt(idColumn) val id = cursor.getLong(idColumn)
val path = cursor.getString(pathColumn) val path = cursor.getString(pathColumn)
if (knownPathById.containsKey(id) && knownPathById[id] != path) { if (knownPathById.containsKey(id) && knownPathById[id] != path) {
obsoleteIds.add(id) obsoleteIds.add(id)
@ -168,6 +172,31 @@ class MediaStoreImageProvider : ImageProvider() {
return obsoleteIds 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( private fun fetchFrom(
context: Context, context: Context,
isValidEntry: NewEntryChecker, isValidEntry: NewEntryChecker,
@ -207,12 +236,12 @@ class MediaStoreImageProvider : ImageProvider() {
val needDuration = projection.contentEquals(VIDEO_PROJECTION) val needDuration = projection.contentEquals(VIDEO_PROJECTION)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val contentId = cursor.getInt(idColumn) val id = cursor.getLong(idColumn)
val dateModifiedSecs = cursor.getInt(dateModifiedColumn) val dateModifiedSecs = cursor.getInt(dateModifiedColumn)
if (isValidEntry(contentId, dateModifiedSecs)) { if (isValidEntry(id, dateModifiedSecs)) {
// for multiple items, `contentUri` is the root without ID, // for multiple items, `contentUri` is the root without ID,
// but for single items, `contentUri` already contains the 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) // `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 // in that case we try to use the MIME type provided along the URI
val mimeType: String? = cursor.getString(mimeTypeColumn) ?: fileMimeType val mimeType: String? = cursor.getString(mimeTypeColumn) ?: fileMimeType
@ -237,7 +266,7 @@ class MediaStoreImageProvider : ImageProvider() {
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, "sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
"durationMillis" to durationMillis, "durationMillis" to durationMillis,
// only for map export // only for map export
"contentId" to contentId, "contentId" to id,
) )
if (MimeTypes.isHeic(mimeType)) { if (MimeTypes.isHeic(mimeType)) {
@ -930,8 +959,10 @@ class MediaStoreImageProvider : ImageProvider() {
try { try {
val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null) val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns._ID).let { val idColumn = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (it != -1) mediaContentUri = ContentUris.withAppendedId(contentUri, cursor.getLong(it)) if (idColumn != -1) {
val id = cursor.getLong(idColumn)
mediaContentUri = ContentUris.withAppendedId(contentUri, id)
} }
cursor.close() cursor.close()
} }
@ -994,4 +1025,4 @@ object MediaColumns {
typealias NewEntryHandler = (entry: FieldMap) -> Unit 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/source/collection_source.dart';
import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/common/services.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/android_file_utils.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves_model/aves_model.dart'; import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class MediaStoreSource extends CollectionSource { class MediaStoreSource extends CollectionSource {
final Debouncer _changeDebouncer = Debouncer(delay: ADurations.mediaContentChangeDebounceDelay);
final Set<String> _changedUris = {};
int? _lastGeneration;
SourceInitializationState _initState = SourceInitializationState.none; SourceInitializationState _initState = SourceInitializationState.none;
@override @override
@ -36,6 +41,7 @@ class MediaStoreSource extends CollectionSource {
_initState = directory != null ? SourceInitializationState.directory : SourceInitializationState.full; _initState = directory != null ? SourceInitializationState.directory : SourceInitializationState.full;
} }
addDirectories(albums: settings.pinnedFilters.whereType<AlbumFilter>().map((v) => v.album).toSet()); addDirectories(albums: settings.pinnedFilters.whereType<AlbumFilter>().map((v) => v.album).toSet());
await updateGeneration();
unawaited(_loadEntries( unawaited(_loadEntries(
analysisController: analysisController, analysisController: analysisController,
directory: directory, directory: directory,
@ -305,6 +311,34 @@ class MediaStoreSource extends CollectionSource {
return tempUris; 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 // vault
Future<void> _loadVaultEntries(String? directory) async { 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<int>> checkObsoletePaths(Map<int?, String?> knownPathById);
Future<List<String>> getChangedUris(int sinceGeneration);
Future<int?> getGeneration();
// knownEntries: map of contentId -> dateModifiedSecs // knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries, {String? directory}); Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries, {String? directory});
@ -47,6 +51,29 @@ class PlatformMediaStoreService implements MediaStoreService {
return []; 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 @override
Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries, {String? directory}) { Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries, {String? directory}) {
try { 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/accessibility_service.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart'; import 'package:aves/theme/colors.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/styles.dart'; import 'package:aves/theme/styles.dart';
import 'package:aves/theme/themes.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_grid.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/scaffold.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<void> _appSetup;
late final Future<bool> _shouldUseBoldFontLoader; late final Future<bool> _shouldUseBoldFontLoader;
final TvRailController _tvRailController = TvRailController(); final TvRailController _tvRailController = TvRailController();
final CollectionSource _mediaStoreSource = MediaStoreSource(); final MediaStoreSource _mediaStoreSource = MediaStoreSource();
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: ADurations.mediaContentChangeDebounceDelay);
final Set<String> _changedUris = {};
Size? _screenSize; Size? _screenSize;
final ValueNotifier<PageTransitionsBuilder> _pageTransitionsBuilderNotifier = ValueNotifier(defaultPageTransitionsBuilder); final ValueNotifier<PageTransitionsBuilder> _pageTransitionsBuilderNotifier = ValueNotifier(defaultPageTransitionsBuilder);
@ -184,7 +180,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
EquatableConfig.stringify = true; EquatableConfig.stringify = true;
_appSetup = _setup(); _appSetup = _setup();
_shouldUseBoldFontLoader = AccessibilityService.shouldUseBoldFont(); _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(_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)));
_subscriptions.add(_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion())); _subscriptions.add(_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion()));
_subscriptions.add(_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?))); _subscriptions.add(_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)));
@ -399,6 +395,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
} }
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
RecentlyAddedFilter.updateNow(); RecentlyAddedFilter.updateNow();
_mediaStoreSource.checkForChanges();
break; break;
default: default:
break; break;
@ -614,21 +611,6 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
_mediaStoreSource.updateDerivedFilters(); _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); void _onError(String? error) => reportService.recordError(error, null);
} }