diff --git a/packages/image_picker/image_picker_android/CHANGELOG.md b/packages/image_picker/image_picker_android/CHANGELOG.md index b237a8c63ee3..1d14021f1536 100644 --- a/packages/image_picker/image_picker_android/CHANGELOG.md +++ b/packages/image_picker/image_picker_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.8 + +* Adds additional category II and III exif tags to be copied during photo resize. + ## 0.8.7+5 * Adds pub topics to package metadata. diff --git a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ExifDataCopier.java b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ExifDataCopier.java index eada546f029a..e80e1602146e 100644 --- a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ExifDataCopier.java +++ b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ExifDataCopier.java @@ -4,47 +4,138 @@ package io.flutter.plugins.imagepicker; -import android.util.Log; import androidx.exifinterface.media.ExifInterface; +import java.io.IOException; import java.util.Arrays; import java.util.List; class ExifDataCopier { - void copyExif(String filePathOri, String filePathDest) { - try { - ExifInterface oldExif = new ExifInterface(filePathOri); - ExifInterface newExif = new ExifInterface(filePathDest); - - List attributes = - Arrays.asList( - "FNumber", - "ExposureTime", - "ISOSpeedRatings", - "GPSAltitude", - "GPSAltitudeRef", - "FocalLength", - "GPSDateStamp", - "WhiteBalance", - "GPSProcessingMethod", - "GPSTimeStamp", - "DateTime", - "Flash", - "GPSLatitude", - "GPSLatitudeRef", - "GPSLongitude", - "GPSLongitudeRef", - "Make", - "Model", - "Orientation"); - for (String attribute : attributes) { - setIfNotNull(oldExif, newExif, attribute); - } - - newExif.saveAttributes(); - - } catch (Exception ex) { - Log.e("ExifDataCopier", "Error preserving Exif data on selected image: " + ex); + /** + * Copies all exif data not related to image structure and orientation tag. Data not related to + * image structure consists of category II (Shooting condition related metadata) and category III + * (Metadata storing other information) tags. Category I tags are not copied because they may be + * invalidated as a result of resizing. The exception is the orientation tag which is known to not + * be invalidated and is crucial for proper display of the image. + * + *

The categories mentioned refer to standard "CIPA DC-008-Translation-2012 Exchangeable image + * file format for digital still cameras: Exif Version 2.3" + * https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf. Version 2.3 has been chosen because + * {@code ExifInterface} is based on it. + */ + void copyExif(ExifInterface oldExif, ExifInterface newExif) throws IOException { + @SuppressWarnings("deprecation") + List attributes = + Arrays.asList( + ExifInterface.TAG_IMAGE_DESCRIPTION, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_SOFTWARE, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_F_NUMBER, + ExifInterface.TAG_EXPOSURE_PROGRAM, + ExifInterface.TAG_SPECTRAL_SENSITIVITY, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, + ExifInterface.TAG_ISO_SPEED_RATINGS, + ExifInterface.TAG_OECF, + ExifInterface.TAG_SENSITIVITY_TYPE, + ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY, + ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX, + ExifInterface.TAG_ISO_SPEED, + ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY, + ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ, + ExifInterface.TAG_EXIF_VERSION, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_SHUTTER_SPEED_VALUE, + ExifInterface.TAG_APERTURE_VALUE, + ExifInterface.TAG_BRIGHTNESS_VALUE, + ExifInterface.TAG_EXPOSURE_BIAS_VALUE, + ExifInterface.TAG_MAX_APERTURE_VALUE, + ExifInterface.TAG_SUBJECT_DISTANCE, + ExifInterface.TAG_METERING_MODE, + ExifInterface.TAG_LIGHT_SOURCE, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_MAKER_NOTE, + ExifInterface.TAG_USER_COMMENT, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, + ExifInterface.TAG_FLASHPIX_VERSION, + ExifInterface.TAG_FLASH_ENERGY, + ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE, + ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT, + ExifInterface.TAG_EXPOSURE_INDEX, + ExifInterface.TAG_SENSING_METHOD, + ExifInterface.TAG_FILE_SOURCE, + ExifInterface.TAG_SCENE_TYPE, + ExifInterface.TAG_CFA_PATTERN, + ExifInterface.TAG_CUSTOM_RENDERED, + ExifInterface.TAG_EXPOSURE_MODE, + ExifInterface.TAG_WHITE_BALANCE, + ExifInterface.TAG_DIGITAL_ZOOM_RATIO, + ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM, + ExifInterface.TAG_SCENE_CAPTURE_TYPE, + ExifInterface.TAG_GAIN_CONTROL, + ExifInterface.TAG_CONTRAST, + ExifInterface.TAG_SATURATION, + ExifInterface.TAG_SHARPNESS, + ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION, + ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, + ExifInterface.TAG_IMAGE_UNIQUE_ID, + ExifInterface.TAG_CAMERA_OWNER_NAME, + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SPECIFICATION, + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_LENS_SERIAL_NUMBER, + ExifInterface.TAG_GPS_VERSION_ID, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_H_POSITIONING_ERROR, + ExifInterface.TAG_INTEROPERABILITY_INDEX, + ExifInterface.TAG_ORIENTATION); + for (String attribute : attributes) { + setIfNotNull(oldExif, newExif, attribute); } + + newExif.saveAttributes(); } private static void setIfNotNull(ExifInterface oldExif, ExifInterface newExif, String property) { diff --git a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java index 6e04ed40a11a..b5024d6780c3 100644 --- a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java +++ b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java @@ -11,6 +11,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.SizeFCompat; +import androidx.exifinterface.media.ExifInterface; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -137,7 +138,11 @@ private FileOutputStream createOutputStream(File imageFile) throws IOException { } private void copyExif(String filePathOri, String filePathDest) { - exifDataCopier.copyExif(filePathOri, filePathDest); + try { + exifDataCopier.copyExif(new ExifInterface(filePathOri), new ExifInterface(filePathDest)); + } catch (Exception ex) { + Log.e("ImageResizer", "Error preserving Exif data on selected image: " + ex); + } } private SizeFCompat readFileDimensions(String path) { diff --git a/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ExifDataCopierTest.java b/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ExifDataCopierTest.java new file mode 100644 index 000000000000..4e2521e954d8 --- /dev/null +++ b/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ExifDataCopierTest.java @@ -0,0 +1,114 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepicker; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import androidx.exifinterface.media.ExifInterface; +import java.io.IOException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class ExifDataCopierTest { + @Mock ExifInterface mockOldExif; + @Mock ExifInterface mockNewExif; + + ExifDataCopier exifDataCopier = new ExifDataCopier(); + + AutoCloseable mockCloseable; + + String orientationValue = "Horizontal (normal)"; + String imageWidthValue = "4032"; + String whitePointValue = "0.96419 1 0.82489"; + String colorSpaceValue = "Uncalibrated"; + String exposureTimeValue = "1/9"; + String exposureModeValue = "Auto"; + String exifVersionValue = "0232"; + String makeValue = "Apple"; + String dateTimeOriginalValue = "2023:02:14 18:55:19"; + String offsetTimeValue = "+01:00"; + + @Before + public void setUp() { + mockCloseable = MockitoAnnotations.openMocks(this); + } + + @After + public void tearDown() throws Exception { + mockCloseable.close(); + } + + @Test + public void copyExif_copiesOrientationAttribute() throws IOException { + when(mockOldExif.getAttribute(ExifInterface.TAG_ORIENTATION)).thenReturn(orientationValue); + + exifDataCopier.copyExif(mockOldExif, mockNewExif); + + verify(mockNewExif).setAttribute(ExifInterface.TAG_ORIENTATION, orientationValue); + } + + @Test + public void copyExif_doesNotCopyCategory1AttributesExceptForOrientation() throws IOException { + when(mockOldExif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)).thenReturn(imageWidthValue); + when(mockOldExif.getAttribute(ExifInterface.TAG_WHITE_POINT)).thenReturn(whitePointValue); + when(mockOldExif.getAttribute(ExifInterface.TAG_COLOR_SPACE)).thenReturn(colorSpaceValue); + + exifDataCopier.copyExif(mockOldExif, mockNewExif); + + verify(mockNewExif, never()).setAttribute(eq(ExifInterface.TAG_IMAGE_WIDTH), any()); + verify(mockNewExif, never()).setAttribute(eq(ExifInterface.TAG_WHITE_POINT), any()); + verify(mockNewExif, never()).setAttribute(eq(ExifInterface.TAG_COLOR_SPACE), any()); + } + + @Test + public void copyExif_copiesCategory2Attributes() throws IOException { + when(mockOldExif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)).thenReturn(exposureTimeValue); + when(mockOldExif.getAttribute(ExifInterface.TAG_EXPOSURE_MODE)).thenReturn(exposureModeValue); + when(mockOldExif.getAttribute(ExifInterface.TAG_EXIF_VERSION)).thenReturn(exifVersionValue); + + exifDataCopier.copyExif(mockOldExif, mockNewExif); + + verify(mockNewExif).setAttribute(ExifInterface.TAG_EXPOSURE_TIME, exposureTimeValue); + verify(mockNewExif).setAttribute(ExifInterface.TAG_EXPOSURE_MODE, exposureModeValue); + verify(mockNewExif).setAttribute(ExifInterface.TAG_EXIF_VERSION, exifVersionValue); + } + + @Test + public void copyExif_copiesCategory3Attributes() throws IOException { + when(mockOldExif.getAttribute(ExifInterface.TAG_MAKE)).thenReturn(makeValue); + when(mockOldExif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)) + .thenReturn(dateTimeOriginalValue); + when(mockOldExif.getAttribute(ExifInterface.TAG_OFFSET_TIME)).thenReturn(offsetTimeValue); + + exifDataCopier.copyExif(mockOldExif, mockNewExif); + + verify(mockNewExif).setAttribute(ExifInterface.TAG_MAKE, makeValue); + verify(mockNewExif).setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTimeOriginalValue); + verify(mockNewExif).setAttribute(ExifInterface.TAG_OFFSET_TIME, offsetTimeValue); + } + + @Test + public void copyExif_doesNotCopyUnsetAttributes() throws IOException { + when(mockOldExif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)).thenReturn(null); + + exifDataCopier.copyExif(mockOldExif, mockNewExif); + + verify(mockNewExif, never()).setAttribute(eq(ExifInterface.TAG_EXPOSURE_TIME), any()); + } + + @Test + public void copyExif_savesAttributes() throws IOException { + exifDataCopier.copyExif(mockOldExif, mockNewExif); + + verify(mockNewExif).saveAttributes(); + } +} diff --git a/packages/image_picker/image_picker_android/pubspec.yaml b/packages/image_picker/image_picker_android/pubspec.yaml index a8bfb2133a4f..434005967530 100755 --- a/packages/image_picker/image_picker_android/pubspec.yaml +++ b/packages/image_picker/image_picker_android/pubspec.yaml @@ -3,7 +3,7 @@ description: Android implementation of the image_picker plugin. repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.7+5 +version: 0.8.8 environment: sdk: ">=2.19.0 <4.0.0"