Skip to content

Commit

Permalink
[image_picker] Copy exif tags in categories II and III (flutter#4738)
Browse files Browse the repository at this point in the history
Added all exif tags from Category II and Category III (excluding ones that don't have consts in `ExifInterface` WaterDepth etc.). Those tags include data not related to the structure of the file itself so resizing definitely doesn't invalidate them and thus they should be copied over.

I've also switched from list of raw strings to consts provided be the `ExifInterface`

I'm not sure show to handle `TAG_ISO_SPEED_RATINGS` deprecation in this case. The const is deprecated and the tag itself is deprecated in the standard however it would be reasonable to copy it as well to handle legacy files which include it. It was also being copied in the previous versions of this package.

*List which issues are fixed by this PR. You must list at least one issue.*
flutter#132827

*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
  • Loading branch information
RobertOdrowaz authored Sep 12, 2023
1 parent 8d553e3 commit 4512e4d
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 37 deletions.
4 changes: 4 additions & 0 deletions packages/image_picker/image_picker_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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.
*
* <p>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<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
2 changes: 1 addition & 1 deletion packages/image_picker/image_picker_android/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 4512e4d

Please sign in to comment.