rotate PNG/WEBP by EXIF orientation
This commit is contained in:
parent
5c0e9063f4
commit
465bf9ceb6
3 changed files with 39 additions and 105 deletions
|
@ -3,8 +3,6 @@ package deckers.thibault.aves.model.provider;
|
||||||
import android.content.ContentUris;
|
import android.content.ContentUris;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.graphics.BitmapFactory;
|
|
||||||
import android.media.MediaScannerConnection;
|
import android.media.MediaScannerConnection;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.provider.MediaStore;
|
import android.provider.MediaStore;
|
||||||
|
@ -13,21 +11,18 @@ import android.util.Log;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.exifinterface.media.ExifInterface;
|
import androidx.exifinterface.media.ExifInterface;
|
||||||
|
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.AvesImageEntry;
|
import deckers.thibault.aves.model.AvesImageEntry;
|
||||||
import deckers.thibault.aves.utils.MetadataHelper;
|
|
||||||
import deckers.thibault.aves.utils.MimeTypes;
|
import deckers.thibault.aves.utils.MimeTypes;
|
||||||
import deckers.thibault.aves.utils.StorageUtils;
|
import deckers.thibault.aves.utils.StorageUtils;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.Utils;
|
||||||
|
@ -86,21 +81,24 @@ public abstract class ImageProvider {
|
||||||
scanNewPath(context, newFile.getPath(), mimeType, callback);
|
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) {
|
switch (mimeType) {
|
||||||
case MimeTypes.JPEG:
|
case "image/jpeg":
|
||||||
rotateJpeg(context, path, uri, clockwise, callback);
|
case "image/png":
|
||||||
break;
|
case "image/webp":
|
||||||
case MimeTypes.PNG:
|
return true;
|
||||||
rotatePng(context, path, uri, clockwise, callback);
|
|
||||||
break;
|
|
||||||
default:
|
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) {
|
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||||
final String mimeType = MimeTypes.JPEG;
|
if (!canEditExif(mimeType)) {
|
||||||
|
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
|
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
|
||||||
if (originalDocumentFile == null) {
|
if (originalDocumentFile == null) {
|
||||||
|
@ -115,38 +113,29 @@ public abstract class ImageProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int newOrientationCode;
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
try {
|
try {
|
||||||
ExifInterface exif = new ExifInterface(editablePath);
|
ExifInterface exif = new ExifInterface(editablePath);
|
||||||
switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
|
||||||
case ExifInterface.ORIENTATION_ROTATE_90:
|
// in that case we explicitely set it to `normal` first
|
||||||
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_180 : ExifInterface.ORIENTATION_NORMAL;
|
// because ExifInterface fails to rotate an image with undefined orientation
|
||||||
break;
|
// as of androidx.exifinterface:exifinterface:1.3.0
|
||||||
case ExifInterface.ORIENTATION_ROTATE_180:
|
int currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
|
||||||
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_270 : ExifInterface.ORIENTATION_ROTATE_90;
|
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||||
break;
|
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(newOrientationCode));
|
exif.rotate(clockwise ? 90 : -90);
|
||||||
exif.saveAttributes();
|
exif.saveAttributes();
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile);
|
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile);
|
||||||
|
|
||||||
|
newFields.put("orientationDegrees", exif.getRotationDegrees());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
callback.onFailure(e);
|
callback.onFailure(e);
|
||||||
return;
|
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();
|
// ContentResolver contentResolver = context.getContentResolver();
|
||||||
// ContentValues values = new ContentValues();
|
// ContentValues values = new ContentValues();
|
||||||
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
// // 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) {
|
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) -> {
|
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||||
long contentId = 0;
|
long contentId = 0;
|
||||||
|
|
|
@ -54,7 +54,7 @@ class MimeFilter extends CollectionFilter {
|
||||||
RegExp('.*/'), // remove type, keep subtype
|
RegExp('.*/'), // remove type, keep subtype
|
||||||
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
|
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
|
||||||
'+XML', // noisy suffix
|
'+XML', // noisy suffix
|
||||||
RegExp('ADOBE\.'), // for PSD
|
RegExp('ADOBE\\\.'), // for PSD
|
||||||
];
|
];
|
||||||
mime = mime.toUpperCase();
|
mime = mime.toUpperCase();
|
||||||
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
|
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
|
||||||
|
|
|
@ -177,7 +177,20 @@ class ImageEntry {
|
||||||
|
|
||||||
bool get canPrint => !isVideo;
|
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;
|
bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue