diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 3c9600d5c..9d7b0ad95 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -3,8 +3,6 @@ package deckers.thibault.aves.model.provider; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.media.MediaScannerConnection; import android.net.Uri; import android.provider.MediaStore; @@ -13,21 +11,18 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.exifinterface.media.ExifInterface; -import com.bumptech.glide.load.resource.bitmap.TransformationUtils; import com.commonsware.cwac.document.DocumentFileCompat; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import java.io.File; import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; import deckers.thibault.aves.model.AvesImageEntry; -import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.Utils; @@ -86,21 +81,24 @@ public abstract class ImageProvider { scanNewPath(context, newFile.getPath(), mimeType, callback); } - public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { + // support for writing EXIF + // as of androidx.exifinterface:exifinterface:1.3.0 + private boolean canEditExif(@NonNull String mimeType) { switch (mimeType) { - case MimeTypes.JPEG: - rotateJpeg(context, path, uri, clockwise, callback); - break; - case MimeTypes.PNG: - rotatePng(context, path, uri, clockwise, callback); - break; + case "image/jpeg": + case "image/png": + case "image/webp": + return true; default: - callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); + return false; } } - private void rotateJpeg(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { - final String mimeType = MimeTypes.JPEG; + public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { + if (!canEditExif(mimeType)) { + callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); + return; + } final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri); if (originalDocumentFile == null) { @@ -115,38 +113,29 @@ public abstract class ImageProvider { return; } - int newOrientationCode; + Map newFields = new HashMap<>(); try { ExifInterface exif = new ExifInterface(editablePath); - switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { - case ExifInterface.ORIENTATION_ROTATE_90: - newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_180 : ExifInterface.ORIENTATION_NORMAL; - break; - case ExifInterface.ORIENTATION_ROTATE_180: - newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_270 : ExifInterface.ORIENTATION_ROTATE_90; - break; - case ExifInterface.ORIENTATION_ROTATE_270: - newOrientationCode = clockwise ? ExifInterface.ORIENTATION_NORMAL : ExifInterface.ORIENTATION_ROTATE_180; - break; - default: - newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_90 : ExifInterface.ORIENTATION_ROTATE_270; - break; + // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` + // in that case we explicitely set it to `normal` first + // because ExifInterface fails to rotate an image with undefined orientation + // as of androidx.exifinterface:exifinterface:1.3.0 + int currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) { + exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL)); } - exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(newOrientationCode)); + exif.rotate(clockwise ? 90 : -90); exif.saveAttributes(); // copy the edited temporary file back to the original DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile); + + newFields.put("orientationDegrees", exif.getRotationDegrees()); } catch (IOException e) { callback.onFailure(e); return; } - // update fields in media store - int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode); - Map newFields = new HashMap<>(); - newFields.put("orientationDegrees", orientationDegrees); - // ContentResolver contentResolver = context.getContentResolver(); // ContentValues values = new ContentValues(); // // from Android Q, media store update needs to be flagged IS_PENDING first @@ -169,74 +158,6 @@ public abstract class ImageProvider { // } } - private void rotatePng(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { - final String mimeType = MimeTypes.PNG; - - final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri); - if (originalDocumentFile == null) { - callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); - return; - } - - // copy original file to a temporary file for editing - final String editablePath = StorageUtils.copyFileToTemp(originalDocumentFile, path); - if (editablePath == null) { - callback.onFailure(new Exception("failed to create a temporary file for path=" + path)); - return; - } - - Bitmap originalImage; - try { - originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(context, uri)); - } catch (FileNotFoundException e) { - callback.onFailure(e); - return; - } - if (originalImage == null) { - callback.onFailure(new Exception("failed to decode image at path=" + path)); - return; - } - Bitmap rotatedImage = TransformationUtils.rotateImage(originalImage, clockwise ? 90 : -90); - - try (FileOutputStream fos = new FileOutputStream(editablePath)) { - rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos); - - // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile); - } catch (IOException e) { - callback.onFailure(e); - return; - } - - // update fields in media store - int rotatedWidth = originalImage.getHeight(); - int rotatedHeight = originalImage.getWidth(); - Map newFields = new HashMap<>(); - newFields.put("width", rotatedWidth); - newFields.put("height", rotatedHeight); - -// ContentResolver contentResolver = context.getContentResolver(); -// ContentValues values = new ContentValues(); -// // from Android Q, media store update needs to be flagged IS_PENDING first -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { -// values.put(MediaStore.MediaColumns.IS_PENDING, 1); -// // TODO TLAD catch RecoverableSecurityException -// contentResolver.update(uri, values, null, null); -// values.clear(); -// values.put(MediaStore.MediaColumns.IS_PENDING, 0); -// } -// values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth); -// values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight); -// // TODO TLAD catch RecoverableSecurityException -// int updatedRowCount = contentResolver.update(uri, values, null, null); -// if (updatedRowCount > 0) { - MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); -// } else { -// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); -// callback.onSuccess(newFields); -// } - } - protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) { MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { long contentId = 0; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index fc2671cf6..242843d7a 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -54,7 +54,7 @@ class MimeFilter extends CollectionFilter { RegExp('.*/'), // remove type, keep subtype RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes '+XML', // noisy suffix - RegExp('ADOBE\.'), // for PSD + RegExp('ADOBE\\\.'), // for PSD ]; mime = mime.toUpperCase(); patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, '')); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 1b2fb754a..04a692e3c 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -177,7 +177,20 @@ class ImageEntry { bool get canPrint => !isVideo; - bool get canRotate => canEdit && (mimeType == MimeTypes.jpeg || mimeType == MimeTypes.png); + bool get canRotate => canEdit && canEditExif; + + // support for writing EXIF + // as of androidx.exifinterface:exifinterface:1.3.0 + bool get canEditExif { + switch (mimeType.toLowerCase()) { + case MimeTypes.jpeg: + case MimeTypes.png: + case MimeTypes.webp: + return true; + default: + return false; + } + } bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;