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 b1cb53d20..0cac74386 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 @@ -20,10 +20,10 @@ import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface +import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta import deckers.thibault.aves.utils.MimeTypes.isImage -import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface -import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor -import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.UriUtils.tryParseId @@ -60,31 +60,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } - private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - if (mimeType == null || uri == null) { - result.error("getPixyMetadata-args", "failed because of missing arguments", null) - return - } - - if (!isSupportedByPixyMeta(mimeType)) { - result.error("getPixyMetadata-unsupported", "PixyMeta does not support mimeType=$mimeType", null) - return - } - - val metadataMap = HashMap() - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - metadataMap.putAll(PixyMetaHelper.describe(input)) - } - } catch (e: Exception) { - result.error("getPixyMetadata-exception", e.message, e.stackTraceToString()) - return - } - result.success(metadataMap) - } - private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { val dirs = hashMapOf( "cacheDir" to context.cacheDir, @@ -206,7 +181,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap() - if (isSupportedByExifInterface(mimeType, strict = false)) { + if (canReadWithExifInterface(mimeType, strict = false)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) @@ -258,7 +233,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap() - if (isSupportedByMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) @@ -290,6 +265,28 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getPixyMetadata-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + if (canReadWithPixyMeta(mimeType)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + metadataMap.putAll(PixyMetaHelper.describe(input)) + } + } catch (e: Exception) { + result.error("getPixyMetadata-exception", e.message, e.stackTraceToString()) + return + } + } + result.success(metadataMap) + } + private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index e97b685cb..61fa7d5c0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -27,8 +27,8 @@ import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isImage -import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface -import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface +import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodCall @@ -62,7 +62,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { } val thumbnails = ArrayList() - if (isSupportedByExifInterface(mimeType)) { + if (canReadWithExifInterface(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> @Suppress("BlockingMethodInNonBlockingContext") @@ -150,7 +150,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { return } - if (isSupportedByMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 5e021d88b..8a00d5cf1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -202,17 +202,17 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { } val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW - changeOrientation(call, result, op) + editOrientation(call, result, op) } private fun flip(call: MethodCall, result: MethodChannel.Result) { - changeOrientation(call, result, ExifOrientationOp.FLIP) + editOrientation(call, result, ExifOrientationOp.FLIP) } - private fun changeOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) { + private fun editOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) { val entryMap = call.argument("entry") if (entryMap == null) { - result.error("changeOrientation-args", "failed because of missing arguments", null) + result.error("editOrientation-args", "failed because of missing arguments", null) return } @@ -220,19 +220,19 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val path = entryMap["path"] as String? val mimeType = entryMap["mimeType"] as String? if (uri == null || path == null || mimeType == null) { - result.error("changeOrientation-args", "failed because entry fields are missing", null) + result.error("editOrientation-args", "failed because entry fields are missing", null) return } val provider = getProvider(uri) if (provider == null) { - result.error("changeOrientation-provider", "failed to find provider for uri=$uri", null) + result.error("editOrientation-provider", "failed to find provider for uri=$uri", null) return } - provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback { + provider.editOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation", throwable.message) }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 386aa0781..7eab6c492 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -54,8 +54,8 @@ import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isImage -import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface -import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface +import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern import deckers.thibault.aves.utils.StorageUtils @@ -97,7 +97,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { var foundExif = false var foundXmp = false - if (isSupportedByMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) @@ -225,7 +225,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif && isSupportedByExifInterface(mimeType)) { + if (!foundExif && canReadWithExifInterface(mimeType)) { // fallback to read EXIF via ExifInterface try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> @@ -337,7 +337,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int var foundExif = false - if (isSupportedByMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) @@ -480,7 +480,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif && isSupportedByExifInterface(mimeType)) { + if (!foundExif && canReadWithExifInterface(mimeType)) { // fallback to read EXIF via ExifInterface try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> @@ -584,7 +584,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } var foundExif = false - if (isSupportedByMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) @@ -603,7 +603,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif && isSupportedByExifInterface(mimeType)) { + if (!foundExif && canReadWithExifInterface(mimeType)) { // fallback to read EXIF via ExifInterface try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> @@ -654,7 +654,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return } - if (isSupportedByMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 8fc0b0f9c..66f81b146 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -17,7 +17,7 @@ import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isHeic -import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter +import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils @@ -96,7 +96,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen if (isVideo(mimeType)) { streamVideoByGlide(uri, mimeType) - } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { + } else if (!canDecodeWithFlutter(mimeType, rotationDegrees, isFlipped)) { // decode exotic format on platform side, then encode it in portable format for Flutter streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index 07efb26fe..924c76a1d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -150,7 +150,7 @@ class SourceEntry { // finds: width, height, orientation, date, duration private fun fillByMetadataExtractor(context: Context) { // skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions - if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) + if (!MimeTypes.canReadWithMetadataExtractor(sourceMimeType) || MimeTypes.isRaw(sourceMimeType) ) return @@ -204,7 +204,7 @@ class SourceEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { - if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return + if (!MimeTypes.canReadWithExifInterface(sourceMimeType)) return try { Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input -> 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 d818b5605..db85dc07d 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 @@ -1,13 +1,10 @@ package deckers.thibault.aves.model.provider import android.app.Activity -import android.content.ContentUris import android.content.Context import android.graphics.Bitmap -import android.media.MediaScannerConnection import android.net.Uri import android.os.Build -import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide @@ -28,18 +25,17 @@ import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.* +import deckers.thibault.aves.utils.MimeTypes.canEditExif +import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.extensionFor -import deckers.thibault.aves.utils.MimeTypes.isImage -import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils.getDocumentFile -import deckers.thibault.aves.utils.UriUtils.tryParseId -import java.io.* +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException import java.util.* -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine abstract class ImageProvider { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { @@ -54,6 +50,18 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException()) } + open fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { + throw UnsupportedOperationException() + } + + open fun scanObsoletePath(context: Context, path: String, mimeType: String) { + throw UnsupportedOperationException() + } + + open suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap { + throw UnsupportedOperationException() + } + suspend fun exportMultiple( context: Context, imageExportMimeType: String, @@ -324,7 +332,7 @@ abstract class ImageProvider { return } - MediaScannerConnection.scanFile(context, arrayOf(oldPath), arrayOf(mimeType), null) + scanObsoletePath(context, oldPath, mimeType) try { callback.onSuccess(scanNewPath(context, newFile.path, mimeType)) } catch (e: Exception) { @@ -332,23 +340,6 @@ abstract class ImageProvider { } } - // support for writing EXIF - // as of androidx.exifinterface:exifinterface:1.3.3 - private fun canEditExif(mimeType: String): Boolean { - return when (mimeType) { - MimeTypes.DNG, - MimeTypes.JPEG, - MimeTypes.PNG, - MimeTypes.WEBP -> true - else -> false - } - } - - // support for writing XMP - private fun canEditXmp(mimeType: String): Boolean { - return isSupportedByPixyMeta(mimeType) - } - private fun editExif( context: Context, path: String, @@ -524,28 +515,7 @@ abstract class ImageProvider { } } - private fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { - MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> - val projection = arrayOf( - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.MediaColumns.SIZE, - ) - try { - val cursor = context.contentResolver.query(uri, projection, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) } - cursor.close() - } - } catch (e: Exception) { - callback.onFailure(e) - return@scanFile - } - callback.onSuccess(newFields) - } - } - - fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) { + fun editOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) { val newFields = HashMap() val success = editExif(context, path, uri, mimeType, callback) { exif -> @@ -666,65 +636,6 @@ 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? -> - 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 (newUri == null) { - cont.resumeWithException(Exception("failed to get URI of item at path=$path")) - return@scanFile - } - - 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) - } - } - - // 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)")) - } - } - } - interface ImageOpCallback { fun onSuccess(fields: FieldMap) fun onFailure(throwable: Throwable) 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 6f1e00378..df9c6249f 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 @@ -5,6 +5,7 @@ import android.app.Activity import android.app.RecoverableSecurityException import android.content.ContentUris import android.content.Context +import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.provider.MediaStore @@ -27,6 +28,9 @@ import java.io.File import java.util.* import java.util.concurrent.CompletableFuture import kotlin.collections.ArrayList +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine class MediaStoreImageProvider : ImageProvider() { fun fetchAll(context: Context, knownEntries: Map, handleNewEntry: NewEntryHandler) { @@ -374,6 +378,90 @@ class MediaStoreImageProvider : ImageProvider() { } } + override fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> + val projection = arrayOf( + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.SIZE, + ) + try { + val cursor = context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) } + cursor.close() + } + } catch (e: Exception) { + callback.onFailure(e) + return@scanFile + } + callback.onSuccess(newFields) + } + } + + override fun scanObsoletePath(context: Context, path: String, mimeType: String) { + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType), null) + } + + override suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap = + suspendCoroutine { cont -> + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? -> + 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 (newUri == null) { + cont.resumeWithException(Exception("failed to get URI of item at path=$path")) + return@scanFile + } + + 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) + } + } + + // 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)")) + } + } + } + companion object { private val LOG_TAG = LogUtils.createTag() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 7cf0e2efe..b7a14ff5d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -64,28 +64,47 @@ object MimeTypes { else -> false } - // as of Flutter v1.22.0, with additional custom handling for SVG - fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { - JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true + // as of Flutter v1.22.0 + fun canDecodeWithFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { + JPEG, GIF, WEBP, BMP, WBMP, ICO -> true PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) else -> false } // as of `metadata-extractor` v2.14.0 - fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { + fun canReadWithMetadataExtractor(mimeType: String) = when (mimeType) { DJVU, WBMP, MKV, MP2T, MP2TS, OGV, WEBM -> false else -> true } // as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports // no support for TIFF images, but it can actually open them (maybe other formats too) - fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict + fun canReadWithExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict - fun isSupportedByPixyMeta(mimeType: String) = when (mimeType) { + // as of latest PixyMeta + fun canReadWithPixyMeta(mimeType: String) = when (mimeType) { JPEG, TIFF, PNG, GIF, BMP -> true else -> false } + // as of androidx.exifinterface:exifinterface:1.3.3 + fun canEditExif(mimeType: String) = when (mimeType) { + DNG, + JPEG, + PNG, + WEBP -> true + else -> false + } + + // as of latest PixyMeta + fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType) + + // as of latest PixyMeta + fun canRemoveMetadata(mimeType: String) = when (mimeType) { + JPEG, TIFF -> true + else -> false + } + // Glide automatically applies EXIF orientation when decoding images of known formats // but we need to rotate the decoded bitmap for the other formats // maybe related to ExifInterface version used by Glide: