diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index f3c8e551c..51a883a0a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -252,7 +252,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } private fun getShareableUri(uri: Uri): Uri? { - return when (uri.scheme?.toLowerCase(Locale.ROOT)) { + return when (uri.scheme?.lowercase(Locale.ROOT)) { ContentResolver.SCHEME_FILE -> { uri.path?.let { path -> val authority = "${context.applicationContext.packageName}.fileprovider" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index be5cbdb78..fa0726fff 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -116,6 +116,16 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } + // prefer image/video content URI, fallback to original URI (possibly a file content URI) + val metadataMap = getContentResolverMetadataForUri(contentUri) ?: getContentResolverMetadataForUri(uri) + if (metadataMap != null) { + result.success(metadataMap) + } else { + result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null) + } + } + + private fun getContentResolverMetadataForUri(contentUri: Uri): FieldMap? { val cursor = context.contentResolver.query(contentUri, null, null, null, null) if (cursor != null && cursor.moveToFirst()) { val metadataMap = HashMap() @@ -137,10 +147,9 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } cursor.close() - result.success(metadataMap) - } else { - result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null) + return metadataMap } + return null } private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 68811e69b..37f99e16e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -73,7 +73,7 @@ object Metadata { var timeZone: TimeZone? = null val timeZoneMatcher = VIDEO_TIMEZONE_PATTERN.matcher(dateString) if (timeZoneMatcher.find()) { - timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z".toRegex(), "")}") + timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z", "")}") dateString = timeZoneMatcher.replaceAll("") } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index ed696ab0e..6cbcb77c2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -436,52 +436,58 @@ abstract class ImageProvider { protected suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap = suspendCoroutine { cont -> MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? -> - var contentId: Long? = null - var contentUri: Uri? = null - if (newUri != null) { - // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") - // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") - contentId = newUri.tryParseId() - if (contentId != null) { - if (isImage(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) - } else if (isVideo(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) + fun scanUri(uri: Uri?): FieldMap? { + uri ?: return null + + // we retrieve updated fields as the renamed/moved file became a new entry in the Media Store + val projection = arrayOf( + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.TITLE, + ) + try { + val cursor = context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + val newFields = HashMap() + newFields["uri"] = uri.toString() + newFields["contentId"] = uri.tryParseId() + newFields["path"] = path + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) } + cursor.close() + return newFields } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to scan uri=$uri", e) } + return null } - if (contentUri == null) { - cont.resumeWithException(Exception("failed to get content URI of item at path=$path")) + + if (newUri == null) { + cont.resumeWithException(Exception("failed to get URI of item at path=$path")) return@scanFile } - val newFields = HashMap() - // we retrieve updated fields as the renamed/moved file became a new entry in the Media Store - val projection = arrayOf( - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.TITLE, - ) - try { - val cursor = context.contentResolver.query(contentUri, projection, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - newFields["uri"] = contentUri.toString() - newFields["contentId"] = contentId - newFields["path"] = path - cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) } - cursor.close() + var contentUri: Uri? = null + // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") + // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") + val contentId = newUri.tryParseId() + if (contentId != null) { + if (isImage(mimeType)) { + contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) + } else if (isVideo(mimeType)) { + contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) } - } catch (e: Exception) { - cont.resumeWithException(e) - return@scanFile } - if (newFields.isEmpty()) { - cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) - } else { + // prefer image/video content URI, fallback to original URI (possibly a file content URI) + val newFields = scanUri(contentUri) ?: scanUri(newUri) + + if (newFields != null) { cont.resume(newFields) + } else { + cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt index a9a9045dc..2cfe1844b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt @@ -7,11 +7,11 @@ import java.util.* object ImageProviderFactory { fun getProvider(uri: Uri): ImageProvider? { - return when (uri.scheme?.toLowerCase(Locale.ROOT)) { + return when (uri.scheme?.lowercase(Locale.ROOT)) { ContentResolver.SCHEME_CONTENT -> { // a URI's authority is [userinfo@]host[:port] // but we only want the host when comparing to Media Store's "authority" - return when (uri.host?.toLowerCase(Locale.ROOT)) { + return when (uri.host?.lowercase(Locale.ROOT)) { MediaStore.AUTHORITY -> MediaStoreImageProvider() else -> ContentImageProvider() } 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 9e80a925e..1e2954363 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 @@ -300,8 +300,17 @@ object StorageUtils { Log.w(LOG_TAG, "failed to get document URI for mediaUri=$mediaUri", e) } } + // fallback for older APIs - return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) } + val df = getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) } + if (df != null) return df + + // try to strip user info, if any + if (mediaUri.userInfo != null) { + val genericMediaUri = Uri.parse(mediaUri.toString().replaceFirst("${mediaUri.userInfo}@", "")) + Log.d(LOG_TAG, "retry getDocumentFile for mediaUri=$mediaUri without userInfo: $genericMediaUri") + return getDocumentFile(context, anyPath, genericMediaUri) + } } // good old `File` return DocumentFileCompat.fromFile(File(anyPath)) diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 9cd9c5a11..e72fb22ad 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -418,7 +418,7 @@ class AvesEntry { addressDetails = null; } - Future catalog({bool background = false}) async { + Future catalog({bool background = false, bool persist = true}) async { if (isCatalogued) return; if (isSvg) { // vector image sizing is not essential, so we should not spend time for it during loading @@ -428,7 +428,7 @@ class AvesEntry { await _applyNewFields({ 'width': size.width.round(), 'height': size.height.round(), - }); + }, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { @@ -538,7 +538,7 @@ class AvesEntry { _addressDetails?.locality, }.any((s) => s != null && s.toUpperCase().contains(query)); - Future _applyNewFields(Map newFields) async { + Future _applyNewFields(Map newFields, {required bool persist}) async { final uri = newFields['uri']; if (uri is String) this.uri = uri; final path = newFields['path']; @@ -560,8 +560,10 @@ class AvesEntry { final isFlipped = newFields['isFlipped']; if (isFlipped is bool) this.isFlipped = isFlipped; - await metadataDb.saveEntries({this}); - if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); + if (persist) { + await metadataDb.saveEntries({this}); + if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); + } metadataChangeNotifier.notifyListeners(); } @@ -573,7 +575,7 @@ class AvesEntry { final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; - await _applyNewFields(newFields); + await _applyNewFields(newFields, persist: true); await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } @@ -585,7 +587,7 @@ class AvesEntry { final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; - await _applyNewFields(newFields); + await _applyNewFields(newFields, persist: true); await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 8acb9c8d8..fb8bdbffb 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -119,7 +119,7 @@ class _HomePageState extends State { final entry = await imageFileService.getEntry(uri, mimeType); if (entry != null) { // cataloguing is essential for coordinates and video rotation - await entry.catalog(); + await entry.catalog(background: false, persist: false); } return entry; } diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 4b7cc6bfa..3deb23604 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -187,6 +187,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final selectionCount = selection.length; showOpReport( context: context, + // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) opStream: imageFileService.export( selection, mimeType: MimeTypes.jpeg,