rotate PNG/WEBP by EXIF orientation

This commit is contained in:
Thibault Deckers 2020-10-04 14:02:11 +09:00
parent 5c0e9063f4
commit 465bf9ceb6
3 changed files with 39 additions and 105 deletions

View file

@ -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<String, Object> 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<String, Object> 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<String, Object> 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;

View file

@ -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, ''));

View file

@ -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;