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 ef7abef99..c17870a08 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 @@ -27,6 +27,8 @@ import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils.PathSegments +import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator +import deckers.thibault.aves.utils.StorageUtils.removeTrailingSeparator import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.io.OutputStream @@ -47,14 +49,33 @@ class MediaStoreImageProvider : ImageProvider() { val knownDate = knownEntries[contentId] return knownDate == null || knownDate < dateModifiedSecs } + val handleNew: NewEntryHandler var selection: String? = null var selectionArgs: Array? = null if (directory != null) { - selection = "${MediaColumns.PATH} LIKE ?" - selectionArgs = arrayOf("${StorageUtils.ensureTrailingSeparator(directory)}%") + val relativePathDirectory = ensureTrailingSeparator(directory) + val relativePath = PathSegments(context, relativePathDirectory).relativeDir + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && relativePath != null) { + selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaColumns.PATH} LIKE ?" + selectionArgs = arrayOf(relativePath, "relativePathDirectory%") + } else { + selection = "${MediaColumns.PATH} LIKE ?" + selectionArgs = arrayOf("$relativePathDirectory%") + } + + val parentCheckDirectory = removeTrailingSeparator(directory) + handleNew = { entry -> + // skip entries in subfolders + val path = entry["path"] as String? + if (path != null && File(path).parent == parentCheckDirectory) { + handleNewEntry(entry) + } + } + } else { + handleNew = handleNewEntry } - fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection = selection, selectionArgs = selectionArgs) - fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection = selection, selectionArgs = selectionArgs) + fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs) + fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs) } // the provided URI can point to the wrong media collection, @@ -407,7 +428,7 @@ class MediaStoreImageProvider : ImageProvider() { if (toBin) { val trashDir = StorageUtils.trashDirFor(activity, sourcePath) if (trashDir != null) { - effectiveTargetDir = StorageUtils.ensureTrailingSeparator(trashDir.path) + effectiveTargetDir = ensureTrailingSeparator(trashDir.path) targetDirDocFile = DocumentFileCompat.fromFile(trashDir) } } @@ -452,7 +473,7 @@ class MediaStoreImageProvider : ImageProvider() { toBin: Boolean, ): FieldMap { val sourcePath = sourceFile.path - val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) } + val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { // nothing to do unless it's a renamed copy return skippedFieldMap @@ -550,10 +571,7 @@ class MediaStoreImageProvider : ImageProvider() { } private fun isDownloadDir(context: Context, dirPath: String): Boolean { - var relativeDir = PathSegments(context, dirPath).relativeDir ?: "" - if (relativeDir.endsWith(File.separator)) { - relativeDir = relativeDir.substring(0, relativeDir.length - 1) - } + val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "") return relativeDir == Environment.DIRECTORY_DOWNLOADS } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 4ddb4ef08..a420fb1f3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -294,10 +294,7 @@ object StorageUtils { fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? { val uuid = getVolumeUuidForTreeUri(context, dirPath) if (uuid != null) { - var relativeDir = PathSegments(context, dirPath).relativeDir ?: "" - if (relativeDir.endsWith(File.separator)) { - relativeDir = relativeDir.substring(0, relativeDir.length - 1) - } + val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "") return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir") } Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI") @@ -579,6 +576,10 @@ object StorageUtils { return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator } + fun removeTrailingSeparator(dirPath: String): String { + return if (dirPath.endsWith(File.separator)) dirPath.substring(0, dirPath.length - 1) else dirPath + } + // `fullPath` should match "volumePath + relativeDir + fileName" class PathSegments(context: Context, fullPath: String) { var volumePath: String? = null // `volumePath` with trailing "/" diff --git a/lib/model/db/db_metadata_sqflite.dart b/lib/model/db/db_metadata_sqflite.dart index 9c21e96e1..e5133647c 100644 --- a/lib/model/db/db_metadata_sqflite.dart +++ b/lib/model/db/db_metadata_sqflite.dart @@ -160,13 +160,28 @@ class SqfliteMetadataDb implements MetadataDb { @override Future> loadEntries({String? directory}) async { - String? where; - List? whereArgs; if (directory != null) { - where = 'path LIKE ?'; - whereArgs = ['$directory%']; + final separator = pContext.separator; + if (!directory.endsWith(separator)) { + directory = '$directory$separator'; + } + + const where = 'path LIKE ?'; + final whereArgs = ['$directory%']; + final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs); + + final dirLength = directory.length; + return rows + .whereNot((row) { + // skip entries in subfolders + final path = row['path'] as String?; + return path == null || path.substring(dirLength).contains(separator); + }) + .map(AvesEntry.fromMap) + .toSet(); } - final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs); + + final rows = await _db.query(entryTable); return rows.map(AvesEntry.fromMap).toSet(); } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 11f11fab0..03d433eec 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -88,10 +88,11 @@ class MediaStoreSource extends CollectionSource { final knownContentIds = knownDateByContentId.keys.toList(); final removedContentIds = (await mediaStoreService.checkObsoleteContentIds(knownContentIds)).toSet(); if (topEntries.isNotEmpty) { - final obsoleteTopEntries = topEntries.where((entry) => removedContentIds.contains(entry.contentId)); - await removeEntries(obsoleteTopEntries.map((entry) => entry.uri).toSet(), includeTrash: false); + final removedTopEntries = topEntries.where((entry) => removedContentIds.contains(entry.contentId)); + await removeEntries(removedTopEntries.map((entry) => entry.uri).toSet(), includeTrash: false); } - knownEntries.removeWhere((entry) => removedContentIds.contains(entry.contentId)); + final removedEntries = knownEntries.where((entry) => removedContentIds.contains(entry.contentId)).toSet(); + knownEntries.removeAll(removedEntries); // show known entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); @@ -109,8 +110,10 @@ class MediaStoreSource extends CollectionSource { } // clean up obsolete entries - debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); - await metadataDb.removeIds(removedContentIds); + if (removedEntries.isNotEmpty) { + debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); + await metadataDb.removeIds(removedEntries.map((entry) => entry.id)); + } if (directory != null) { // trash @@ -173,6 +176,7 @@ class MediaStoreSource extends CollectionSource { updateDirectories(); } + debugPrint('$runtimeType refresh ${stopwatch.elapsed} analyze'); Set? analysisEntries; final analysisIds = analysisController?.entryIds; if (analysisIds != null) { @@ -180,7 +184,7 @@ class MediaStoreSource extends CollectionSource { } await analyze(analysisController, entries: analysisEntries); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${knownEntries.length} known, ${allNewEntries.length} new, ${removedContentIds.length} obsolete'); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${knownEntries.length} known, ${allNewEntries.length} new, ${removedEntries.length} removed'); }, onError: (error) => debugPrint('$runtimeType stream error=$error'), ); diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 5509fe2c4..7223094ec 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -143,6 +143,10 @@ class _AppDebugPageState extends State { onPressed: () => source.init(directory: '${androidFileUtils.dcimPath}/Camera'), child: const Text('Source refresh (camera)'), ), + ElevatedButton( + onPressed: () => source.init(directory: androidFileUtils.picturesPath), + child: const Text('Source refresh (pictures)'), + ), ElevatedButton( onPressed: () => AnalysisService.startService(force: false), child: const Text('Start analysis service'),