#900 check media store changes on app resume
This commit is contained in:
parent
fcd2e493da
commit
f287dd4c04
6 changed files with 140 additions and 44 deletions
|
@ -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")
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue