blob: 92e43ce20b5f10b5ce881dce58bc61f1c18ec2f0 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.camera.core;
import android.location.Location;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.exifinterface.media.ExifInterface;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* Utility class for modifying metadata on JPEG files.
*
* <p>Call {@link #save()} to persist changes to disc.
*
* @hide
*/
@RestrictTo(Scope.LIBRARY_GROUP)
public final class Exif {
/** Timestamp value indicating a timestamp value that is either not set or not valid */
public static final long INVALID_TIMESTAMP = -1;
private static final String TAG = Exif.class.getSimpleName();
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
new ThreadLocal<SimpleDateFormat>() {
@Override
public SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy:MM:dd", Locale.US);
}
};
private static final ThreadLocal<SimpleDateFormat> TIME_FORMAT =
new ThreadLocal<SimpleDateFormat>() {
@Override
public SimpleDateFormat initialValue() {
return new SimpleDateFormat("HH:mm:ss", Locale.US);
}
};
private static final ThreadLocal<SimpleDateFormat> DATETIME_FORMAT =
new ThreadLocal<SimpleDateFormat>() {
@Override
public SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US);
}
};
private static final String KILOMETERS_PER_HOUR = "K";
private static final String MILES_PER_HOUR = "M";
private static final String KNOTS = "N";
private final ExifInterface mExifInterface;
// When true, avoid saving any time. This is a privacy issue.
private boolean mRemoveTimestamp = false;
private Exif(ExifInterface exifInterface) {
mExifInterface = exifInterface;
}
/**
* Returns an Exif from the exif data contained in the file.
*
* @param file the file to read exif data from
*/
public static Exif createFromFile(File file) throws IOException {
return createFromFileString(file.toString());
}
/**
* Returns an Exif from the exif data contained in the file at the filePath
*
* @param filePath the path to the file to read exif data from
*/
public static Exif createFromFileString(String filePath) throws IOException {
return new Exif(new ExifInterface(filePath));
}
/**
* Returns an Exif from the exif data contain in the input stream.
* @param is the input stream to read exif data from
*/
public static Exif createFromInputStream(InputStream is) throws IOException {
return new Exif(new ExifInterface(is));
}
private static String convertToExifDateTime(long timestamp) {
return DATETIME_FORMAT.get().format(new Date(timestamp));
}
private static Date convertFromExifDateTime(String dateTime) throws ParseException {
return DATETIME_FORMAT.get().parse(dateTime);
}
private static Date convertFromExifDate(String date) throws ParseException {
return DATE_FORMAT.get().parse(date);
}
private static Date convertFromExifTime(String time) throws ParseException {
return TIME_FORMAT.get().parse(time);
}
/** Persists changes to disc. */
public void save() throws IOException {
if (!mRemoveTimestamp) {
attachLastModifiedTimestamp();
}
mExifInterface.saveAttributes();
}
@Override
public String toString() {
return String.format(
Locale.ENGLISH,
"Exif{width=%s, height=%s, rotation=%d, "
+ "isFlippedVertically=%s, isFlippedHorizontally=%s, location=%s, "
+ "timestamp=%s, description=%s}",
getWidth(),
getHeight(),
getRotation(),
isFlippedVertically(),
isFlippedHorizontally(),
getLocation(),
getTimestamp(),
getDescription());
}
private int getOrientation() {
return mExifInterface.getAttributeInt(
ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
}
/** Returns the width of the photo in pixels. */
public int getWidth() {
return mExifInterface.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0);
}
/** Returns the height of the photo in pixels. */
public int getHeight() {
return mExifInterface.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0);
}
@Nullable
public String getDescription() {
return mExifInterface.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION);
}
/** Sets the description for the exif. */
public void setDescription(@Nullable String description) {
mExifInterface.setAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION, description);
}
/** @return The degree of rotation (eg. 0, 90, 180, 270). */
public int getRotation() {
switch (getOrientation()) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
return 0;
case ExifInterface.ORIENTATION_ROTATE_180:
return 180;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
return 180;
case ExifInterface.ORIENTATION_TRANSPOSE:
return 270;
case ExifInterface.ORIENTATION_ROTATE_90:
return 90;
case ExifInterface.ORIENTATION_TRANSVERSE:
return 90;
case ExifInterface.ORIENTATION_ROTATE_270:
return 270;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
return 0;
}
}
/** @return True if the image is flipped vertically after rotation. */
public boolean isFlippedVertically() {
switch (getOrientation()) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
return false;
case ExifInterface.ORIENTATION_ROTATE_180:
return false;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
return true;
case ExifInterface.ORIENTATION_TRANSPOSE:
return true;
case ExifInterface.ORIENTATION_ROTATE_90:
return false;
case ExifInterface.ORIENTATION_TRANSVERSE:
return true;
case ExifInterface.ORIENTATION_ROTATE_270:
return false;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
return false;
}
}
/** @return True if the image is flipped horizontally after rotation. */
public boolean isFlippedHorizontally() {
switch (getOrientation()) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
return true;
case ExifInterface.ORIENTATION_ROTATE_180:
return false;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
return false;
case ExifInterface.ORIENTATION_TRANSPOSE:
return false;
case ExifInterface.ORIENTATION_ROTATE_90:
return false;
case ExifInterface.ORIENTATION_TRANSVERSE:
return false;
case ExifInterface.ORIENTATION_ROTATE_270:
return false;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
return false;
}
}
private void attachLastModifiedTimestamp() {
long now = System.currentTimeMillis();
String datetime = convertToExifDateTime(now);
mExifInterface.setAttribute(ExifInterface.TAG_DATETIME, datetime);
try {
String subsec = Long.toString(now - convertFromExifDateTime(datetime).getTime());
mExifInterface.setAttribute(ExifInterface.TAG_SUBSEC_TIME, subsec);
} catch (ParseException e) {
}
}
/**
* @return The timestamp (in millis) that this picture was modified, or {@link
* #INVALID_TIMESTAMP} if no time is available.
*/
public long getLastModifiedTimestamp() {
long timestamp = parseTimestamp(mExifInterface.getAttribute(ExifInterface.TAG_DATETIME));
if (timestamp == INVALID_TIMESTAMP) {
return INVALID_TIMESTAMP;
}
String subSecs = mExifInterface.getAttribute(ExifInterface.TAG_SUBSEC_TIME);
if (subSecs != null) {
try {
long sub = Long.parseLong(subSecs);
while (sub > 1000) {
sub /= 10;
}
timestamp += sub;
} catch (NumberFormatException e) {
// Ignored
}
}
return timestamp;
}
/**
* @return The timestamp (in millis) that this picture was taken, or {@link #INVALID_TIMESTAMP}
* if no time is available.
*/
public long getTimestamp() {
long timestamp =
parseTimestamp(mExifInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL));
if (timestamp == INVALID_TIMESTAMP) {
return INVALID_TIMESTAMP;
}
String subSecs = mExifInterface.getAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL);
if (subSecs != null) {
try {
long sub = Long.parseLong(subSecs);
while (sub > 1000) {
sub /= 10;
}
timestamp += sub;
} catch (NumberFormatException e) {
// Ignored
}
}
return timestamp;
}
/** @return The location this picture was taken, or null if no location is available. */
@Nullable
public Location getLocation() {
String provider = mExifInterface.getAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD);
double[] latlng = mExifInterface.getLatLong();
double altitude = mExifInterface.getAltitude(0);
double speed = mExifInterface.getAttributeDouble(ExifInterface.TAG_GPS_SPEED, 0);
String speedRef = mExifInterface.getAttribute(ExifInterface.TAG_GPS_SPEED_REF);
speedRef = speedRef == null ? KILOMETERS_PER_HOUR : speedRef; // Ensure speedRef is not null
long timestamp =
parseTimestamp(
mExifInterface.getAttribute(ExifInterface.TAG_GPS_DATESTAMP),
mExifInterface.getAttribute(ExifInterface.TAG_GPS_TIMESTAMP));
if (latlng == null) {
return null;
}
if (provider == null) {
provider = TAG;
}
Location location = new Location(provider);
location.setLatitude(latlng[0]);
location.setLongitude(latlng[1]);
if (altitude != 0) {
location.setAltitude(altitude);
}
if (speed != 0) {
switch (speedRef) {
case MILES_PER_HOUR:
speed = Speed.fromMilesPerHour(speed).toMetersPerSecond();
break;
case KNOTS:
speed = Speed.fromKnots(speed).toMetersPerSecond();
break;
case KILOMETERS_PER_HOUR:
// fall through
default:
speed = Speed.fromKilometersPerHour(speed).toMetersPerSecond();
break;
}
location.setSpeed((float) speed);
}
if (timestamp != INVALID_TIMESTAMP) {
location.setTime(timestamp);
}
return location;
}
/**
* Rotates the image by the given degrees. Can only rotate by right angles (eg. 90, 180, -90).
* Other increments will set the orientation to undefined.
*/
public void rotate(int degrees) {
if (degrees % 90 != 0) {
Log.w(
TAG,
String.format(
"Can only rotate in right angles (eg. 0, 90, 180, 270). %d is "
+ "unsupported.",
degrees));
mExifInterface.setAttribute(
ExifInterface.TAG_ORIENTATION,
String.valueOf(ExifInterface.ORIENTATION_UNDEFINED));
return;
}
degrees %= 360;
int orientation = getOrientation();
while (degrees < 0) {
degrees += 90;
switch (orientation) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
orientation = ExifInterface.ORIENTATION_TRANSPOSE;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
orientation = ExifInterface.ORIENTATION_ROTATE_90;
break;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
orientation = ExifInterface.ORIENTATION_TRANSVERSE;
break;
case ExifInterface.ORIENTATION_TRANSPOSE:
orientation = ExifInterface.ORIENTATION_FLIP_VERTICAL;
break;
case ExifInterface.ORIENTATION_ROTATE_90:
orientation = ExifInterface.ORIENTATION_NORMAL;
break;
case ExifInterface.ORIENTATION_TRANSVERSE:
orientation = ExifInterface.ORIENTATION_FLIP_HORIZONTAL;
break;
case ExifInterface.ORIENTATION_ROTATE_270:
orientation = ExifInterface.ORIENTATION_ROTATE_90;
break;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
orientation = ExifInterface.ORIENTATION_ROTATE_270;
break;
}
}
while (degrees > 0) {
degrees -= 90;
switch (orientation) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
orientation = ExifInterface.ORIENTATION_TRANSVERSE;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
orientation = ExifInterface.ORIENTATION_ROTATE_270;
break;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
orientation = ExifInterface.ORIENTATION_TRANSPOSE;
break;
case ExifInterface.ORIENTATION_TRANSPOSE:
orientation = ExifInterface.ORIENTATION_FLIP_HORIZONTAL;
break;
case ExifInterface.ORIENTATION_ROTATE_90:
orientation = ExifInterface.ORIENTATION_ROTATE_180;
break;
case ExifInterface.ORIENTATION_TRANSVERSE:
orientation = ExifInterface.ORIENTATION_FLIP_VERTICAL;
break;
case ExifInterface.ORIENTATION_ROTATE_270:
orientation = ExifInterface.ORIENTATION_NORMAL;
break;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
orientation = ExifInterface.ORIENTATION_ROTATE_90;
break;
}
}
mExifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(orientation));
}
/**
* Sets attributes to represent a flip of the image over the horizon so that the top and bottom
* are reversed.
*/
public void flipVertically() {
int orientation;
switch (getOrientation()) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
orientation = ExifInterface.ORIENTATION_ROTATE_180;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
orientation = ExifInterface.ORIENTATION_FLIP_HORIZONTAL;
break;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
orientation = ExifInterface.ORIENTATION_NORMAL;
break;
case ExifInterface.ORIENTATION_TRANSPOSE:
orientation = ExifInterface.ORIENTATION_ROTATE_270;
break;
case ExifInterface.ORIENTATION_ROTATE_90:
orientation = ExifInterface.ORIENTATION_TRANSVERSE;
break;
case ExifInterface.ORIENTATION_TRANSVERSE:
orientation = ExifInterface.ORIENTATION_ROTATE_90;
break;
case ExifInterface.ORIENTATION_ROTATE_270:
orientation = ExifInterface.ORIENTATION_TRANSPOSE;
break;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
orientation = ExifInterface.ORIENTATION_FLIP_VERTICAL;
break;
}
mExifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(orientation));
}
/**
* Sets attributes to represent a flip of the image over the vertical so that the left and right
* are reversed.
*/
public void flipHorizontally() {
int orientation;
switch (getOrientation()) {
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
orientation = ExifInterface.ORIENTATION_NORMAL;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
orientation = ExifInterface.ORIENTATION_FLIP_VERTICAL;
break;
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
orientation = ExifInterface.ORIENTATION_ROTATE_180;
break;
case ExifInterface.ORIENTATION_TRANSPOSE:
orientation = ExifInterface.ORIENTATION_ROTATE_90;
break;
case ExifInterface.ORIENTATION_ROTATE_90:
orientation = ExifInterface.ORIENTATION_TRANSPOSE;
break;
case ExifInterface.ORIENTATION_TRANSVERSE:
orientation = ExifInterface.ORIENTATION_ROTATE_270;
break;
case ExifInterface.ORIENTATION_ROTATE_270:
orientation = ExifInterface.ORIENTATION_TRANSVERSE;
break;
case ExifInterface.ORIENTATION_NORMAL:
// Fall-through
case ExifInterface.ORIENTATION_UNDEFINED:
// Fall-through
default:
orientation = ExifInterface.ORIENTATION_FLIP_HORIZONTAL;
break;
}
mExifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(orientation));
}
/** Attaches the current timestamp to the file. */
public void attachTimestamp() {
long now = System.currentTimeMillis();
String datetime = convertToExifDateTime(now);
mExifInterface.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, datetime);
mExifInterface.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, datetime);
try {
String subsec = Long.toString(now - convertFromExifDateTime(datetime).getTime());
mExifInterface.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subsec);
mExifInterface.setAttribute(ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, subsec);
} catch (ParseException e) {
}
mRemoveTimestamp = false;
}
/** Removes the timestamp from the file. */
public void removeTimestamp() {
mExifInterface.setAttribute(ExifInterface.TAG_DATETIME, null);
mExifInterface.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, null);
mExifInterface.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, null);
mExifInterface.setAttribute(ExifInterface.TAG_SUBSEC_TIME, null);
mExifInterface.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, null);
mExifInterface.setAttribute(ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, null);
mRemoveTimestamp = true;
}
/** Attaches the given location to the file. */
public void attachLocation(Location location) {
mExifInterface.setGpsInfo(location);
}
/** Removes the location from the file. */
public void removeLocation() {
mExifInterface.setAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_LATITUDE, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_SPEED, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_SPEED_REF, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, null);
mExifInterface.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, null);
}
/** @return The timestamp (in millis), or {@link #INVALID_TIMESTAMP} if no time is available. */
private long parseTimestamp(@Nullable String date, @Nullable String time) {
if (date == null && time == null) {
return INVALID_TIMESTAMP;
}
if (time == null) {
try {
return convertFromExifDate(date).getTime();
} catch (ParseException e) {
return INVALID_TIMESTAMP;
}
}
if (date == null) {
try {
return convertFromExifTime(time).getTime();
} catch (ParseException e) {
return INVALID_TIMESTAMP;
}
}
return parseTimestamp(date + " " + time);
}
/** @return The timestamp (in millis), or {@link #INVALID_TIMESTAMP} if no time is available. */
private long parseTimestamp(@Nullable String datetime) {
if (datetime == null) {
return INVALID_TIMESTAMP;
}
try {
return convertFromExifDateTime(datetime).getTime();
} catch (ParseException e) {
return INVALID_TIMESTAMP;
}
}
private static final class Speed {
static Converter fromKilometersPerHour(double kph) {
return new Converter(kph * 0.621371);
}
static Converter fromMetersPerSecond(double mps) {
return new Converter(mps * 2.23694);
}
static Converter fromMilesPerHour(double mph) {
return new Converter(mph);
}
static Converter fromKnots(double knots) {
return new Converter(knots * 1.15078);
}
static final class Converter {
final double mMph;
Converter(double mph) {
mMph = mph;
}
double toKilometersPerHour() {
return mMph / 0.621371;
}
double toMilesPerHour() {
return mMph;
}
double toKnots() {
return mMph / 1.15078;
}
double toMetersPerSecond() {
return mMph / 2.23694;
}
}
}
}