feat(exif): Implement writeExif for iOS, Android improvements (#2577)

* - Improve Android code so skipProcessing is not needed, the code is more in line with iOS, and is "fast" by default. This means that skipProcessing is no longer needed (nor used), and adding additional options will "slow down" the capture as expected, rather than having always a lot of processing. This shouldn't be a breaking change.

- document the writeExif option, and implement it for iOS as well.

* Release CF object which could cause a memleak
This commit is contained in:
cristianoccazinsp 2019-11-20 00:22:08 -03:00 committed by Sibelius Seraphini
parent 7fa631f32d
commit aa22fd1fff
8 changed files with 300 additions and 131 deletions

View File

@ -671,7 +671,19 @@ class Camera1 extends CameraViewImpl implements MediaRecorder.OnInfoListener,
mCamera.setParameters(mCameraParameters);
}
catch(RuntimeException e ) {
Log.e("CAMERA_1::", "setParameters failed", e);
Log.e("CAMERA_1::", "setParameters rotation failed", e);
}
}
// set quality on capture since we might not process the image bitmap if not needed now.
// This also achieves a much faster JPEG compression speed since it's done on the hardware
if(options.hasKey("quality")){
mCameraParameters.setJpegQuality((int) (options.getDouble("quality") * 100));
try{
mCamera.setParameters(mCameraParameters);
}
catch(RuntimeException e ) {
Log.e("CAMERA_1::", "setParameters quality failed", e);
}
}

View File

@ -1254,6 +1254,13 @@ class Camera2 extends CameraViewImpl implements MediaRecorder.OnInfoListener, Me
break;
}
captureRequestBuilder.set(CaptureRequest.JPEG_ORIENTATION, getOutputRotation());
if(mCaptureCallback.getOptions().hasKey("quality")){
int quality = (int) (mCaptureCallback.getOptions().getDouble("quality") * 100);
captureRequestBuilder.set(CaptureRequest.JPEG_QUALITY, (byte)quality);
}
captureRequestBuilder.set(CaptureRequest.SCALER_CROP_REGION, mPreviewRequestBuilder.get(CaptureRequest.SCALER_CROP_REGION));
// Stop preview and capture a still picture.
mCaptureSession.stopRepeating();

View File

@ -11,6 +11,7 @@ import android.view.ViewGroup;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReadableMapKeySetIterator;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.uimanager.UIManagerModule;
@ -373,7 +374,7 @@ public class RNCameraViewHelper {
return exifMap;
}
public static void setExifData(ExifInterface exifInterface, WritableMap exifMap) {
public static void setExifData(ExifInterface exifInterface, ReadableMap exifMap) {
for (String[] tagInfo : exifTags) {
String name = tagInfo[1];
if (exifMap.hasKey(name)) {
@ -394,15 +395,27 @@ public class RNCameraViewHelper {
}
}
if (exifMap.hasKey(ExifInterface.TAG_GPS_LATITUDE) &&
exifMap.hasKey(ExifInterface.TAG_GPS_LONGITUDE) &&
exifMap.hasKey(ExifInterface.TAG_GPS_ALTITUDE)) {
if (exifMap.hasKey(ExifInterface.TAG_GPS_LATITUDE) && exifMap.hasKey(ExifInterface.TAG_GPS_LONGITUDE)) {
exifInterface.setLatLong(exifMap.getDouble(ExifInterface.TAG_GPS_LATITUDE),
exifMap.getDouble(ExifInterface.TAG_GPS_LONGITUDE));
}
if(exifMap.hasKey(ExifInterface.TAG_GPS_ALTITUDE)){
exifInterface.setAltitude(exifMap.getDouble(ExifInterface.TAG_GPS_ALTITUDE));
}
}
// clears exif values in place
public static void clearExifData(ExifInterface exifInterface) {
for (String[] tagInfo : exifTags) {
exifInterface.setAttribute(tagInfo[1], null);
}
// these are not part of our tag list, remove by hand
exifInterface.setAttribute(ExifInterface.TAG_GPS_LATITUDE, null);
exifInterface.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, null);
exifInterface.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, null);
}
public static Bitmap generateSimulatorPhoto(int width, int height) {
Bitmap fakePhoto = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(fakePhoto);

View File

@ -27,10 +27,10 @@ import java.io.IOException;
public class ResolveTakenPictureAsyncTask extends AsyncTask<Void, Void, WritableMap> {
private static final String ERROR_TAG = "E_TAKING_PICTURE_FAILED";
private Promise mPromise;
private Bitmap mBitmap;
private byte[] mImageData;
private ReadableMap mOptions;
private File mCacheDirectory;
private Bitmap mBitmap;
private int mDeviceOrientation;
private PictureSavedDelegate mPictureSavedDelegate;
@ -47,115 +47,109 @@ public class ResolveTakenPictureAsyncTask extends AsyncTask<Void, Void, Writable
return (int) (mOptions.getDouble("quality") * 100);
}
// loads bitmap only if necessary
private void loadBitmap() throws IOException {
if(mBitmap == null){
mBitmap = BitmapFactory.decodeByteArray(mImageData, 0, mImageData.length);
}
if(mBitmap == null){
throw new IOException("Failed to decode Image Bitmap");
}
}
@Override
protected WritableMap doInBackground(Void... voids) {
WritableMap response = Arguments.createMap();
ByteArrayInputStream inputStream = null;
ExifInterface exifInterface = null;
WritableMap exifData = null;
ReadableMap exifExtraData = null;
boolean orientationChanged = false;
response.putInt("deviceOrientation", mDeviceOrientation);
response.putInt("pictureOrientation", mOptions.hasKey("orientation") ? mOptions.getInt("orientation") : mDeviceOrientation);
if (mOptions.hasKey("skipProcessing")) {
try {
// Prepare file output
File imageFile = new File(RNFileUtils.getOutputFilePath(mCacheDirectory, ".jpg"));
imageFile.createNewFile();
FileOutputStream fOut = new FileOutputStream(imageFile);
// Save byte array (it is already a JPEG)
fOut.write(mImageData);
try{
// this replaces the skipProcessing flag, we will process only if needed, and in
// an orderly manner, so that skipProcessing is the default behaviour if no options are given
// and this behaves more like the iOS version.
// We will load all data lazily only when needed.
// get image size
if (mBitmap == null) {
mBitmap = BitmapFactory.decodeByteArray(mImageData, 0, mImageData.length);
// this should not incurr in any overhead if not read/used
inputStream = new ByteArrayInputStream(mImageData);
// Rotate the bitmap to the proper orientation if requested
if(mOptions.hasKey("fixOrientation") && mOptions.getBoolean("fixOrientation")){
exifInterface = new ExifInterface(inputStream);
// Get orientation of the image from mImageData via inputStream
int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
if(orientation != ExifInterface.ORIENTATION_UNDEFINED){
loadBitmap();
mBitmap = rotateBitmap(mBitmap, getImageRotation(orientation));
orientationChanged = true;
}
if(mBitmap == null){
throw new IOException("Failed to decode Image bitmap.");
}
response.putInt("width", mBitmap.getWidth());
response.putInt("height", mBitmap.getHeight());
// Return file system URI
String fileUri = Uri.fromFile(imageFile).toString();
response.putString("uri", fileUri);
} catch (Resources.NotFoundException e) {
response = null; // do not resolve
mPromise.reject(ERROR_TAG, "Documents directory of the app could not be found.", e);
e.printStackTrace();
} catch (IOException e) {
response = null; // do not resolve
mPromise.reject(ERROR_TAG, "An unknown I/O exception has occurred.", e);
e.printStackTrace();
}
return response;
}
if (mOptions.hasKey("width")) {
loadBitmap();
mBitmap = resizeBitmap(mBitmap, mOptions.getInt("width"));
}
// we need the stream only for photos from a device
if (mBitmap == null) {
mBitmap = BitmapFactory.decodeByteArray(mImageData, 0, mImageData.length);
inputStream = new ByteArrayInputStream(mImageData);
}
if (mOptions.hasKey("mirrorImage") && mOptions.getBoolean("mirrorImage")) {
loadBitmap();
mBitmap = flipHorizontally(mBitmap);
}
try {
WritableMap fileExifData = null;
if (inputStream != null) {
ExifInterface exifInterface = new ExifInterface(inputStream);
// Get orientation of the image from mImageData via inputStream
int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED);
// EXIF code - we will adjust exif info later if we manipulated the bitmap
boolean writeExifToResponse = mOptions.hasKey("exif") && mOptions.getBoolean("exif");
// Rotate the bitmap to the proper orientation if needed
boolean fixOrientation = mOptions.hasKey("fixOrientation")
&& mOptions.getBoolean("fixOrientation")
&& orientation != ExifInterface.ORIENTATION_UNDEFINED;
if (fixOrientation) {
mBitmap = rotateBitmap(mBitmap, getImageRotation(orientation));
// default to true if not provided so it is consistent with iOS and with what happens if no
// processing is done and the image is saved as is.
boolean writeExifToFile = true;
if (mOptions.hasKey("writeExif")) {
switch (mOptions.getType("writeExif")) {
case Boolean:
writeExifToFile = mOptions.getBoolean("writeExif");
break;
case Map:
exifExtraData = mOptions.getMap("writeExif");
writeExifToFile = true;
break;
}
}
if (mOptions.hasKey("width")) {
mBitmap = resizeBitmap(mBitmap, mOptions.getInt("width"));
}
// Read Exif data if needed
if (writeExifToResponse || writeExifToFile) {
if (mOptions.hasKey("mirrorImage") && mOptions.getBoolean("mirrorImage")) {
mBitmap = flipHorizontally(mBitmap);
}
WritableMap exifData = null;
ReadableMap exifExtraData = null;
boolean writeExifToResponse = mOptions.hasKey("exif") && mOptions.getBoolean("exif");
boolean writeExifToFile = false;
if (mOptions.hasKey("writeExif")) {
switch (mOptions.getType("writeExif")) {
case Boolean:
writeExifToFile = mOptions.getBoolean("writeExif");
break;
case Map:
exifExtraData = mOptions.getMap("writeExif");
writeExifToFile = true;
break;
// if we manipulated the image, or need to add extra data, or need to add it to the response,
// then we need to load the actual exif data.
// Otherwise we can just use w/e exif data we have right now in our byte array
if(mBitmap != null || exifExtraData != null || writeExifToResponse){
if(exifInterface == null){
exifInterface = new ExifInterface(inputStream);
}
}
// Read Exif data if needed
if (writeExifToResponse || writeExifToFile) {
exifData = RNCameraViewHelper.getExifData(exifInterface);
if(exifExtraData != null){
exifData.merge(exifExtraData);
}
}
// Write Exif data to output file if requested
if (writeExifToFile) {
fileExifData = Arguments.createMap();
fileExifData.merge(exifData);
fileExifData.putInt("width", mBitmap.getWidth());
fileExifData.putInt("height", mBitmap.getHeight());
if (fixOrientation) {
fileExifData.putInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
}
if (exifExtraData != null) {
fileExifData.merge(exifExtraData);
// if we did anything to the bitmap, adjust exif
if(mBitmap != null){
exifData.putInt("width", mBitmap.getWidth());
exifData.putInt("height", mBitmap.getHeight());
if(orientationChanged){
exifData.putInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
}
}
@ -165,47 +159,108 @@ public class ResolveTakenPictureAsyncTask extends AsyncTask<Void, Void, Writable
}
}
// Upon rotating, write the image's dimensions to the response
response.putInt("width", mBitmap.getWidth());
response.putInt("height", mBitmap.getHeight());
// Cache compressed image in imageStream
ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
mBitmap.compress(Bitmap.CompressFormat.JPEG, getQuality(), imageStream);
// Write compressed image to file in cache directory unless otherwise specified
if (!mOptions.hasKey("doNotSave") || !mOptions.getBoolean("doNotSave")) {
String filePath = writeStreamToFile(imageStream);
if (fileExifData != null) {
ExifInterface fileExifInterface = new ExifInterface(filePath);
RNCameraViewHelper.setExifData(fileExifInterface, fileExifData);
fileExifInterface.saveAttributes();
// final processing
// Based on whether or not we loaded the full bitmap into memory, final processing differs
if(mBitmap == null){
// set response dimensions. If we haven't read our bitmap, get it efficiently
// without loading the actual bitmap into memory
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeByteArray(mImageData, 0, mImageData.length, options);
if(options != null){
response.putInt("width", options.outWidth);
response.putInt("height", options.outHeight);
}
File imageFile = new File(filePath);
String fileUri = Uri.fromFile(imageFile).toString();
response.putString("uri", fileUri);
}
// Write base64-encoded image to the response if requested
if (mOptions.hasKey("base64") && mOptions.getBoolean("base64")) {
response.putString("base64", Base64.encodeToString(imageStream.toByteArray(), Base64.NO_WRAP));
}
// Cleanup
imageStream.close();
if (inputStream != null) {
inputStream.close();
inputStream = null;
// save to file if requested
if (!mOptions.hasKey("doNotSave") || !mOptions.getBoolean("doNotSave")) {
// Prepare file output
File imageFile = new File(RNFileUtils.getOutputFilePath(mCacheDirectory, ".jpg"));
imageFile.createNewFile();
FileOutputStream fOut = new FileOutputStream(imageFile);
// Save byte array (it is already a JPEG)
fOut.write(mImageData);
fOut.flush();
fOut.close();
// update exif data if needed.
// Since we didn't modify the image, we only update if we have extra exif info
if (writeExifToFile && exifExtraData != null) {
ExifInterface fileExifInterface = new ExifInterface(imageFile.getAbsolutePath());
RNCameraViewHelper.setExifData(fileExifInterface, exifExtraData);
fileExifInterface.saveAttributes();
}
else if (!writeExifToFile){
// if we were requested to NOT store exif, we actually need to
// clear the exif tags
ExifInterface fileExifInterface = new ExifInterface(imageFile.getAbsolutePath());
RNCameraViewHelper.clearExifData(fileExifInterface);
fileExifInterface.saveAttributes();
}
// else: exif is unmodified, no need to update anything
// Return file system URI
String fileUri = Uri.fromFile(imageFile).toString();
response.putString("uri", fileUri);
}
if (mOptions.hasKey("base64") && mOptions.getBoolean("base64")) {
response.putString("base64", Base64.encodeToString(mImageData, Base64.NO_WRAP));
}
}
else{
// get response dimensions right from the bitmap if we have it
response.putInt("width", mBitmap.getWidth());
response.putInt("height", mBitmap.getHeight());
// Cache compressed image in imageStream
ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
mBitmap.compress(Bitmap.CompressFormat.JPEG, getQuality(), imageStream);
// Write compressed image to file in cache directory unless otherwise specified
if (!mOptions.hasKey("doNotSave") || !mOptions.getBoolean("doNotSave")) {
String filePath = writeStreamToFile(imageStream);
// since we lost any exif data on bitmap creation, we only need
// to add it if requested
if (writeExifToFile && exifData != null) {
ExifInterface fileExifInterface = new ExifInterface(filePath);
RNCameraViewHelper.setExifData(fileExifInterface, exifData);
fileExifInterface.saveAttributes();
}
File imageFile = new File(filePath);
String fileUri = Uri.fromFile(imageFile).toString();
response.putString("uri", fileUri);
}
// Write base64-encoded image to the response if requested
if (mOptions.hasKey("base64") && mOptions.getBoolean("base64")) {
response.putString("base64", Base64.encodeToString(imageStream.toByteArray(), Base64.NO_WRAP));
}
}
return response;
} catch (Resources.NotFoundException e) {
}
catch (Resources.NotFoundException e) {
mPromise.reject(ERROR_TAG, "Documents directory of the app could not be found.", e);
e.printStackTrace();
} catch (IOException e) {
}
catch (IOException e) {
mPromise.reject(ERROR_TAG, "An unknown I/O exception has occurred.", e);
e.printStackTrace();
} finally {
}
finally {
try {
if (inputStream != null) {
inputStream.close();
@ -215,7 +270,6 @@ public class ResolveTakenPictureAsyncTask extends AsyncTask<Void, Void, Writable
}
}
// An exception had to occur, promise has already been rejected. Do not try to resolve it again.
return null;
}

View File

@ -25,11 +25,10 @@ interface TakePictureOptions {
mirrorImage?: boolean;
doNotSave?: boolean;
pauseAfterCapture?: boolean;
writeExif?: boolean | { [name: string]: any };
/** Android only */
skipProcessing?: boolean;
fixOrientation?: boolean;
writeExif?: boolean | { [name: string]: any };
/** iOS only */
forceUpOrientation?: boolean;

View File

@ -524,7 +524,7 @@ Method to be called when text is detected. Receives a Text Recognized Event obje
### `takePictureAsync([options]): Promise`
Takes a picture, saves in your app's cache directory and returns a promise.
Takes a picture, saves in your app's cache directory and returns a promise. Note: additional image processing, such as mirror, orientation, and width, can be significantly slow on Android.
Supported options:
@ -536,14 +536,21 @@ Supported options:
- `mirrorImage` (boolean true or false). Use this with `true` if you want the resulting rendered picture to be mirrored (inverted in the vertical axis). If no value is specified `mirrorImage:false` is used.
- `writeExif`: (boolean or object, defaults to true). Setting this to a boolean indicates if the image exif should be preserved after capture, or removed. Setting it to an object, merges any data with the final exif output. This is useful, for example, to add GPS metadata (note that GPS info is correctly transalted from double values to the EXIF format, so there's no need to read the EXIF protocol).
```js
writeExif = {
"GPSLatitude": latitude,
"GPSLongitude": longitude,
"GPSAltitude": altitude
}
```
- `exif` (boolean true or false) Use this with `true` if you want a exif data map of the picture taken on the return data of your promise. If no value is specified `exif:false` is used.
- `fixOrientation` (android only, boolean true or false) Use this with `true` if you want to fix incorrect image orientation (can take up to 5 seconds on some devices). Do not provide this if you only need EXIF based orientation.
- `forceUpOrientation` (iOS only, boolean true or false). This property allows to force portrait orientation based on actual data instead of exif data.
- `skipProcessing` (android only, boolean). This property skips all image processing on android, this makes taking photos super fast, but you loose some of the information, width, height and the ability to do some processing on the image (base64, width, quality, mirrorImage, exif, etc)
- `doNotSave` (boolean true or false). Use this with `true` if you do not want the picture to be saved as a file to cache. If no value is specified `doNotSave:false` is used. If you only need the base64 for the image, you can use this with `base64:true` and avoid having to save the file.
- `pauseAfterCapture` (boolean true or false). If true, pause the preview layer immediately after capturing the image. You will need to call `cameraRef.resumePreview()` before using the camera again. If no value is specified `pauseAfterCapture:false` is used.

View File

@ -791,7 +791,15 @@ BOOL _sessionInterrupted = NO;
// get image metadata so we can re-add it later
// make it mutable since we need to adjust quality/compression
CFDictionaryRef metaDict = CMCopyDictionaryOfAttachments(NULL, imageSampleBuffer, kCMAttachmentMode_ShouldPropagate);
NSMutableDictionary *metadata = [(__bridge NSDictionary*)metaDict mutableCopy];
CFMutableDictionaryRef mutableMetaDict = CFDictionaryCreateMutableCopy(NULL, 0, metaDict);
// release the meta dict now that we've copied it
// to Objective-C land
CFRelease(metaDict);
// bridge the copy for auto release
NSMutableDictionary *metadata = (NSMutableDictionary *)CFBridgingRelease(mutableMetaDict);
// Get final JPEG image and set compression
@ -813,7 +821,77 @@ BOOL _sessionInterrupted = NO;
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)destData, kUTTypeJPEG, 1, NULL);
CGImageDestinationAddImage(destination, takenImage.CGImage, (__bridge CFDictionaryRef) metadata);
// defaults to true, must like Android
bool writeExif = true;
if(options[@"writeExif"]){
// if we received an object, merge with our meta
if ([options[@"writeExif"] isKindOfClass:[NSDictionary class]]){
NSDictionary *newExif = options[@"writeExif"];
// need to update both, since apple splits data
// across exif and tiff dicts. No problems with duplicates
// they will be handled appropiately.
NSMutableDictionary *exif = metadata[(NSString*)kCGImagePropertyExifDictionary];
NSMutableDictionary *tiff = metadata[(NSString*)kCGImagePropertyTIFFDictionary];
// initialize exif dict if not built
if(!exif){
exif = [[NSMutableDictionary alloc] init];
metadata[(NSString*)kCGImagePropertyExifDictionary] = exif;
}
if(!tiff){
tiff = [[NSMutableDictionary alloc] init];
metadata[(NSString*)kCGImagePropertyTIFFDictionary] = exif;
}
// merge new exif info
[exif addEntriesFromDictionary:newExif];
[tiff addEntriesFromDictionary:newExif];
// correct any GPS metadata like Android does
// need to get the right format for each value.
NSMutableDictionary *gpsDict = [[NSMutableDictionary alloc] init];
if(newExif[@"GPSLatitude"]){
gpsDict[(NSString *)kCGImagePropertyGPSLatitude] = @(fabs([newExif[@"GPSLatitude"] floatValue]));
gpsDict[(NSString *)kCGImagePropertyGPSLatitudeRef] = [newExif[@"GPSLatitude"] floatValue] >= 0 ? @"N" : @"S";
}
if(newExif[@"GPSLongitude"]){
gpsDict[(NSString *)kCGImagePropertyGPSLongitude] = @(fabs([newExif[@"GPSLongitude"] floatValue]));
gpsDict[(NSString *)kCGImagePropertyGPSLongitudeRef] = [newExif[@"GPSLongitude"] floatValue] >= 0 ? @"E" : @"W";
}
if(newExif[@"GPSAltitude"]){
gpsDict[(NSString *)kCGImagePropertyGPSAltitude] = @(fabs([newExif[@"GPSAltitude"] floatValue]));
gpsDict[(NSString *)kCGImagePropertyGPSAltitudeRef] = [newExif[@"GPSAltitude"] floatValue] >= 0 ? @(0) : @(1);
}
// if we don't have gps info, add it
// otherwise, merge it
if(!metadata[(NSString *)kCGImagePropertyGPSDictionary]){
metadata[(NSString *)kCGImagePropertyGPSDictionary] = gpsDict;
}
else{
[metadata[(NSString *)kCGImagePropertyGPSDictionary] addEntriesFromDictionary:gpsDict];
}
}
else{
writeExif = [options[@"writeExif"] boolValue];
}
}
CGImageDestinationAddImage(destination, takenImage.CGImage, writeExif ? ((__bridge CFDictionaryRef) metadata) : nil);
// write final image data with metadata to our destination
@ -1490,7 +1568,7 @@ BOOL _sessionInterrupted = NO;
dispatch_async(self.sessionQueue, ^{
[self removeAudioCaptureSessionInput];
});
}
}

3
types/index.d.ts vendored
View File

@ -362,11 +362,10 @@ interface TakePictureOptions {
mirrorImage?: boolean;
doNotSave?: boolean;
pauseAfterCapture?: boolean;
writeExif?: boolean | { [name: string]: any };
/** Android only */
skipProcessing?: boolean;
fixOrientation?: boolean;
writeExif?: boolean | { [name: string]: any };
/** iOS only */
forceUpOrientation?: boolean;