FLIPPED_ROTATION_ORDER = Arrays.asList(
+ ORIENTATION_FLIP_HORIZONTAL, ORIENTATION_TRANSVERSE, ORIENTATION_FLIP_VERTICAL,
+ ORIENTATION_TRANSPOSE);
+
+ /**
+ * The constant used by {@link #TAG_PLANAR_CONFIGURATION} to denote Chunky format.
+ */
+ public static final short FORMAT_CHUNKY = 1;
+ /**
+ * The constant used by {@link #TAG_PLANAR_CONFIGURATION} to denote Planar format.
+ */
+ public static final short FORMAT_PLANAR = 2;
+
+ /**
+ * The constant used by {@link #TAG_Y_CB_CR_POSITIONING} to denote Centered positioning.
+ */
+ public static final short Y_CB_CR_POSITIONING_CENTERED = 1;
+ /**
+ * The constant used by {@link #TAG_Y_CB_CR_POSITIONING} to denote Co-sited positioning.
+ */
+ public static final short Y_CB_CR_POSITIONING_CO_SITED = 2;
+
+ /**
+ * The constant used to denote resolution unit as inches.
+ */
+ public static final short RESOLUTION_UNIT_INCHES = 2;
+ /**
+ * The constant used to denote resolution unit as centimeters.
+ */
+ public static final short RESOLUTION_UNIT_CENTIMETERS = 3;
+
+ /**
+ * The constant used by {@link #TAG_COLOR_SPACE} to denote sRGB color space.
+ */
+ public static final int COLOR_SPACE_S_RGB = 1;
+ /**
+ * The constant used by {@link #TAG_COLOR_SPACE} to denote Uncalibrated.
+ */
+ public static final int COLOR_SPACE_UNCALIBRATED = 65535;
+
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is not defined.
+ */
+ public static final short EXPOSURE_PROGRAM_NOT_DEFINED = 0;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is Manual.
+ */
+ public static final short EXPOSURE_PROGRAM_MANUAL = 1;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is Normal.
+ */
+ public static final short EXPOSURE_PROGRAM_NORMAL = 2;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is
+ * Aperture priority.
+ */
+ public static final short EXPOSURE_PROGRAM_APERTURE_PRIORITY = 3;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is
+ * Shutter priority.
+ */
+ public static final short EXPOSURE_PROGRAM_SHUTTER_PRIORITY = 4;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is Creative
+ * program (biased toward depth of field).
+ */
+ public static final short EXPOSURE_PROGRAM_CREATIVE = 5;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is Action
+ * program (biased toward fast shutter speed).
+ */
+ public static final short EXPOSURE_PROGRAM_ACTION = 6;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is Portrait
+ * mode (for closeup photos with the background out of focus).
+ */
+ public static final short EXPOSURE_PROGRAM_PORTRAIT_MODE = 7;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_PROGRAM} to denote exposure program is Landscape
+ * mode (for landscape photos with the background in focus).
+ */
+ public static final short EXPOSURE_PROGRAM_LANDSCAPE_MODE = 8;
+
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is unknown.
+ */
+ public static final short SENSITIVITY_TYPE_UNKNOWN = 0;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is Standard
+ * output sensitivity (SOS).
+ */
+ public static final short SENSITIVITY_TYPE_SOS = 1;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is Recommended
+ * exposure index (REI).
+ */
+ public static final short SENSITIVITY_TYPE_REI = 2;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is ISO speed.
+ */
+ public static final short SENSITIVITY_TYPE_ISO_SPEED = 3;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is Standard
+ * output sensitivity (SOS) and recommended exposure index (REI).
+ */
+ public static final short SENSITIVITY_TYPE_SOS_AND_REI = 4;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is Standard
+ * output sensitivity (SOS) and ISO speed.
+ */
+ public static final short SENSITIVITY_TYPE_SOS_AND_ISO = 5;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is Recommended
+ * exposure index (REI) and ISO speed.
+ */
+ public static final short SENSITIVITY_TYPE_REI_AND_ISO = 6;
+ /**
+ * The constant used by {@link #TAG_SENSITIVITY_TYPE} to denote sensitivity type is Standard
+ * output sensitivity (SOS) and recommended exposure index (REI) and ISO speed.
+ */
+ public static final short SENSITIVITY_TYPE_SOS_AND_REI_AND_ISO = 7;
+
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is unknown.
+ */
+ public static final short METERING_MODE_UNKNOWN = 0;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is Average.
+ */
+ public static final short METERING_MODE_AVERAGE = 1;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is
+ * CenterWeightedAverage.
+ */
+ public static final short METERING_MODE_CENTER_WEIGHT_AVERAGE = 2;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is Spot.
+ */
+ public static final short METERING_MODE_SPOT = 3;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is MultiSpot.
+ */
+ public static final short METERING_MODE_MULTI_SPOT = 4;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is Pattern.
+ */
+ public static final short METERING_MODE_PATTERN = 5;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is Partial.
+ */
+ public static final short METERING_MODE_PARTIAL = 6;
+ /**
+ * The constant used by {@link #TAG_METERING_MODE} to denote metering mode is other.
+ */
+ public static final short METERING_MODE_OTHER = 255;
+
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is unknown.
+ */
+ public static final short LIGHT_SOURCE_UNKNOWN = 0;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Daylight.
+ */
+ public static final short LIGHT_SOURCE_DAYLIGHT = 1;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Fluorescent.
+ */
+ public static final short LIGHT_SOURCE_FLUORESCENT = 2;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Tungsten
+ * (incandescent light).
+ */
+ public static final short LIGHT_SOURCE_TUNGSTEN = 3;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Flash.
+ */
+ public static final short LIGHT_SOURCE_FLASH = 4;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Fine weather.
+ */
+ public static final short LIGHT_SOURCE_FINE_WEATHER = 9;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Cloudy weather.
+ */
+ public static final short LIGHT_SOURCE_CLOUDY_WEATHER = 10;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Shade.
+ */
+ public static final short LIGHT_SOURCE_SHADE = 11;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Daylight fluorescent
+ * (D 5700 - 7100K).
+ */
+ public static final short LIGHT_SOURCE_DAYLIGHT_FLUORESCENT = 12;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Day white
+ * fluorescent (N 4600 - 5500K).
+ */
+ public static final short LIGHT_SOURCE_DAY_WHITE_FLUORESCENT = 13;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Cool white
+ * fluorescent (W 3800 - 4500K).
+ */
+ public static final short LIGHT_SOURCE_COOL_WHITE_FLUORESCENT = 14;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is White fluorescent
+ * (WW 3250 - 3800K).
+ */
+ public static final short LIGHT_SOURCE_WHITE_FLUORESCENT = 15;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Warm white
+ * fluorescent (L 2600 - 3250K).
+ */
+ public static final short LIGHT_SOURCE_WARM_WHITE_FLUORESCENT = 16;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Standard light A.
+ */
+ public static final short LIGHT_SOURCE_STANDARD_LIGHT_A = 17;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Standard light B.
+ */
+ public static final short LIGHT_SOURCE_STANDARD_LIGHT_B = 18;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is Standard light C.
+ */
+ public static final short LIGHT_SOURCE_STANDARD_LIGHT_C = 19;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is D55.
+ */
+ public static final short LIGHT_SOURCE_D55 = 20;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is D65.
+ */
+ public static final short LIGHT_SOURCE_D65 = 21;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is D75.
+ */
+ public static final short LIGHT_SOURCE_D75 = 22;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is D50.
+ */
+ public static final short LIGHT_SOURCE_D50 = 23;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is ISO studio tungsten.
+ */
+ public static final short LIGHT_SOURCE_ISO_STUDIO_TUNGSTEN = 24;
+ /**
+ * The constant used by {@link #TAG_LIGHT_SOURCE} to denote light source is other.
+ */
+ public static final short LIGHT_SOURCE_OTHER = 255;
+
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate whether the flash is fired.
+ */
+ public static final short FLAG_FLASH_FIRED = 0b0000_0001;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate strobe return light is not detected.
+ */
+ public static final short FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED = 0b0000_0100;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate strobe return light is detected.
+ */
+ public static final short FLAG_FLASH_RETURN_LIGHT_DETECTED = 0b0000_0110;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate the camera's flash mode is Compulsory flash
+ * firing.
+ *
+ * @see #FLAG_FLASH_MODE_COMPULSORY_SUPPRESSION
+ * @see #FLAG_FLASH_MODE_AUTO
+ */
+ public static final short FLAG_FLASH_MODE_COMPULSORY_FIRING = 0b0000_1000;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate the camera's flash mode is Compulsory flash
+ * suppression.
+ *
+ * @see #FLAG_FLASH_MODE_COMPULSORY_FIRING
+ * @see #FLAG_FLASH_MODE_AUTO
+ */
+ public static final short FLAG_FLASH_MODE_COMPULSORY_SUPPRESSION = 0b0001_0000;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate the camera's flash mode is Auto.
+ *
+ * @see #FLAG_FLASH_MODE_COMPULSORY_FIRING
+ * @see #FLAG_FLASH_MODE_COMPULSORY_SUPPRESSION
+ */
+ public static final short FLAG_FLASH_MODE_AUTO = 0b0001_1000;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate no flash function is present.
+ */
+ public static final short FLAG_FLASH_NO_FLASH_FUNCTION = 0b0010_0000;
+ /**
+ * The flag used by {@link #TAG_FLASH} to indicate red-eye reduction is supported.
+ */
+ public static final short FLAG_FLASH_RED_EYE_SUPPORTED = 0b0100_0000;
+
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is not
+ * defined.
+ */
+ public static final short SENSOR_TYPE_NOT_DEFINED = 1;
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is One-chip
+ * color area sensor.
+ */
+ public static final short SENSOR_TYPE_ONE_CHIP = 2;
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is Two-chip
+ * color area sensor.
+ */
+ public static final short SENSOR_TYPE_TWO_CHIP = 3;
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is
+ * Three-chip color area sensor.
+ */
+ public static final short SENSOR_TYPE_THREE_CHIP = 4;
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is Color
+ * sequential area sensor.
+ */
+ public static final short SENSOR_TYPE_COLOR_SEQUENTIAL = 5;
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is Trilinear
+ * sensor.
+ */
+ public static final short SENSOR_TYPE_TRILINEAR = 7;
+ /**
+ * The constant used by {@link #TAG_SENSING_METHOD} to denote the image sensor type is Color
+ * sequential linear sensor.
+ */
+ public static final short SENSOR_TYPE_COLOR_SEQUENTIAL_LINEAR = 8;
+
+ /**
+ * The constant used by {@link #TAG_FILE_SOURCE} to denote the source is other.
+ */
+ public static final short FILE_SOURCE_OTHER = 0;
+ /**
+ * The constant used by {@link #TAG_FILE_SOURCE} to denote the source is scanner of transparent
+ * type.
+ */
+ public static final short FILE_SOURCE_TRANSPARENT_SCANNER = 1;
+ /**
+ * The constant used by {@link #TAG_FILE_SOURCE} to denote the source is scanner of reflex type.
+ */
+ public static final short FILE_SOURCE_REFLEX_SCANNER = 2;
+ /**
+ * The constant used by {@link #TAG_FILE_SOURCE} to denote the source is DSC.
+ */
+ public static final short FILE_SOURCE_DSC = 3;
+
+ /**
+ * The constant used by {@link #TAG_SCENE_TYPE} to denote the scene is directly photographed.
+ */
+ public static final short SCENE_TYPE_DIRECTLY_PHOTOGRAPHED = 1;
+
+ /**
+ * The constant used by {@link #TAG_CUSTOM_RENDERED} to denote no special processing is used.
+ */
+ public static final short RENDERED_PROCESS_NORMAL = 0;
+ /**
+ * The constant used by {@link #TAG_CUSTOM_RENDERED} to denote special processing is used.
+ */
+ public static final short RENDERED_PROCESS_CUSTOM = 1;
+
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_MODE} to denote the exposure mode is Auto.
+ */
+ public static final short EXPOSURE_MODE_AUTO = 0;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_MODE} to denote the exposure mode is Manual.
+ */
+ public static final short EXPOSURE_MODE_MANUAL = 1;
+ /**
+ * The constant used by {@link #TAG_EXPOSURE_MODE} to denote the exposure mode is Auto bracket.
+ */
+ public static final short EXPOSURE_MODE_AUTO_BRACKET = 2;
+
+ /**
+ * The constant used by {@link #TAG_WHITE_BALANCE} to denote the white balance is Auto.
+ *
+ * @deprecated Use {@link #WHITE_BALANCE_AUTO} instead.
+ */
+ @Deprecated
+ public static final int WHITEBALANCE_AUTO = 0;
+ /**
+ * The constant used by {@link #TAG_WHITE_BALANCE} to denote the white balance is Manual.
+ *
+ * @deprecated Use {@link #WHITE_BALANCE_MANUAL} instead.
+ */
+ @Deprecated
+ public static final int WHITEBALANCE_MANUAL = 1;
+ /**
+ * The constant used by {@link #TAG_WHITE_BALANCE} to denote the white balance is Auto.
+ */
+ public static final short WHITE_BALANCE_AUTO = 0;
+ /**
+ * The constant used by {@link #TAG_WHITE_BALANCE} to denote the white balance is Manual.
+ */
+ public static final short WHITE_BALANCE_MANUAL = 1;
+
+ /**
+ * The constant used by {@link #TAG_SCENE_CAPTURE_TYPE} to denote the scene capture type is
+ * Standard.
+ */
+ public static final short SCENE_CAPTURE_TYPE_STANDARD = 0;
+ /**
+ * The constant used by {@link #TAG_SCENE_CAPTURE_TYPE} to denote the scene capture type is
+ * Landscape.
+ */
+ public static final short SCENE_CAPTURE_TYPE_LANDSCAPE = 1;
+ /**
+ * The constant used by {@link #TAG_SCENE_CAPTURE_TYPE} to denote the scene capture type is
+ * Portrait.
+ */
+ public static final short SCENE_CAPTURE_TYPE_PORTRAIT = 2;
+ /**
+ * The constant used by {@link #TAG_SCENE_CAPTURE_TYPE} to denote the scene capture type is
+ * Night scene.
+ */
+ public static final short SCENE_CAPTURE_TYPE_NIGHT = 3;
+
+ /**
+ * The constant used by {@link #TAG_GAIN_CONTROL} to denote none gain adjustment.
+ */
+ public static final short GAIN_CONTROL_NONE = 0;
+ /**
+ * The constant used by {@link #TAG_GAIN_CONTROL} to denote low gain up.
+ */
+ public static final short GAIN_CONTROL_LOW_GAIN_UP = 1;
+ /**
+ * The constant used by {@link #TAG_GAIN_CONTROL} to denote high gain up.
+ */
+ public static final short GAIN_CONTROL_HIGH_GAIN_UP = 2;
+ /**
+ * The constant used by {@link #TAG_GAIN_CONTROL} to denote low gain down.
+ */
+ public static final short GAIN_CONTROL_LOW_GAIN_DOWN = 3;
+ /**
+ * The constant used by {@link #TAG_GAIN_CONTROL} to denote high gain down.
+ */
+ public static final short GAIN_CONTROL_HIGH_GAIN_DOWN = 4;
+
+ /**
+ * The constant used by {@link #TAG_CONTRAST} to denote normal contrast.
+ */
+ public static final short CONTRAST_NORMAL = 0;
+ /**
+ * The constant used by {@link #TAG_CONTRAST} to denote soft contrast.
+ */
+ public static final short CONTRAST_SOFT = 1;
+ /**
+ * The constant used by {@link #TAG_CONTRAST} to denote hard contrast.
+ */
+ public static final short CONTRAST_HARD = 2;
+
+ /**
+ * The constant used by {@link #TAG_SATURATION} to denote normal saturation.
+ */
+ public static final short SATURATION_NORMAL = 0;
+ /**
+ * The constant used by {@link #TAG_SATURATION} to denote low saturation.
+ */
+ public static final short SATURATION_LOW = 0;
+ /**
+ * The constant used by {@link #TAG_SHARPNESS} to denote high saturation.
+ */
+ public static final short SATURATION_HIGH = 0;
+
+ /**
+ * The constant used by {@link #TAG_SHARPNESS} to denote normal sharpness.
+ */
+ public static final short SHARPNESS_NORMAL = 0;
+ /**
+ * The constant used by {@link #TAG_SHARPNESS} to denote soft sharpness.
+ */
+ public static final short SHARPNESS_SOFT = 1;
+ /**
+ * The constant used by {@link #TAG_SHARPNESS} to denote hard sharpness.
+ */
+ public static final short SHARPNESS_HARD = 2;
+
+ /**
+ * The constant used by {@link #TAG_SUBJECT_DISTANCE_RANGE} to denote the subject distance range
+ * is unknown.
+ */
+ public static final short SUBJECT_DISTANCE_RANGE_UNKNOWN = 0;
+ /**
+ * The constant used by {@link #TAG_SUBJECT_DISTANCE_RANGE} to denote the subject distance range
+ * is Macro.
+ */
+ public static final short SUBJECT_DISTANCE_RANGE_MACRO = 1;
+ /**
+ * The constant used by {@link #TAG_SUBJECT_DISTANCE_RANGE} to denote the subject distance range
+ * is Close view.
+ */
+ public static final short SUBJECT_DISTANCE_RANGE_CLOSE_VIEW = 2;
+ /**
+ * The constant used by {@link #TAG_SUBJECT_DISTANCE_RANGE} to denote the subject distance range
+ * is Distant view.
+ */
+ public static final short SUBJECT_DISTANCE_RANGE_DISTANT_VIEW = 3;
+
+ /**
+ * The constant used by GPS latitude-related tags to denote the latitude is North latitude.
+ *
+ * @see #TAG_GPS_LATITUDE_REF
+ * @see #TAG_GPS_DEST_LATITUDE_REF
+ */
+ public static final String LATITUDE_NORTH = "N";
+ /**
+ * The constant used by GPS latitude-related tags to denote the latitude is South latitude.
+ *
+ * @see #TAG_GPS_LATITUDE_REF
+ * @see #TAG_GPS_DEST_LATITUDE_REF
+ */
+ public static final String LATITUDE_SOUTH = "S";
+
+ /**
+ * The constant used by GPS longitude-related tags to denote the longitude is East longitude.
+ *
+ * @see #TAG_GPS_LONGITUDE_REF
+ * @see #TAG_GPS_DEST_LONGITUDE_REF
+ */
+ public static final String LONGITUDE_EAST = "E";
+ /**
+ * The constant used by GPS longitude-related tags to denote the longitude is West longitude.
+ *
+ * @see #TAG_GPS_LONGITUDE_REF
+ * @see #TAG_GPS_DEST_LONGITUDE_REF
+ */
+ public static final String LONGITUDE_WEST = "W";
+
+ /**
+ * The constant used by {@link #TAG_GPS_ALTITUDE_REF} to denote the altitude is above sea level.
+ */
+ public static final short ALTITUDE_ABOVE_SEA_LEVEL = 0;
+ /**
+ * The constant used by {@link #TAG_GPS_ALTITUDE_REF} to denote the altitude is below sea level.
+ */
+ public static final short ALTITUDE_BELOW_SEA_LEVEL = 1;
+
+ /**
+ * The constant used by {@link #TAG_GPS_STATUS} to denote GPS measurement is in progress.
+ */
+ public static final String GPS_MEASUREMENT_IN_PROGRESS = "A";
+ /**
+ * The constant used by {@link #TAG_GPS_STATUS} to denote GPS measurement is interrupted.
+ */
+ public static final String GPS_MEASUREMENT_INTERRUPTED = "V";
+
+ /**
+ * The constant used by {@link #TAG_GPS_MEASURE_MODE} to denote GPS measurement is
+ * 2-dimensional.
+ */
+ public static final String GPS_MEASUREMENT_2D = "2";
+ /**
+ * The constant used by {@link #TAG_GPS_MEASURE_MODE} to denote GPS measurement is
+ * 3-dimensional.
+ */
+ public static final String GPS_MEASUREMENT_3D = "3";
+
+ /**
+ * The constant used by {@link #TAG_GPS_SPEED_REF} to denote the speed unit is kilometers per
+ * hour.
+ */
+ public static final String GPS_SPEED_KILOMETERS_PER_HOUR = "K";
+ /**
+ * The constant used by {@link #TAG_GPS_SPEED_REF} to denote the speed unit is miles per hour.
+ */
+ public static final String GPS_SPEED_MILES_PER_HOUR = "M";
+ /**
+ * The constant used by {@link #TAG_GPS_SPEED_REF} to denote the speed unit is knots.
+ */
+ public static final String GPS_SPEED_KNOTS = "N";
+
+ /**
+ * The constant used by GPS attributes to denote the direction is true direction.
+ */
+ public static final String GPS_DIRECTION_TRUE = "T";
+ /**
+ * The constant used by GPS attributes to denote the direction is magnetic direction.
+ */
+ public static final String GPS_DIRECTION_MAGNETIC = "M";
+
+ /**
+ * The constant used by {@link #TAG_GPS_DEST_DISTANCE_REF} to denote the distance unit is
+ * kilometers.
+ */
+ public static final String GPS_DISTANCE_KILOMETERS = "K";
+ /**
+ * The constant used by {@link #TAG_GPS_DEST_DISTANCE_REF} to denote the distance unit is miles.
+ */
+ public static final String GPS_DISTANCE_MILES = "M";
+ /**
+ * The constant used by {@link #TAG_GPS_DEST_DISTANCE_REF} to denote the distance unit is
+ * nautical miles.
+ */
+ public static final String GPS_DISTANCE_NAUTICAL_MILES = "N";
+
+ /**
+ * The constant used by {@link #TAG_GPS_DIFFERENTIAL} to denote no differential correction is
+ * applied.
+ */
+ public static final short GPS_MEASUREMENT_NO_DIFFERENTIAL = 0;
+ /**
+ * The constant used by {@link #TAG_GPS_DIFFERENTIAL} to denote differential correction is
+ * applied.
+ */
+ public static final short GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED = 1;
+
+ /**
+ * The constant used by {@link #TAG_COMPRESSION} to denote the image is not compressed.
+ */
+ public static final int DATA_UNCOMPRESSED = 1;
+ /**
+ * The constant used by {@link #TAG_COMPRESSION} to denote the image is huffman compressed.
+ */
+ public static final int DATA_HUFFMAN_COMPRESSED = 2;
+ /**
+ * The constant used by {@link #TAG_COMPRESSION} to denote the image is JPEG.
+ */
+ public static final int DATA_JPEG = 6;
+ /**
+ * The constant used by {@link #TAG_COMPRESSION}, see DNG Specification 1.4.0.0.
+ * Section 3, Compression
+ */
+ public static final int DATA_JPEG_COMPRESSED = 7;
+ /**
+ * The constant used by {@link #TAG_COMPRESSION}, see DNG Specification 1.4.0.0.
+ * Section 3, Compression
+ */
+ public static final int DATA_DEFLATE_ZIP = 8;
+ /**
+ * The constant used by {@link #TAG_COMPRESSION} to denote the image is pack-bits compressed.
+ */
+ public static final int DATA_PACK_BITS_COMPRESSED = 32773;
+ /**
+ * The constant used by {@link #TAG_COMPRESSION}, see DNG Specification 1.4.0.0.
+ * Section 3, Compression
+ */
+ public static final int DATA_LOSSY_JPEG = 34892;
+
+ /**
+ * The constant used by {@link #TAG_BITS_PER_SAMPLE}.
+ * See JEITA CP-3451C Spec Section 6, Differences from Palette Color Images
+ */
+ public static final int[] BITS_PER_SAMPLE_RGB = new int[]{8, 8, 8};
+ /**
+ * The constant used by {@link #TAG_BITS_PER_SAMPLE}.
+ * See JEITA CP-3451C Spec Section 4, Differences from Bilevel Images
+ */
+ public static final int[] BITS_PER_SAMPLE_GREYSCALE_1 = new int[]{4};
+ /**
+ * The constant used by {@link #TAG_BITS_PER_SAMPLE}.
+ * See JEITA CP-3451C Spec Section 4, Differences from Bilevel Images
+ */
+ public static final int[] BITS_PER_SAMPLE_GREYSCALE_2 = new int[]{8};
+
+ /**
+ * The constant used by {@link #TAG_PHOTOMETRIC_INTERPRETATION}.
+ */
+ public static final int PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO = 0;
+ /**
+ * The constant used by {@link #TAG_PHOTOMETRIC_INTERPRETATION}.
+ */
+ public static final int PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO = 1;
+ /**
+ * The constant used by {@link #TAG_PHOTOMETRIC_INTERPRETATION}.
+ */
+ public static final int PHOTOMETRIC_INTERPRETATION_RGB = 2;
+ /**
+ * The constant used by {@link #TAG_PHOTOMETRIC_INTERPRETATION}.
+ */
+ public static final int PHOTOMETRIC_INTERPRETATION_YCBCR = 6;
+
+ /**
+ * The constant used by {@link #TAG_NEW_SUBFILE_TYPE}. See JEITA CP-3451C Spec Section 8.
+ */
+ public static final int ORIGINAL_RESOLUTION_IMAGE = 0;
+ /**
+ * The constant used by {@link #TAG_NEW_SUBFILE_TYPE}. See JEITA CP-3451C Spec Section 8.
+ */
+ public static final int REDUCED_RESOLUTION_IMAGE = 1;
+
+ /**
+ * Constant used to indicate that the input stream contains the full image data.
+ *
+ * The format of the image data should follow one of the image formats supported by this class.
+ */
+ public static final int STREAM_TYPE_FULL_IMAGE_DATA = 0;
+ /**
+ * Constant used to indicate that the input stream contains only Exif data.
+ *
+ * The format of the Exif-only data must follow the below structure:
+ * Exif Identifier Code ("Exif\0\0") + TIFF header + IFD data
+ * See JEITA CP-3451C Section 4.5.2 and 4.5.4 specifications for more details.
+ */
+ public static final int STREAM_TYPE_EXIF_DATA_ONLY = 1;
+
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({STREAM_TYPE_FULL_IMAGE_DATA, STREAM_TYPE_EXIF_DATA_ONLY})
+ public @interface ExifStreamType {
+ }
+
+ // Maximum size for checking file type signature (see image_type_recognition_lite.cc)
+ private static final int SIGNATURE_CHECK_SIZE = 5000;
+
+ static final byte[] JPEG_SIGNATURE = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff};
+ private static final String RAF_SIGNATURE = "FUJIFILMCCD-RAW";
+ private static final int RAF_OFFSET_TO_JPEG_IMAGE_OFFSET = 84;
+
+ private static final byte[] HEIF_TYPE_FTYP = new byte[]{'f', 't', 'y', 'p'};
+ private static final byte[] HEIF_BRAND_MIF1 = new byte[]{'m', 'i', 'f', '1'};
+ private static final byte[] HEIF_BRAND_HEIC = new byte[]{'h', 'e', 'i', 'c'};
+
+ // See http://fileformats.archiveteam.org/wiki/Olympus_ORF
+ private static final short ORF_SIGNATURE_1 = 0x4f52;
+ private static final short ORF_SIGNATURE_2 = 0x5352;
+ // There are two formats for Olympus Makernote Headers. Each has different identifiers and
+ // offsets to the actual data.
+ // See http://www.exiv2.org/makernote.html#R1
+ private static final byte[] ORF_MAKER_NOTE_HEADER_1 = new byte[]{(byte) 0x4f, (byte) 0x4c,
+ (byte) 0x59, (byte) 0x4d, (byte) 0x50, (byte) 0x00}; // "OLYMP\0"
+ private static final byte[] ORF_MAKER_NOTE_HEADER_2 = new byte[]{(byte) 0x4f, (byte) 0x4c,
+ (byte) 0x59, (byte) 0x4d, (byte) 0x50, (byte) 0x55, (byte) 0x53, (byte) 0x00,
+ (byte) 0x49, (byte) 0x49}; // "OLYMPUS\0II"
+ private static final int ORF_MAKER_NOTE_HEADER_1_SIZE = 8;
+ private static final int ORF_MAKER_NOTE_HEADER_2_SIZE = 12;
+
+ // See http://fileformats.archiveteam.org/wiki/RW2
+ private static final short RW2_SIGNATURE = 0x0055;
+
+ // See http://fileformats.archiveteam.org/wiki/Pentax_PEF
+ private static final String PEF_SIGNATURE = "PENTAX";
+ // See http://www.exiv2.org/makernote.html#R11
+ private static final int PEF_MAKER_NOTE_SKIP_SIZE = 6;
+
+ // See PNG (Portable Network Graphics) Specification, Version 1.2,
+ // 3.1. PNG file signature
+ private static final byte[] PNG_SIGNATURE = new byte[]{(byte) 0x89, (byte) 0x50, (byte) 0x4e,
+ (byte) 0x47, (byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a};
+ // See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
+ // 3.7. eXIf Exchangeable Image File (Exif) Profile
+ private static final byte[] PNG_CHUNK_TYPE_EXIF = new byte[]{(byte) 0x65, (byte) 0x58,
+ (byte) 0x49, (byte) 0x66};
+ private static final byte[] PNG_CHUNK_TYPE_IHDR = new byte[]{(byte) 0x49, (byte) 0x48,
+ (byte) 0x44, (byte) 0x52};
+ private static final byte[] PNG_CHUNK_TYPE_IEND = new byte[]{(byte) 0x49, (byte) 0x45,
+ (byte) 0x4e, (byte) 0x44};
+ private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
+ private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
+
+ // See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
+ private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'};
+ private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'};
+ private static final int WEBP_FILE_SIZE_BYTE_LENGTH = 4;
+ private static final byte[] WEBP_CHUNK_TYPE_EXIF = new byte[]{(byte) 0x45, (byte) 0x58,
+ (byte) 0x49, (byte) 0x46};
+ private static final byte[] WEBP_VP8_SIGNATURE = new byte[]{(byte) 0x9d, (byte) 0x01,
+ (byte) 0x2a};
+ private static final byte WEBP_VP8L_SIGNATURE = (byte) 0x2f;
+ private static final byte[] WEBP_CHUNK_TYPE_VP8X = "VP8X".getBytes(Charset.defaultCharset());
+ private static final byte[] WEBP_CHUNK_TYPE_VP8L = "VP8L".getBytes(Charset.defaultCharset());
+ private static final byte[] WEBP_CHUNK_TYPE_VP8 = "VP8 ".getBytes(Charset.defaultCharset());
+ private static final byte[] WEBP_CHUNK_TYPE_ANIM = "ANIM".getBytes(Charset.defaultCharset());
+ private static final byte[] WEBP_CHUNK_TYPE_ANMF = "ANMF".getBytes(Charset.defaultCharset());
+ private static final int WEBP_CHUNK_TYPE_VP8X_DEFAULT_LENGTH = 10;
+ private static final int WEBP_CHUNK_TYPE_BYTE_LENGTH = 4;
+ private static final int WEBP_CHUNK_SIZE_BYTE_LENGTH = 4;
+
+ private static SimpleDateFormat sFormatterPrimary;
+ private static SimpleDateFormat sFormatterSecondary;
+
+ // See Exchangeable image file format for digital still cameras: Exif version 2.2.
+ // The following values are for parsing EXIF data area. There are tag groups in EXIF data area.
+ // They are called "Image File Directory". They have multiple data formats to cover various
+ // image metadata from GPS longitude to camera model name.
+
+ // Types of Exif byte alignments (see JEITA CP-3451C Section 4.5.2)
+ static final short BYTE_ALIGN_II = 0x4949; // II: Intel order
+ static final short BYTE_ALIGN_MM = 0x4d4d; // MM: Motorola order
+
+ // TIFF Header Fixed Constant (see JEITA CP-3451C Section 4.5.2)
+ static final byte START_CODE = 0x2a; // 42
+ private static final int IFD_OFFSET = 8;
+
+ // Formats for the value in IFD entry (See TIFF 6.0 Section 2, "Image File Directory".)
+ private static final int IFD_FORMAT_BYTE = 1;
+ private static final int IFD_FORMAT_STRING = 2;
+ private static final int IFD_FORMAT_USHORT = 3;
+ private static final int IFD_FORMAT_ULONG = 4;
+ private static final int IFD_FORMAT_URATIONAL = 5;
+ private static final int IFD_FORMAT_SBYTE = 6;
+ private static final int IFD_FORMAT_UNDEFINED = 7;
+ private static final int IFD_FORMAT_SSHORT = 8;
+ private static final int IFD_FORMAT_SLONG = 9;
+ private static final int IFD_FORMAT_SRATIONAL = 10;
+ private static final int IFD_FORMAT_SINGLE = 11;
+ private static final int IFD_FORMAT_DOUBLE = 12;
+ // Format indicating a new IFD entry (See Adobe PageMaker® 6.0 TIFF Technical Notes, "New Tag")
+ private static final int IFD_FORMAT_IFD = 13;
+
+ private static final int SKIP_BUFFER_SIZE = 8192;
+
+ // Names for the data formats for debugging purpose.
+ static final String[] IFD_FORMAT_NAMES = new String[]{
+ "", "BYTE", "STRING", "USHORT", "ULONG", "URATIONAL", "SBYTE", "UNDEFINED", "SSHORT",
+ "SLONG", "SRATIONAL", "SINGLE", "DOUBLE", "IFD"
+ };
+ // Sizes of the components of each IFD value format
+ static final int[] IFD_FORMAT_BYTES_PER_FORMAT = new int[]{
+ 0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8, 1
+ };
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ static final byte[] EXIF_ASCII_PREFIX = new byte[]{
+ 0x41, 0x53, 0x43, 0x49, 0x49, 0x0, 0x0, 0x0
+ };
+
+ // A class for indicating EXIF rational type.
+ private static class Rational {
+ public final long numerator;
+ public final long denominator;
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ Rational(double value) {
+ this((long) (value * 10000), 10000);
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ Rational(long numerator, long denominator) {
+ // Handle erroneous case
+ if (denominator == 0) {
+ this.numerator = 0;
+ this.denominator = 1;
+ return;
+ }
+ this.numerator = numerator;
+ this.denominator = denominator;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return numerator + "/" + denominator;
+ }
+
+ public double calculate() {
+ return (double) numerator / denominator;
+ }
+ }
+
+ // A class for indicating EXIF attribute.
+ private static class ExifAttribute {
+ public static final long BYTES_OFFSET_UNKNOWN = -1;
+
+ public final int format;
+ public final int numberOfComponents;
+ public final long bytesOffset;
+ public final byte[] bytes;
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ ExifAttribute(int format, int numberOfComponents, byte[] bytes) {
+ this(format, numberOfComponents, BYTES_OFFSET_UNKNOWN, bytes);
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ ExifAttribute(int format, int numberOfComponents, long bytesOffset, byte[] bytes) {
+ this.format = format;
+ this.numberOfComponents = numberOfComponents;
+ this.bytesOffset = bytesOffset;
+ this.bytes = bytes;
+ }
+
+ public static ExifAttribute createUShort(int[] values, ByteOrder byteOrder) {
+ final ByteBuffer buffer = ByteBuffer.wrap(
+ new byte[IFD_FORMAT_BYTES_PER_FORMAT[IFD_FORMAT_USHORT] * values.length]);
+ buffer.order(byteOrder);
+ for (int value : values) {
+ buffer.putShort((short) value);
+ }
+ return new ExifAttribute(IFD_FORMAT_USHORT, values.length, buffer.array());
+ }
+
+ public static ExifAttribute createUShort(int value, ByteOrder byteOrder) {
+ return createUShort(new int[]{value}, byteOrder);
+ }
+
+ public static ExifAttribute createULong(long[] values, ByteOrder byteOrder) {
+ final ByteBuffer buffer = ByteBuffer.wrap(
+ new byte[IFD_FORMAT_BYTES_PER_FORMAT[IFD_FORMAT_ULONG] * values.length]);
+ buffer.order(byteOrder);
+ for (long value : values) {
+ buffer.putInt((int) value);
+ }
+ return new ExifAttribute(IFD_FORMAT_ULONG, values.length, buffer.array());
+ }
+
+ public static ExifAttribute createULong(long value, ByteOrder byteOrder) {
+ return createULong(new long[]{value}, byteOrder);
+ }
+
+ public static ExifAttribute createSLong(int[] values, ByteOrder byteOrder) {
+ final ByteBuffer buffer = ByteBuffer.wrap(
+ new byte[IFD_FORMAT_BYTES_PER_FORMAT[IFD_FORMAT_SLONG] * values.length]);
+ buffer.order(byteOrder);
+ for (int value : values) {
+ buffer.putInt(value);
+ }
+ return new ExifAttribute(IFD_FORMAT_SLONG, values.length, buffer.array());
+ }
+
+ public static ExifAttribute createByte(String value) {
+ // Exception for GPSAltitudeRef tag
+ if (value.length() == 1 && value.charAt(0) >= '0' && value.charAt(0) <= '1') {
+ final byte[] bytes = new byte[]{(byte) (value.charAt(0) - '0')};
+ return new ExifAttribute(IFD_FORMAT_BYTE, bytes.length, bytes);
+ }
+ final byte[] ascii = value.getBytes(ASCII);
+ return new ExifAttribute(IFD_FORMAT_BYTE, ascii.length, ascii);
+ }
+
+ public static ExifAttribute createString(String value) {
+ final byte[] ascii = (value + '\0').getBytes(ASCII);
+ return new ExifAttribute(IFD_FORMAT_STRING, ascii.length, ascii);
+ }
+
+ public static ExifAttribute createURational(Rational[] values, ByteOrder byteOrder) {
+ final ByteBuffer buffer = ByteBuffer.wrap(
+ new byte[IFD_FORMAT_BYTES_PER_FORMAT[IFD_FORMAT_URATIONAL] * values.length]);
+ buffer.order(byteOrder);
+ for (Rational value : values) {
+ buffer.putInt((int) value.numerator);
+ buffer.putInt((int) value.denominator);
+ }
+ return new ExifAttribute(IFD_FORMAT_URATIONAL, values.length, buffer.array());
+ }
+
+ public static ExifAttribute createURational(Rational value, ByteOrder byteOrder) {
+ return createURational(new Rational[]{value}, byteOrder);
+ }
+
+ public static ExifAttribute createSRational(Rational[] values, ByteOrder byteOrder) {
+ final ByteBuffer buffer = ByteBuffer.wrap(
+ new byte[IFD_FORMAT_BYTES_PER_FORMAT[IFD_FORMAT_SRATIONAL] * values.length]);
+ buffer.order(byteOrder);
+ for (Rational value : values) {
+ buffer.putInt((int) value.numerator);
+ buffer.putInt((int) value.denominator);
+ }
+ return new ExifAttribute(IFD_FORMAT_SRATIONAL, values.length, buffer.array());
+ }
+
+ public static ExifAttribute createDouble(double[] values, ByteOrder byteOrder) {
+ final ByteBuffer buffer = ByteBuffer.wrap(
+ new byte[IFD_FORMAT_BYTES_PER_FORMAT[IFD_FORMAT_DOUBLE] * values.length]);
+ buffer.order(byteOrder);
+ for (double value : values) {
+ buffer.putDouble(value);
+ }
+ return new ExifAttribute(IFD_FORMAT_DOUBLE, values.length, buffer.array());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "(" + IFD_FORMAT_NAMES[format] + ", data length:" + bytes.length + ")";
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ Object getValue(ByteOrder byteOrder) {
+ ByteOrderedDataInputStream inputStream = null;
+ try {
+ inputStream = new ByteOrderedDataInputStream(bytes);
+ inputStream.setByteOrder(byteOrder);
+ switch (format) {
+ case IFD_FORMAT_BYTE:
+ case IFD_FORMAT_SBYTE: {
+ // Exception for GPSAltitudeRef tag
+ if (bytes.length == 1 && bytes[0] >= 0 && bytes[0] <= 1) {
+ return new String(new char[]{(char) (bytes[0] + '0')});
+ }
+ return new String(bytes, ASCII);
+ }
+ case IFD_FORMAT_UNDEFINED:
+ case IFD_FORMAT_STRING: {
+ int index = 0;
+ if (numberOfComponents >= EXIF_ASCII_PREFIX.length) {
+ boolean same = true;
+ for (int i = 0; i < EXIF_ASCII_PREFIX.length; ++i) {
+ if (bytes[i] != EXIF_ASCII_PREFIX[i]) {
+ same = false;
+ break;
+ }
+ }
+ if (same) {
+ index = EXIF_ASCII_PREFIX.length;
+ }
+ }
+
+ StringBuilder stringBuilder = new StringBuilder();
+ while (index < numberOfComponents) {
+ int ch = bytes[index];
+ if (ch == 0) {
+ break;
+ }
+ if (ch >= 32) {
+ stringBuilder.append((char) ch);
+ } else {
+ stringBuilder.append('?');
+ }
+ ++index;
+ }
+ return stringBuilder.toString();
+ }
+ case IFD_FORMAT_USHORT: {
+ final int[] values = new int[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ values[i] = inputStream.readUnsignedShort();
+ }
+ return values;
+ }
+ case IFD_FORMAT_ULONG: {
+ final long[] values = new long[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ values[i] = inputStream.readUnsignedInt();
+ }
+ return values;
+ }
+ case IFD_FORMAT_URATIONAL: {
+ final Rational[] values = new Rational[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ final long numerator = inputStream.readUnsignedInt();
+ final long denominator = inputStream.readUnsignedInt();
+ values[i] = new Rational(numerator, denominator);
+ }
+ return values;
+ }
+ case IFD_FORMAT_SSHORT: {
+ final int[] values = new int[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ values[i] = inputStream.readShort();
+ }
+ return values;
+ }
+ case IFD_FORMAT_SLONG: {
+ final int[] values = new int[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ values[i] = inputStream.readInt();
+ }
+ return values;
+ }
+ case IFD_FORMAT_SRATIONAL: {
+ final Rational[] values = new Rational[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ final long numerator = inputStream.readInt();
+ final long denominator = inputStream.readInt();
+ values[i] = new Rational(numerator, denominator);
+ }
+ return values;
+ }
+ case IFD_FORMAT_SINGLE: {
+ final double[] values = new double[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ values[i] = inputStream.readFloat();
+ }
+ return values;
+ }
+ case IFD_FORMAT_DOUBLE: {
+ final double[] values = new double[numberOfComponents];
+ for (int i = 0; i < numberOfComponents; ++i) {
+ values[i] = inputStream.readDouble();
+ }
+ return values;
+ }
+ default:
+ return null;
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "IOException occurred during reading a value", e);
+ return null;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ Log.e(TAG, "IOException occurred while closing InputStream", e);
+ }
+ }
+ }
+ }
+
+ public double getDoubleValue(ByteOrder byteOrder) {
+ Object value = getValue(byteOrder);
+ if (value == null) {
+ throw new NumberFormatException("NULL can't be converted to a double value");
+ }
+ if (value instanceof String) {
+ return Double.parseDouble((String) value);
+ }
+ if (value instanceof long[]) {
+ long[] array = (long[]) value;
+ if (array.length == 1) {
+ return array[0];
+ }
+ throw new NumberFormatException("There are more than one component");
+ }
+ if (value instanceof int[]) {
+ int[] array = (int[]) value;
+ if (array.length == 1) {
+ return array[0];
+ }
+ throw new NumberFormatException("There are more than one component");
+ }
+ if (value instanceof double[]) {
+ double[] array = (double[]) value;
+ if (array.length == 1) {
+ return array[0];
+ }
+ throw new NumberFormatException("There are more than one component");
+ }
+ if (value instanceof Rational[]) {
+ Rational[] array = (Rational[]) value;
+ if (array.length == 1) {
+ return array[0].calculate();
+ }
+ throw new NumberFormatException("There are more than one component");
+ }
+ throw new NumberFormatException("Couldn't find a double value");
+ }
+
+ public int getIntValue(ByteOrder byteOrder) {
+ Object value = getValue(byteOrder);
+ if (value == null) {
+ throw new NumberFormatException("NULL can't be converted to a integer value");
+ }
+ if (value instanceof String) {
+ return Integer.parseInt((String) value);
+ }
+ if (value instanceof long[]) {
+ long[] array = (long[]) value;
+ if (array.length == 1) {
+ return (int) array[0];
+ }
+ throw new NumberFormatException("There are more than one component");
+ }
+ if (value instanceof int[]) {
+ int[] array = (int[]) value;
+ if (array.length == 1) {
+ return array[0];
+ }
+ throw new NumberFormatException("There are more than one component");
+ }
+ throw new NumberFormatException("Couldn't find a integer value");
+ }
+
+ public String getStringValue(ByteOrder byteOrder) {
+ Object value = getValue(byteOrder);
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof String) {
+ return (String) value;
+ }
+
+ final StringBuilder stringBuilder = new StringBuilder();
+ if (value instanceof long[]) {
+ long[] array = (long[]) value;
+ for (int i = 0; i < array.length; ++i) {
+ stringBuilder.append(array[i]);
+ if (i + 1 != array.length) {
+ stringBuilder.append(",");
+ }
+ }
+ return stringBuilder.toString();
+ }
+ if (value instanceof int[]) {
+ int[] array = (int[]) value;
+ for (int i = 0; i < array.length; ++i) {
+ stringBuilder.append(array[i]);
+ if (i + 1 != array.length) {
+ stringBuilder.append(",");
+ }
+ }
+ return stringBuilder.toString();
+ }
+ if (value instanceof double[]) {
+ double[] array = (double[]) value;
+ for (int i = 0; i < array.length; ++i) {
+ stringBuilder.append(array[i]);
+ if (i + 1 != array.length) {
+ stringBuilder.append(",");
+ }
+ }
+ return stringBuilder.toString();
+ }
+ if (value instanceof Rational[]) {
+ Rational[] array = (Rational[]) value;
+ for (int i = 0; i < array.length; ++i) {
+ stringBuilder.append(array[i].numerator);
+ stringBuilder.append('/');
+ stringBuilder.append(array[i].denominator);
+ if (i + 1 != array.length) {
+ stringBuilder.append(",");
+ }
+ }
+ return stringBuilder.toString();
+ }
+ return null;
+ }
+
+ public int size() {
+ return IFD_FORMAT_BYTES_PER_FORMAT[format] * numberOfComponents;
+ }
+ }
+
+ // A class for indicating EXIF tag.
+ static class ExifTag {
+ public final int number;
+ public final String name;
+ public final int primaryFormat;
+ public final int secondaryFormat;
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ ExifTag(String name, int number, int format) {
+ this.name = name;
+ this.number = number;
+ this.primaryFormat = format;
+ this.secondaryFormat = -1;
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ ExifTag(String name, int number, int primaryFormat, int secondaryFormat) {
+ this.name = name;
+ this.number = number;
+ this.primaryFormat = primaryFormat;
+ this.secondaryFormat = secondaryFormat;
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ boolean isFormatCompatible(int format) {
+ if (primaryFormat == IFD_FORMAT_UNDEFINED || format == IFD_FORMAT_UNDEFINED) {
+ return true;
+ } else if (primaryFormat == format || secondaryFormat == format) {
+ return true;
+ } else if ((primaryFormat == IFD_FORMAT_ULONG || secondaryFormat == IFD_FORMAT_ULONG)
+ && format == IFD_FORMAT_USHORT) {
+ return true;
+ } else if ((primaryFormat == IFD_FORMAT_SLONG || secondaryFormat == IFD_FORMAT_SLONG)
+ && format == IFD_FORMAT_SSHORT) {
+ return true;
+ } else if ((primaryFormat == IFD_FORMAT_DOUBLE || secondaryFormat == IFD_FORMAT_DOUBLE)
+ && format == IFD_FORMAT_SINGLE) {
+ return true;
+ }
+ return false;
+ }
+ }
+
+ // Primary image IFD TIFF tags (See JEITA CP-3451C Section 4.6.8 Tag Support Levels)
+ private static final ExifTag[] IFD_TIFF_TAGS = new ExifTag[]{
+ // For below two, see TIFF 6.0 Spec Section 3: Bilevel Images.
+ new ExifTag(TAG_NEW_SUBFILE_TYPE, 254, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_SUBFILE_TYPE, 255, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_IMAGE_WIDTH, 256, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_IMAGE_LENGTH, 257, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_BITS_PER_SAMPLE, 258, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_COMPRESSION, 259, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_PHOTOMETRIC_INTERPRETATION, 262, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_IMAGE_DESCRIPTION, 270, IFD_FORMAT_STRING),
+ new ExifTag(TAG_MAKE, 271, IFD_FORMAT_STRING),
+ new ExifTag(TAG_MODEL, 272, IFD_FORMAT_STRING),
+ new ExifTag(TAG_STRIP_OFFSETS, 273, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ORIENTATION, 274, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SAMPLES_PER_PIXEL, 277, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_ROWS_PER_STRIP, 278, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_STRIP_BYTE_COUNTS, 279, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_X_RESOLUTION, 282, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_Y_RESOLUTION, 283, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_PLANAR_CONFIGURATION, 284, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_RESOLUTION_UNIT, 296, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_TRANSFER_FUNCTION, 301, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SOFTWARE, 305, IFD_FORMAT_STRING),
+ new ExifTag(TAG_DATETIME, 306, IFD_FORMAT_STRING),
+ new ExifTag(TAG_ARTIST, 315, IFD_FORMAT_STRING),
+ new ExifTag(TAG_WHITE_POINT, 318, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_PRIMARY_CHROMATICITIES, 319, IFD_FORMAT_URATIONAL),
+ // See Adobe PageMaker® 6.0 TIFF Technical Notes, Note 1.
+ new ExifTag(TAG_SUB_IFD_POINTER, 330, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_JPEG_INTERCHANGE_FORMAT, 513, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, 514, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_Y_CB_CR_COEFFICIENTS, 529, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_Y_CB_CR_SUB_SAMPLING, 530, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_Y_CB_CR_POSITIONING, 531, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_REFERENCE_BLACK_WHITE, 532, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_COPYRIGHT, 33432, IFD_FORMAT_STRING),
+ new ExifTag(TAG_EXIF_IFD_POINTER, 34665, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_GPS_INFO_IFD_POINTER, 34853, IFD_FORMAT_ULONG),
+ // RW2 file tags
+ // See http://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/PanasonicRaw.html)
+ new ExifTag(TAG_RW2_SENSOR_TOP_BORDER, 4, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_RW2_SENSOR_LEFT_BORDER, 5, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_RW2_SENSOR_BOTTOM_BORDER, 6, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_RW2_SENSOR_RIGHT_BORDER, 7, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_RW2_ISO, 23, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_RW2_JPG_FROM_RAW, 46, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_XMP, 700, IFD_FORMAT_BYTE),
+ };
+
+ // Primary image IFD Exif Private tags (See JEITA CP-3451C Section 4.6.8 Tag Support Levels)
+ private static final ExifTag[] IFD_EXIF_TAGS = new ExifTag[]{
+ new ExifTag(TAG_EXPOSURE_TIME, 33434, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_F_NUMBER, 33437, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_EXPOSURE_PROGRAM, 34850, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SPECTRAL_SENSITIVITY, 34852, IFD_FORMAT_STRING),
+ new ExifTag(TAG_PHOTOGRAPHIC_SENSITIVITY, 34855, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_OECF, 34856, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_SENSITIVITY_TYPE, 34864, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_STANDARD_OUTPUT_SENSITIVITY, 34865, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_RECOMMENDED_EXPOSURE_INDEX, 34866, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ISO_SPEED, 34867, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ISO_SPEED_LATITUDE_YYY, 34868, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ISO_SPEED_LATITUDE_ZZZ, 34869, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_EXIF_VERSION, 36864, IFD_FORMAT_STRING),
+ new ExifTag(TAG_DATETIME_ORIGINAL, 36867, IFD_FORMAT_STRING),
+ new ExifTag(TAG_DATETIME_DIGITIZED, 36868, IFD_FORMAT_STRING),
+ new ExifTag(TAG_OFFSET_TIME, 36880, IFD_FORMAT_STRING),
+ new ExifTag(TAG_OFFSET_TIME_ORIGINAL, 36881, IFD_FORMAT_STRING),
+ new ExifTag(TAG_OFFSET_TIME_DIGITIZED, 36882, IFD_FORMAT_STRING),
+ new ExifTag(TAG_COMPONENTS_CONFIGURATION, 37121, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_COMPRESSED_BITS_PER_PIXEL, 37122, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_SHUTTER_SPEED_VALUE, 37377, IFD_FORMAT_SRATIONAL),
+ new ExifTag(TAG_APERTURE_VALUE, 37378, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_BRIGHTNESS_VALUE, 37379, IFD_FORMAT_SRATIONAL),
+ new ExifTag(TAG_EXPOSURE_BIAS_VALUE, 37380, IFD_FORMAT_SRATIONAL),
+ new ExifTag(TAG_MAX_APERTURE_VALUE, 37381, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_SUBJECT_DISTANCE, 37382, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_METERING_MODE, 37383, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_LIGHT_SOURCE, 37384, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_FLASH, 37385, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_FOCAL_LENGTH, 37386, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_SUBJECT_AREA, 37396, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_MAKER_NOTE, 37500, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_USER_COMMENT, 37510, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_SUBSEC_TIME, 37520, IFD_FORMAT_STRING),
+ new ExifTag(TAG_SUBSEC_TIME_ORIGINAL, 37521, IFD_FORMAT_STRING),
+ new ExifTag(TAG_SUBSEC_TIME_DIGITIZED, 37522, IFD_FORMAT_STRING),
+ new ExifTag(TAG_FLASHPIX_VERSION, 40960, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_COLOR_SPACE, 40961, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_PIXEL_X_DIMENSION, 40962, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_PIXEL_Y_DIMENSION, 40963, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_RELATED_SOUND_FILE, 40964, IFD_FORMAT_STRING),
+ new ExifTag(TAG_INTEROPERABILITY_IFD_POINTER, 40965, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_FLASH_ENERGY, 41483, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_SPATIAL_FREQUENCY_RESPONSE, 41484, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_FOCAL_PLANE_X_RESOLUTION, 41486, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_FOCAL_PLANE_Y_RESOLUTION, 41487, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_FOCAL_PLANE_RESOLUTION_UNIT, 41488, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SUBJECT_LOCATION, 41492, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_EXPOSURE_INDEX, 41493, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_SENSING_METHOD, 41495, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_FILE_SOURCE, 41728, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_SCENE_TYPE, 41729, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_CFA_PATTERN, 41730, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_CUSTOM_RENDERED, 41985, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_EXPOSURE_MODE, 41986, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_WHITE_BALANCE, 41987, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_DIGITAL_ZOOM_RATIO, 41988, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_FOCAL_LENGTH_IN_35MM_FILM, 41989, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SCENE_CAPTURE_TYPE, 41990, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_GAIN_CONTROL, 41991, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_CONTRAST, 41992, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SATURATION, 41993, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SHARPNESS, 41994, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_DEVICE_SETTING_DESCRIPTION, 41995, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_SUBJECT_DISTANCE_RANGE, 41996, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_IMAGE_UNIQUE_ID, 42016, IFD_FORMAT_STRING),
+ new ExifTag(TAG_CAMERA_OWNER_NAME, 42032, IFD_FORMAT_STRING),
+ new ExifTag(TAG_BODY_SERIAL_NUMBER, 42033, IFD_FORMAT_STRING),
+ new ExifTag(TAG_LENS_SPECIFICATION, 42034, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_LENS_MAKE, 42035, IFD_FORMAT_STRING),
+ new ExifTag(TAG_LENS_MODEL, 42036, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GAMMA, 42240, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_DNG_VERSION, 50706, IFD_FORMAT_BYTE),
+ new ExifTag(TAG_DEFAULT_CROP_SIZE, 50720, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG)
+ };
+
+ // Primary image IFD GPS Info tags (See JEITA CP-3451C Section 4.6.6 Tag Support Levels)
+ private static final ExifTag[] IFD_GPS_TAGS = new ExifTag[]{
+ new ExifTag(TAG_GPS_VERSION_ID, 0, IFD_FORMAT_BYTE),
+ new ExifTag(TAG_GPS_LATITUDE_REF, 1, IFD_FORMAT_STRING),
+ // Allow SRATIONAL to be compatible with apps using wrong format and
+ // even if it is negative, it may be valid latitude / longitude.
+ new ExifTag(TAG_GPS_LATITUDE, 2, IFD_FORMAT_URATIONAL, IFD_FORMAT_SRATIONAL),
+ new ExifTag(TAG_GPS_LONGITUDE_REF, 3, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_LONGITUDE, 4, IFD_FORMAT_URATIONAL, IFD_FORMAT_SRATIONAL),
+ new ExifTag(TAG_GPS_ALTITUDE_REF, 5, IFD_FORMAT_BYTE),
+ new ExifTag(TAG_GPS_ALTITUDE, 6, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_TIMESTAMP, 7, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_SATELLITES, 8, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_STATUS, 9, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_MEASURE_MODE, 10, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DOP, 11, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_SPEED_REF, 12, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_SPEED, 13, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_TRACK_REF, 14, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_TRACK, 15, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_IMG_DIRECTION_REF, 16, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_IMG_DIRECTION, 17, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_MAP_DATUM, 18, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DEST_LATITUDE_REF, 19, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DEST_LATITUDE, 20, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_DEST_LONGITUDE_REF, 21, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DEST_LONGITUDE, 22, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_DEST_BEARING_REF, 23, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DEST_BEARING, 24, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_DEST_DISTANCE_REF, 25, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DEST_DISTANCE, 26, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_GPS_PROCESSING_METHOD, 27, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_GPS_AREA_INFORMATION, 28, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_GPS_DATESTAMP, 29, IFD_FORMAT_STRING),
+ new ExifTag(TAG_GPS_DIFFERENTIAL, 30, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_GPS_H_POSITIONING_ERROR, 31, IFD_FORMAT_URATIONAL)
+ };
+ // Primary image IFD Interoperability tag (See JEITA CP-3451C Section 4.6.8 Tag Support Levels)
+ private static final ExifTag[] IFD_INTEROPERABILITY_TAGS = new ExifTag[]{
+ new ExifTag(TAG_INTEROPERABILITY_INDEX, 1, IFD_FORMAT_STRING)
+ };
+ // IFD Thumbnail tags (See JEITA CP-3451C Section 4.6.8 Tag Support Levels)
+ private static final ExifTag[] IFD_THUMBNAIL_TAGS = new ExifTag[]{
+ // For below two, see TIFF 6.0 Spec Section 3: Bilevel Images.
+ new ExifTag(TAG_NEW_SUBFILE_TYPE, 254, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_SUBFILE_TYPE, 255, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_THUMBNAIL_IMAGE_WIDTH, 256, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_THUMBNAIL_IMAGE_LENGTH, 257, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_BITS_PER_SAMPLE, 258, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_COMPRESSION, 259, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_PHOTOMETRIC_INTERPRETATION, 262, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_IMAGE_DESCRIPTION, 270, IFD_FORMAT_STRING),
+ new ExifTag(TAG_MAKE, 271, IFD_FORMAT_STRING),
+ new ExifTag(TAG_MODEL, 272, IFD_FORMAT_STRING),
+ new ExifTag(TAG_STRIP_OFFSETS, 273, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_THUMBNAIL_ORIENTATION, 274, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SAMPLES_PER_PIXEL, 277, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_ROWS_PER_STRIP, 278, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_STRIP_BYTE_COUNTS, 279, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_X_RESOLUTION, 282, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_Y_RESOLUTION, 283, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_PLANAR_CONFIGURATION, 284, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_RESOLUTION_UNIT, 296, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_TRANSFER_FUNCTION, 301, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_SOFTWARE, 305, IFD_FORMAT_STRING),
+ new ExifTag(TAG_DATETIME, 306, IFD_FORMAT_STRING),
+ new ExifTag(TAG_ARTIST, 315, IFD_FORMAT_STRING),
+ new ExifTag(TAG_WHITE_POINT, 318, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_PRIMARY_CHROMATICITIES, 319, IFD_FORMAT_URATIONAL),
+ // See Adobe PageMaker® 6.0 TIFF Technical Notes, Note 1.
+ new ExifTag(TAG_SUB_IFD_POINTER, 330, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_JPEG_INTERCHANGE_FORMAT, 513, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, 514, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_Y_CB_CR_COEFFICIENTS, 529, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_Y_CB_CR_SUB_SAMPLING, 530, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_Y_CB_CR_POSITIONING, 531, IFD_FORMAT_USHORT),
+ new ExifTag(TAG_REFERENCE_BLACK_WHITE, 532, IFD_FORMAT_URATIONAL),
+ new ExifTag(TAG_COPYRIGHT, 33432, IFD_FORMAT_STRING),
+ new ExifTag(TAG_EXIF_IFD_POINTER, 34665, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_GPS_INFO_IFD_POINTER, 34853, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_DNG_VERSION, 50706, IFD_FORMAT_BYTE),
+ new ExifTag(TAG_DEFAULT_CROP_SIZE, 50720, IFD_FORMAT_USHORT, IFD_FORMAT_ULONG)
+ };
+
+ // RAF file tag (See piex.cc line 372)
+ private static final ExifTag TAG_RAF_IMAGE_SIZE =
+ new ExifTag(TAG_STRIP_OFFSETS, 273, IFD_FORMAT_USHORT);
+
+ // ORF file tags (See http://www.exiv2.org/tags-olympus.html)
+ private static final ExifTag[] ORF_MAKER_NOTE_TAGS = new ExifTag[]{
+ new ExifTag(TAG_ORF_THUMBNAIL_IMAGE, 256, IFD_FORMAT_UNDEFINED),
+ new ExifTag(TAG_ORF_CAMERA_SETTINGS_IFD_POINTER, 8224, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ORF_IMAGE_PROCESSING_IFD_POINTER, 8256, IFD_FORMAT_ULONG)
+ };
+ private static final ExifTag[] ORF_CAMERA_SETTINGS_TAGS = new ExifTag[]{
+ new ExifTag(TAG_ORF_PREVIEW_IMAGE_START, 257, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ORF_PREVIEW_IMAGE_LENGTH, 258, IFD_FORMAT_ULONG)
+ };
+ private static final ExifTag[] ORF_IMAGE_PROCESSING_TAGS = new ExifTag[]{
+ new ExifTag(TAG_ORF_ASPECT_FRAME, 4371, IFD_FORMAT_USHORT)
+ };
+ // PEF file tag (See http://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/Pentax.html)
+ private static final ExifTag[] PEF_TAGS = new ExifTag[]{
+ new ExifTag(TAG_COLOR_SPACE, 55, IFD_FORMAT_USHORT)
+ };
+
+ // See JEITA CP-3451C Section 4.6.3: Exif-specific IFD.
+ // The following values are used for indicating pointers to the other Image File Directories.
+
+ // Indices of Exif Ifd tag groups
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({IFD_TYPE_PRIMARY, IFD_TYPE_EXIF, IFD_TYPE_GPS, IFD_TYPE_INTEROPERABILITY,
+ IFD_TYPE_THUMBNAIL, IFD_TYPE_PREVIEW, IFD_TYPE_ORF_MAKER_NOTE,
+ IFD_TYPE_ORF_CAMERA_SETTINGS, IFD_TYPE_ORF_IMAGE_PROCESSING, IFD_TYPE_PEF})
+ public @interface IfdType {
+ }
+
+ static final int IFD_TYPE_PRIMARY = 0;
+ private static final int IFD_TYPE_EXIF = 1;
+ private static final int IFD_TYPE_GPS = 2;
+ private static final int IFD_TYPE_INTEROPERABILITY = 3;
+ static final int IFD_TYPE_THUMBNAIL = 4;
+ static final int IFD_TYPE_PREVIEW = 5;
+ private static final int IFD_TYPE_ORF_MAKER_NOTE = 6;
+ private static final int IFD_TYPE_ORF_CAMERA_SETTINGS = 7;
+ private static final int IFD_TYPE_ORF_IMAGE_PROCESSING = 8;
+ private static final int IFD_TYPE_PEF = 9;
+
+ // List of Exif tag groups
+ static final ExifTag[][] EXIF_TAGS = new ExifTag[][]{
+ IFD_TIFF_TAGS, IFD_EXIF_TAGS, IFD_GPS_TAGS, IFD_INTEROPERABILITY_TAGS,
+ IFD_THUMBNAIL_TAGS, IFD_TIFF_TAGS, ORF_MAKER_NOTE_TAGS, ORF_CAMERA_SETTINGS_TAGS,
+ ORF_IMAGE_PROCESSING_TAGS, PEF_TAGS
+ };
+ // List of tags for pointing to the other image file directory offset.
+ private static final ExifTag[] EXIF_POINTER_TAGS = new ExifTag[]{
+ new ExifTag(TAG_SUB_IFD_POINTER, 330, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_EXIF_IFD_POINTER, 34665, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_GPS_INFO_IFD_POINTER, 34853, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_INTEROPERABILITY_IFD_POINTER, 40965, IFD_FORMAT_ULONG),
+ new ExifTag(TAG_ORF_CAMERA_SETTINGS_IFD_POINTER, 8224, IFD_FORMAT_BYTE),
+ new ExifTag(TAG_ORF_IMAGE_PROCESSING_IFD_POINTER, 8256, IFD_FORMAT_BYTE)
+ };
+
+ // Mappings from tag number to tag name and each item represents one IFD tag group.
+ @SuppressWarnings("unchecked")
+ private static final HashMap[] sExifTagMapsForReading =
+ new HashMap[EXIF_TAGS.length];
+ // Mappings from tag name to tag number and each item represents one IFD tag group.
+ @SuppressWarnings("unchecked")
+ private static final HashMap[] sExifTagMapsForWriting =
+ new HashMap[EXIF_TAGS.length];
+ private static final HashSet sTagSetForCompatibility = new HashSet<>(Arrays.asList(
+ TAG_F_NUMBER, TAG_DIGITAL_ZOOM_RATIO, TAG_EXPOSURE_TIME, TAG_SUBJECT_DISTANCE,
+ TAG_GPS_TIMESTAMP));
+ // Mappings from tag number to IFD type for pointer tags.
+ private static final HashMap sExifPointerTagMap = new HashMap<>();
+
+ // See JPEG File Interchange Format Version 1.02.
+ // The following values are defined for handling JPEG streams. In this implementation, we are
+ // not only getting information from EXIF but also from some JPEG special segments such as
+ // MARKER_COM for user comment and MARKER_SOFx for image width and height.
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ static final Charset ASCII = Charset.forName("US-ASCII");
+ // Identifier for EXIF APP1 segment in JPEG
+ static final byte[] IDENTIFIER_EXIF_APP1 = "Exif\0\0".getBytes(ASCII);
+ // Identifier for XMP APP1 segment in JPEG
+ private static final byte[] IDENTIFIER_XMP_APP1 =
+ "http://ns.adobe.com/xap/1.0/\0".getBytes(ASCII);
+ // JPEG segment markers, that each marker consumes two bytes beginning with 0xff and ending with
+ // the indicator. There is no SOF4, SOF8, SOF16 markers in JPEG and SOFx markers indicates start
+ // of frame(baseline DCT) and the image size info exists in its beginning part.
+ static final byte MARKER = (byte) 0xff;
+ private static final byte MARKER_SOI = (byte) 0xd8;
+ private static final byte MARKER_SOF0 = (byte) 0xc0;
+ private static final byte MARKER_SOF1 = (byte) 0xc1;
+ private static final byte MARKER_SOF2 = (byte) 0xc2;
+ private static final byte MARKER_SOF3 = (byte) 0xc3;
+ private static final byte MARKER_SOF5 = (byte) 0xc5;
+ private static final byte MARKER_SOF6 = (byte) 0xc6;
+ private static final byte MARKER_SOF7 = (byte) 0xc7;
+ private static final byte MARKER_SOF9 = (byte) 0xc9;
+ private static final byte MARKER_SOF10 = (byte) 0xca;
+ private static final byte MARKER_SOF11 = (byte) 0xcb;
+ private static final byte MARKER_SOF13 = (byte) 0xcd;
+ private static final byte MARKER_SOF14 = (byte) 0xce;
+ private static final byte MARKER_SOF15 = (byte) 0xcf;
+ private static final byte MARKER_SOS = (byte) 0xda;
+ static final byte MARKER_APP1 = (byte) 0xe1;
+ private static final byte MARKER_COM = (byte) 0xfe;
+ static final byte MARKER_EOI = (byte) 0xd9;
+
+ // Supported Image File Types
+ static final int IMAGE_TYPE_UNKNOWN = 0;
+ static final int IMAGE_TYPE_ARW = 1;
+ static final int IMAGE_TYPE_CR2 = 2;
+ static final int IMAGE_TYPE_DNG = 3;
+ static final int IMAGE_TYPE_JPEG = 4;
+ static final int IMAGE_TYPE_NEF = 5;
+ static final int IMAGE_TYPE_NRW = 6;
+ static final int IMAGE_TYPE_ORF = 7;
+ static final int IMAGE_TYPE_PEF = 8;
+ static final int IMAGE_TYPE_RAF = 9;
+ static final int IMAGE_TYPE_RW2 = 10;
+ static final int IMAGE_TYPE_SRW = 11;
+ static final int IMAGE_TYPE_HEIF = 12;
+ static final int IMAGE_TYPE_PNG = 13;
+ static final int IMAGE_TYPE_WEBP = 14;
+
+ static {
+ sFormatterPrimary = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US);
+ sFormatterPrimary.setTimeZone(TimeZone.getTimeZone("UTC"));
+ sFormatterSecondary = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);
+ sFormatterSecondary.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+ // Build up the hash tables to look up Exif tags for reading Exif tags.
+ for (int ifdType = 0; ifdType < EXIF_TAGS.length; ++ifdType) {
+ sExifTagMapsForReading[ifdType] = new HashMap<>();
+ sExifTagMapsForWriting[ifdType] = new HashMap<>();
+ for (ExifTag tag : EXIF_TAGS[ifdType]) {
+ sExifTagMapsForReading[ifdType].put(tag.number, tag);
+ sExifTagMapsForWriting[ifdType].put(tag.name, tag);
+ }
+ }
+
+ // Build up the hash table to look up Exif pointer tags.
+ sExifPointerTagMap.put(EXIF_POINTER_TAGS[0].number, IFD_TYPE_PREVIEW); // 330
+ sExifPointerTagMap.put(EXIF_POINTER_TAGS[1].number, IFD_TYPE_EXIF); // 34665
+ sExifPointerTagMap.put(EXIF_POINTER_TAGS[2].number, IFD_TYPE_GPS); // 34853
+ sExifPointerTagMap.put(EXIF_POINTER_TAGS[3].number, IFD_TYPE_INTEROPERABILITY); // 40965
+ sExifPointerTagMap.put(EXIF_POINTER_TAGS[4].number, IFD_TYPE_ORF_CAMERA_SETTINGS); // 8224
+ sExifPointerTagMap.put(EXIF_POINTER_TAGS[5].number, IFD_TYPE_ORF_IMAGE_PROCESSING); // 8256
+ }
+
+ private String mFilename;
+ private FileDescriptor mSeekableFileDescriptor;
+ private AssetManager.AssetInputStream mAssetInputStream;
+ private int mMimeType;
+ private boolean mIsExifDataOnly;
+ @SuppressWarnings("unchecked")
+ private final HashMap[] mAttributes = new HashMap[EXIF_TAGS.length];
+ private Set mAttributesOffsets = new HashSet<>(EXIF_TAGS.length);
+ private ByteOrder mExifByteOrder = BIG_ENDIAN;
+ private boolean mHasThumbnail;
+ private boolean mHasThumbnailStrips;
+ private boolean mAreThumbnailStripsConsecutive;
+ // Used to indicate the position of the thumbnail (doesn't include offset to EXIF data segment).
+ private int mThumbnailOffset;
+ private int mThumbnailLength;
+ private byte[] mThumbnailBytes;
+ private int mThumbnailCompression;
+ // Used to indicate offset from the start of the original input stream to EXIF data
+ private int mOffsetToExifData;
+ private int mOrfMakerNoteOffset;
+ private int mOrfThumbnailOffset;
+ private int mOrfThumbnailLength;
+ private boolean mModified;
+ // XMP data can be contained as either part of the EXIF data (tag number 700), or as a
+ // separate data marker (a separate MARKER_APP1).
+ private boolean mXmpIsFromSeparateMarker;
+
+ // Pattern to check non zero timestamp
+ private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
+ // Pattern to check gps timestamp
+ private static final Pattern GPS_TIMESTAMP_PATTERN =
+ Pattern.compile("^(\\d{2}):(\\d{2}):(\\d{2})$");
+ // Pattern to check date time primary format (e.g. 2020:01:01 00:00:00)
+ private static final Pattern DATETIME_PRIMARY_FORMAT_PATTERN =
+ Pattern.compile("^(\\d{4}):(\\d{2}):(\\d{2})\\s(\\d{2}):(\\d{2}):(\\d{2})$");
+ // Pattern to check date time secondary format (e.g. 2020-01-01 00:00:00)
+ private static final Pattern DATETIME_SECONDARY_FORMAT_PATTERN =
+ Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})\\s(\\d{2}):(\\d{2}):(\\d{2})$");
+ private static final int DATETIME_VALUE_STRING_LENGTH = 19;
+
+ /**
+ * Reads Exif tags from the specified image file.
+ *
+ * @param file the file of the image data
+ * @throws NullPointerException if file is null
+ * @throws IOException if an I/O error occurs while retrieving file descriptor via
+ * {@link FileInputStream#getFD()}.
+ */
+ public ExifInterface(@NonNull File file) throws IOException {
+ if (file == null) {
+ throw new NullPointerException("file cannot be null");
+ }
+ initForFilename(file.getAbsolutePath());
+ }
+
+ /**
+ * Reads Exif tags from the specified image file.
+ *
+ * @param filename the name of the file of the image data
+ * @throws NullPointerException if file name is null
+ * @throws IOException if an I/O error occurs while retrieving file descriptor via
+ * {@link FileInputStream#getFD()}.
+ */
+ public ExifInterface(@NonNull String filename) throws IOException {
+ if (filename == null) {
+ throw new NullPointerException("filename cannot be null");
+ }
+ initForFilename(filename);
+ }
+
+ /**
+ * Reads Exif tags from the specified image file descriptor. Attribute mutation is supported
+ * for writable and seekable file descriptors only. This constructor will not rewind the offset
+ * of the given file descriptor. Developers should close the file descriptor after use.
+ *
+ * @param fileDescriptor the file descriptor of the image data
+ * @throws NullPointerException if file descriptor is null
+ * @throws IOException if an error occurs while duplicating the file descriptor.
+ */
+ public ExifInterface(@NonNull FileDescriptor fileDescriptor) throws IOException {
+ if (fileDescriptor == null) {
+ throw new NullPointerException("fileDescriptor cannot be null");
+ }
+ mAssetInputStream = null;
+ mFilename = null;
+
+ boolean isFdDuped = false;
+ if (Build.VERSION.SDK_INT >= 21 && isSeekableFD(fileDescriptor)) {
+ mSeekableFileDescriptor = fileDescriptor;
+ // Keep the original file descriptor in order to save attributes when it's seekable.
+ // Otherwise, just close the given file descriptor after reading it because the save
+ // feature won't be working.
+ try {
+ fileDescriptor = Api21Impl.dup(fileDescriptor);
+ isFdDuped = true;
+ } catch (Exception e) {
+ throw new IOException("Failed to duplicate file descriptor", e);
+ }
+ } else {
+ mSeekableFileDescriptor = null;
+ }
+ FileInputStream in = null;
+ try {
+ in = new FileInputStream(fileDescriptor);
+ loadAttributes(in);
+ } finally {
+ closeQuietly(in);
+ if (isFdDuped) {
+ closeFileDescriptor(fileDescriptor);
+ }
+ }
+ }
+
+ /**
+ * Reads Exif tags from the specified image input stream. Attribute mutation is not supported
+ * for input streams. The given input stream will proceed from its current position. Developers
+ * should close the input stream after use. This constructor is not intended to be used with
+ * an input stream that performs any networking operations.
+ *
+ * @param inputStream the input stream that contains the image data
+ * @throws NullPointerException if the input stream is null
+ */
+ public ExifInterface(@NonNull InputStream inputStream) throws IOException {
+ this(inputStream, STREAM_TYPE_FULL_IMAGE_DATA);
+ }
+
+ /**
+ * Reads Exif tags from the specified image input stream based on the stream type. Attribute
+ * mutation is not supported for input streams. The given input stream will proceed from its
+ * current position. Developers should close the input stream after use. This constructor is not
+ * intended to be used with an input stream that performs any networking operations.
+ *
+ * @param inputStream the input stream that contains the image data
+ * @param streamType the type of input stream
+ * @throws NullPointerException if the input stream is null
+ * @throws IOException if an I/O error occurs while retrieving file descriptor via
+ * {@link FileInputStream#getFD()}.
+ */
+ public ExifInterface(@NonNull InputStream inputStream, @ExifStreamType int streamType)
+ throws IOException {
+ if (inputStream == null) {
+ throw new NullPointerException("inputStream cannot be null");
+ }
+ mFilename = null;
+
+ mIsExifDataOnly = streamType == STREAM_TYPE_EXIF_DATA_ONLY;
+ if (mIsExifDataOnly) {
+ mAssetInputStream = null;
+ mSeekableFileDescriptor = null;
+ } else {
+ if (inputStream instanceof AssetManager.AssetInputStream) {
+ mAssetInputStream = (AssetManager.AssetInputStream) inputStream;
+ mSeekableFileDescriptor = null;
+ } else if (inputStream instanceof FileInputStream
+ && isSeekableFD(((FileInputStream) inputStream).getFD())) {
+ mAssetInputStream = null;
+ mSeekableFileDescriptor = ((FileInputStream) inputStream).getFD();
+ } else {
+ mAssetInputStream = null;
+ mSeekableFileDescriptor = null;
+ }
+ }
+ loadAttributes(inputStream);
+ }
+
+ /**
+ * Returns whether ExifInterface currently supports reading data from the specified mime type
+ * or not.
+ *
+ * @param mimeType the string value of mime type
+ */
+ public static boolean isSupportedMimeType(@NonNull String mimeType) {
+ if (mimeType == null) {
+ throw new NullPointerException("mimeType shouldn't be null");
+ }
+
+ switch (mimeType.toLowerCase(Locale.ROOT)) {
+ case "image/jpeg":
+ case "image/x-adobe-dng":
+ case "image/x-canon-cr2":
+ case "image/x-nikon-nef":
+ case "image/x-nikon-nrw":
+ case "image/x-sony-arw":
+ case "image/x-panasonic-rw2":
+ case "image/x-olympus-orf":
+ case "image/x-pentax-pef":
+ case "image/x-samsung-srw":
+ case "image/x-fuji-raf":
+ case "image/heic":
+ case "image/heif":
+ case "image/png":
+ case "image/webp":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Returns the EXIF attribute of the specified tag or {@code null} if there is no such tag in
+ * the image file.
+ *
+ * @param tag the name of the tag.
+ */
+ @SuppressWarnings("deprecation")
+ @Nullable
+ private ExifAttribute getExifAttribute(@NonNull String tag) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ // Maintain compatibility.
+ if (TAG_ISO_SPEED_RATINGS.equals(tag)) {
+ if (DEBUG) {
+ Log.d(TAG, "getExifAttribute: Replacing TAG_ISO_SPEED_RATINGS with "
+ + "TAG_PHOTOGRAPHIC_SENSITIVITY.");
+ }
+ tag = TAG_PHOTOGRAPHIC_SENSITIVITY;
+ }
+ // Retrieves all tag groups. The value from primary image tag group has a higher priority
+ // than the value from the thumbnail tag group if there are more than one candidates.
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ ExifAttribute value = mAttributes[i].get(tag);
+ if (value != null) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the value of the specified tag or {@code null} if there
+ * is no such tag in the image file.
+ *
+ * @param tag the name of the tag.
+ */
+ @Nullable
+ public String getAttribute(@NonNull String tag) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ ExifAttribute attribute = getExifAttribute(tag);
+ if (attribute != null) {
+ if (!sTagSetForCompatibility.contains(tag)) {
+ return attribute.getStringValue(mExifByteOrder);
+ }
+ if (tag.equals(TAG_GPS_TIMESTAMP)) {
+ // Convert the rational values to the custom formats for backwards compatibility.
+ if (attribute.format != IFD_FORMAT_URATIONAL
+ && attribute.format != IFD_FORMAT_SRATIONAL) {
+ Log.w(TAG, "GPS Timestamp format is not rational. format=" + attribute.format);
+ return null;
+ }
+ Rational[] array = (Rational[]) attribute.getValue(mExifByteOrder);
+ if (array == null || array.length != 3) {
+ Log.w(TAG, "Invalid GPS Timestamp array. array=" + Arrays.toString(array));
+ return null;
+ }
+ return String.format(Locale.ROOT, "%02d:%02d:%02d",
+ (int) ((float) array[0].numerator / array[0].denominator),
+ (int) ((float) array[1].numerator / array[1].denominator),
+ (int) ((float) array[2].numerator / array[2].denominator));
+ }
+ try {
+ return Double.toString(attribute.getDoubleValue(mExifByteOrder));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the integer value of the specified tag. If there is no such tag
+ * in the image file or the value cannot be parsed as integer, return
+ * defaultValue.
+ *
+ * @param tag the name of the tag.
+ * @param defaultValue the value to return if the tag is not available.
+ */
+ public int getAttributeInt(@NonNull String tag, int defaultValue) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ ExifAttribute exifAttribute = getExifAttribute(tag);
+ if (exifAttribute == null) {
+ return defaultValue;
+ }
+
+ try {
+ return exifAttribute.getIntValue(mExifByteOrder);
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Returns the double value of the tag that is specified as rational or contains a
+ * double-formatted value. If there is no such tag in the image file or the value cannot be
+ * parsed as double, return defaultValue.
+ *
+ * @param tag the name of the tag.
+ * @param defaultValue the value to return if the tag is not available.
+ */
+ public double getAttributeDouble(@NonNull String tag, double defaultValue) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ ExifAttribute exifAttribute = getExifAttribute(tag);
+ if (exifAttribute == null) {
+ return defaultValue;
+ }
+
+ try {
+ return exifAttribute.getDoubleValue(mExifByteOrder);
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Sets the value of the specified tag.
+ *
+ * @param tag the name of the tag.
+ * @param value the value of the tag.
+ */
+ @SuppressWarnings("deprecation")
+ public void setAttribute(@NonNull String tag, @Nullable String value) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ // Validate and convert if necessary.
+ if (TAG_DATETIME.equals(tag) || TAG_DATETIME_ORIGINAL.equals(tag)
+ || TAG_DATETIME_DIGITIZED.equals(tag)) {
+ if (value != null) {
+ boolean isPrimaryFormat = DATETIME_PRIMARY_FORMAT_PATTERN.matcher(value).find();
+ boolean isSecondaryFormat = DATETIME_SECONDARY_FORMAT_PATTERN.matcher(value).find();
+ // Validate
+ if (value.length() != DATETIME_VALUE_STRING_LENGTH
+ || (!isPrimaryFormat && !isSecondaryFormat)) {
+ Log.w(TAG, "Invalid value for " + tag + " : " + value);
+ return;
+ }
+ // If datetime value has secondary format (e.g. 2020-01-01 00:00:00), convert it to
+ // primary format (e.g. 2020:01:01 00:00:00) since it is the format in the
+ // official documentation.
+ // See JEITA CP-3451C Section 4.6.4. D. Other Tags, DateTime
+ if (isSecondaryFormat) {
+ // Replace "-" with ":" to match the primary format.
+ value = value.replaceAll("-", ":");
+ }
+ }
+ }
+ // Maintain compatibility.
+ if (TAG_ISO_SPEED_RATINGS.equals(tag)) {
+ if (DEBUG) {
+ Log.d(TAG, "setAttribute: Replacing TAG_ISO_SPEED_RATINGS with "
+ + "TAG_PHOTOGRAPHIC_SENSITIVITY.");
+ }
+ tag = TAG_PHOTOGRAPHIC_SENSITIVITY;
+ }
+ // Convert the given value to rational values for backwards compatibility.
+ if (value != null && sTagSetForCompatibility.contains(tag)) {
+ if (tag.equals(TAG_GPS_TIMESTAMP)) {
+ Matcher m = GPS_TIMESTAMP_PATTERN.matcher(value);
+ if (!m.find()) {
+ Log.w(TAG, "Invalid value for " + tag + " : " + value);
+ return;
+ }
+ value = Integer.parseInt(m.group(1)) + "/1," + Integer.parseInt(m.group(2)) + "/1,"
+ + Integer.parseInt(m.group(3)) + "/1";
+ } else {
+ try {
+ double doubleValue = Double.parseDouble(value);
+ value = new Rational(doubleValue).toString();
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Invalid value for " + tag + " : " + value);
+ return;
+ }
+ }
+ }
+
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ if (i == IFD_TYPE_THUMBNAIL && !mHasThumbnail) {
+ continue;
+ }
+ final ExifTag exifTag = sExifTagMapsForWriting[i].get(tag);
+ if (exifTag != null) {
+ if (value == null) {
+ mAttributes[i].remove(tag);
+ continue;
+ }
+ Pair guess = guessDataFormat(value);
+ int dataFormat;
+ if (exifTag.primaryFormat == guess.first || exifTag.primaryFormat == guess.second) {
+ dataFormat = exifTag.primaryFormat;
+ } else if (exifTag.secondaryFormat != -1 && (exifTag.secondaryFormat == guess.first
+ || exifTag.secondaryFormat == guess.second)) {
+ dataFormat = exifTag.secondaryFormat;
+ } else if (exifTag.primaryFormat == IFD_FORMAT_BYTE
+ || exifTag.primaryFormat == IFD_FORMAT_UNDEFINED
+ || exifTag.primaryFormat == IFD_FORMAT_STRING) {
+ dataFormat = exifTag.primaryFormat;
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Given tag (" + tag
+ + ") value didn't match with one of expected "
+ + "formats: " + IFD_FORMAT_NAMES[exifTag.primaryFormat]
+ + (exifTag.secondaryFormat == -1 ? "" : ", "
+ + IFD_FORMAT_NAMES[exifTag.secondaryFormat]) + " (guess: "
+ + IFD_FORMAT_NAMES[guess.first] + (guess.second == -1 ? "" : ", "
+ + IFD_FORMAT_NAMES[guess.second]) + ")");
+ }
+ continue;
+ }
+ switch (dataFormat) {
+ case IFD_FORMAT_BYTE: {
+ mAttributes[i].put(tag, ExifAttribute.createByte(value));
+ break;
+ }
+ case IFD_FORMAT_UNDEFINED:
+ case IFD_FORMAT_STRING: {
+ mAttributes[i].put(tag, ExifAttribute.createString(value));
+ break;
+ }
+ case IFD_FORMAT_USHORT: {
+ final String[] values = value.split(",", -1);
+ final int[] intArray = new int[values.length];
+ for (int j = 0; j < values.length; ++j) {
+ intArray[j] = Integer.parseInt(values[j]);
+ }
+ mAttributes[i].put(tag,
+ ExifAttribute.createUShort(intArray, mExifByteOrder));
+ break;
+ }
+ case IFD_FORMAT_SLONG: {
+ final String[] values = value.split(",", -1);
+ final int[] intArray = new int[values.length];
+ for (int j = 0; j < values.length; ++j) {
+ intArray[j] = Integer.parseInt(values[j]);
+ }
+ mAttributes[i].put(tag,
+ ExifAttribute.createSLong(intArray, mExifByteOrder));
+ break;
+ }
+ case IFD_FORMAT_ULONG: {
+ final String[] values = value.split(",", -1);
+ final long[] longArray = new long[values.length];
+ for (int j = 0; j < values.length; ++j) {
+ longArray[j] = Long.parseLong(values[j]);
+ }
+ mAttributes[i].put(tag,
+ ExifAttribute.createULong(longArray, mExifByteOrder));
+ break;
+ }
+ case IFD_FORMAT_URATIONAL: {
+ final String[] values = value.split(",", -1);
+ final Rational[] rationalArray = new Rational[values.length];
+ for (int j = 0; j < values.length; ++j) {
+ final String[] numbers = values[j].split("/", -1);
+ rationalArray[j] = new Rational((long) Double.parseDouble(numbers[0]),
+ (long) Double.parseDouble(numbers[1]));
+ }
+ mAttributes[i].put(tag,
+ ExifAttribute.createURational(rationalArray, mExifByteOrder));
+ break;
+ }
+ case IFD_FORMAT_SRATIONAL: {
+ final String[] values = value.split(",", -1);
+ final Rational[] rationalArray = new Rational[values.length];
+ for (int j = 0; j < values.length; ++j) {
+ final String[] numbers = values[j].split("/", -1);
+ rationalArray[j] = new Rational((long) Double.parseDouble(numbers[0]),
+ (long) Double.parseDouble(numbers[1]));
+ }
+ mAttributes[i].put(tag,
+ ExifAttribute.createSRational(rationalArray, mExifByteOrder));
+ break;
+ }
+ case IFD_FORMAT_DOUBLE: {
+ final String[] values = value.split(",", -1);
+ final double[] doubleArray = new double[values.length];
+ for (int j = 0; j < values.length; ++j) {
+ doubleArray[j] = Double.parseDouble(values[j]);
+ }
+ mAttributes[i].put(tag,
+ ExifAttribute.createDouble(doubleArray, mExifByteOrder));
+ break;
+ }
+ default:
+ if (DEBUG) {
+ Log.d(TAG, "Data format isn't one of expected formats: " + dataFormat);
+ }
+ continue;
+ }
+ }
+ }
+ }
+
+ /**
+ * Resets the {@link #TAG_ORIENTATION} of the image to be {@link #ORIENTATION_NORMAL}.
+ */
+ public void resetOrientation() {
+ setAttribute(TAG_ORIENTATION, Integer.toString(ORIENTATION_NORMAL));
+ }
+
+ /**
+ * Rotates the image by the given degree clockwise. The degree should be a multiple of
+ * 90 (e.g, 90, 180, -90, etc.).
+ *
+ * @param degree The degree of rotation.
+ */
+ public void rotate(int degree) {
+ if (degree % 90 != 0) {
+ throw new IllegalArgumentException("degree should be a multiple of 90");
+ }
+
+ int currentOrientation = getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL);
+ int currentIndex, newIndex;
+ int resultOrientation;
+ if (ROTATION_ORDER.contains(currentOrientation)) {
+ currentIndex = ROTATION_ORDER.indexOf(currentOrientation);
+ newIndex = (currentIndex + degree / 90) % 4;
+ newIndex += newIndex < 0 ? 4 : 0;
+ resultOrientation = ROTATION_ORDER.get(newIndex);
+ } else if (FLIPPED_ROTATION_ORDER.contains(currentOrientation)) {
+ currentIndex = FLIPPED_ROTATION_ORDER.indexOf(currentOrientation);
+ newIndex = (currentIndex + degree / 90) % 4;
+ newIndex += newIndex < 0 ? 4 : 0;
+ resultOrientation = FLIPPED_ROTATION_ORDER.get(newIndex);
+ } else {
+ resultOrientation = ORIENTATION_UNDEFINED;
+ }
+
+ setAttribute(TAG_ORIENTATION, Integer.toString(resultOrientation));
+ }
+
+ /**
+ * Flips the image vertically.
+ */
+ public void flipVertically() {
+ int currentOrientation = getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL);
+ int resultOrientation;
+ switch (currentOrientation) {
+ case ORIENTATION_FLIP_HORIZONTAL:
+ resultOrientation = ORIENTATION_ROTATE_180;
+ break;
+ case ORIENTATION_ROTATE_180:
+ resultOrientation = ORIENTATION_FLIP_HORIZONTAL;
+ break;
+ case ORIENTATION_FLIP_VERTICAL:
+ resultOrientation = ORIENTATION_NORMAL;
+ break;
+ case ORIENTATION_TRANSPOSE:
+ resultOrientation = ORIENTATION_ROTATE_270;
+ break;
+ case ORIENTATION_ROTATE_90:
+ resultOrientation = ORIENTATION_TRANSVERSE;
+ break;
+ case ORIENTATION_TRANSVERSE:
+ resultOrientation = ORIENTATION_ROTATE_90;
+ break;
+ case ORIENTATION_ROTATE_270:
+ resultOrientation = ORIENTATION_TRANSPOSE;
+ break;
+ case ORIENTATION_NORMAL:
+ resultOrientation = ORIENTATION_FLIP_VERTICAL;
+ break;
+ case ORIENTATION_UNDEFINED:
+ default:
+ resultOrientation = ORIENTATION_UNDEFINED;
+ break;
+ }
+ setAttribute(TAG_ORIENTATION, Integer.toString(resultOrientation));
+ }
+
+ /**
+ * Flips the image horizontally.
+ */
+ public void flipHorizontally() {
+ int currentOrientation = getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL);
+ int resultOrientation;
+ switch (currentOrientation) {
+ case ORIENTATION_FLIP_HORIZONTAL:
+ resultOrientation = ORIENTATION_NORMAL;
+ break;
+ case ORIENTATION_ROTATE_180:
+ resultOrientation = ORIENTATION_FLIP_VERTICAL;
+ break;
+ case ORIENTATION_FLIP_VERTICAL:
+ resultOrientation = ORIENTATION_ROTATE_180;
+ break;
+ case ORIENTATION_TRANSPOSE:
+ resultOrientation = ORIENTATION_ROTATE_90;
+ break;
+ case ORIENTATION_ROTATE_90:
+ resultOrientation = ORIENTATION_TRANSPOSE;
+ break;
+ case ORIENTATION_TRANSVERSE:
+ resultOrientation = ORIENTATION_ROTATE_270;
+ break;
+ case ORIENTATION_ROTATE_270:
+ resultOrientation = ORIENTATION_TRANSVERSE;
+ break;
+ case ORIENTATION_NORMAL:
+ resultOrientation = ORIENTATION_FLIP_HORIZONTAL;
+ break;
+ case ORIENTATION_UNDEFINED:
+ default:
+ resultOrientation = ORIENTATION_UNDEFINED;
+ break;
+ }
+ setAttribute(TAG_ORIENTATION, Integer.toString(resultOrientation));
+ }
+
+ /**
+ * Returns if the current image orientation is flipped.
+ *
+ * @see #getRotationDegrees()
+ */
+ public boolean isFlipped() {
+ int orientation = getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL);
+ switch (orientation) {
+ case ORIENTATION_FLIP_HORIZONTAL:
+ case ORIENTATION_TRANSVERSE:
+ case ORIENTATION_FLIP_VERTICAL:
+ case ORIENTATION_TRANSPOSE:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Returns the rotation degrees for the current image orientation. If the image is flipped,
+ * i.e., {@link #isFlipped()} returns {@code true}, the rotation degrees will be base on
+ * the assumption that the image is first flipped horizontally (along Y-axis), and then do
+ * the rotation. For example, {@link #ORIENTATION_TRANSPOSE} will be interpreted as flipped
+ * horizontally first, and then rotate 270 degrees clockwise.
+ *
+ * @return The rotation degrees of the image after the horizontal flipping is applied, if any.
+ * @see #isFlipped()
+ */
+ public int getRotationDegrees() {
+ int orientation = getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL);
+ switch (orientation) {
+ case ORIENTATION_ROTATE_90:
+ case ORIENTATION_TRANSVERSE:
+ return 90;
+ case ORIENTATION_ROTATE_180:
+ case ORIENTATION_FLIP_VERTICAL:
+ return 180;
+ case ORIENTATION_ROTATE_270:
+ case ORIENTATION_TRANSPOSE:
+ return 270;
+ case ORIENTATION_UNDEFINED:
+ case ORIENTATION_NORMAL:
+ case ORIENTATION_FLIP_HORIZONTAL:
+ default:
+ return 0;
+ }
+ }
+
+ /**
+ * Remove any values of the specified tag.
+ *
+ * @param tag the name of the tag.
+ */
+ private void removeAttribute(String tag) {
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ mAttributes[i].remove(tag);
+ }
+ }
+
+ /**
+ * This function decides which parser to read the image data according to the given input stream
+ * type and the content of the input stream.
+ */
+ private void loadAttributes(@NonNull InputStream in) {
+ try {
+ // Initialize mAttributes.
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ mAttributes[i] = new HashMap<>();
+ }
+
+ // Check file type
+ if (!mIsExifDataOnly) {
+ in = new BufferedInputStream(in, SIGNATURE_CHECK_SIZE);
+ mMimeType = getMimeType((BufferedInputStream) in);
+ }
+
+ if (shouldSupportSeek(mMimeType)) {
+ SeekableByteOrderedDataInputStream inputStream =
+ new SeekableByteOrderedDataInputStream(in);
+ if (mIsExifDataOnly) {
+ if (!getStandaloneAttributes(inputStream)) {
+ return;
+ }
+ } else {
+ if (mMimeType == IMAGE_TYPE_HEIF) {
+ getHeifAttributes(inputStream);
+ } else if (mMimeType == IMAGE_TYPE_ORF) {
+ getOrfAttributes(inputStream);
+ } else if (mMimeType == IMAGE_TYPE_RW2) {
+ getRw2Attributes(inputStream);
+ } else {
+ getRawAttributes(inputStream);
+ }
+ }
+ // Set thumbnail image offset and length
+ inputStream.seek(mOffsetToExifData);
+ setThumbnailData(inputStream);
+ } else {
+ ByteOrderedDataInputStream inputStream = new ByteOrderedDataInputStream(in);
+ if (mMimeType == IMAGE_TYPE_JPEG) {
+ getJpegAttributes(inputStream, /* offsetToJpeg= */ 0,
+ IFD_TYPE_PRIMARY);
+ } else if (mMimeType == IMAGE_TYPE_PNG) {
+ getPngAttributes(inputStream);
+ } else if (mMimeType == IMAGE_TYPE_RAF) {
+ getRafAttributes(inputStream);
+ } else if (mMimeType == IMAGE_TYPE_WEBP) {
+ getWebpAttributes(inputStream);
+ }
+ }
+ } catch (IOException | UnsupportedOperationException e) {
+ // Ignore exceptions in order to keep the compatibility with the old versions of
+ // ExifInterface.
+ if (DEBUG) {
+ Log.w(TAG, "Invalid image: ExifInterface got an unsupported image format file"
+ + "(ExifInterface supports JPEG and some RAW image formats only) "
+ + "or a corrupted JPEG file to ExifInterface.", e);
+ }
+ } finally {
+ addDefaultValuesForCompatibility();
+
+ if (DEBUG) {
+ printAttributes();
+ }
+ }
+ }
+
+ private static boolean isSeekableFD(FileDescriptor fd) {
+ if (Build.VERSION.SDK_INT >= 21) {
+ try {
+ Api21Impl.lseek(fd, 0, OsConstants.SEEK_CUR);
+ return true;
+ } catch (Exception e) {
+ if (DEBUG) {
+ Log.d(TAG, "The file descriptor for the given input is not seekable");
+ }
+ return false;
+ }
+ }
+ return false;
+ }
+
+ // Prints out attributes for debugging.
+ private void printAttributes() {
+ for (int i = 0; i < mAttributes.length; ++i) {
+ Log.d(TAG, "The size of tag group[" + i + "]: " + mAttributes[i].size());
+ for (Map.Entry entry : mAttributes[i].entrySet()) {
+ final ExifAttribute tagValue = entry.getValue();
+ Log.d(TAG, "tagName: " + entry.getKey() + ", tagType: " + tagValue.toString()
+ + ", tagValue: '" + tagValue.getStringValue(mExifByteOrder) + "'");
+ }
+ }
+ }
+
+ /**
+ * Save the tag data into the original image file. This is expensive because it involves
+ * copying all the data from one file to another and deleting the old file and renaming the
+ * other. It's best to use {@link #setAttribute(String, String)} to set all attributes to write
+ * and make a single call rather than multiple calls for each attribute.
+ *
+ * This method is supported for JPEG, PNG, and WebP formats.
+ *
+ * Note: after calling this method, any attempts to obtain range information
+ * from {@link #getAttributeRange(String)} or {@link #getThumbnailRange()}
+ * will throw {@link IllegalStateException}, since the offsets may have
+ * changed in the newly written file.
+ *
+ * For WebP format, the Exif data will be stored as an Extended File Format, and it may not be
+ * supported for older readers.
+ *
+ * For PNG format, the Exif data will be stored as an "eXIf" chunk as per
+ * "Extensions to the PNG 1.2 Specification, Version 1.5.0".
+ */
+ public void saveAttributes() throws IOException {
+ if (!isSupportedFormatForSavingAttributes(mMimeType)) {
+ throw new IOException("ExifInterface only supports saving attributes for JPEG, PNG, "
+ + "and WebP formats.");
+ }
+ if (mSeekableFileDescriptor == null && mFilename == null) {
+ throw new IOException(
+ "ExifInterface does not support saving attributes for the current input.");
+ }
+ if (mHasThumbnail && mHasThumbnailStrips && !mAreThumbnailStripsConsecutive) {
+ throw new IOException("ExifInterface does not support saving attributes when the image "
+ + "file has non-consecutive thumbnail strips");
+ }
+
+ // Remember the fact that we've changed the file on disk from what was
+ // originally parsed, meaning we can't answer range questions
+ mModified = true;
+
+ // Keep the thumbnail in memory
+ mThumbnailBytes = getThumbnail();
+
+ FileInputStream in = null;
+ FileOutputStream out = null;
+ File tempFile;
+ try {
+ // Copy the original file to temporary file.
+ tempFile = File.createTempFile("temp", "tmp");
+ if (mFilename != null) {
+ in = new FileInputStream(mFilename);
+ } else {
+ // mSeekableFileDescriptor will be non-null only for SDK_INT >= 21, but this check
+ // is needed to prevent calling Os.lseek at runtime for SDK < 21.
+ if (Build.VERSION.SDK_INT >= 21) {
+ Api21Impl.lseek(mSeekableFileDescriptor, 0, OsConstants.SEEK_SET);
+ in = new FileInputStream(mSeekableFileDescriptor);
+ }
+ }
+ out = new FileOutputStream(tempFile);
+ copy(in, out);
+ } catch (Exception e) {
+ throw new IOException("Failed to copy original file to temp file", e);
+ } finally {
+ closeQuietly(in);
+ closeQuietly(out);
+ }
+
+ in = null;
+ out = null;
+ BufferedInputStream bufferedIn = null;
+ BufferedOutputStream bufferedOut = null;
+ boolean shouldKeepTempFile = false;
+ try {
+ // Save the new file.
+ in = new FileInputStream(tempFile);
+ if (mFilename != null) {
+ out = new FileOutputStream(mFilename);
+ } else {
+ // mSeekableFileDescriptor will be non-null only for SDK_INT >= 21, but this check
+ // is needed to prevent calling Os.lseek at runtime for SDK < 21.
+ if (Build.VERSION.SDK_INT >= 21) {
+ Api21Impl.lseek(mSeekableFileDescriptor, 0, OsConstants.SEEK_SET);
+ out = new FileOutputStream(mSeekableFileDescriptor);
+ }
+ }
+ bufferedIn = new BufferedInputStream(in);
+ bufferedOut = new BufferedOutputStream(out);
+ if (mMimeType == IMAGE_TYPE_JPEG) {
+ saveJpegAttributes(bufferedIn, bufferedOut);
+ } else if (mMimeType == IMAGE_TYPE_PNG) {
+ savePngAttributes(bufferedIn, bufferedOut);
+ } else if (mMimeType == IMAGE_TYPE_WEBP) {
+ saveWebpAttributes(bufferedIn, bufferedOut);
+ }
+ } catch (Exception e) {
+ try {
+ // Restore original file
+ in = new FileInputStream(tempFile);
+ if (mFilename != null) {
+ out = new FileOutputStream(mFilename);
+ } else {
+ // mSeekableFileDescriptor will be non-null only for SDK_INT >= 21, but this
+ // check is needed to prevent calling Os.lseek at runtime for SDK < 21.
+ if (Build.VERSION.SDK_INT >= 21) {
+ Api21Impl.lseek(mSeekableFileDescriptor, 0, OsConstants.SEEK_SET);
+ out = new FileOutputStream(mSeekableFileDescriptor);
+ }
+ }
+ copy(in, out);
+ } catch (Exception exception) {
+ shouldKeepTempFile = true;
+ throw new IOException("Failed to save new file. Original file is stored in "
+ + tempFile.getAbsolutePath(), exception);
+ } finally {
+ closeQuietly(in);
+ closeQuietly(out);
+ }
+ throw new IOException("Failed to save new file", e);
+ } finally {
+ closeQuietly(bufferedIn);
+ closeQuietly(bufferedOut);
+ if (!shouldKeepTempFile) {
+ tempFile.delete();
+ }
+ }
+
+ // Discard the thumbnail in memory
+ mThumbnailBytes = null;
+ }
+
+ /**
+ * Returns true if the image file has a thumbnail.
+ */
+ public boolean hasThumbnail() {
+ return mHasThumbnail;
+ }
+
+ /**
+ * Returns true if the image file has the given attribute defined.
+ *
+ * @param tag the name of the tag.
+ */
+ public boolean hasAttribute(@NonNull String tag) {
+ return getExifAttribute(tag) != null;
+ }
+
+ /**
+ * Returns the JPEG compressed thumbnail inside the image file, or {@code null} if there is no
+ * JPEG compressed thumbnail.
+ * The returned data can be decoded using
+ * {@link BitmapFactory#decodeByteArray(byte[], int, int)}
+ */
+ @Nullable
+ public byte[] getThumbnail() {
+ if (mThumbnailCompression == DATA_JPEG || mThumbnailCompression == DATA_JPEG_COMPRESSED) {
+ return getThumbnailBytes();
+ }
+ return null;
+ }
+
+ /**
+ * Returns the thumbnail bytes inside the image file, regardless of the compression type of the
+ * thumbnail image.
+ */
+ @Nullable
+ public byte[] getThumbnailBytes() {
+ if (!mHasThumbnail) {
+ return null;
+ }
+ if (mThumbnailBytes != null) {
+ return mThumbnailBytes;
+ }
+
+ // Read the thumbnail.
+ InputStream in = null;
+ FileDescriptor newFileDescriptor = null;
+ try {
+ if (mAssetInputStream != null) {
+ in = mAssetInputStream;
+ if (in.markSupported()) {
+ in.reset();
+ } else {
+ Log.d(TAG, "Cannot read thumbnail from inputstream without mark/reset support");
+ return null;
+ }
+ } else if (mFilename != null) {
+ in = new FileInputStream(mFilename);
+ } else {
+ // mSeekableFileDescriptor will be non-null only for SDK_INT >= 21, but this check
+ // is needed to prevent calling Os.lseek and Os.dup at runtime for SDK < 21.
+ if (Build.VERSION.SDK_INT >= 21) {
+ newFileDescriptor = Api21Impl.dup(mSeekableFileDescriptor);
+ Api21Impl.lseek(newFileDescriptor, 0, OsConstants.SEEK_SET);
+ in = new FileInputStream(newFileDescriptor);
+ }
+ }
+ if (in == null) {
+ // Should not be reached this.
+ throw new FileNotFoundException();
+ }
+
+ ByteOrderedDataInputStream inputStream = new ByteOrderedDataInputStream(in);
+ inputStream.skipFully(mThumbnailOffset + mOffsetToExifData);
+ // TODO: Need to handle potential OutOfMemoryError
+ byte[] buffer = new byte[mThumbnailLength];
+ inputStream.readFully(buffer);
+ mThumbnailBytes = buffer;
+ return buffer;
+ } catch (Exception e) {
+ // Couldn't get a thumbnail image.
+ Log.d(TAG, "Encountered exception while getting thumbnail", e);
+ } finally {
+ closeQuietly(in);
+ if (newFileDescriptor != null) {
+ closeFileDescriptor(newFileDescriptor);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Creates and returns a Bitmap object of the thumbnail image based on the byte array and the
+ * thumbnail compression value, or {@code null} if the compression type is unsupported.
+ */
+ @Nullable
+ public Bitmap getThumbnailBitmap() {
+ if (!mHasThumbnail) {
+ return null;
+ } else if (mThumbnailBytes == null) {
+ mThumbnailBytes = getThumbnailBytes();
+ }
+
+ if (mThumbnailCompression == DATA_JPEG || mThumbnailCompression == DATA_JPEG_COMPRESSED) {
+ return BitmapFactory.decodeByteArray(mThumbnailBytes, 0, mThumbnailLength);
+ } else if (mThumbnailCompression == DATA_UNCOMPRESSED) {
+ int[] rgbValues = new int[mThumbnailBytes.length / 3];
+ byte alpha = (byte) 0xff000000;
+ for (int i = 0; i < rgbValues.length; i++) {
+ rgbValues[i] = alpha + (mThumbnailBytes[3 * i] << 16)
+ + (mThumbnailBytes[3 * i + 1] << 8) + mThumbnailBytes[3 * i + 2];
+ }
+
+ ExifAttribute imageLengthAttribute =
+ mAttributes[IFD_TYPE_THUMBNAIL].get(TAG_THUMBNAIL_IMAGE_LENGTH);
+ ExifAttribute imageWidthAttribute =
+ mAttributes[IFD_TYPE_THUMBNAIL].get(TAG_THUMBNAIL_IMAGE_WIDTH);
+ if (imageLengthAttribute != null && imageWidthAttribute != null) {
+ int imageLength = imageLengthAttribute.getIntValue(mExifByteOrder);
+ int imageWidth = imageWidthAttribute.getIntValue(mExifByteOrder);
+ return Bitmap.createBitmap(
+ rgbValues, imageWidth, imageLength, Bitmap.Config.ARGB_8888);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if thumbnail image is JPEG Compressed, or false if either thumbnail image does
+ * not exist or thumbnail image is uncompressed.
+ */
+ public boolean isThumbnailCompressed() {
+ if (!mHasThumbnail) {
+ return false;
+ }
+ if (mThumbnailCompression == DATA_JPEG || mThumbnailCompression == DATA_JPEG_COMPRESSED) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns the offset and length of thumbnail inside the image file, or
+ * {@code null} if either there is no thumbnail or the thumbnail bytes are stored
+ * non-consecutively.
+ *
+ * @return two-element array, the offset in the first value, and length in
+ * the second, or {@code null} if no thumbnail was found or the thumbnail strips are
+ * not placed consecutively.
+ * @throws IllegalStateException if {@link #saveAttributes()} has been
+ * called since the underlying file was initially parsed, since
+ * that means offsets may have changed.
+ */
+ @Nullable
+ public long[] getThumbnailRange() {
+ if (mModified) {
+ throw new IllegalStateException(
+ "The underlying file has been modified since being parsed");
+ }
+
+ if (mHasThumbnail) {
+ if (mHasThumbnailStrips && !mAreThumbnailStripsConsecutive) {
+ return null;
+ }
+ return new long[]{mThumbnailOffset + mOffsetToExifData, mThumbnailLength};
+ }
+ return null;
+ }
+
+ /**
+ * Returns the offset and length of the requested tag inside the image file,
+ * or {@code null} if the tag is not contained.
+ *
+ * @return two-element array, the offset in the first value, and length in
+ * the second, or {@code null} if no tag was found.
+ * @throws IllegalStateException if {@link #saveAttributes()} has been
+ * called since the underlying file was initially parsed, since
+ * that means offsets may have changed.
+ */
+ @Nullable
+ public long[] getAttributeRange(@NonNull String tag) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ if (mModified) {
+ throw new IllegalStateException(
+ "The underlying file has been modified since being parsed");
+ }
+
+ final ExifAttribute attribute = getExifAttribute(tag);
+ if (attribute != null) {
+ return new long[]{attribute.bytesOffset, attribute.bytes.length};
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the raw bytes for the value of the requested tag inside the image
+ * file, or {@code null} if the tag is not contained.
+ *
+ * @return raw bytes for the value of the requested tag, or {@code null} if
+ * no tag was found.
+ */
+ @Nullable
+ public byte[] getAttributeBytes(@NonNull String tag) {
+ if (tag == null) {
+ throw new NullPointerException("tag shouldn't be null");
+ }
+ final ExifAttribute attribute = getExifAttribute(tag);
+ if (attribute != null) {
+ return attribute.bytes;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Stores the latitude and longitude value in a float array. The first element is the latitude,
+ * and the second element is the longitude. Returns false if the Exif tags are not available.
+ *
+ * @deprecated Use {@link #getLatLong()} instead.
+ */
+ @Deprecated
+ public boolean getLatLong(float output[]) {
+ double[] latLong = getLatLong();
+ if (latLong == null) {
+ return false;
+ }
+
+ output[0] = (float) latLong[0];
+ output[1] = (float) latLong[1];
+ return true;
+ }
+
+ /**
+ * Gets the latitude and longitude values.
+ *
+ * If there are valid latitude and longitude values in the image, this method returns a double
+ * array where the first element is the latitude and the second element is the longitude.
+ * Otherwise, it returns null.
+ */
+ @Nullable
+ public double[] getLatLong() {
+ String latValue = getAttribute(TAG_GPS_LATITUDE);
+ String latRef = getAttribute(TAG_GPS_LATITUDE_REF);
+ String lngValue = getAttribute(TAG_GPS_LONGITUDE);
+ String lngRef = getAttribute(TAG_GPS_LONGITUDE_REF);
+
+ if (latValue != null && latRef != null && lngValue != null && lngRef != null) {
+ try {
+ double latitude = convertRationalLatLonToDouble(latValue, latRef);
+ double longitude = convertRationalLatLonToDouble(lngValue, lngRef);
+ return new double[]{latitude, longitude};
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "Latitude/longitude values are not parsable. "
+ + String.format("latValue=%s, latRef=%s, lngValue=%s, lngRef=%s",
+ latValue, latRef, lngValue, lngRef));
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Sets the GPS-related information. It will set GPS processing method, latitude and longitude
+ * values, GPS timestamp, and speed information at the same time.
+ *
+ * This method is a No-Op if the location parameter is null.
+ *
+ * @param location the {@link Location} object returned by GPS service.
+ */
+ public void setGpsInfo(@Nullable Location location) {
+ if (location == null) {
+ return;
+ }
+ setAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD, location.getProvider());
+ setLatLong(location.getLatitude(), location.getLongitude());
+ setAltitude(location.getAltitude());
+ // Location objects store speeds in m/sec. Translates it to km/hr here.
+ setAttribute(TAG_GPS_SPEED_REF, "K");
+ setAttribute(TAG_GPS_SPEED, new Rational(location.getSpeed()
+ * TimeUnit.HOURS.toSeconds(1) / 1000).toString());
+ String[] dateTime = sFormatterPrimary.format(
+ new Date(location.getTime())).split("\\s+", -1);
+ setAttribute(ExifInterface.TAG_GPS_DATESTAMP, dateTime[0]);
+ setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, dateTime[1]);
+ }
+
+ /**
+ * Sets the latitude and longitude values.
+ *
+ * @param latitude the decimal value of latitude. Must be a valid double value between -90.0 and
+ * 90.0.
+ * @param longitude the decimal value of longitude. Must be a valid double value between -180.0
+ * and 180.0.
+ * @throws IllegalArgumentException If {@code latitude} or {@code longitude} is outside the
+ * specified range.
+ */
+ public void setLatLong(double latitude, double longitude) {
+ if (latitude < -90.0 || latitude > 90.0 || Double.isNaN(latitude)) {
+ throw new IllegalArgumentException("Latitude value " + latitude + " is not valid.");
+ }
+ if (longitude < -180.0 || longitude > 180.0 || Double.isNaN(longitude)) {
+ throw new IllegalArgumentException("Longitude value " + longitude + " is not valid.");
+ }
+ setAttribute(TAG_GPS_LATITUDE_REF, latitude >= 0 ? "N" : "S");
+ setAttribute(TAG_GPS_LATITUDE, convertDecimalDegree(Math.abs(latitude)));
+ setAttribute(TAG_GPS_LONGITUDE_REF, longitude >= 0 ? "E" : "W");
+ setAttribute(TAG_GPS_LONGITUDE, convertDecimalDegree(Math.abs(longitude)));
+ }
+
+ /**
+ * Return the altitude in meters. If the exif tag does not exist, return
+ * defaultValue.
+ *
+ * @param defaultValue the value to return if the tag is not available.
+ */
+ public double getAltitude(double defaultValue) {
+ double altitude = getAttributeDouble(TAG_GPS_ALTITUDE, -1);
+ int ref = getAttributeInt(TAG_GPS_ALTITUDE_REF, -1);
+
+ if (altitude >= 0 && ref >= 0) {
+ return (altitude * ((ref == 1) ? -1 : 1));
+ } else {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Sets the altitude in meters.
+ */
+ public void setAltitude(double altitude) {
+ String ref = altitude >= 0 ? "0" : "1";
+ setAttribute(TAG_GPS_ALTITUDE, new Rational(Math.abs(altitude)).toString());
+ setAttribute(TAG_GPS_ALTITUDE_REF, ref);
+ }
+
+ /**
+ * Set the date time value.
+ *
+ * @param timeStamp number of milliseconds since Jan. 1, 1970, midnight local time.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ public void setDateTime(@NonNull Long timeStamp) {
+ if (timeStamp == null) {
+ throw new NullPointerException("Timestamp should not be null.");
+ }
+
+ if (timeStamp < 0) {
+ throw new IllegalArgumentException("Timestamp should a positive value.");
+ }
+
+ long subsec = timeStamp % 1000;
+ String subsecString = Long.toString(subsec);
+ for (int i = subsecString.length(); i < 3; i++) {
+ subsecString = "0" + subsecString;
+ }
+ setAttribute(TAG_DATETIME, sFormatterPrimary.format(new Date(timeStamp)));
+ setAttribute(TAG_SUBSEC_TIME, subsecString);
+ }
+
+ /**
+ * Returns parsed {@link ExifInterface#TAG_DATETIME} value as number of milliseconds since
+ * Jan. 1, 1970, midnight local time.
+ *
+ *
Note: The return value includes the first three digits (or less depending on the length
+ * of the string) of {@link ExifInterface#TAG_SUBSEC_TIME}.
+ *
+ * @return null if date time information is unavailable or invalid.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @Nullable
+ public Long getDateTime() {
+ return parseDateTime(getAttribute(TAG_DATETIME),
+ getAttribute(TAG_SUBSEC_TIME),
+ getAttribute(TAG_OFFSET_TIME));
+ }
+
+ /**
+ * Returns parsed {@link ExifInterface#TAG_DATETIME_DIGITIZED} value as number of
+ * milliseconds since Jan. 1, 1970, midnight local time.
+ *
+ *
Note: The return value includes the first three digits (or less depending on the length
+ * of the string) of {@link ExifInterface#TAG_SUBSEC_TIME_DIGITIZED}.
+ *
+ * @return null if digitized date time information is unavailable or invalid.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @Nullable
+ public Long getDateTimeDigitized() {
+ return parseDateTime(getAttribute(TAG_DATETIME_DIGITIZED),
+ getAttribute(TAG_SUBSEC_TIME_DIGITIZED),
+ getAttribute(TAG_OFFSET_TIME_DIGITIZED));
+ }
+
+ /**
+ * Returns parsed {@link ExifInterface#TAG_DATETIME_ORIGINAL} value as number of
+ * milliseconds since Jan. 1, 1970, midnight local time.
+ *
+ *
Note: The return value includes the first three digits (or less depending on the length
+ * of the string) of {@link ExifInterface#TAG_SUBSEC_TIME_ORIGINAL}.
+ *
+ * @return null if original date time information is unavailable or invalid.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @Nullable
+ public Long getDateTimeOriginal() {
+ return parseDateTime(getAttribute(TAG_DATETIME_ORIGINAL),
+ getAttribute(TAG_SUBSEC_TIME_ORIGINAL),
+ getAttribute(TAG_OFFSET_TIME_ORIGINAL));
+ }
+
+ private static Long parseDateTime(@Nullable String dateTimeString, @Nullable String subSecs,
+ @Nullable String offsetString) {
+ if (dateTimeString == null || !NON_ZERO_TIME_PATTERN.matcher(dateTimeString).matches()) {
+ return null;
+ }
+
+ ParsePosition pos = new ParsePosition(0);
+ try {
+ // The exif field is in local time. Parsing it as if it is UTC will yield time
+ // since 1/1/1970 local time
+ Date dateTime = sFormatterPrimary.parse(dateTimeString, pos);
+ if (dateTime == null) {
+ dateTime = sFormatterSecondary.parse(dateTimeString, pos);
+ if (dateTime == null) {
+ return null;
+ }
+ }
+ long msecs = dateTime.getTime();
+ if (offsetString != null) {
+ String sign = offsetString.substring(0, 1);
+ int hour = Integer.parseInt(offsetString.substring(1, 3));
+ int min = Integer.parseInt(offsetString.substring(4, 6));
+ if (("+".equals(sign) || "-".equals(sign))
+ && ":".equals(offsetString.substring(3, 4))
+ && hour <= 14 /* max UTC hour value */) {
+ msecs += (hour * 60 + min) * 60 * 1000 * ("-".equals(sign) ? 1 : -1);
+ }
+ }
+
+ if (subSecs != null) {
+ msecs += parseSubSeconds(subSecs);
+ }
+ return msecs;
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns number of milliseconds since 1970-01-01 00:00:00 UTC.
+ *
+ * @return null if the date time information is not available.
+ */
+ @SuppressLint("AutoBoxing") /* Not a performance-critical call, thus not a big concern. */
+ @Nullable
+ public Long getGpsDateTime() {
+ String date = getAttribute(TAG_GPS_DATESTAMP);
+ String time = getAttribute(TAG_GPS_TIMESTAMP);
+ if (date == null || time == null
+ || (!NON_ZERO_TIME_PATTERN.matcher(date).matches()
+ && !NON_ZERO_TIME_PATTERN.matcher(time).matches())) {
+ return null;
+ }
+
+ String dateTimeString = date + ' ' + time;
+
+ ParsePosition pos = new ParsePosition(0);
+ try {
+ Date dateTime = sFormatterPrimary.parse(dateTimeString, pos);
+ if (dateTime == null) {
+ dateTime = sFormatterSecondary.parse(dateTimeString, pos);
+ if (dateTime == null) {
+ return null;
+ }
+ }
+ return dateTime.getTime();
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private void initForFilename(String filename) throws IOException {
+ if (filename == null) {
+ throw new NullPointerException("filename cannot be null");
+ }
+ FileInputStream in = null;
+ mAssetInputStream = null;
+ mFilename = filename;
+ try {
+ in = new FileInputStream(filename);
+ if (isSeekableFD(in.getFD())) {
+ mSeekableFileDescriptor = in.getFD();
+ } else {
+ mSeekableFileDescriptor = null;
+ }
+ loadAttributes(in);
+ } finally {
+ closeQuietly(in);
+ }
+ }
+
+ private static double convertRationalLatLonToDouble(String rationalString, String ref) {
+ try {
+ String[] parts = rationalString.split(",", -1);
+
+ String[] pair;
+ pair = parts[0].split("/", -1);
+ double degrees = Double.parseDouble(pair[0].trim())
+ / Double.parseDouble(pair[1].trim());
+
+ pair = parts[1].split("/", -1);
+ double minutes = Double.parseDouble(pair[0].trim())
+ / Double.parseDouble(pair[1].trim());
+
+ pair = parts[2].split("/", -1);
+ double seconds = Double.parseDouble(pair[0].trim())
+ / Double.parseDouble(pair[1].trim());
+
+ double result = degrees + (minutes / 60.0) + (seconds / 3600.0);
+ if ((ref.equals("S") || ref.equals("W"))) {
+ return -result;
+ } else if (ref.equals("N") || ref.equals("E")) {
+ return result;
+ } else {
+ // Not valid
+ throw new IllegalArgumentException();
+ }
+ } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
+ // Not valid
+ throw new IllegalArgumentException();
+ }
+ }
+
+ private String convertDecimalDegree(double decimalDegree) {
+ long degrees = (long) decimalDegree;
+ long minutes = (long) ((decimalDegree - degrees) * 60.0);
+ long seconds = Math.round((decimalDegree - degrees - minutes / 60.0) * 3600.0 * 1e7);
+ return degrees + "/1," + minutes + "/1," + seconds + "/10000000";
+ }
+
+ // Checks the type of image file
+ private int getMimeType(BufferedInputStream in) throws IOException {
+ in.mark(SIGNATURE_CHECK_SIZE);
+ byte[] signatureCheckBytes = new byte[SIGNATURE_CHECK_SIZE];
+ in.read(signatureCheckBytes);
+ in.reset();
+ if (isJpegFormat(signatureCheckBytes)) {
+ return IMAGE_TYPE_JPEG;
+ } else if (isRafFormat(signatureCheckBytes)) {
+ return IMAGE_TYPE_RAF;
+ } else if (isHeifFormat(signatureCheckBytes)) {
+ return IMAGE_TYPE_HEIF;
+ } else if (isOrfFormat(signatureCheckBytes)) {
+ return IMAGE_TYPE_ORF;
+ } else if (isRw2Format(signatureCheckBytes)) {
+ return IMAGE_TYPE_RW2;
+ } else if (isPngFormat(signatureCheckBytes)) {
+ return IMAGE_TYPE_PNG;
+ } else if (isWebpFormat(signatureCheckBytes)) {
+ return IMAGE_TYPE_WEBP;
+ }
+ // Certain file formats (PEF) are identified in readImageFileDirectory()
+ return IMAGE_TYPE_UNKNOWN;
+ }
+
+ /**
+ * This method looks at the first 3 bytes to determine if this file is a JPEG file.
+ * See http://www.media.mit.edu/pia/Research/deepview/exif.html, "JPEG format and Marker"
+ */
+ private static boolean isJpegFormat(byte[] signatureCheckBytes) throws IOException {
+ for (int i = 0; i < JPEG_SIGNATURE.length; i++) {
+ if (signatureCheckBytes[i] != JPEG_SIGNATURE[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * This method looks at the first 15 bytes to determine if this file is a RAF file.
+ * There is no official specification for RAF files from Fuji, but there is an online archive of
+ * image file specifications:
+ * http://fileformats.archiveteam.org/wiki/Fujifilm_RAF
+ */
+ private boolean isRafFormat(byte[] signatureCheckBytes) throws IOException {
+ byte[] rafSignatureBytes = RAF_SIGNATURE.getBytes(Charset.defaultCharset());
+ for (int i = 0; i < rafSignatureBytes.length; i++) {
+ if (signatureCheckBytes[i] != rafSignatureBytes[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean isHeifFormat(byte[] signatureCheckBytes) throws IOException {
+ ByteOrderedDataInputStream signatureInputStream = null;
+ try {
+ signatureInputStream = new ByteOrderedDataInputStream(signatureCheckBytes);
+
+ long chunkSize = signatureInputStream.readInt();
+ byte[] chunkType = new byte[4];
+ signatureInputStream.readFully(chunkType);
+
+ if (!Arrays.equals(chunkType, HEIF_TYPE_FTYP)) {
+ return false;
+ }
+
+ long chunkDataOffset = 8;
+ if (chunkSize == 1) {
+ // This indicates that the next 8 bytes represent the chunk size,
+ // and chunk data comes after that.
+ chunkSize = signatureInputStream.readLong();
+ if (chunkSize < 16) {
+ // The smallest valid chunk is 16 bytes long in this case.
+ return false;
+ }
+ chunkDataOffset += 8;
+ }
+
+ // only sniff up to signatureCheckBytes.length
+ if (chunkSize > signatureCheckBytes.length) {
+ chunkSize = signatureCheckBytes.length;
+ }
+
+ long chunkDataSize = chunkSize - chunkDataOffset;
+
+ // It should at least have major brand (4-byte) and minor version (4-byte).
+ // The rest of the chunk (if any) is a list of (4-byte) compatible brands.
+ if (chunkDataSize < 8) {
+ return false;
+ }
+
+ byte[] brand = new byte[4];
+ boolean isMif1 = false;
+ boolean isHeic = false;
+ for (long i = 0; i < chunkDataSize / 4; ++i) {
+ try {
+ signatureInputStream.readFully(brand);
+ } catch (EOFException e) {
+ return false;
+ }
+ if (i == 1) {
+ // Skip this index, it refers to the minorVersion, not a brand.
+ continue;
+ }
+ if (Arrays.equals(brand, HEIF_BRAND_MIF1)) {
+ isMif1 = true;
+ } else if (Arrays.equals(brand, HEIF_BRAND_HEIC)) {
+ isHeic = true;
+ }
+ if (isMif1 && isHeic) {
+ return true;
+ }
+ }
+ } catch (Exception e) {
+ if (DEBUG) {
+ Log.d(TAG, "Exception parsing HEIF file type box.", e);
+ }
+ } finally {
+ if (signatureInputStream != null) {
+ signatureInputStream.close();
+ signatureInputStream = null;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * ORF has a similar structure to TIFF but it contains a different signature at the TIFF Header.
+ * This method looks at the 2 bytes following the Byte Order bytes to determine if this file is
+ * an ORF file.
+ * There is no official specification for ORF files from Olympus, but there is an online archive
+ * of image file specifications:
+ * http://fileformats.archiveteam.org/wiki/Olympus_ORF
+ */
+ private boolean isOrfFormat(byte[] signatureCheckBytes) throws IOException {
+ ByteOrderedDataInputStream signatureInputStream = null;
+
+ try {
+ signatureInputStream = new ByteOrderedDataInputStream(signatureCheckBytes);
+
+ // Read byte order
+ mExifByteOrder = readByteOrder(signatureInputStream);
+ // Set byte order
+ signatureInputStream.setByteOrder(mExifByteOrder);
+
+ short orfSignature = signatureInputStream.readShort();
+ return orfSignature == ORF_SIGNATURE_1 || orfSignature == ORF_SIGNATURE_2;
+ } catch (Exception e) {
+ // Do nothing
+ } finally {
+ if (signatureInputStream != null) {
+ signatureInputStream.close();
+ }
+ }
+ return false;
+ }
+
+ /**
+ * RW2 is TIFF-based, but stores 0x55 signature byte instead of 0x42 at the header
+ * See http://lclevy.free.fr/raw/
+ */
+ private boolean isRw2Format(byte[] signatureCheckBytes) throws IOException {
+ ByteOrderedDataInputStream signatureInputStream = null;
+
+ try {
+ signatureInputStream = new ByteOrderedDataInputStream(signatureCheckBytes);
+
+ // Read byte order
+ mExifByteOrder = readByteOrder(signatureInputStream);
+ // Set byte order
+ signatureInputStream.setByteOrder(mExifByteOrder);
+
+ short signatureByte = signatureInputStream.readShort();
+ return signatureByte == RW2_SIGNATURE;
+ } catch (Exception e) {
+ // Do nothing
+ } finally {
+ if (signatureInputStream != null) {
+ signatureInputStream.close();
+ }
+ }
+ return false;
+ }
+
+ /**
+ * PNG's file signature is first 8 bytes.
+ * See PNG (Portable Network Graphics) Specification, Version 1.2, 3.1. PNG file signature
+ */
+ private boolean isPngFormat(byte[] signatureCheckBytes) throws IOException {
+ for (int i = 0; i < PNG_SIGNATURE.length; i++) {
+ if (signatureCheckBytes[i] != PNG_SIGNATURE[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * WebP's file signature is composed of 12 bytes:
+ * 'RIFF' (4 bytes) + file length value (4 bytes) + 'WEBP' (4 bytes)
+ * See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
+ */
+ private boolean isWebpFormat(byte[] signatureCheckBytes) throws IOException {
+ for (int i = 0; i < WEBP_SIGNATURE_1.length; i++) {
+ if (signatureCheckBytes[i] != WEBP_SIGNATURE_1[i]) {
+ return false;
+ }
+ }
+ for (int i = 0; i < WEBP_SIGNATURE_2.length; i++) {
+ if (signatureCheckBytes[i + WEBP_SIGNATURE_1.length + WEBP_FILE_SIZE_BYTE_LENGTH]
+ != WEBP_SIGNATURE_2[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Loads EXIF attributes from a JPEG input stream.
+ *
+ * @param in The input stream that starts with the JPEG data.
+ * @param offsetToJpeg The offset to JPEG data for the original input stream.
+ * @param imageType The image type from which to retrieve metadata. Use IFD_TYPE_PRIMARY for
+ * primary image, IFD_TYPE_PREVIEW for preview image, and
+ * IFD_TYPE_THUMBNAIL for thumbnail image.
+ * @throws IOException If the data contains invalid JPEG markers, offsets, or length values.
+ */
+ private void getJpegAttributes(ByteOrderedDataInputStream in, int offsetToJpeg, int imageType)
+ throws IOException {
+ // See JPEG File Interchange Format Specification, "JFIF Specification"
+ if (DEBUG) {
+ Log.d(TAG, "getJpegAttributes starting with: " + in);
+ }
+ // JPEG uses Big Endian by default. See https://people.cs.umass.edu/~verts/cs32/endian.html
+ in.setByteOrder(BIG_ENDIAN);
+
+ int bytesRead = 0;
+
+ byte marker;
+ if ((marker = in.readByte()) != MARKER) {
+ throw new IOException("Invalid marker: " + Integer.toHexString(marker & 0xff));
+ }
+ ++bytesRead;
+ if (in.readByte() != MARKER_SOI) {
+ throw new IOException("Invalid marker: " + Integer.toHexString(marker & 0xff));
+ }
+ ++bytesRead;
+ while (true) {
+ marker = in.readByte();
+ if (marker != MARKER) {
+ throw new IOException("Invalid marker:" + Integer.toHexString(marker & 0xff));
+ }
+ ++bytesRead;
+ marker = in.readByte();
+ if (DEBUG) {
+ Log.d(TAG, "Found JPEG segment indicator: " + Integer.toHexString(marker & 0xff));
+ }
+ ++bytesRead;
+
+ // EOI indicates the end of an image and in case of SOS, JPEG image stream starts and
+ // the image data will terminate right after.
+ if (marker == MARKER_EOI || marker == MARKER_SOS) {
+ break;
+ }
+ int length = in.readUnsignedShort() - 2;
+ bytesRead += 2;
+ if (DEBUG) {
+ Log.d(TAG, "JPEG segment: " + Integer.toHexString(marker & 0xff) + " (length: "
+ + (length + 2) + ")");
+ }
+ if (length < 0) {
+ throw new IOException("Invalid length");
+ }
+ switch (marker) {
+ case MARKER_APP1: {
+ final int start = bytesRead;
+ final byte[] bytes = new byte[length];
+ in.readFully(bytes);
+ bytesRead += length;
+ length = 0;
+
+ if (startsWith(bytes, IDENTIFIER_EXIF_APP1)) {
+ final byte[] value = Arrays.copyOfRange(bytes, IDENTIFIER_EXIF_APP1.length,
+ bytes.length);
+ // Save offset to EXIF data for handling thumbnail and attribute offsets.
+ mOffsetToExifData = offsetToJpeg
+ + /* offset to EXIF from JPEG start */ start
+ + IDENTIFIER_EXIF_APP1.length;
+ readExifSegment(value, imageType);
+
+ setThumbnailData(new ByteOrderedDataInputStream(value));
+ } else if (startsWith(bytes, IDENTIFIER_XMP_APP1)) {
+ // See XMP Specification Part 3: Storage in Files, 1.1.3 JPEG, Table 6
+ final int offset = start + IDENTIFIER_XMP_APP1.length;
+ final byte[] value = Arrays.copyOfRange(bytes,
+ IDENTIFIER_XMP_APP1.length, bytes.length);
+ // TODO: check if ignoring separate XMP data when tag 700 already exists is
+ // valid.
+ if (getAttribute(TAG_XMP) == null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, new ExifAttribute(
+ IFD_FORMAT_BYTE, value.length, offset, value));
+ mXmpIsFromSeparateMarker = true;
+ }
+ }
+ break;
+ }
+
+ case MARKER_COM: {
+ byte[] bytes = new byte[length];
+ in.readFully(bytes);
+ length = 0;
+ if (getAttribute(TAG_USER_COMMENT) == null) {
+ mAttributes[IFD_TYPE_EXIF].put(TAG_USER_COMMENT, ExifAttribute.createString(
+ new String(bytes, ASCII)));
+ }
+ break;
+ }
+
+ case MARKER_SOF0:
+ case MARKER_SOF1:
+ case MARKER_SOF2:
+ case MARKER_SOF3:
+ case MARKER_SOF5:
+ case MARKER_SOF6:
+ case MARKER_SOF7:
+ case MARKER_SOF9:
+ case MARKER_SOF10:
+ case MARKER_SOF11:
+ case MARKER_SOF13:
+ case MARKER_SOF14:
+ case MARKER_SOF15: {
+ in.skipFully(1);
+ mAttributes[imageType].put(imageType != IFD_TYPE_THUMBNAIL
+ ? TAG_IMAGE_LENGTH : TAG_THUMBNAIL_IMAGE_LENGTH,
+ ExifAttribute.createULong(in.readUnsignedShort(), mExifByteOrder));
+ mAttributes[imageType].put(imageType != IFD_TYPE_THUMBNAIL
+ ? TAG_IMAGE_WIDTH : TAG_THUMBNAIL_IMAGE_WIDTH,
+ ExifAttribute.createULong(in.readUnsignedShort(), mExifByteOrder));
+ length -= 5;
+ break;
+ }
+
+ default: {
+ break;
+ }
+ }
+ if (length < 0) {
+ throw new IOException("Invalid length");
+ }
+ in.skipFully(length);
+ bytesRead += length;
+ }
+ // Restore original byte order
+ in.setByteOrder(mExifByteOrder);
+ }
+
+ private void getRawAttributes(SeekableByteOrderedDataInputStream in) throws IOException {
+ // Parse TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
+ parseTiffHeaders(in);
+
+ // Read TIFF image file directories. See JEITA CP-3451C Section 4.5.2. Figure 6.
+ readImageFileDirectory(in, IFD_TYPE_PRIMARY);
+
+ // Update ImageLength/Width tags for all image data.
+ updateImageSizeValues(in, IFD_TYPE_PRIMARY);
+ updateImageSizeValues(in, IFD_TYPE_PREVIEW);
+ updateImageSizeValues(in, IFD_TYPE_THUMBNAIL);
+
+ // Check if each image data is in valid position.
+ validateImages();
+
+ if (mMimeType == IMAGE_TYPE_PEF) {
+ // PEF files contain a MakerNote data, which contains the data for ColorSpace tag.
+ // See http://lclevy.free.fr/raw/ and piex.cc PefGetPreviewData()
+ ExifAttribute makerNoteAttribute =
+ mAttributes[IFD_TYPE_EXIF].get(TAG_MAKER_NOTE);
+ if (makerNoteAttribute != null) {
+ // Create an ordered DataInputStream for MakerNote
+ SeekableByteOrderedDataInputStream makerNoteDataInputStream =
+ new SeekableByteOrderedDataInputStream(makerNoteAttribute.bytes);
+ makerNoteDataInputStream.setByteOrder(mExifByteOrder);
+
+ // Skip to MakerNote data
+ makerNoteDataInputStream.skipFully(PEF_MAKER_NOTE_SKIP_SIZE);
+
+ // Read IFD data from MakerNote
+ readImageFileDirectory(makerNoteDataInputStream, IFD_TYPE_PEF);
+
+ // Update ColorSpace tag
+ ExifAttribute colorSpaceAttribute =
+ mAttributes[IFD_TYPE_PEF].get(TAG_COLOR_SPACE);
+ if (colorSpaceAttribute != null) {
+ mAttributes[IFD_TYPE_EXIF].put(TAG_COLOR_SPACE, colorSpaceAttribute);
+ }
+ }
+ }
+ }
+
+ /**
+ * RAF files contains a JPEG and a CFA data.
+ * The JPEG contains two images, a preview and a thumbnail, while the CFA contains a RAW image.
+ * This method looks at the first 160 bytes of a RAF file to retrieve the offset and length
+ * values for the JPEG and CFA data.
+ * Using that data, it parses the JPEG data to retrieve the preview and thumbnail image data,
+ * then parses the CFA metadata to retrieve the primary image length/width values.
+ * For data format details, see http://fileformats.archiveteam.org/wiki/Fujifilm_RAF
+ */
+ private void getRafAttributes(ByteOrderedDataInputStream in) throws IOException {
+ if (DEBUG) {
+ Log.d(TAG, "getRafAttributes starting with: " + in);
+ }
+ // Retrieve offset & length values
+ in.skipFully(RAF_OFFSET_TO_JPEG_IMAGE_OFFSET);
+ byte[] offsetToJpegBytes = new byte[4];
+ byte[] jpegLengthBytes = new byte[4];
+ byte[] cfaHeaderOffsetBytes = new byte[4];
+ in.readFully(offsetToJpegBytes);
+ in.readFully(jpegLengthBytes);
+ in.readFully(cfaHeaderOffsetBytes);
+ int offsetToJpeg = ByteBuffer.wrap(offsetToJpegBytes).getInt();
+ int jpegLength = ByteBuffer.wrap(jpegLengthBytes).getInt();
+ int cfaHeaderOffset = ByteBuffer.wrap(cfaHeaderOffsetBytes).getInt();
+
+ byte[] jpegBytes = new byte[jpegLength];
+ in.skipFully(offsetToJpeg - in.position());
+ in.readFully(jpegBytes);
+
+ // Retrieve JPEG image metadata
+ ByteOrderedDataInputStream jpegInputStream = new ByteOrderedDataInputStream(jpegBytes);
+ getJpegAttributes(jpegInputStream, offsetToJpeg, IFD_TYPE_PREVIEW);
+
+ // Skip to CFA header offset.
+ in.skipFully(cfaHeaderOffset - in.position());
+
+ // Retrieve primary image length/width values, if TAG_RAF_IMAGE_SIZE exists
+ in.setByteOrder(BIG_ENDIAN);
+ int numberOfDirectoryEntry = in.readInt();
+ if (DEBUG) {
+ Log.d(TAG, "numberOfDirectoryEntry: " + numberOfDirectoryEntry);
+ }
+ // CFA stores some metadata about the RAW image. Since CFA uses proprietary tags, can only
+ // find and retrieve image size information tags, while skipping others.
+ // See piex.cc RafGetDimension()
+ for (int i = 0; i < numberOfDirectoryEntry; ++i) {
+ int tagNumber = in.readUnsignedShort();
+ int numberOfBytes = in.readUnsignedShort();
+ if (tagNumber == TAG_RAF_IMAGE_SIZE.number) {
+ int imageLength = in.readShort();
+ int imageWidth = in.readShort();
+ ExifAttribute imageLengthAttribute =
+ ExifAttribute.createUShort(imageLength, mExifByteOrder);
+ ExifAttribute imageWidthAttribute =
+ ExifAttribute.createUShort(imageWidth, mExifByteOrder);
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_LENGTH, imageLengthAttribute);
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_WIDTH, imageWidthAttribute);
+ if (DEBUG) {
+ Log.d(TAG, "Updated to length: " + imageLength + ", width: " + imageWidth);
+ }
+ return;
+ }
+ in.skipFully(numberOfBytes);
+ }
+ }
+
+ // Support for getting MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET and
+ // MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH was added SDK 28.
+ private void getHeifAttributes(final SeekableByteOrderedDataInputStream in) throws IOException {
+ if (Build.VERSION.SDK_INT >= 28) {
+ MediaMetadataRetriever retriever = new MediaMetadataRetriever();
+ try {
+ Api23Impl.setDataSource(retriever, new MediaDataSource() {
+ long mPosition;
+
+ @Override
+ public void close() throws IOException {
+ }
+
+ @Override
+ public int readAt(long position, byte[] buffer, int offset, int size)
+ throws IOException {
+ if (size == 0) {
+ return 0;
+ }
+ if (position < 0) {
+ return -1;
+ }
+ try {
+ if (mPosition != position) {
+ // We don't allow seek to positions after the available bytes,
+ // the input stream won't be able to seek back then.
+ // However, if we hit an exception before (mPosition set to -1),
+ // let it try the seek in hope it might recover.
+ if (mPosition >= 0 && position >= mPosition + in.available()) {
+ return -1;
+ }
+ in.seek(position);
+ mPosition = position;
+ }
+
+ // If the read will cause us to go over the available bytes,
+ // reduce the size so that we stay in the available range.
+ // Otherwise the input stream may not be able to seek back.
+ if (size > in.available()) {
+ size = in.available();
+ }
+
+ int bytesRead = in.read(buffer, offset, size);
+ if (bytesRead >= 0) {
+ mPosition += bytesRead;
+ return bytesRead;
+ }
+ } catch (IOException e) {
+ // do nothing
+ }
+ mPosition = -1; // need to seek on next read
+ return -1;
+ }
+
+ @Override
+ public long getSize() throws IOException {
+ return -1;
+ }
+ });
+
+ String exifOffsetStr = null, exifLengthStr = null;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ exifOffsetStr = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET);
+ exifLengthStr = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH);
+ }
+
+ String hasImage = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE);
+ String hasVideo = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO);
+
+ String width = null;
+ String height = null;
+ String rotation = null;
+ final String metadataValueYes = "yes";
+ // If the file has both image and video, prefer image info over video info.
+ // App querying ExifInterface is most likely using the bitmap path which
+ // picks the image first.
+ if (metadataValueYes.equals(hasImage)) {
+ width = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH);
+ height = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT);
+ rotation = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION);
+ } else if (metadataValueYes.equals(hasVideo)) {
+ width = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
+ height = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
+ rotation = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
+ }
+
+ if (width != null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_WIDTH,
+ ExifAttribute.createUShort(Integer.parseInt(width), mExifByteOrder));
+ }
+
+ if (height != null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_LENGTH,
+ ExifAttribute.createUShort(Integer.parseInt(height), mExifByteOrder));
+ }
+
+ if (rotation != null) {
+ int orientation = ExifInterface.ORIENTATION_NORMAL;
+
+ // all rotation angles in CW
+ switch (Integer.parseInt(rotation)) {
+ case 90:
+ orientation = ExifInterface.ORIENTATION_ROTATE_90;
+ break;
+ case 180:
+ orientation = ExifInterface.ORIENTATION_ROTATE_180;
+ break;
+ case 270:
+ orientation = ExifInterface.ORIENTATION_ROTATE_270;
+ break;
+ }
+
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_ORIENTATION,
+ ExifAttribute.createUShort(orientation, mExifByteOrder));
+ }
+
+ if (exifOffsetStr != null && exifLengthStr != null) {
+ int offset = Integer.parseInt(exifOffsetStr);
+ int length = Integer.parseInt(exifLengthStr);
+ if (length <= 6) {
+ throw new IOException("Invalid exif length");
+ }
+ in.seek(offset);
+ byte[] identifier = new byte[6];
+ in.readFully(identifier);
+ offset += 6;
+ length -= 6;
+ if (!Arrays.equals(identifier, IDENTIFIER_EXIF_APP1)) {
+ throw new IOException("Invalid identifier");
+ }
+
+ // TODO: Need to handle potential OutOfMemoryError
+ byte[] bytes = new byte[length];
+ in.readFully(bytes);
+ // Save offset to EXIF data for handling thumbnail and attribute offsets.
+ mOffsetToExifData = offset;
+ readExifSegment(bytes, IFD_TYPE_PRIMARY);
+ }
+
+ String xmpOffsetStr = null, xmpLengthStr = null;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ xmpOffsetStr = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_XMP_OFFSET);
+ xmpLengthStr = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_XMP_LENGTH);
+ }
+ if (xmpOffsetStr != null && xmpLengthStr != null) {
+ int offset = Integer.parseInt(xmpOffsetStr);
+ int length = Integer.parseInt(xmpLengthStr);
+ in.seek(offset);
+ byte[] xmpBytes = new byte[length];
+ in.readFully(xmpBytes);
+ if (getAttribute(TAG_XMP) == null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, new ExifAttribute(
+ IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes));
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "Heif meta: " + width + "x" + height + ", rotation " + rotation);
+ }
+ } catch (RuntimeException e) {
+ throw new UnsupportedOperationException("Failed to read EXIF from HEIF file. "
+ + "Given stream is either malformed or unsupported.");
+ } finally {
+ try {
+ retriever.release();
+ } catch (IOException e) {
+ // Nothing we can do about it.
+ }
+ }
+ } else {
+ throw new UnsupportedOperationException("Reading EXIF from HEIF files "
+ + "is supported from SDK 28 and above");
+ }
+ }
+
+ /**
+ * Reads standalone EXIF data, returning whether the data was read successfully.
+ */
+ private boolean getStandaloneAttributes(SeekableByteOrderedDataInputStream in)
+ throws IOException {
+ byte[] signatureCheckBytes = new byte[IDENTIFIER_EXIF_APP1.length];
+ in.readFully(signatureCheckBytes);
+ if (!Arrays.equals(signatureCheckBytes, IDENTIFIER_EXIF_APP1)) {
+ Log.w(TAG, "Given data is not EXIF-only.");
+ return false;
+ }
+ // TODO: Need to handle potential OutOfMemoryError
+ byte[] data = in.readToEnd();
+ // Save offset to EXIF data for handling thumbnail and attribute offsets.
+ mOffsetToExifData = IDENTIFIER_EXIF_APP1.length;
+ readExifSegment(data, IFD_TYPE_PRIMARY);
+ return true;
+ }
+
+ /**
+ * ORF files contains a primary image data and a MakerNote data that contains preview/thumbnail
+ * images. Both data takes the form of IFDs and can therefore be read with the
+ * readImageFileDirectory() method.
+ * This method reads all the necessary data and updates the primary/preview/thumbnail image
+ * information according to the GetOlympusPreviewImage() method in piex.cc.
+ * For data format details, see the following:
+ * http://fileformats.archiveteam.org/wiki/Olympus_ORF
+ * https://libopenraw.freedesktop.org/wiki/Olympus_ORF
+ */
+ private void getOrfAttributes(SeekableByteOrderedDataInputStream in) throws IOException {
+ // Retrieve primary image data
+ // Other Exif data will be located in the Makernote.
+ getRawAttributes(in);
+
+ // Additionally retrieve preview/thumbnail information from MakerNote tag, which contains
+ // proprietary tags and therefore does not have offical documentation
+ // See GetOlympusPreviewImage() in piex.cc & http://www.exiv2.org/tags-olympus.html
+ ExifAttribute makerNoteAttribute =
+ mAttributes[IFD_TYPE_EXIF].get(TAG_MAKER_NOTE);
+ if (makerNoteAttribute != null) {
+ // Create an ordered DataInputStream for MakerNote
+ SeekableByteOrderedDataInputStream makerNoteDataInputStream =
+ new SeekableByteOrderedDataInputStream(makerNoteAttribute.bytes);
+ makerNoteDataInputStream.setByteOrder(mExifByteOrder);
+
+ // There are two types of headers for Olympus MakerNotes
+ // See http://www.exiv2.org/makernote.html#R1
+ byte[] makerNoteHeader1Bytes = new byte[ORF_MAKER_NOTE_HEADER_1.length];
+ makerNoteDataInputStream.readFully(makerNoteHeader1Bytes);
+ makerNoteDataInputStream.seek(0);
+ byte[] makerNoteHeader2Bytes = new byte[ORF_MAKER_NOTE_HEADER_2.length];
+ makerNoteDataInputStream.readFully(makerNoteHeader2Bytes);
+ // Skip the corresponding amount of bytes for each header type
+ if (Arrays.equals(makerNoteHeader1Bytes, ORF_MAKER_NOTE_HEADER_1)) {
+ makerNoteDataInputStream.seek(ORF_MAKER_NOTE_HEADER_1_SIZE);
+ } else if (Arrays.equals(makerNoteHeader2Bytes, ORF_MAKER_NOTE_HEADER_2)) {
+ makerNoteDataInputStream.seek(ORF_MAKER_NOTE_HEADER_2_SIZE);
+ }
+
+ // Read IFD data from MakerNote
+ readImageFileDirectory(makerNoteDataInputStream, IFD_TYPE_ORF_MAKER_NOTE);
+
+ // Retrieve & update preview image offset & length values
+ ExifAttribute imageStartAttribute =
+ mAttributes[IFD_TYPE_ORF_CAMERA_SETTINGS].get(TAG_ORF_PREVIEW_IMAGE_START);
+ ExifAttribute imageLengthAttribute =
+ mAttributes[IFD_TYPE_ORF_CAMERA_SETTINGS].get(TAG_ORF_PREVIEW_IMAGE_LENGTH);
+
+ if (imageStartAttribute != null && imageLengthAttribute != null) {
+ mAttributes[IFD_TYPE_PREVIEW].put(TAG_JPEG_INTERCHANGE_FORMAT,
+ imageStartAttribute);
+ mAttributes[IFD_TYPE_PREVIEW].put(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
+ imageLengthAttribute);
+ }
+
+ // TODO: Check this behavior in other ORF files
+ // Retrieve primary image length & width values
+ // See piex.cc GetOlympusPreviewImage()
+ ExifAttribute aspectFrameAttribute =
+ mAttributes[IFD_TYPE_ORF_IMAGE_PROCESSING].get(TAG_ORF_ASPECT_FRAME);
+ if (aspectFrameAttribute != null) {
+ int[] aspectFrameValues = (int[]) aspectFrameAttribute.getValue(mExifByteOrder);
+ if (aspectFrameValues == null || aspectFrameValues.length != 4) {
+ Log.w(TAG, "Invalid aspect frame values. frame="
+ + Arrays.toString(aspectFrameValues));
+ return;
+ }
+ if (aspectFrameValues[2] > aspectFrameValues[0] &&
+ aspectFrameValues[3] > aspectFrameValues[1]) {
+ int primaryImageWidth = aspectFrameValues[2] - aspectFrameValues[0] + 1;
+ int primaryImageLength = aspectFrameValues[3] - aspectFrameValues[1] + 1;
+ // Swap width & length values
+ if (primaryImageWidth < primaryImageLength) {
+ primaryImageWidth += primaryImageLength;
+ primaryImageLength = primaryImageWidth - primaryImageLength;
+ primaryImageWidth -= primaryImageLength;
+ }
+ ExifAttribute primaryImageWidthAttribute =
+ ExifAttribute.createUShort(primaryImageWidth, mExifByteOrder);
+ ExifAttribute primaryImageLengthAttribute =
+ ExifAttribute.createUShort(primaryImageLength, mExifByteOrder);
+
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_WIDTH, primaryImageWidthAttribute);
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_LENGTH, primaryImageLengthAttribute);
+ }
+ }
+ }
+ }
+
+ // RW2 contains the primary image data in IFD0 and the preview and/or thumbnail image data in
+ // the JpgFromRaw tag
+ // See https://libopenraw.freedesktop.org/wiki/Panasonic_RAW/ and piex.cc Rw2GetPreviewData()
+ private void getRw2Attributes(SeekableByteOrderedDataInputStream in) throws IOException {
+ if (DEBUG) {
+ Log.d(TAG, "getRw2Attributes starting with: " + in);
+ }
+ // Retrieve primary image data
+ getRawAttributes(in);
+
+ // Retrieve preview and/or thumbnail image data
+ ExifAttribute jpgFromRawAttribute =
+ mAttributes[IFD_TYPE_PRIMARY].get(TAG_RW2_JPG_FROM_RAW);
+ if (jpgFromRawAttribute != null) {
+ ByteOrderedDataInputStream jpegInputStream =
+ new ByteOrderedDataInputStream(jpgFromRawAttribute.bytes);
+ getJpegAttributes(jpegInputStream, (int) jpgFromRawAttribute.bytesOffset,
+ IFD_TYPE_PREVIEW);
+ }
+
+ // Set ISO tag value if necessary
+ ExifAttribute rw2IsoAttribute =
+ mAttributes[IFD_TYPE_PRIMARY].get(TAG_RW2_ISO);
+ ExifAttribute exifIsoAttribute =
+ mAttributes[IFD_TYPE_EXIF].get(TAG_PHOTOGRAPHIC_SENSITIVITY);
+ if (rw2IsoAttribute != null && exifIsoAttribute == null) {
+ // Place this attribute only if it doesn't exist
+ mAttributes[IFD_TYPE_EXIF].put(TAG_PHOTOGRAPHIC_SENSITIVITY, rw2IsoAttribute);
+ }
+ }
+
+ // PNG contains the EXIF data as a Special-Purpose Chunk
+ private void getPngAttributes(ByteOrderedDataInputStream in) throws IOException {
+ if (DEBUG) {
+ Log.d(TAG, "getPngAttributes starting with: " + in);
+ }
+ // PNG uses Big Endian by default.
+ // See PNG (Portable Network Graphics) Specification, Version 1.2,
+ // 2.1. Integers and byte order
+ in.setByteOrder(BIG_ENDIAN);
+
+ int bytesRead = 0;
+
+ // Skip the signature bytes
+ in.skipFully(PNG_SIGNATURE.length);
+ bytesRead += PNG_SIGNATURE.length;
+
+ // Each chunk is made up of four parts:
+ // 1) Length: 4-byte unsigned integer indicating the number of bytes in the
+ // Chunk Data field. Excludes Chunk Type and CRC bytes.
+ // 2) Chunk Type: 4-byte chunk type code.
+ // 3) Chunk Data: The data bytes. Can be zero-length.
+ // 4) CRC: 4-byte data calculated on the preceding bytes in the chunk. Always
+ // present.
+ // --> 4 (length bytes) + 4 (type bytes) + X (data bytes) + 4 (CRC bytes)
+ // See PNG (Portable Network Graphics) Specification, Version 1.2,
+ // 3.2. Chunk layout
+ try {
+ while (true) {
+ int length = in.readInt();
+ bytesRead += 4;
+
+ byte[] type = new byte[PNG_CHUNK_TYPE_BYTE_LENGTH];
+ in.readFully(type);
+ bytesRead += PNG_CHUNK_TYPE_BYTE_LENGTH;
+
+ // The first chunk must be the IHDR chunk
+ if (bytesRead == 16 && !Arrays.equals(type, PNG_CHUNK_TYPE_IHDR)) {
+ throw new IOException("Encountered invalid PNG file--IHDR chunk should appear"
+ + "as the first chunk");
+ }
+
+ if (Arrays.equals(type, PNG_CHUNK_TYPE_IEND)) {
+ // IEND marks the end of the image.
+ break;
+ } else if (Arrays.equals(type, PNG_CHUNK_TYPE_EXIF)) {
+ // TODO: Need to handle potential OutOfMemoryError
+ byte[] data = new byte[length];
+ in.readFully(data);
+
+ // Compare CRC values for potential data corruption.
+ int dataCrcValue = in.readInt();
+ // Cyclic Redundancy Code used to check for corruption of the data
+ CRC32 crc = new CRC32();
+ crc.update(type);
+ crc.update(data);
+ if ((int) crc.getValue() != dataCrcValue) {
+ throw new IOException("Encountered invalid CRC value for PNG-EXIF chunk."
+ + "\n recorded CRC value: " + dataCrcValue + ", calculated CRC "
+ + "value: " + crc.getValue());
+ }
+ // Save offset to EXIF data for handling thumbnail and attribute offsets.
+ mOffsetToExifData = bytesRead;
+ readExifSegment(data, IFD_TYPE_PRIMARY);
+ validateImages();
+
+ setThumbnailData(new ByteOrderedDataInputStream(data));
+ break;
+ } else {
+ // Skip to next chunk
+ in.skipFully(length + PNG_CHUNK_CRC_BYTE_LENGTH);
+ bytesRead += length + PNG_CHUNK_CRC_BYTE_LENGTH;
+ }
+ }
+ } catch (EOFException e) {
+ // Should not reach here. Will only reach here if the file is corrupted or
+ // does not follow the PNG specifications
+ throw new IOException("Encountered corrupt PNG file.");
+ }
+ }
+
+ // WebP contains EXIF data as a RIFF File Format Chunk
+ // All references below can be found in the following link.
+ // https://developers.google.com/speed/webp/docs/riff_container
+ private void getWebpAttributes(ByteOrderedDataInputStream in) throws IOException {
+ if (DEBUG) {
+ Log.d(TAG, "getWebpAttributes starting with: " + in);
+ }
+ // WebP uses little-endian by default.
+ // See Section "Terminology & Basics"
+ in.setByteOrder(LITTLE_ENDIAN);
+
+ in.skipFully(WEBP_SIGNATURE_1.length);
+ // File size corresponds to the size of the entire file from offset 8.
+ // See Section "WebP File Header"
+ int fileSize = in.readInt() + 8;
+ int bytesRead = 8;
+
+ in.skipFully(WEBP_SIGNATURE_2.length);
+ bytesRead += WEBP_SIGNATURE_2.length;
+
+ try {
+ while (true) {
+ // TODO: Check the first Chunk Type, and if it is VP8X, check if the chunks are
+ // ordered properly.
+
+ // Each chunk is made up of three parts:
+ // 1) Chunk FourCC: 4-byte concatenating four ASCII characters.
+ // 2) Chunk Size: 4-byte unsigned integer indicating the size of the chunk.
+ // Excludes Chunk FourCC and Chunk Size bytes.
+ // 3) Chunk Payload: data payload. A single padding byte ('0') is added if
+ // Chunk Size is odd.
+ // See Section "RIFF File Format"
+ byte[] code = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
+ in.readFully(code);
+ bytesRead += WEBP_CHUNK_TYPE_BYTE_LENGTH;
+
+ int chunkSize = in.readInt();
+ bytesRead += 4;
+
+ if (Arrays.equals(WEBP_CHUNK_TYPE_EXIF, code)) {
+ // TODO: Need to handle potential OutOfMemoryError
+ byte[] payload = new byte[chunkSize];
+ in.readFully(payload);
+
+ // Skip a JPEG APP1 marker that some image libraries incorrectly include in the
+ // Exif data in WebP images (e.g.
+ // https://github.com/ImageMagick/ImageMagick/issues/3140)
+ if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
+ int adjustedChunkSize = chunkSize - IDENTIFIER_EXIF_APP1.length;
+ payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length,
+ adjustedChunkSize);
+ }
+
+ // Save offset to EXIF data for handling thumbnail and attribute offsets.
+ mOffsetToExifData = bytesRead;
+ readExifSegment(payload, IFD_TYPE_PRIMARY);
+
+ setThumbnailData(new ByteOrderedDataInputStream(payload));
+ break;
+ } else {
+ // Add a single padding byte at end if chunk size is odd
+ chunkSize = (chunkSize % 2 == 1) ? chunkSize + 1 : chunkSize;
+
+ // Check if skipping to next chunk is necessary
+ if (bytesRead + chunkSize == fileSize) {
+ // Reached end of file
+ break;
+ } else if (bytesRead + chunkSize > fileSize) {
+ throw new IOException("Encountered WebP file with invalid chunk size");
+ }
+
+ // Skip to next chunk
+ in.skipFully(chunkSize);
+ bytesRead += chunkSize;
+ }
+ }
+ } catch (EOFException e) {
+ // Should not reach here. Will only reach here if the file is corrupted or
+ // does not follow the WebP specifications
+ throw new IOException("Encountered corrupt WebP file.");
+ }
+ }
+
+ // Stores a new JPEG image with EXIF attributes into a given output stream.
+ private void saveJpegAttributes(InputStream inputStream, OutputStream outputStream)
+ throws IOException {
+ // See JPEG File Interchange Format Specification, "JFIF Specification"
+ if (DEBUG) {
+ Log.d(TAG, "saveJpegAttributes starting with (inputStream: " + inputStream
+ + ", outputStream: " + outputStream + ")");
+ }
+ ByteOrderedDataInputStream dataInputStream = new ByteOrderedDataInputStream(inputStream);
+ ByteOrderedDataOutputStream dataOutputStream =
+ new ByteOrderedDataOutputStream(outputStream, BIG_ENDIAN);
+ if (dataInputStream.readByte() != MARKER) {
+ throw new IOException("Invalid marker");
+ }
+ dataOutputStream.writeByte(MARKER);
+ if (dataInputStream.readByte() != MARKER_SOI) {
+ throw new IOException("Invalid marker");
+ }
+ dataOutputStream.writeByte(MARKER_SOI);
+
+ // Remove XMP data if it is from a separate marker (IDENTIFIER_XMP_APP1, not
+ // IDENTIFIER_EXIF_APP1)
+ // Will re-add it later after the rest of the file is written
+ ExifAttribute xmpAttribute = null;
+ if (getAttribute(TAG_XMP) != null && mXmpIsFromSeparateMarker) {
+ xmpAttribute = mAttributes[IFD_TYPE_PRIMARY].remove(TAG_XMP);
+ }
+
+ // Write EXIF APP1 segment
+ dataOutputStream.writeByte(MARKER);
+ dataOutputStream.writeByte(MARKER_APP1);
+ writeExifSegment(dataOutputStream);
+
+ // Re-add previously removed XMP data.
+ if (xmpAttribute != null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, xmpAttribute);
+ }
+
+ byte[] bytes = new byte[4096];
+
+ while (true) {
+ byte marker = dataInputStream.readByte();
+ if (marker != MARKER) {
+ throw new IOException("Invalid marker");
+ }
+ marker = dataInputStream.readByte();
+ switch (marker) {
+ case MARKER_APP1: {
+ int length = dataInputStream.readUnsignedShort() - 2;
+ if (length < 0) {
+ throw new IOException("Invalid length");
+ }
+ byte[] identifier = new byte[6];
+ if (length >= 6) {
+ dataInputStream.readFully(identifier);
+ if (Arrays.equals(identifier, IDENTIFIER_EXIF_APP1)) {
+ // Skip the original EXIF APP1 segment.
+ dataInputStream.skipFully(length - 6);
+ break;
+ }
+ }
+ // Copy non-EXIF APP1 segment.
+ dataOutputStream.writeByte(MARKER);
+ dataOutputStream.writeByte(marker);
+ dataOutputStream.writeUnsignedShort(length + 2);
+ if (length >= 6) {
+ length -= 6;
+ dataOutputStream.write(identifier);
+ }
+ int read;
+ while (length > 0 && (read = dataInputStream.read(
+ bytes, 0, Math.min(length, bytes.length))) >= 0) {
+ dataOutputStream.write(bytes, 0, read);
+ length -= read;
+ }
+ break;
+ }
+ case MARKER_EOI:
+ case MARKER_SOS: {
+ dataOutputStream.writeByte(MARKER);
+ dataOutputStream.writeByte(marker);
+ // Copy all the remaining data
+ copy(dataInputStream, dataOutputStream);
+ return;
+ }
+ default: {
+ // Copy JPEG segment
+ dataOutputStream.writeByte(MARKER);
+ dataOutputStream.writeByte(marker);
+ int length = dataInputStream.readUnsignedShort();
+ dataOutputStream.writeUnsignedShort(length);
+ length -= 2;
+ if (length < 0) {
+ throw new IOException("Invalid length");
+ }
+ int read;
+ while (length > 0 && (read = dataInputStream.read(
+ bytes, 0, Math.min(length, bytes.length))) >= 0) {
+ dataOutputStream.write(bytes, 0, read);
+ length -= read;
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ private void savePngAttributes(InputStream inputStream, OutputStream outputStream)
+ throws IOException {
+ if (DEBUG) {
+ Log.d(TAG, "savePngAttributes starting with (inputStream: " + inputStream
+ + ", outputStream: " + outputStream + ")");
+ }
+ ByteOrderedDataInputStream dataInputStream = new ByteOrderedDataInputStream(inputStream);
+ ByteOrderedDataOutputStream dataOutputStream =
+ new ByteOrderedDataOutputStream(outputStream, BIG_ENDIAN);
+
+ // Copy PNG signature bytes
+ copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
+
+ // EXIF chunk can appear anywhere between the first (IHDR) and last (IEND) chunks, except
+ // between IDAT chunks.
+ // Adhering to these rules,
+ // 1) if EXIF chunk did not exist in the original file, it will be stored right after the
+ // first chunk,
+ // 2) if EXIF chunk existed in the original file, it will be stored in the same location.
+ if (mOffsetToExifData == 0) {
+ // Copy IHDR chunk bytes
+ int ihdrChunkLength = dataInputStream.readInt();
+ dataOutputStream.writeInt(ihdrChunkLength);
+ copy(dataInputStream, dataOutputStream, PNG_CHUNK_TYPE_BYTE_LENGTH
+ + ihdrChunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
+ } else {
+ // Copy up until the point where EXIF chunk length information is stored.
+ int copyLength = mOffsetToExifData - PNG_SIGNATURE.length
+ - 4 /* PNG EXIF chunk length bytes */
+ - PNG_CHUNK_TYPE_BYTE_LENGTH;
+ copy(dataInputStream, dataOutputStream, copyLength);
+
+ // Skip to the start of the chunk after the EXIF chunk
+ int exifChunkLength = dataInputStream.readInt();
+ dataInputStream.skipFully(PNG_CHUNK_TYPE_BYTE_LENGTH + exifChunkLength
+ + PNG_CHUNK_CRC_BYTE_LENGTH);
+ }
+
+ // Write EXIF data
+ ByteArrayOutputStream exifByteArrayOutputStream = null;
+ try {
+ // A byte array is needed to calculate the CRC value of this chunk which requires
+ // the chunk type bytes and the chunk data bytes.
+ exifByteArrayOutputStream = new ByteArrayOutputStream();
+ ByteOrderedDataOutputStream exifDataOutputStream =
+ new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN);
+
+ // Store Exif data in separate byte array
+ writeExifSegment(exifDataOutputStream);
+ byte[] exifBytes =
+ ((ByteArrayOutputStream) exifDataOutputStream.mOutputStream).toByteArray();
+
+ // Write EXIF chunk data
+ dataOutputStream.write(exifBytes);
+
+ // Write EXIF chunk CRC
+ CRC32 crc = new CRC32();
+ crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
+ dataOutputStream.writeInt((int) crc.getValue());
+ } finally {
+ closeQuietly(exifByteArrayOutputStream);
+ }
+
+ // Copy the rest of the file
+ copy(dataInputStream, dataOutputStream);
+ }
+
+ // A WebP file has a header and a series of chunks.
+ // The header is composed of:
+ // "RIFF" + File Size + "WEBP"
+ //
+ // The structure of the chunks can be divided largely into two categories:
+ // 1) Contains only image data,
+ // 2) Contains image data and extra data.
+ // In the first category, there is only one chunk: type "VP8" (compression with loss) or "VP8L"
+ // (lossless compression).
+ // In the second category, the first chunk will be of type "VP8X", which contains flags
+ // indicating which extra data exist in later chunks. The proceeding chunks must conform to
+ // the following order based on type (if they exist):
+ // Color Profile ("ICCP") + Animation Control Data ("ANIM") + Image Data ("VP8"/"VP8L")
+ // + Exif metadata ("EXIF") + XMP metadata ("XMP")
+ //
+ // And in order to have EXIF data, a WebP file must be of the second structure and thus follow
+ // the following rules:
+ // 1) "VP8X" chunk as the first chunk,
+ // 2) flag for EXIF inside "VP8X" chunk set to 1, and
+ // 3) contain the "EXIF" chunk in the correct order amongst other chunks.
+ //
+ // Based on these rules, this API will support three different cases depending on the contents
+ // of the original file:
+ // 1) "EXIF" chunk already exists
+ // -> replace it with the new "EXIF" chunk
+ // 2) "EXIF" chunk does not exist and the first chunk is "VP8" or "VP8L"
+ // -> add "VP8X" before the "VP8"/"VP8L" chunk (with EXIF flag set to 1), and add new
+ // "EXIF" chunk after the "VP8"/"VP8L" chunk.
+ // 3) "EXIF" chunk does not exist and the first chunk is "VP8X"
+ // -> set EXIF flag in "VP8X" chunk to 1, and add new "EXIF" chunk at the proper location.
+ //
+ // See https://developers.google.com/speed/webp/docs/riff_container for more details.
+ private void saveWebpAttributes(InputStream inputStream, OutputStream outputStream)
+ throws IOException {
+ if (DEBUG) {
+ Log.d(TAG, "saveWebpAttributes starting with (inputStream: " + inputStream
+ + ", outputStream: " + outputStream + ")");
+ }
+ ByteOrderedDataInputStream totalInputStream =
+ new ByteOrderedDataInputStream(inputStream, LITTLE_ENDIAN);
+ ByteOrderedDataOutputStream totalOutputStream =
+ new ByteOrderedDataOutputStream(outputStream, LITTLE_ENDIAN);
+
+ // WebP signature
+ copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
+ // File length will be written after all the chunks have been written
+ totalInputStream.skipFully(WEBP_FILE_SIZE_BYTE_LENGTH + WEBP_SIGNATURE_2.length);
+
+ // Create a separate byte array to calculate file length
+ ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
+ try {
+ nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
+ ByteOrderedDataOutputStream nonHeaderOutputStream =
+ new ByteOrderedDataOutputStream(nonHeaderByteArrayOutputStream, LITTLE_ENDIAN);
+
+ if (mOffsetToExifData != 0) {
+ // EXIF chunk exists in the original file
+ // Tested by webp_with_exif.webp
+ int bytesRead = WEBP_SIGNATURE_1.length + WEBP_FILE_SIZE_BYTE_LENGTH
+ + WEBP_SIGNATURE_2.length;
+ copy(totalInputStream, nonHeaderOutputStream,
+ mOffsetToExifData - bytesRead - WEBP_CHUNK_TYPE_BYTE_LENGTH
+ - WEBP_CHUNK_SIZE_BYTE_LENGTH);
+
+ // Skip input stream to the end of the EXIF chunk
+ totalInputStream.skipFully(WEBP_CHUNK_TYPE_BYTE_LENGTH);
+ int exifChunkLength = totalInputStream.readInt();
+ // RIFF chunks have a single padding byte at the end if the declared chunk size is
+ // odd.
+ if (exifChunkLength % 2 != 0) {
+ exifChunkLength++;
+ }
+ totalInputStream.skipFully(exifChunkLength);
+
+ // Write new EXIF chunk to output stream
+ writeExifSegment(nonHeaderOutputStream);
+ } else {
+ // EXIF chunk does not exist in the original file
+ byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
+ totalInputStream.readFully(firstChunkType);
+
+ if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8X)) {
+ // Original file already includes other extra data
+ int size = totalInputStream.readInt();
+ // WebP files have a single padding byte at the end if the chunk size is odd.
+ byte[] data = new byte[(size % 2) == 1 ? size + 1 : size];
+ totalInputStream.readFully(data);
+
+ // Set the EXIF flag to 1
+ data[0] = (byte) (data[0] | (1 << 3));
+
+ // Retrieve Animation flag--in order to check where EXIF data should start
+ boolean containsAnimation = ((data[0] >> 1) & 1) == 1;
+
+ // Write the original VP8X chunk
+ nonHeaderOutputStream.write(WEBP_CHUNK_TYPE_VP8X);
+ nonHeaderOutputStream.writeInt(size);
+ nonHeaderOutputStream.write(data);
+
+ // Animation control data is composed of 1 ANIM chunk and multiple ANMF
+ // chunks and since the image data (VP8/VP8L) chunks are included in the ANMF
+ // chunks, EXIF data should come after the last ANMF chunk.
+ // Also, because there is no value indicating the amount of ANMF chunks, we need
+ // to keep iterating through chunks until we either reach the end of the file or
+ // the XMP chunk (if it exists).
+ // Tested by webp_with_anim_without_exif.webp
+ if (containsAnimation) {
+ copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
+ WEBP_CHUNK_TYPE_ANIM, null);
+
+ while (true) {
+ byte[] type = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
+ boolean animationFinished = false;
+ try {
+ totalInputStream.readFully(type);
+ animationFinished = !Arrays.equals(type, WEBP_CHUNK_TYPE_ANMF);
+ } catch (EOFException e) {
+ animationFinished = true;
+ }
+ if (animationFinished) {
+ writeExifSegment(nonHeaderOutputStream);
+ break;
+ }
+ copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
+ }
+ } else {
+ // Skip until we find the VP8 or VP8L chunk
+ copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
+ WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
+ writeExifSegment(nonHeaderOutputStream);
+ }
+ } else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
+ || Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
+ int size = totalInputStream.readInt();
+ int bytesToRead = size;
+ // WebP files have a single padding byte at the end if the chunk size is odd.
+ if (size % 2 == 1) {
+ bytesToRead += 1;
+ }
+
+ // Retrieve image width/height
+ int widthAndHeight = 0;
+ int width = 0;
+ int height = 0;
+ boolean alpha = false;
+ // Save VP8 frame data for later
+ byte[] vp8Frame = new byte[3];
+
+ if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)) {
+ totalInputStream.readFully(vp8Frame);
+
+ // Check signature
+ byte[] vp8Signature = new byte[3];
+ totalInputStream.readFully(vp8Signature);
+ if (!Arrays.equals(WEBP_VP8_SIGNATURE, vp8Signature)) {
+ throw new IOException("Error checking VP8 signature");
+ }
+
+ // Retrieve image width/height
+ widthAndHeight = totalInputStream.readInt();
+ width = (widthAndHeight << 18) >> 18;
+ height = (widthAndHeight << 2) >> 18;
+ bytesToRead -= (vp8Frame.length + vp8Signature.length + 4);
+ } else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
+ // Check signature
+ byte vp8lSignature = totalInputStream.readByte();
+ if (vp8lSignature != WEBP_VP8L_SIGNATURE) {
+ throw new IOException("Error checking VP8L signature");
+ }
+
+ // Retrieve image width/height
+ widthAndHeight = totalInputStream.readInt();
+ // VP8L stores 14-bit 'width - 1' and 'height - 1' values. See "RIFF Header"
+ // of "WebP Lossless Bitstream Specification".
+ width = (widthAndHeight & 0x3FFF) + 1; // Read bits 0 - 13
+ height = ((widthAndHeight & 0xFFFC000) >>> 14) + 1; // Read bits 14 - 27
+ // Retrieve alpha bit 28
+ alpha = (widthAndHeight & 1 << 28) != 0;
+ bytesToRead -= (1 /* VP8L signature */ + 4);
+ }
+
+ // Create VP8X with Exif flag set to 1
+ nonHeaderOutputStream.write(WEBP_CHUNK_TYPE_VP8X);
+ nonHeaderOutputStream.writeInt(WEBP_CHUNK_TYPE_VP8X_DEFAULT_LENGTH);
+ byte[] data = new byte[WEBP_CHUNK_TYPE_VP8X_DEFAULT_LENGTH];
+ // ALPHA flag
+ if (alpha) {
+ data[0] = (byte) (data[0] | (1 << 4));
+ }
+ // EXIF flag
+ data[0] = (byte) (data[0] | (1 << 3));
+ // VP8X stores Width - 1 and Height - 1 values
+ width -= 1;
+ height -= 1;
+ data[4] = (byte) width;
+ data[5] = (byte) (width >> 8);
+ data[6] = (byte) (width >> 16);
+ data[7] = (byte) height;
+ data[8] = (byte) (height >> 8);
+ data[9] = (byte) (height >> 16);
+ nonHeaderOutputStream.write(data);
+
+ // Write VP8 or VP8L data
+ nonHeaderOutputStream.write(firstChunkType);
+ nonHeaderOutputStream.writeInt(size);
+ if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)) {
+ nonHeaderOutputStream.write(vp8Frame);
+ nonHeaderOutputStream.write(WEBP_VP8_SIGNATURE);
+ nonHeaderOutputStream.writeInt(widthAndHeight);
+ } else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
+ nonHeaderOutputStream.write(WEBP_VP8L_SIGNATURE);
+ nonHeaderOutputStream.writeInt(widthAndHeight);
+ }
+ copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
+
+ // Write EXIF chunk
+ writeExifSegment(nonHeaderOutputStream);
+ }
+ }
+
+ // Copy the rest of the file
+ copy(totalInputStream, nonHeaderOutputStream);
+
+ // Write file length + second signature
+ totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
+ + WEBP_SIGNATURE_2.length);
+ totalOutputStream.write(WEBP_SIGNATURE_2);
+ nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
+ } catch (Exception e) {
+ throw new IOException("Failed to save WebP file", e);
+ } finally {
+ closeQuietly(nonHeaderByteArrayOutputStream);
+ }
+ }
+
+ private void copyChunksUpToGivenChunkType(ByteOrderedDataInputStream inputStream,
+ ByteOrderedDataOutputStream outputStream, byte[] firstGivenType,
+ byte[] secondGivenType) throws IOException {
+ while (true) {
+ byte[] type = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
+ inputStream.readFully(type);
+ copyWebPChunk(inputStream, outputStream, type);
+ if (Arrays.equals(type, firstGivenType)
+ || (secondGivenType != null && Arrays.equals(type, secondGivenType))) {
+ break;
+ }
+ }
+ }
+
+ private void copyWebPChunk(ByteOrderedDataInputStream inputStream,
+ ByteOrderedDataOutputStream outputStream, byte[] type) throws IOException {
+ int size = inputStream.readInt();
+ outputStream.write(type);
+ outputStream.writeInt(size);
+ // WebP files have a single padding byte at the end if the chunk size is odd.
+ copy(inputStream, outputStream, (size % 2) == 1 ? size + 1 : size);
+ }
+
+ // Reads the given EXIF byte area and save its tag data into attributes.
+ private void readExifSegment(byte[] exifBytes, int imageType) throws IOException {
+ SeekableByteOrderedDataInputStream dataInputStream =
+ new SeekableByteOrderedDataInputStream(exifBytes);
+
+ // Parse TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
+ parseTiffHeaders(dataInputStream);
+
+ // Read TIFF image file directories. See JEITA CP-3451C Section 4.5.2. Figure 6.
+ readImageFileDirectory(dataInputStream, imageType);
+ }
+
+ private void addDefaultValuesForCompatibility() {
+ // If DATETIME tag has no value, then set the value to DATETIME_ORIGINAL tag's.
+ String valueOfDateTimeOriginal = getAttribute(TAG_DATETIME_ORIGINAL);
+ if (valueOfDateTimeOriginal != null && getAttribute(TAG_DATETIME) == null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_DATETIME,
+ ExifAttribute.createString(valueOfDateTimeOriginal));
+ }
+
+ // Add the default value.
+ if (getAttribute(TAG_IMAGE_WIDTH) == null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_WIDTH,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ if (getAttribute(TAG_IMAGE_LENGTH) == null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_LENGTH,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ if (getAttribute(TAG_ORIENTATION) == null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_ORIENTATION,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ if (getAttribute(TAG_LIGHT_SOURCE) == null) {
+ mAttributes[IFD_TYPE_EXIF].put(TAG_LIGHT_SOURCE,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ }
+
+ private ByteOrder readByteOrder(ByteOrderedDataInputStream dataInputStream)
+ throws IOException {
+ // Read byte order.
+ short byteOrder = dataInputStream.readShort();
+ switch (byteOrder) {
+ case BYTE_ALIGN_II:
+ if (DEBUG) {
+ Log.d(TAG, "readExifSegment: Byte Align II");
+ }
+ return LITTLE_ENDIAN;
+ case BYTE_ALIGN_MM:
+ if (DEBUG) {
+ Log.d(TAG, "readExifSegment: Byte Align MM");
+ }
+ return BIG_ENDIAN;
+ default:
+ throw new IOException("Invalid byte order: " + Integer.toHexString(byteOrder));
+ }
+ }
+
+ private void parseTiffHeaders(ByteOrderedDataInputStream dataInputStream) throws IOException {
+ // Read byte order
+ mExifByteOrder = readByteOrder(dataInputStream);
+ // Set byte order
+ dataInputStream.setByteOrder(mExifByteOrder);
+
+ // Check start code
+ int startCode = dataInputStream.readUnsignedShort();
+ if (mMimeType != IMAGE_TYPE_ORF && mMimeType != IMAGE_TYPE_RW2 && startCode != START_CODE) {
+ throw new IOException("Invalid start code: " + Integer.toHexString(startCode));
+ }
+
+ // Read and skip to first ifd offset
+ int firstIfdOffset = dataInputStream.readInt();
+ if (firstIfdOffset < 8) {
+ throw new IOException("Invalid first Ifd offset: " + firstIfdOffset);
+ }
+ firstIfdOffset -= 8;
+ if (firstIfdOffset > 0) {
+ dataInputStream.skipFully(firstIfdOffset);
+ }
+ }
+
+ // Reads image file directory, which is a tag group in EXIF.
+ private void readImageFileDirectory(SeekableByteOrderedDataInputStream dataInputStream,
+ @IfdType int ifdType) throws IOException {
+ // Save offset of current IFD to prevent reading an IFD that is already read.
+ mAttributesOffsets.add(dataInputStream.position());
+
+ // See TIFF 6.0 Section 2: TIFF Structure, Figure 1.
+ short numberOfDirectoryEntry = dataInputStream.readShort();
+ if (DEBUG) {
+ Log.d(TAG, "numberOfDirectoryEntry: " + numberOfDirectoryEntry);
+ }
+ if (numberOfDirectoryEntry <= 0) {
+ // Return if the size of entries is negative.
+ return;
+ }
+
+ // See TIFF 6.0 Section 2: TIFF Structure, "Image File Directory".
+ for (short i = 0; i < numberOfDirectoryEntry; ++i) {
+ int tagNumber = dataInputStream.readUnsignedShort();
+ int dataFormat = dataInputStream.readUnsignedShort();
+ int numberOfComponents = dataInputStream.readInt();
+ // Next four bytes is for data offset or value.
+ long nextEntryOffset = dataInputStream.position() + 4L;
+
+ // Look up a corresponding tag from tag number
+ ExifTag tag = sExifTagMapsForReading[ifdType].get(tagNumber);
+
+ if (DEBUG) {
+ Log.d(TAG, String.format("ifdType: %d, tagNumber: %d, tagName: %s, dataFormat: %d, "
+ + "numberOfComponents: %d", ifdType, tagNumber,
+ tag != null ? tag.name : null, dataFormat, numberOfComponents));
+ }
+
+ long byteCount = 0;
+ boolean valid = false;
+ if (tag == null) {
+ if (DEBUG) {
+ Log.d(TAG, "Skip the tag entry since tag number is not defined: " + tagNumber);
+ }
+ } else if (dataFormat <= 0 || dataFormat >= IFD_FORMAT_BYTES_PER_FORMAT.length) {
+ if (DEBUG) {
+ Log.d(TAG, "Skip the tag entry since data format is invalid: " + dataFormat);
+ }
+ } else if (!tag.isFormatCompatible(dataFormat)) {
+ if (DEBUG) {
+ Log.d(TAG, "Skip the tag entry since data format ("
+ + IFD_FORMAT_NAMES[dataFormat] + ") is unexpected for tag: "
+ + tag.name);
+ }
+ } else {
+ if (dataFormat == IFD_FORMAT_UNDEFINED) {
+ dataFormat = tag.primaryFormat;
+ }
+ byteCount = (long) numberOfComponents * IFD_FORMAT_BYTES_PER_FORMAT[dataFormat];
+ if (byteCount < 0 || byteCount > Integer.MAX_VALUE) {
+ if (DEBUG) {
+ Log.d(TAG, "Skip the tag entry since the number of components is invalid: "
+ + numberOfComponents);
+ }
+ } else {
+ valid = true;
+ }
+ }
+ if (!valid) {
+ dataInputStream.seek(nextEntryOffset);
+ continue;
+ }
+
+ // Read a value from data field or seek to the value offset which is stored in data
+ // field if the size of the entry value is bigger than 4.
+ if (byteCount > 4) {
+ int offset = dataInputStream.readInt();
+ if (DEBUG) {
+ Log.d(TAG, "seek to data offset: " + offset);
+ }
+ if (mMimeType == IMAGE_TYPE_ORF) {
+ if (TAG_MAKER_NOTE.equals(tag.name)) {
+ // Save offset value for reading thumbnail
+ mOrfMakerNoteOffset = offset;
+ } else if (ifdType == IFD_TYPE_ORF_MAKER_NOTE
+ && TAG_ORF_THUMBNAIL_IMAGE.equals(tag.name)) {
+ // Retrieve & update values for thumbnail offset and length values for ORF
+ mOrfThumbnailOffset = offset;
+ mOrfThumbnailLength = numberOfComponents;
+
+ ExifAttribute compressionAttribute =
+ ExifAttribute.createUShort(DATA_JPEG, mExifByteOrder);
+ ExifAttribute jpegInterchangeFormatAttribute =
+ ExifAttribute.createULong(mOrfThumbnailOffset, mExifByteOrder);
+ ExifAttribute jpegInterchangeFormatLengthAttribute =
+ ExifAttribute.createULong(mOrfThumbnailLength, mExifByteOrder);
+
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_COMPRESSION, compressionAttribute);
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_JPEG_INTERCHANGE_FORMAT,
+ jpegInterchangeFormatAttribute);
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
+ jpegInterchangeFormatLengthAttribute);
+ }
+ }
+ dataInputStream.seek(offset);
+ }
+
+ // Recursively parse IFD when a IFD pointer tag appears.
+ Integer nextIfdType = sExifPointerTagMap.get(tagNumber);
+ if (DEBUG) {
+ Log.d(TAG, "nextIfdType: " + nextIfdType + " byteCount: " + byteCount);
+ }
+
+ if (nextIfdType != null) {
+ long offset = -1L;
+ // Get offset from data field
+ switch (dataFormat) {
+ case IFD_FORMAT_USHORT: {
+ offset = dataInputStream.readUnsignedShort();
+ break;
+ }
+ case IFD_FORMAT_SSHORT: {
+ offset = dataInputStream.readShort();
+ break;
+ }
+ case IFD_FORMAT_ULONG: {
+ offset = dataInputStream.readUnsignedInt();
+ break;
+ }
+ case IFD_FORMAT_SLONG:
+ case IFD_FORMAT_IFD: {
+ offset = dataInputStream.readInt();
+ break;
+ }
+ default: {
+ // Nothing to do
+ break;
+ }
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("Offset: %d, tagName: %s", offset, tag.name));
+ }
+
+ // Check if the next IFD offset
+ // 1. Is a non-negative value (within the length of the input, if known), and
+ // 2. Does not point to a previously read IFD.
+ if (offset > 0L
+ && (dataInputStream.length() == ByteOrderedDataInputStream.LENGTH_UNSET
+ || offset < dataInputStream.length())) {
+ if (!mAttributesOffsets.contains((int) offset)) {
+ dataInputStream.seek(offset);
+ readImageFileDirectory(dataInputStream, nextIfdType);
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Skip jump into the IFD since it has already been read: "
+ + "IfdType " + nextIfdType + " (at " + offset + ")");
+ }
+ }
+ } else {
+ if (DEBUG) {
+ String message =
+ "Skip jump into the IFD since its offset is invalid: " + offset;
+ if (dataInputStream.length() != ByteOrderedDataInputStream.LENGTH_UNSET) {
+ message += " (total length: " + dataInputStream.length() + ")";
+ }
+ Log.d(TAG, message);
+ }
+ }
+
+ dataInputStream.seek(nextEntryOffset);
+ continue;
+ }
+
+ final int bytesOffset = dataInputStream.position() + mOffsetToExifData;
+ if (byteCount > 0 && byteCount < ATTRIBUTE_SIZE_DANGER_THRESHOLD) {
+ throw new IOException("dangerous attribute size=" + byteCount);
+ }
+ final byte[] bytes = new byte[(int) byteCount];
+ dataInputStream.readFully(bytes);
+ ExifAttribute attribute = new ExifAttribute(dataFormat, numberOfComponents,
+ bytesOffset, bytes);
+ mAttributes[ifdType].put(tag.name, attribute);
+
+ // DNG files have a DNG Version tag specifying the version of specifications that the
+ // image file is following.
+ // See http://fileformats.archiveteam.org/wiki/DNG
+ if (TAG_DNG_VERSION.equals(tag.name)) {
+ mMimeType = IMAGE_TYPE_DNG;
+ }
+
+ // PEF files have a Make or Model tag that begins with "PENTAX" or a compression tag
+ // that is 65535.
+ // See http://fileformats.archiveteam.org/wiki/Pentax_PEF
+ if (((TAG_MAKE.equals(tag.name) || TAG_MODEL.equals(tag.name))
+ && attribute.getStringValue(mExifByteOrder).contains(PEF_SIGNATURE))
+ || (TAG_COMPRESSION.equals(tag.name)
+ && attribute.getIntValue(mExifByteOrder) == 65535)) {
+ mMimeType = IMAGE_TYPE_PEF;
+ }
+
+ // Seek to next tag offset
+ if (dataInputStream.position() != nextEntryOffset) {
+ dataInputStream.seek(nextEntryOffset);
+ }
+ }
+
+ int nextIfdOffset = dataInputStream.readInt();
+ if (DEBUG) {
+ Log.d(TAG, String.format("nextIfdOffset: %d", nextIfdOffset));
+ }
+ // Check if the next IFD offset
+ // 1. Is a non-negative value, and
+ // 2. Does not point to a previously read IFD.
+ if (nextIfdOffset > 0L) {
+ if (!mAttributesOffsets.contains(nextIfdOffset)) {
+ dataInputStream.seek(nextIfdOffset);
+ if (mAttributes[IFD_TYPE_THUMBNAIL].isEmpty()) {
+ // Do not overwrite thumbnail IFD data if it already exists.
+ readImageFileDirectory(dataInputStream, IFD_TYPE_THUMBNAIL);
+ } else if (mAttributes[IFD_TYPE_PREVIEW].isEmpty()) {
+ readImageFileDirectory(dataInputStream, IFD_TYPE_PREVIEW);
+ }
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Stop reading file since re-reading an IFD may cause an "
+ + "infinite loop: " + nextIfdOffset);
+ }
+ }
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Stop reading file since a wrong offset may cause an infinite loop: "
+ + nextIfdOffset);
+ }
+ }
+ }
+
+ /**
+ * JPEG compressed images do not contain IMAGE_LENGTH & IMAGE_WIDTH tags.
+ * This value uses JpegInterchangeFormat(JPEG data offset) value, and calls getJpegAttributes()
+ * to locate SOF(Start of Frame) marker and update the image length & width values.
+ * See JEITA CP-3451C Table 5 and Section 4.8.1. B.
+ */
+ private void retrieveJpegImageSize(SeekableByteOrderedDataInputStream in, int imageType)
+ throws IOException {
+ // Check if image already has IMAGE_LENGTH & IMAGE_WIDTH values
+ ExifAttribute imageLengthAttribute =
+ mAttributes[imageType].get(TAG_IMAGE_LENGTH);
+ ExifAttribute imageWidthAttribute =
+ mAttributes[imageType].get(TAG_IMAGE_WIDTH);
+
+ if (imageLengthAttribute == null || imageWidthAttribute == null) {
+ // Find if offset for JPEG data exists
+ ExifAttribute jpegInterchangeFormatAttribute =
+ mAttributes[imageType].get(TAG_JPEG_INTERCHANGE_FORMAT);
+ ExifAttribute jpegInterchangeFormatLengthAttribute =
+ mAttributes[imageType].get(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+ if (jpegInterchangeFormatAttribute != null
+ && jpegInterchangeFormatLengthAttribute != null) {
+ int jpegInterchangeFormat =
+ jpegInterchangeFormatAttribute.getIntValue(mExifByteOrder);
+ int jpegInterchangeFormatLength =
+ jpegInterchangeFormatAttribute.getIntValue(mExifByteOrder);
+
+ // Searches for SOF marker in JPEG data and updates IMAGE_LENGTH & IMAGE_WIDTH tags
+ in.seek(jpegInterchangeFormat);
+ byte[] jpegBytes = new byte[jpegInterchangeFormatLength];
+ in.readFully(jpegBytes);
+ getJpegAttributes(new ByteOrderedDataInputStream(jpegBytes), jpegInterchangeFormat,
+ imageType);
+ }
+ }
+ }
+
+ // Sets thumbnail offset & length attributes based on JpegInterchangeFormat or StripOffsets tags
+ private void setThumbnailData(ByteOrderedDataInputStream in) throws IOException {
+ HashMap thumbnailData = mAttributes[IFD_TYPE_THUMBNAIL];
+
+ ExifAttribute compressionAttribute =
+ thumbnailData.get(TAG_COMPRESSION);
+ if (compressionAttribute != null) {
+ mThumbnailCompression = compressionAttribute.getIntValue(mExifByteOrder);
+ switch (mThumbnailCompression) {
+ case DATA_JPEG: {
+ handleThumbnailFromJfif(in, thumbnailData);
+ break;
+ }
+ case DATA_UNCOMPRESSED:
+ case DATA_JPEG_COMPRESSED: {
+ if (isSupportedDataType(thumbnailData)) {
+ handleThumbnailFromStrips(in, thumbnailData);
+ }
+ break;
+ }
+ }
+ } else {
+ // Thumbnail data may not contain Compression tag value
+ mThumbnailCompression = DATA_JPEG;
+ handleThumbnailFromJfif(in, thumbnailData);
+ }
+ }
+
+ // Check JpegInterchangeFormat(JFIF) tags to retrieve thumbnail offset & length values
+ // and reads the corresponding bytes if stream does not support seek function
+ private void handleThumbnailFromJfif(ByteOrderedDataInputStream in,
+ HashMap thumbnailData) throws IOException {
+ ExifAttribute jpegInterchangeFormatAttribute =
+ thumbnailData.get(TAG_JPEG_INTERCHANGE_FORMAT);
+ ExifAttribute jpegInterchangeFormatLengthAttribute =
+ thumbnailData.get(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+ if (jpegInterchangeFormatAttribute != null
+ && jpegInterchangeFormatLengthAttribute != null) {
+ int thumbnailOffset = jpegInterchangeFormatAttribute.getIntValue(mExifByteOrder);
+ int thumbnailLength = jpegInterchangeFormatLengthAttribute.getIntValue(mExifByteOrder);
+
+ if (mMimeType == IMAGE_TYPE_ORF) {
+ // Update offset value since RAF files have IFD data preceding MakerNote data.
+ thumbnailOffset += mOrfMakerNoteOffset;
+ }
+
+ if (thumbnailOffset > 0 && thumbnailLength > 0) {
+ mHasThumbnail = true;
+ if (mFilename == null && mAssetInputStream == null
+ && mSeekableFileDescriptor == null) {
+ // TODO: Need to handle potential OutOfMemoryError
+ // Save the thumbnail in memory if the input doesn't support reading again.
+ byte[] thumbnailBytes = new byte[thumbnailLength];
+ in.skipFully(thumbnailOffset);
+ in.readFully(thumbnailBytes);
+ mThumbnailBytes = thumbnailBytes;
+ }
+ mThumbnailOffset = thumbnailOffset;
+ mThumbnailLength = thumbnailLength;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "Setting thumbnail attributes with offset: " + thumbnailOffset
+ + ", length: " + thumbnailLength);
+ }
+ }
+ }
+
+ // Check StripOffsets & StripByteCounts tags to retrieve thumbnail offset & length values
+ private void handleThumbnailFromStrips(ByteOrderedDataInputStream in,
+ HashMap thumbnailData) throws IOException {
+ ExifAttribute stripOffsetsAttribute =
+ thumbnailData.get(TAG_STRIP_OFFSETS);
+ ExifAttribute stripByteCountsAttribute =
+ thumbnailData.get(TAG_STRIP_BYTE_COUNTS);
+
+ if (stripOffsetsAttribute != null && stripByteCountsAttribute != null) {
+ long[] stripOffsets =
+ convertToLongArray(stripOffsetsAttribute.getValue(mExifByteOrder));
+ long[] stripByteCounts =
+ convertToLongArray(stripByteCountsAttribute.getValue(mExifByteOrder));
+
+ if (stripOffsets == null || stripOffsets.length == 0) {
+ Log.w(TAG, "stripOffsets should not be null or have zero length.");
+ return;
+ }
+ if (stripByteCounts == null || stripByteCounts.length == 0) {
+ Log.w(TAG, "stripByteCounts should not be null or have zero length.");
+ return;
+ }
+ if (stripOffsets.length != stripByteCounts.length) {
+ Log.w(TAG, "stripOffsets and stripByteCounts should have same length.");
+ return;
+ }
+
+ long totalStripByteCount = 0;
+ for (long byteCount : stripByteCounts) {
+ totalStripByteCount += byteCount;
+ }
+
+ // TODO: Need to handle potential OutOfMemoryError
+ // Set thumbnail byte array data for non-consecutive strip bytes
+ byte[] totalStripBytes = new byte[(int) totalStripByteCount];
+
+ int bytesRead = 0;
+ int bytesAdded = 0;
+ mHasThumbnail = mHasThumbnailStrips = mAreThumbnailStripsConsecutive = true;
+ for (int i = 0; i < stripOffsets.length; i++) {
+ int stripOffset = (int) stripOffsets[i];
+ int stripByteCount = (int) stripByteCounts[i];
+
+ // Check if strips are consecutive
+ // TODO: Add test for non-consecutive thumbnail image
+ if (i < stripOffsets.length - 1
+ && stripOffset + stripByteCount != stripOffsets[i + 1]) {
+ mAreThumbnailStripsConsecutive = false;
+ }
+
+ // Skip to offset
+ int bytesToSkip = stripOffset - bytesRead;
+ if (bytesToSkip < 0) {
+ Log.d(TAG, "Invalid strip offset value");
+ return;
+ }
+ try {
+ in.skipFully(bytesToSkip);
+ } catch (EOFException e) {
+ Log.d(TAG, "Failed to skip " + bytesToSkip + " bytes.");
+ return;
+ }
+ bytesRead += bytesToSkip;
+ // TODO: Need to handle potential OutOfMemoryError
+ byte[] stripBytes = new byte[stripByteCount];
+ try {
+ in.readFully(stripBytes);
+ } catch (EOFException e) {
+ Log.d(TAG, "Failed to read " + stripByteCount + " bytes.");
+ return;
+ }
+ bytesRead += stripByteCount;
+
+ // Add bytes to array
+ System.arraycopy(stripBytes, 0, totalStripBytes, bytesAdded,
+ stripBytes.length);
+ bytesAdded += stripBytes.length;
+ }
+ mThumbnailBytes = totalStripBytes;
+
+ if (mAreThumbnailStripsConsecutive) {
+ mThumbnailOffset = (int) stripOffsets[0];
+ mThumbnailLength = totalStripBytes.length;
+ }
+ }
+ }
+
+ // Check if thumbnail data type is currently supported or not
+ private boolean isSupportedDataType(HashMap thumbnailData) {
+ ExifAttribute bitsPerSampleAttribute =
+ thumbnailData.get(TAG_BITS_PER_SAMPLE);
+ if (bitsPerSampleAttribute != null) {
+ int[] bitsPerSampleValue = (int[]) bitsPerSampleAttribute.getValue(mExifByteOrder);
+
+ if (Arrays.equals(BITS_PER_SAMPLE_RGB, bitsPerSampleValue)) {
+ return true;
+ }
+
+ // See DNG Specification 1.4.0.0. Section 3, Compression.
+ if (mMimeType == IMAGE_TYPE_DNG) {
+ ExifAttribute photometricInterpretationAttribute =
+ thumbnailData.get(TAG_PHOTOMETRIC_INTERPRETATION);
+ if (photometricInterpretationAttribute != null) {
+ int photometricInterpretationValue
+ = photometricInterpretationAttribute.getIntValue(mExifByteOrder);
+ if ((photometricInterpretationValue == PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO
+ && Arrays.equals(bitsPerSampleValue, BITS_PER_SAMPLE_GREYSCALE_2))
+ || ((photometricInterpretationValue == PHOTOMETRIC_INTERPRETATION_YCBCR)
+ && Arrays.equals(bitsPerSampleValue, BITS_PER_SAMPLE_RGB))) {
+ return true;
+ } else {
+ // TODO: Add support for lossless Huffman JPEG data
+ }
+ }
+ }
+ }
+ if (DEBUG) {
+ Log.d(TAG, "Unsupported data type value");
+ }
+ return false;
+ }
+
+ // Returns true if the image length and width values are <= 512.
+ // See Section 4.8 of http://standardsproposals.bsigroup.com/Home/getPDF/567
+ private boolean isThumbnail(HashMap map) {
+ ExifAttribute imageLengthAttribute = map.get(TAG_IMAGE_LENGTH);
+ ExifAttribute imageWidthAttribute = map.get(TAG_IMAGE_WIDTH);
+
+ if (imageLengthAttribute != null && imageWidthAttribute != null) {
+ int imageLengthValue = imageLengthAttribute.getIntValue(mExifByteOrder);
+ int imageWidthValue = imageWidthAttribute.getIntValue(mExifByteOrder);
+ if (imageLengthValue <= MAX_THUMBNAIL_SIZE && imageWidthValue <= MAX_THUMBNAIL_SIZE) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Validate primary, preview, thumbnail image data by comparing image size
+ private void validateImages() throws IOException {
+ // Swap images based on size (primary > preview > thumbnail)
+ swapBasedOnImageSize(IFD_TYPE_PRIMARY, IFD_TYPE_PREVIEW);
+ swapBasedOnImageSize(IFD_TYPE_PRIMARY, IFD_TYPE_THUMBNAIL);
+ swapBasedOnImageSize(IFD_TYPE_PREVIEW, IFD_TYPE_THUMBNAIL);
+
+ // TODO (b/142296453): Revise image width/height setting logic
+ // Check if image has PixelXDimension/PixelYDimension tags, which contain valid image
+ // sizes, excluding padding at the right end or bottom end of the image to make sure that
+ // the values are multiples of 64. See JEITA CP-3451C Table 5 and Section 4.8.1. B.
+ ExifAttribute pixelXDimAttribute =
+ mAttributes[IFD_TYPE_EXIF].get(TAG_PIXEL_X_DIMENSION);
+ ExifAttribute pixelYDimAttribute =
+ mAttributes[IFD_TYPE_EXIF].get(TAG_PIXEL_Y_DIMENSION);
+ if (pixelXDimAttribute != null && pixelYDimAttribute != null) {
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_WIDTH, pixelXDimAttribute);
+ mAttributes[IFD_TYPE_PRIMARY].put(TAG_IMAGE_LENGTH, pixelYDimAttribute);
+ }
+
+ // Check whether thumbnail image exists and whether preview image satisfies the thumbnail
+ // image requirements
+ if (mAttributes[IFD_TYPE_THUMBNAIL].isEmpty()) {
+ if (isThumbnail(mAttributes[IFD_TYPE_PREVIEW])) {
+ mAttributes[IFD_TYPE_THUMBNAIL] = mAttributes[IFD_TYPE_PREVIEW];
+ mAttributes[IFD_TYPE_PREVIEW] = new HashMap<>();
+ }
+ }
+
+ // Check if the thumbnail image satisfies the thumbnail size requirements
+ if (!isThumbnail(mAttributes[IFD_TYPE_THUMBNAIL])) {
+ Log.d(TAG, "No image meets the size requirements of a thumbnail image.");
+ }
+
+ // TAG_THUMBNAIL_* tags should be replaced with TAG_* equivalents and vice versa if needed.
+ replaceInvalidTags(IFD_TYPE_PRIMARY, TAG_THUMBNAIL_ORIENTATION, TAG_ORIENTATION);
+ replaceInvalidTags(IFD_TYPE_PRIMARY, TAG_THUMBNAIL_IMAGE_LENGTH, TAG_IMAGE_LENGTH);
+ replaceInvalidTags(IFD_TYPE_PRIMARY, TAG_THUMBNAIL_IMAGE_WIDTH, TAG_IMAGE_WIDTH);
+ replaceInvalidTags(IFD_TYPE_PREVIEW, TAG_THUMBNAIL_ORIENTATION, TAG_ORIENTATION);
+ replaceInvalidTags(IFD_TYPE_PREVIEW, TAG_THUMBNAIL_IMAGE_LENGTH, TAG_IMAGE_LENGTH);
+ replaceInvalidTags(IFD_TYPE_PREVIEW, TAG_THUMBNAIL_IMAGE_WIDTH, TAG_IMAGE_WIDTH);
+ replaceInvalidTags(IFD_TYPE_THUMBNAIL, TAG_ORIENTATION, TAG_THUMBNAIL_ORIENTATION);
+ replaceInvalidTags(IFD_TYPE_THUMBNAIL, TAG_IMAGE_LENGTH, TAG_THUMBNAIL_IMAGE_LENGTH);
+ replaceInvalidTags(IFD_TYPE_THUMBNAIL, TAG_IMAGE_WIDTH, TAG_THUMBNAIL_IMAGE_WIDTH);
+ }
+
+ /**
+ * If image is uncompressed, ImageWidth/Length tags are used to store size info.
+ * However, uncompressed images often store extra pixels around the edges of the final image,
+ * which results in larger values for TAG_IMAGE_WIDTH and TAG_IMAGE_LENGTH tags.
+ * This method corrects those tag values by checking first the values of TAG_DEFAULT_CROP_SIZE
+ * See DNG Specification 1.4.0.0. Section 4. (DefaultCropSize)
+ *
+ * If image is a RW2 file, valid image sizes are stored in SensorBorder tags.
+ * See tiff_parser.cc GetFullDimension32()
+ */
+ private void updateImageSizeValues(SeekableByteOrderedDataInputStream in, int imageType)
+ throws IOException {
+ // Uncompressed image valid image size values
+ ExifAttribute defaultCropSizeAttribute =
+ mAttributes[imageType].get(TAG_DEFAULT_CROP_SIZE);
+ // RW2 image valid image size values
+ ExifAttribute topBorderAttribute =
+ mAttributes[imageType].get(TAG_RW2_SENSOR_TOP_BORDER);
+ ExifAttribute leftBorderAttribute =
+ mAttributes[imageType].get(TAG_RW2_SENSOR_LEFT_BORDER);
+ ExifAttribute bottomBorderAttribute =
+ mAttributes[imageType].get(TAG_RW2_SENSOR_BOTTOM_BORDER);
+ ExifAttribute rightBorderAttribute =
+ mAttributes[imageType].get(TAG_RW2_SENSOR_RIGHT_BORDER);
+
+ if (defaultCropSizeAttribute != null) {
+ // Update for uncompressed image
+ ExifAttribute defaultCropSizeXAttribute, defaultCropSizeYAttribute;
+ if (defaultCropSizeAttribute.format == IFD_FORMAT_URATIONAL) {
+ Rational[] defaultCropSizeValue =
+ (Rational[]) defaultCropSizeAttribute.getValue(mExifByteOrder);
+ if (defaultCropSizeValue == null || defaultCropSizeValue.length != 2) {
+ Log.w(TAG, "Invalid crop size values. cropSize="
+ + Arrays.toString(defaultCropSizeValue));
+ return;
+ }
+ defaultCropSizeXAttribute =
+ ExifAttribute.createURational(defaultCropSizeValue[0], mExifByteOrder);
+ defaultCropSizeYAttribute =
+ ExifAttribute.createURational(defaultCropSizeValue[1], mExifByteOrder);
+ } else {
+ int[] defaultCropSizeValue =
+ (int[]) defaultCropSizeAttribute.getValue(mExifByteOrder);
+ if (defaultCropSizeValue == null || defaultCropSizeValue.length != 2) {
+ Log.w(TAG, "Invalid crop size values. cropSize="
+ + Arrays.toString(defaultCropSizeValue));
+ return;
+ }
+ defaultCropSizeXAttribute =
+ ExifAttribute.createUShort(defaultCropSizeValue[0], mExifByteOrder);
+ defaultCropSizeYAttribute =
+ ExifAttribute.createUShort(defaultCropSizeValue[1], mExifByteOrder);
+ }
+ mAttributes[imageType].put(TAG_IMAGE_WIDTH, defaultCropSizeXAttribute);
+ mAttributes[imageType].put(TAG_IMAGE_LENGTH, defaultCropSizeYAttribute);
+ } else if (topBorderAttribute != null && leftBorderAttribute != null &&
+ bottomBorderAttribute != null && rightBorderAttribute != null) {
+ // Update for RW2 image
+ int topBorderValue = topBorderAttribute.getIntValue(mExifByteOrder);
+ int bottomBorderValue = bottomBorderAttribute.getIntValue(mExifByteOrder);
+ int rightBorderValue = rightBorderAttribute.getIntValue(mExifByteOrder);
+ int leftBorderValue = leftBorderAttribute.getIntValue(mExifByteOrder);
+ if (bottomBorderValue > topBorderValue && rightBorderValue > leftBorderValue) {
+ int length = bottomBorderValue - topBorderValue;
+ int width = rightBorderValue - leftBorderValue;
+ ExifAttribute imageLengthAttribute =
+ ExifAttribute.createUShort(length, mExifByteOrder);
+ ExifAttribute imageWidthAttribute =
+ ExifAttribute.createUShort(width, mExifByteOrder);
+ mAttributes[imageType].put(TAG_IMAGE_LENGTH, imageLengthAttribute);
+ mAttributes[imageType].put(TAG_IMAGE_WIDTH, imageWidthAttribute);
+ }
+ } else {
+ retrieveJpegImageSize(in, imageType);
+ }
+ }
+
+ // Writes an Exif segment into the given output stream.
+ private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException {
+ // The following variables are for calculating each IFD tag group size in bytes.
+ int[] ifdOffsets = new int[EXIF_TAGS.length];
+ int[] ifdDataSizes = new int[EXIF_TAGS.length];
+
+ // Remove IFD pointer tags (we'll re-add it later.)
+ for (ExifTag tag : EXIF_POINTER_TAGS) {
+ removeAttribute(tag.name);
+ }
+ // Remove old thumbnail data
+ if (mHasThumbnail) {
+ if (mHasThumbnailStrips) {
+ removeAttribute(TAG_STRIP_OFFSETS);
+ removeAttribute(TAG_STRIP_BYTE_COUNTS);
+ } else {
+ removeAttribute(TAG_JPEG_INTERCHANGE_FORMAT);
+ removeAttribute(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+ }
+ }
+
+ // Remove null value tags.
+ for (int ifdType = 0; ifdType < EXIF_TAGS.length; ++ifdType) {
+ Iterator> entrySetIterator =
+ mAttributes[ifdType].entrySet().iterator();
+ while (entrySetIterator.hasNext()) {
+ Map.Entry entry = entrySetIterator.next();
+ if (entry.getValue() == null) {
+ entrySetIterator.remove();
+ }
+ }
+ }
+
+ // Add IFD pointer tags. The next offset of primary image TIFF IFD will have thumbnail IFD
+ // offset when there is one or more tags in the thumbnail IFD.
+ if (!mAttributes[IFD_TYPE_EXIF].isEmpty()) {
+ mAttributes[IFD_TYPE_PRIMARY].put(EXIF_POINTER_TAGS[1].name,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ if (!mAttributes[IFD_TYPE_GPS].isEmpty()) {
+ mAttributes[IFD_TYPE_PRIMARY].put(EXIF_POINTER_TAGS[2].name,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ if (!mAttributes[IFD_TYPE_INTEROPERABILITY].isEmpty()) {
+ mAttributes[IFD_TYPE_EXIF].put(EXIF_POINTER_TAGS[3].name,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ }
+ if (mHasThumbnail) {
+ if (mHasThumbnailStrips) {
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_STRIP_OFFSETS,
+ ExifAttribute.createUShort(0, mExifByteOrder));
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_STRIP_BYTE_COUNTS,
+ ExifAttribute.createUShort(mThumbnailLength, mExifByteOrder));
+ } else {
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_JPEG_INTERCHANGE_FORMAT,
+ ExifAttribute.createULong(0, mExifByteOrder));
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
+ ExifAttribute.createULong(mThumbnailLength, mExifByteOrder));
+ }
+ }
+
+ // Calculate IFD group data area sizes. IFD group data area is assigned to save the entry
+ // value which has a bigger size than 4 bytes.
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ int sum = 0;
+ for (Map.Entry entry : mAttributes[i].entrySet()) {
+ final ExifAttribute exifAttribute = entry.getValue();
+ final int size = exifAttribute.size();
+ if (size > 4) {
+ sum += size;
+ }
+ }
+ ifdDataSizes[i] += sum;
+ }
+
+ // Calculate IFD offsets.
+ // 8 bytes are for TIFF headers: 2 bytes (byte order) + 2 bytes (identifier) + 4 bytes
+ // (offset of IFDs)
+ int position = 8;
+ for (int ifdType = 0; ifdType < EXIF_TAGS.length; ++ifdType) {
+ if (!mAttributes[ifdType].isEmpty()) {
+ ifdOffsets[ifdType] = position;
+ position += 2 + mAttributes[ifdType].size() * 12 + 4 + ifdDataSizes[ifdType];
+ }
+ }
+ if (mHasThumbnail) {
+ int thumbnailOffset = position;
+ if (mHasThumbnailStrips) {
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_STRIP_OFFSETS,
+ ExifAttribute.createUShort(thumbnailOffset, mExifByteOrder));
+ } else {
+ mAttributes[IFD_TYPE_THUMBNAIL].put(TAG_JPEG_INTERCHANGE_FORMAT,
+ ExifAttribute.createULong(thumbnailOffset, mExifByteOrder));
+ }
+ mThumbnailOffset = thumbnailOffset;
+ position += mThumbnailLength;
+ }
+
+ int totalSize = position;
+ if (mMimeType == IMAGE_TYPE_JPEG) {
+ // Add 8 bytes for APP1 size and identifier data
+ totalSize += 8;
+ }
+ if (DEBUG) {
+ for (int i = 0; i < EXIF_TAGS.length; ++i) {
+ Log.d(TAG, String.format("index: %d, offsets: %d, tag count: %d, data sizes: %d, "
+ + "total size: %d", i, ifdOffsets[i], mAttributes[i].size(),
+ ifdDataSizes[i], totalSize));
+ }
+ }
+
+ // Update IFD pointer tags with the calculated offsets.
+ if (!mAttributes[IFD_TYPE_EXIF].isEmpty()) {
+ mAttributes[IFD_TYPE_PRIMARY].put(EXIF_POINTER_TAGS[1].name,
+ ExifAttribute.createULong(ifdOffsets[IFD_TYPE_EXIF], mExifByteOrder));
+ }
+ if (!mAttributes[IFD_TYPE_GPS].isEmpty()) {
+ mAttributes[IFD_TYPE_PRIMARY].put(EXIF_POINTER_TAGS[2].name,
+ ExifAttribute.createULong(ifdOffsets[IFD_TYPE_GPS], mExifByteOrder));
+ }
+ if (!mAttributes[IFD_TYPE_INTEROPERABILITY].isEmpty()) {
+ mAttributes[IFD_TYPE_EXIF].put(EXIF_POINTER_TAGS[3].name, ExifAttribute.createULong(
+ ifdOffsets[IFD_TYPE_INTEROPERABILITY], mExifByteOrder));
+ }
+
+ switch (mMimeType) {
+ case IMAGE_TYPE_JPEG:
+ if (totalSize > 0xFFFF) {
+ throw new IllegalStateException(
+ "Size of exif data (" + totalSize + " bytes) exceeds the max size of a "
+ + "JPEG APP1 segment (65536 bytes)");
+ }
+ // Write JPEG specific data (APP1 size, APP1 identifier)
+ dataOutputStream.writeUnsignedShort(totalSize);
+ dataOutputStream.write(IDENTIFIER_EXIF_APP1);
+ break;
+ case IMAGE_TYPE_PNG:
+ // Write PNG specific data (chunk size, chunk type)
+ dataOutputStream.writeInt(totalSize);
+ dataOutputStream.write(PNG_CHUNK_TYPE_EXIF);
+ break;
+ case IMAGE_TYPE_WEBP:
+ // Write WebP specific data (chunk type, chunk size)
+ dataOutputStream.write(WEBP_CHUNK_TYPE_EXIF);
+ dataOutputStream.writeInt(totalSize);
+ break;
+ }
+
+ // Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
+ dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
+ dataOutputStream.setByteOrder(mExifByteOrder);
+ dataOutputStream.writeUnsignedShort(START_CODE);
+ dataOutputStream.writeUnsignedInt(IFD_OFFSET);
+
+ // Write IFD groups. See JEITA CP-3451C Section 4.5.8. Figure 9.
+ for (int ifdType = 0; ifdType < EXIF_TAGS.length; ++ifdType) {
+ if (!mAttributes[ifdType].isEmpty()) {
+ // See JEITA CP-3451C Section 4.6.2: IFD structure.
+ // Write entry count
+ dataOutputStream.writeUnsignedShort(mAttributes[ifdType].size());
+
+ // Write entry info
+ int dataOffset = ifdOffsets[ifdType] + 2 + mAttributes[ifdType].size() * 12 + 4;
+ for (Map.Entry entry : mAttributes[ifdType].entrySet()) {
+ // Convert tag name to tag number.
+ final ExifTag tag = sExifTagMapsForWriting[ifdType].get(entry.getKey());
+ final int tagNumber = tag.number;
+ final ExifAttribute attribute = entry.getValue();
+ final int size = attribute.size();
+
+ dataOutputStream.writeUnsignedShort(tagNumber);
+ dataOutputStream.writeUnsignedShort(attribute.format);
+ dataOutputStream.writeInt(attribute.numberOfComponents);
+ if (size > 4) {
+ dataOutputStream.writeUnsignedInt(dataOffset);
+ dataOffset += size;
+ } else {
+ dataOutputStream.write(attribute.bytes);
+ // Fill zero up to 4 bytes
+ if (size < 4) {
+ for (int i = size; i < 4; ++i) {
+ dataOutputStream.writeByte(0);
+ }
+ }
+ }
+ }
+
+ // Write the next offset. It writes the offset of thumbnail IFD if there is one or
+ // more tags in the thumbnail IFD when the current IFD is the primary image TIFF
+ // IFD; Otherwise 0.
+ if (ifdType == 0 && !mAttributes[IFD_TYPE_THUMBNAIL].isEmpty()) {
+ dataOutputStream.writeUnsignedInt(ifdOffsets[IFD_TYPE_THUMBNAIL]);
+ } else {
+ dataOutputStream.writeUnsignedInt(0);
+ }
+
+ // Write values of data field exceeding 4 bytes after the next offset.
+ for (Map.Entry entry : mAttributes[ifdType].entrySet()) {
+ ExifAttribute attribute = entry.getValue();
+
+ if (attribute.bytes.length > 4) {
+ dataOutputStream.write(attribute.bytes, 0, attribute.bytes.length);
+ }
+ }
+ }
+ }
+
+ // Write thumbnail
+ if (mHasThumbnail) {
+ dataOutputStream.write(getThumbnailBytes());
+ }
+
+ // For WebP files, add a single padding byte at end if chunk size is odd
+ if (mMimeType == IMAGE_TYPE_WEBP && totalSize % 2 == 1) {
+ dataOutputStream.writeByte(0);
+ }
+
+ // Reset the byte order to big endian in order to write remaining parts of the JPEG file.
+ dataOutputStream.setByteOrder(BIG_ENDIAN);
+
+ return totalSize;
+ }
+
+ /**
+ * Determines the data format of EXIF entry value.
+ *
+ * @param entryValue The value to be determined.
+ * @return Returns two data formats guessed as a pair in integer. If there is no two candidate
+ * data formats for the given entry value, returns {@code -1} in the second of the pair.
+ */
+ private static Pair guessDataFormat(String entryValue) {
+ // See TIFF 6.0 Section 2, "Image File Directory".
+ // Take the first component if there are more than one component.
+ if (entryValue.contains(",")) {
+ String[] entryValues = entryValue.split(",", -1);
+ Pair dataFormat = guessDataFormat(entryValues[0]);
+ if (dataFormat.first == IFD_FORMAT_STRING) {
+ return dataFormat;
+ }
+ for (int i = 1; i < entryValues.length; ++i) {
+ final Pair guessDataFormat = guessDataFormat(entryValues[i]);
+ int first = -1, second = -1;
+ if (guessDataFormat.first.equals(dataFormat.first)
+ || guessDataFormat.second.equals(dataFormat.first)) {
+ first = dataFormat.first;
+ }
+ if (dataFormat.second != -1 && (guessDataFormat.first.equals(dataFormat.second)
+ || guessDataFormat.second.equals(dataFormat.second))) {
+ second = dataFormat.second;
+ }
+ if (first == -1 && second == -1) {
+ return new Pair<>(IFD_FORMAT_STRING, -1);
+ }
+ if (first == -1) {
+ dataFormat = new Pair<>(second, -1);
+ continue;
+ }
+ if (second == -1) {
+ dataFormat = new Pair<>(first, -1);
+ continue;
+ }
+ }
+ return dataFormat;
+ }
+
+ if (entryValue.contains("/")) {
+ String[] rationalNumber = entryValue.split("/", -1);
+ if (rationalNumber.length == 2) {
+ try {
+ long numerator = (long) Double.parseDouble(rationalNumber[0]);
+ long denominator = (long) Double.parseDouble(rationalNumber[1]);
+ if (numerator < 0L || denominator < 0L) {
+ return new Pair<>(IFD_FORMAT_SRATIONAL, -1);
+ }
+ if (numerator > Integer.MAX_VALUE || denominator > Integer.MAX_VALUE) {
+ return new Pair<>(IFD_FORMAT_URATIONAL, -1);
+ }
+ return new Pair<>(IFD_FORMAT_SRATIONAL, IFD_FORMAT_URATIONAL);
+ } catch (NumberFormatException e) {
+ // Ignored
+ }
+ }
+ return new Pair<>(IFD_FORMAT_STRING, -1);
+ }
+ try {
+ Long longValue = Long.parseLong(entryValue);
+ if (longValue >= 0 && longValue <= 65535) {
+ return new Pair<>(IFD_FORMAT_USHORT, IFD_FORMAT_ULONG);
+ }
+ if (longValue < 0) {
+ return new Pair<>(IFD_FORMAT_SLONG, -1);
+ }
+ return new Pair<>(IFD_FORMAT_ULONG, -1);
+ } catch (NumberFormatException e) {
+ // Ignored
+ }
+ try {
+ Double.parseDouble(entryValue);
+ return new Pair<>(IFD_FORMAT_DOUBLE, -1);
+ } catch (NumberFormatException e) {
+ // Ignored
+ }
+ return new Pair<>(IFD_FORMAT_STRING, -1);
+ }
+
+ // An input stream class that can parse both little and big endian order data and also
+ // supports seeking to any position in the stream via mark/reset.
+ private static class SeekableByteOrderedDataInputStream extends ByteOrderedDataInputStream {
+ SeekableByteOrderedDataInputStream(byte[] bytes) throws IOException {
+ super(bytes);
+ // No need to check if mark is supported here since ByteOrderedDataInputStream will
+ // create a ByteArrayInputStream, which supports mark by default.
+ mDataInputStream.mark(Integer.MAX_VALUE);
+ }
+
+ /**
+ * Given input stream should support mark/reset, and should be set to the beginning of
+ * the stream.
+ */
+ SeekableByteOrderedDataInputStream(InputStream in) throws IOException {
+ super(in);
+ if (!in.markSupported()) {
+ throw new IllegalArgumentException("Cannot create "
+ + "SeekableByteOrderedDataInputStream with stream that does not support "
+ + "mark/reset");
+ }
+ // Mark given InputStream to the maximum value (we can't know the length of the
+ // stream for certain) so that InputStream.reset() may be called at any point in the
+ // stream to reset the stream to an earlier position.
+ mDataInputStream.mark(Integer.MAX_VALUE);
+ }
+
+ /**
+ * Seek to the given absolute position in the stream (i.e. the number of bytes from the
+ * beginning of the stream).
+ */
+ public void seek(long position) throws IOException {
+ if (mPosition > position) {
+ mPosition = 0;
+ mDataInputStream.reset();
+ } else {
+ position -= mPosition;
+ }
+ skipFully((int) position);
+ }
+ }
+
+ // An input stream class that can parse both little and big endian order data.
+ private static class ByteOrderedDataInputStream extends InputStream implements DataInput {
+
+ public static final int LENGTH_UNSET = -1;
+ protected final DataInputStream mDataInputStream;
+ protected int mPosition;
+
+ private ByteOrder mByteOrder;
+ private byte[] mSkipBuffer;
+ private int mLength;
+
+ ByteOrderedDataInputStream(byte[] bytes) throws IOException {
+ this(new ByteArrayInputStream(bytes), BIG_ENDIAN);
+ this.mLength = bytes.length;
+ }
+
+ ByteOrderedDataInputStream(InputStream in) throws IOException {
+ this(in, BIG_ENDIAN);
+ }
+
+ ByteOrderedDataInputStream(InputStream in, ByteOrder byteOrder) {
+ mDataInputStream = new DataInputStream(in);
+ mDataInputStream.mark(0);
+ mPosition = 0;
+ mByteOrder = byteOrder;
+ this.mLength = in instanceof ByteOrderedDataInputStream
+ ? ((ByteOrderedDataInputStream) in).length()
+ : LENGTH_UNSET;
+ }
+
+ public void setByteOrder(ByteOrder byteOrder) {
+ mByteOrder = byteOrder;
+ }
+
+ public int position() {
+ return mPosition;
+ }
+
+ /**
+ * Reads all remaining data.
+ */
+ public byte[] readToEnd() throws IOException {
+ byte[] data = new byte[1024];
+ int bytesRead = 0;
+ while (true) {
+ if (bytesRead == data.length) {
+ data = Arrays.copyOf(data, data.length * 2);
+ }
+ int readResult = mDataInputStream.read(data, bytesRead, data.length - bytesRead);
+ if (readResult != -1) {
+ bytesRead += readResult;
+ mPosition += readResult;
+ } else {
+ break;
+ }
+ }
+ return Arrays.copyOf(data, bytesRead);
+ }
+
+ @Override
+ public int available() throws IOException {
+ return mDataInputStream.available();
+ }
+
+ @Override
+ public int read() throws IOException {
+ ++mPosition;
+ return mDataInputStream.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int bytesRead = mDataInputStream.read(b, off, len);
+ mPosition += bytesRead;
+ return bytesRead;
+ }
+
+ @Override
+ public int readUnsignedByte() throws IOException {
+ ++mPosition;
+ return mDataInputStream.readUnsignedByte();
+ }
+
+ @Override
+ public String readLine() throws IOException {
+ Log.d(TAG, "Currently unsupported");
+ return null;
+ }
+
+ @Override
+ public boolean readBoolean() throws IOException {
+ ++mPosition;
+ return mDataInputStream.readBoolean();
+ }
+
+ @Override
+ public char readChar() throws IOException {
+ mPosition += 2;
+ return mDataInputStream.readChar();
+ }
+
+ @Override
+ public String readUTF() throws IOException {
+ mPosition += 2;
+ return mDataInputStream.readUTF();
+ }
+
+ @Override
+ public void readFully(byte[] buffer, int offset, int length) throws IOException {
+ mPosition += length;
+ mDataInputStream.readFully(buffer, offset, length);
+ }
+
+ @Override
+ public void readFully(byte[] buffer) throws IOException {
+ mPosition += buffer.length;
+ mDataInputStream.readFully(buffer);
+ }
+
+ @Override
+ public byte readByte() throws IOException {
+ ++mPosition;
+ int ch = mDataInputStream.read();
+ if (ch < 0) {
+ throw new EOFException();
+ }
+ return (byte) ch;
+ }
+
+ @Override
+ public short readShort() throws IOException {
+ mPosition += 2;
+ int ch1 = mDataInputStream.read();
+ int ch2 = mDataInputStream.read();
+ if ((ch1 | ch2) < 0) {
+ throw new EOFException();
+ }
+ if (mByteOrder == LITTLE_ENDIAN) {
+ return (short) ((ch2 << 8) + ch1);
+ } else if (mByteOrder == BIG_ENDIAN) {
+ return (short) ((ch1 << 8) + ch2);
+ }
+ throw new IOException("Invalid byte order: " + mByteOrder);
+ }
+
+ @Override
+ public int readInt() throws IOException {
+ mPosition += 4;
+ int ch1 = mDataInputStream.read();
+ int ch2 = mDataInputStream.read();
+ int ch3 = mDataInputStream.read();
+ int ch4 = mDataInputStream.read();
+ if ((ch1 | ch2 | ch3 | ch4) < 0) {
+ throw new EOFException();
+ }
+ if (mByteOrder == LITTLE_ENDIAN) {
+ return ((ch4 << 24) + (ch3 << 16) + (ch2 << 8) + ch1);
+ } else if (mByteOrder == BIG_ENDIAN) {
+ return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + ch4);
+ }
+ throw new IOException("Invalid byte order: " + mByteOrder);
+ }
+
+ @Override
+ public int skipBytes(int n) throws IOException {
+ throw new UnsupportedOperationException("skipBytes is currently unsupported");
+ }
+
+ /**
+ * Discards n bytes of data from the input stream. This method will block until either
+ * the full amount has been skipped or the end of the stream is reached, whichever happens
+ * first.
+ */
+ public void skipFully(int n) throws IOException {
+ int totalSkipped = 0;
+ while (totalSkipped < n) {
+ int skipped = (int) mDataInputStream.skip(n - totalSkipped);
+ if (skipped <= 0) {
+ if (mSkipBuffer == null) {
+ mSkipBuffer = new byte[SKIP_BUFFER_SIZE];
+ }
+ int bytesToSkip = Math.min(SKIP_BUFFER_SIZE, n - totalSkipped);
+ if ((skipped = mDataInputStream.read(mSkipBuffer, 0, bytesToSkip)) == -1) {
+ throw new EOFException("Reached EOF while skipping " + n + " bytes.");
+ }
+ }
+ totalSkipped += skipped;
+ }
+ mPosition += totalSkipped;
+ }
+
+ @Override
+ public int readUnsignedShort() throws IOException {
+ mPosition += 2;
+ int ch1 = mDataInputStream.read();
+ int ch2 = mDataInputStream.read();
+ if ((ch1 | ch2) < 0) {
+ throw new EOFException();
+ }
+ if (mByteOrder == LITTLE_ENDIAN) {
+ return ((ch2 << 8) + ch1);
+ } else if (mByteOrder == BIG_ENDIAN) {
+ return ((ch1 << 8) + ch2);
+ }
+ throw new IOException("Invalid byte order: " + mByteOrder);
+ }
+
+ public long readUnsignedInt() throws IOException {
+ return readInt() & 0xffffffffL;
+ }
+
+ @Override
+ public long readLong() throws IOException {
+ mPosition += 8;
+ int ch1 = mDataInputStream.read();
+ int ch2 = mDataInputStream.read();
+ int ch3 = mDataInputStream.read();
+ int ch4 = mDataInputStream.read();
+ int ch5 = mDataInputStream.read();
+ int ch6 = mDataInputStream.read();
+ int ch7 = mDataInputStream.read();
+ int ch8 = mDataInputStream.read();
+ if ((ch1 | ch2 | ch3 | ch4 | ch5 | ch6 | ch7 | ch8) < 0) {
+ throw new EOFException();
+ }
+ if (mByteOrder == LITTLE_ENDIAN) {
+ return (((long) ch8 << 56) + ((long) ch7 << 48) + ((long) ch6 << 40)
+ + ((long) ch5 << 32) + ((long) ch4 << 24) + ((long) ch3 << 16)
+ + ((long) ch2 << 8) + (long) ch1);
+ } else if (mByteOrder == BIG_ENDIAN) {
+ return (((long) ch1 << 56) + ((long) ch2 << 48) + ((long) ch3 << 40)
+ + ((long) ch4 << 32) + ((long) ch5 << 24) + ((long) ch6 << 16)
+ + ((long) ch7 << 8) + (long) ch8);
+ }
+ throw new IOException("Invalid byte order: " + mByteOrder);
+ }
+
+ @Override
+ public float readFloat() throws IOException {
+ return Float.intBitsToFloat(readInt());
+ }
+
+ @Override
+ public double readDouble() throws IOException {
+ return Double.longBitsToDouble(readLong());
+ }
+
+ @Override
+ public void mark(int readlimit) {
+ throw new UnsupportedOperationException("Mark is currently unsupported");
+ }
+
+ @Override
+ public void reset() {
+ throw new UnsupportedOperationException("Reset is currently unsupported");
+ }
+
+ /**
+ * Return the total length (in bytes) of the underlying stream if known, otherwise
+ * {@link #LENGTH_UNSET}.
+ */
+ public int length() {
+ return mLength;
+ }
+ }
+
+ // An output stream to write EXIF data area, which can be written in either little or big endian
+ // order.
+ private static class ByteOrderedDataOutputStream extends FilterOutputStream {
+ final OutputStream mOutputStream;
+ private ByteOrder mByteOrder;
+
+ public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
+ super(out);
+ mOutputStream = out;
+ mByteOrder = byteOrder;
+ }
+
+ public void setByteOrder(ByteOrder byteOrder) {
+ mByteOrder = byteOrder;
+ }
+
+ @Override
+ public void write(byte[] bytes) throws IOException {
+ mOutputStream.write(bytes);
+ }
+
+ @Override
+ public void write(byte[] bytes, int offset, int length) throws IOException {
+ mOutputStream.write(bytes, offset, length);
+ }
+
+ public void writeByte(int val) throws IOException {
+ mOutputStream.write(val);
+ }
+
+ public void writeShort(short val) throws IOException {
+ if (mByteOrder == LITTLE_ENDIAN) {
+ mOutputStream.write((val >>> 0) & 0xFF);
+ mOutputStream.write((val >>> 8) & 0xFF);
+ } else if (mByteOrder == BIG_ENDIAN) {
+ mOutputStream.write((val >>> 8) & 0xFF);
+ mOutputStream.write((val >>> 0) & 0xFF);
+ }
+ }
+
+ public void writeInt(int val) throws IOException {
+ if (mByteOrder == LITTLE_ENDIAN) {
+ mOutputStream.write((val >>> 0) & 0xFF);
+ mOutputStream.write((val >>> 8) & 0xFF);
+ mOutputStream.write((val >>> 16) & 0xFF);
+ mOutputStream.write((val >>> 24) & 0xFF);
+ } else if (mByteOrder == BIG_ENDIAN) {
+ mOutputStream.write((val >>> 24) & 0xFF);
+ mOutputStream.write((val >>> 16) & 0xFF);
+ mOutputStream.write((val >>> 8) & 0xFF);
+ mOutputStream.write((val >>> 0) & 0xFF);
+ }
+ }
+
+ public void writeUnsignedShort(int val) throws IOException {
+ if (val > 0xFFFF) {
+ throw new IllegalArgumentException("val is larger than the maximum value of a "
+ + "16-bit unsigned integer");
+ }
+ writeShort((short) val);
+ }
+
+ public void writeUnsignedInt(long val) throws IOException {
+ if (val > 0xFFFF_FFFFL) {
+ throw new IllegalArgumentException("val is larger than the maximum value of a "
+ + "32-bit unsigned integer");
+ }
+ writeInt((int) val);
+ }
+ }
+
+ // Swaps image data based on image size
+ private void swapBasedOnImageSize(@IfdType int firstIfdType, @IfdType int secondIfdType)
+ throws IOException {
+ if (mAttributes[firstIfdType].isEmpty() || mAttributes[secondIfdType].isEmpty()) {
+ if (DEBUG) {
+ Log.d(TAG, "Cannot perform swap since only one image data exists");
+ }
+ return;
+ }
+
+ ExifAttribute firstImageLengthAttribute =
+ mAttributes[firstIfdType].get(TAG_IMAGE_LENGTH);
+ ExifAttribute firstImageWidthAttribute =
+ mAttributes[firstIfdType].get(TAG_IMAGE_WIDTH);
+ ExifAttribute secondImageLengthAttribute =
+ mAttributes[secondIfdType].get(TAG_IMAGE_LENGTH);
+ ExifAttribute secondImageWidthAttribute =
+ mAttributes[secondIfdType].get(TAG_IMAGE_WIDTH);
+
+ if (firstImageLengthAttribute == null || firstImageWidthAttribute == null) {
+ if (DEBUG) {
+ Log.d(TAG, "First image does not contain valid size information");
+ }
+ } else if (secondImageLengthAttribute == null || secondImageWidthAttribute == null) {
+ if (DEBUG) {
+ Log.d(TAG, "Second image does not contain valid size information");
+ }
+ } else {
+ int firstImageLengthValue = firstImageLengthAttribute.getIntValue(mExifByteOrder);
+ int firstImageWidthValue = firstImageWidthAttribute.getIntValue(mExifByteOrder);
+ int secondImageLengthValue = secondImageLengthAttribute.getIntValue(mExifByteOrder);
+ int secondImageWidthValue = secondImageWidthAttribute.getIntValue(mExifByteOrder);
+
+ if (firstImageLengthValue < secondImageLengthValue &&
+ firstImageWidthValue < secondImageWidthValue) {
+ HashMap tempMap = mAttributes[firstIfdType];
+ mAttributes[firstIfdType] = mAttributes[secondIfdType];
+ mAttributes[secondIfdType] = tempMap;
+ }
+ }
+ }
+
+ private void replaceInvalidTags(@IfdType int ifdType, String invalidTag, String validTag) {
+ if (!mAttributes[ifdType].isEmpty()) {
+ if (mAttributes[ifdType].get(invalidTag) != null) {
+ mAttributes[ifdType].put(validTag,
+ mAttributes[ifdType].get(invalidTag));
+ mAttributes[ifdType].remove(invalidTag);
+ }
+ }
+ }
+
+ /**
+ * Parsing EXIF data requires seek (moving to any position in the stream), so all MIME
+ * types should support seek via mark/reset, unless the MIME type specifies the position and
+ * length of the EXIF data and the EXIF data can be read from the file and wrapped with a
+ * ByteArrayInputStream.
+ */
+ private static boolean shouldSupportSeek(int mimeType) {
+ if (mimeType == IMAGE_TYPE_JPEG || mimeType == IMAGE_TYPE_RAF || mimeType == IMAGE_TYPE_PNG
+ || mimeType == IMAGE_TYPE_WEBP) {
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isSupportedFormatForSavingAttributes(int mimeType) {
+ if (mimeType == IMAGE_TYPE_JPEG || mimeType == IMAGE_TYPE_PNG
+ || mimeType == IMAGE_TYPE_WEBP) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/android/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterfaceUtils.java b/android/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterfaceUtils.java
new file mode 100644
index 000000000..a7033b4ae
--- /dev/null
+++ b/android/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterfaceUtils.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2020 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.exifinterface.media;
+
+import android.media.MediaDataSource;
+import android.media.MediaMetadataRetriever;
+import android.os.Build;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+
+import androidx.annotation.DoNotInline;
+import androidx.annotation.RequiresApi;
+
+import java.io.Closeable;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+class ExifInterfaceUtils {
+ private static final String TAG = "ExifInterfaceUtils";
+
+ private ExifInterfaceUtils() {
+ // Prevent instantiation
+ }
+ /**
+ * Copies all of the bytes from {@code in} to {@code out}. Neither stream is closed.
+ * Returns the total number of bytes transferred.
+ */
+ static int copy(InputStream in, OutputStream out) throws IOException {
+ int total = 0;
+ byte[] buffer = new byte[8192];
+ int c;
+ while ((c = in.read(buffer)) != -1) {
+ total += c;
+ out.write(buffer, 0, c);
+ }
+ return total;
+ }
+
+ /**
+ * Copies the given number of the bytes from {@code in} to {@code out}. Neither stream is
+ * closed.
+ */
+ static void copy(InputStream in, OutputStream out, int numBytes) throws IOException {
+ int remainder = numBytes;
+ byte[] buffer = new byte[8192];
+ while (remainder > 0) {
+ int bytesToRead = Math.min(remainder, 8192);
+ int bytesRead = in.read(buffer, 0, bytesToRead);
+ if (bytesRead != bytesToRead) {
+ throw new IOException("Failed to copy the given amount of bytes from the input"
+ + "stream to the output stream.");
+ }
+ remainder -= bytesRead;
+ out.write(buffer, 0, bytesRead);
+ }
+ }
+
+ /**
+ * Convert given int[] to long[]. If long[] is given, just return it.
+ * Return null for other types of input.
+ */
+ static long[] convertToLongArray(Object inputObj) {
+ if (inputObj instanceof int[]) {
+ int[] input = (int[]) inputObj;
+ long[] result = new long[input.length];
+ for (int i = 0; i < input.length; i++) {
+ result[i] = input[i];
+ }
+ return result;
+ } else if (inputObj instanceof long[]) {
+ return (long[]) inputObj;
+ }
+ return null;
+ }
+
+ static boolean startsWith(byte[] cur, byte[] val) {
+ if (cur == null || val == null) {
+ return false;
+ }
+ if (cur.length < val.length) {
+ return false;
+ }
+ for (int i = 0; i < val.length; i++) {
+ if (cur[i] != val[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ static String byteArrayToHexString(byte[] bytes) {
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (int i = 0; i < bytes.length; i++) {
+ sb.append(String.format("%02x", bytes[i]));
+ }
+ return sb.toString();
+ }
+
+ static long parseSubSeconds(String subSec) {
+ try {
+ final int len = Math.min(subSec.length(), 3);
+ long sub = Long.parseLong(subSec.substring(0, len));
+ for (int i = len; i < 3; i++) {
+ sub *= 10;
+ }
+ return sub;
+ } catch (NumberFormatException e) {
+ // Ignored
+ }
+ return 0L;
+ }
+
+
+ /**
+ * Closes 'closeable', ignoring any checked exceptions. Does nothing if 'closeable' is null.
+ */
+ static void closeQuietly(Closeable closeable) {
+ if (closeable != null) {
+ try {
+ closeable.close();
+ } catch (RuntimeException rethrown) {
+ throw rethrown;
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ /**
+ * Closes a file descriptor that has been duplicated.
+ */
+ static void closeFileDescriptor(FileDescriptor fd) {
+ // Os.dup and Os.close was introduced in API 21 so this method shouldn't be called
+ // in API < 21.
+ if (Build.VERSION.SDK_INT >= 21) {
+ try {
+ Api21Impl.close(fd);
+ // Catching ErrnoException will raise error in API < 21
+ } catch (Exception ex) {
+ Log.e(TAG, "Error closing fd.");
+ }
+ } else {
+ Log.e(TAG, "closeFileDescriptor is called in API < 21, which must be wrong.");
+ }
+ }
+
+ @RequiresApi(21)
+ static class Api21Impl {
+ private Api21Impl() {}
+
+ @DoNotInline
+ static FileDescriptor dup(FileDescriptor fileDescriptor) throws ErrnoException {
+ return Os.dup(fileDescriptor);
+ }
+
+ @DoNotInline
+ static long lseek(FileDescriptor fd, long offset, int whence) throws ErrnoException {
+ return Os.lseek(fd, offset, whence);
+ }
+
+ @DoNotInline
+ static void close(FileDescriptor fd) throws ErrnoException {
+ Os.close(fd);
+ }
+ }
+
+ @RequiresApi(23)
+ static class Api23Impl {
+ private Api23Impl() {}
+
+ @DoNotInline
+ static void setDataSource(MediaMetadataRetriever retriever, MediaDataSource dataSource) {
+ retriever.setDataSource(dataSource);
+ }
+ }
+}
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index 09174e6c6..75e605d83 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -23,4 +23,5 @@ assert(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
apply {
from("$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle")
-}
\ No newline at end of file
+}
+include(":exifinterface")
diff --git a/fastlane/metadata/android/ca/full_description.txt b/fastlane/metadata/android/ca/full_description.txt
new file mode 100644
index 000000000..6b96ec3ea
--- /dev/null
+++ b/fastlane/metadata/android/ca/full_description.txt
@@ -0,0 +1,5 @@
+Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like multi-page TIFFs, SVGs, old AVIs and more! It scans your media collection to identify motion photos, panoramas (aka photo spheres), 360° videos, as well as GeoTIFF files.
+
+Navigation and search is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
+
+Aves integrates with Android (from KitKat to Android 14, including Android TV) with features such as widgets, app shortcuts, screen saver and global search handling. It also works as a media viewer and picker.
diff --git a/fastlane/metadata/android/ca/images/featureGraphic.png b/fastlane/metadata/android/ca/images/featureGraphic.png
new file mode 100644
index 000000000..ad29dfe39
Binary files /dev/null and b/fastlane/metadata/android/ca/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/1.png b/fastlane/metadata/android/ca/images/phoneScreenshots/1.png
new file mode 100644
index 000000000..ae462818a
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/2.png b/fastlane/metadata/android/ca/images/phoneScreenshots/2.png
new file mode 100644
index 000000000..78175c595
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/3.png b/fastlane/metadata/android/ca/images/phoneScreenshots/3.png
new file mode 100644
index 000000000..7e38caa67
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/4.png b/fastlane/metadata/android/ca/images/phoneScreenshots/4.png
new file mode 100644
index 000000000..494b4c6de
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/5.png b/fastlane/metadata/android/ca/images/phoneScreenshots/5.png
new file mode 100644
index 000000000..4e5ca98f5
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/6.png b/fastlane/metadata/android/ca/images/phoneScreenshots/6.png
new file mode 100644
index 000000000..95bd8f9e5
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/7.png b/fastlane/metadata/android/ca/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..60adcc959
Binary files /dev/null and b/fastlane/metadata/android/ca/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/ca/short_description.txt b/fastlane/metadata/android/ca/short_description.txt
new file mode 100644
index 000000000..8c9445bd5
--- /dev/null
+++ b/fastlane/metadata/android/ca/short_description.txt
@@ -0,0 +1 @@
+Gallery and metadata explorer
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/114.txt b/fastlane/metadata/android/en-US/changelogs/114.txt
new file mode 100644
index 000000000..49ab02a6b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/114.txt
@@ -0,0 +1,3 @@
+In v1.10.5:
+- enjoy the app in Catalan
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/11401.txt b/fastlane/metadata/android/en-US/changelogs/11401.txt
new file mode 100644
index 000000000..49ab02a6b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/11401.txt
@@ -0,0 +1,3 @@
+In v1.10.5:
+- enjoy the app in Catalan
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index 8a2b2a011..e95890030 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -53,7 +53,7 @@
"@previousTooltip": {},
"welcomeMessage": "مرحبا بكم في Aves",
"@welcomeMessage": {},
- "applyButtonLabel": "تقديم",
+ "applyButtonLabel": "تأكيد",
"@applyButtonLabel": {},
"nextButtonLabel": "التالي",
"@nextButtonLabel": {},
@@ -103,7 +103,7 @@
"@pickTooltip": {},
"chipActionGoToCountryPage": "عرض في الدول",
"@chipActionGoToCountryPage": {},
- "applyTooltip": "تقدم",
+ "applyTooltip": "تأكيد",
"@applyTooltip": {},
"chipActionUnpin": "إلغاء التثبيت في الأعلى",
"@chipActionUnpin": {},
diff --git a/lib/l10n/app_be.arb b/lib/l10n/app_be.arb
index 322e9e141..2b693374a 100644
--- a/lib/l10n/app_be.arb
+++ b/lib/l10n/app_be.arb
@@ -70,7 +70,7 @@
"@chipActionGoToTagPage": {},
"chipActionLock": "Заблакаваць",
"@chipActionLock": {},
- "chipActionSetCover": "Усталяваць вокладку",
+ "chipActionSetCover": "Ўсталяваць вокладку",
"@chipActionSetCover": {},
"chipActionRename": "Перайменаваць",
"@chipActionRename": {},
@@ -124,11 +124,11 @@
"@entryActionConvertMotionPhotoToStillImage": {},
"entryActionViewMotionPhotoVideo": "Адкрыць відэа",
"@entryActionViewMotionPhotoVideo": {},
- "entryActionSetAs": "Усталяваць як",
+ "entryActionSetAs": "Ўсталяваць як",
"@entryActionSetAs": {},
"entryActionAddFavourite": "Дадаць у абранае",
"@entryActionAddFavourite": {},
- "videoActionUnmute": "Уключыць гук",
+ "videoActionUnmute": "Ўключыць гук",
"@videoActionUnmute": {},
"videoActionCaptureFrame": "Захоп кадра",
"@videoActionCaptureFrame": {},
@@ -449,7 +449,7 @@
"@wallpaperTargetHomeLock": {},
"widgetTapUpdateWidget": "Абнавіць віджэт",
"@widgetTapUpdateWidget": {},
- "storageVolumeDescriptionFallbackPrimary": "Унутраная памяць",
+ "storageVolumeDescriptionFallbackPrimary": "Ўнутраная памяць",
"@storageVolumeDescriptionFallbackPrimary": {},
"restrictedAccessDialogMessage": "Гэтай праграме забаронена змяняць файлы ў {directory} «{volume}».\n\nКаб перамясціць элементы ў іншую дырэкторыю, выкарыстоўвайце папярэдне ўсталяваны дыспетчар файлаў або праграму галерэі.",
"@restrictedAccessDialogMessage": {
@@ -465,7 +465,7 @@
}
}
},
- "missingSystemFilePickerDialogMessage": "Сродак выбару сістэмных файлаў адсутнічае або адключаны. Уключыце яго і паўтарыце спробу.",
+ "missingSystemFilePickerDialogMessage": "Сродак выбару сістэмных файлаў адсутнічае або адключаны. Ўключыце яго і паўтарыце спробу.",
"@missingSystemFilePickerDialogMessage": {},
"unsupportedTypeDialogMessage": "{count, plural, =1{Гэта аперацыя не падтрымліваецца для элементаў наступнага тыпу: {types}.} other{Гэта аперацыя не падтрымліваецца для элементаў наступных тыпаў: {types}.}}",
"@unsupportedTypeDialogMessage": {
@@ -517,15 +517,15 @@
"@configureVaultDialogTitle": {},
"vaultDialogLockTypeLabel": "Тып блакіроўкі",
"@vaultDialogLockTypeLabel": {},
- "pinDialogEnter": "Увядзіце PIN-код",
+ "pinDialogEnter": "Ўвядзіце PIN-код",
"@pinDialogEnter": {},
- "patternDialogEnter": "Увядзіце графічны ключ",
+ "patternDialogEnter": "Ўвядзіце графічны ключ",
"@patternDialogEnter": {},
"patternDialogConfirm": "Пацвердзіце графічны ключ",
"@patternDialogConfirm": {},
"pinDialogConfirm": "Пацвердзіце PIN-код",
"@pinDialogConfirm": {},
- "passwordDialogEnter": "Увядзіце пароль",
+ "passwordDialogEnter": "Ўвядзіце пароль",
"@passwordDialogEnter": {},
"passwordDialogConfirm": "Пацвердзіце пароль",
"@passwordDialogConfirm": {},
@@ -577,7 +577,7 @@
"@sourceViewerPageTitle": {},
"panoramaDisableSensorControl": "Адключыць сэнсарнае кіраванне",
"@panoramaDisableSensorControl": {},
- "panoramaEnableSensorControl": "Уключыць сэнсарнае кіраванне",
+ "panoramaEnableSensorControl": "Ўключыць сэнсарнае кіраванне",
"@panoramaEnableSensorControl": {},
"tagPlaceholderPlace": "Месца",
"@tagPlaceholderPlace": {},
@@ -601,7 +601,7 @@
"@videoControlsNone": {},
"viewerErrorUnknown": "Ой!",
"@viewerErrorUnknown": {},
- "viewerSetWallpaperButtonLabel": "УСТАНАВІЦЬ ШПАЛЕРЫ",
+ "viewerSetWallpaperButtonLabel": "ЎСТАНАВІЦЬ ШПАЛЕРЫ",
"@viewerSetWallpaperButtonLabel": {},
"statsTopAlbumsSectionTitle": "Лепшыя альбомы",
"@statsTopAlbumsSectionTitle": {},
@@ -819,7 +819,7 @@
"@aboutDataUsageMisc": {},
"albumVideoCaptures": "Відэазапісы",
"@albumVideoCaptures": {},
- "editEntryDateDialogSetCustom": "Усталяваць карыстацкую дату",
+ "editEntryDateDialogSetCustom": "Ўсталяваць карыстацкую дату",
"@editEntryDateDialogSetCustom": {},
"settingsSearchEmpty": "Няма адпаведнай налады",
"@settingsSearchEmpty": {},
@@ -845,7 +845,7 @@
"@collectionSelectSectionTooltip": {},
"aboutLicensesBanner": "Гэта праграма выкарыстоўвае наступныя пакеты і бібліятэкі з адкрытым зыходным кодам.",
"@aboutLicensesBanner": {},
- "dateYesterday": "Учора",
+ "dateYesterday": "Ўчора",
"@dateYesterday": {},
"aboutDataUsageDatabase": "База дадзеных",
"@aboutDataUsageDatabase": {},
@@ -879,7 +879,7 @@
"@videoStreamSelectionDialogAudio": {},
"videoSpeedDialogLabel": "Хуткасць прайгравання",
"@videoSpeedDialogLabel": {},
- "editEntryLocationDialogSetCustom": "Устанавіць карыстацкае месцазнаходжанне",
+ "editEntryLocationDialogSetCustom": "Ўстанавіць карыстацкае месцазнаходжанне",
"@editEntryLocationDialogSetCustom": {},
"placeEmpty": "Няма месцаў",
"@placeEmpty": {},
@@ -1141,7 +1141,7 @@
"@genericFailureFeedback": {},
"aboutDataUsageData": "Дадзеныя",
"@aboutDataUsageData": {},
- "aboutDataUsageInternal": "Унутраны",
+ "aboutDataUsageInternal": "Ўнутраны",
"@aboutDataUsageInternal": {},
"albumDownload": "Загрузкі",
"@albumDownload": {},
@@ -1519,7 +1519,7 @@
"minutes": {}
}
},
- "collectionActionSetHome": "Усталяваць як галоўную",
+ "collectionActionSetHome": "Ўсталяваць як галоўную",
"@collectionActionSetHome": {},
"setHomeCustomCollection": "Уласная калекцыя",
"@setHomeCustomCollection": {},
diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb
new file mode 100644
index 000000000..e34d3a686
--- /dev/null
+++ b/lib/l10n/app_ca.arb
@@ -0,0 +1,1528 @@
+{
+ "appName": "Aves",
+ "@appName": {},
+ "welcomeMessage": "Benvingut a Aves",
+ "@welcomeMessage": {},
+ "welcomeOptional": "Opcional",
+ "@welcomeOptional": {},
+ "welcomeTermsToggle": "Estic d’acord amb els termes i condicions",
+ "@welcomeTermsToggle": {},
+ "itemCount": "{count, plural, =1{1 element} other{{count} elements}}",
+ "@itemCount": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "columnCount": "{count, plural, =1{1 columna} other{{count} columnes}}",
+ "@columnCount": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "cancelTooltip": "Canceŀlar",
+ "@cancelTooltip": {},
+ "changeTooltip": "Canviar",
+ "@changeTooltip": {},
+ "actionRemove": "Eliminar",
+ "@actionRemove": {},
+ "resetTooltip": "Restableix",
+ "@resetTooltip": {},
+ "saveTooltip": "Desa",
+ "@saveTooltip": {},
+ "pickTooltip": "Escollir",
+ "@pickTooltip": {},
+ "doubleBackExitMessage": "Torneu a tocar «enrere» per sortir.",
+ "@doubleBackExitMessage": {},
+ "doNotAskAgain": "No tornis a preguntar",
+ "@doNotAskAgain": {},
+ "sourceStateLoading": "Carregant",
+ "@sourceStateLoading": {},
+ "sourceStateCataloguing": "Catalogant",
+ "@sourceStateCataloguing": {},
+ "sourceStateLocatingCountries": "Localitzant països",
+ "@sourceStateLocatingCountries": {},
+ "sourceStateLocatingPlaces": "Localitzant llocs",
+ "@sourceStateLocatingPlaces": {},
+ "chipActionDelete": "Suprimeix",
+ "@chipActionDelete": {},
+ "chipActionGoToAlbumPage": "Mostra als Àlbums",
+ "@chipActionGoToAlbumPage": {},
+ "chipActionGoToPlacePage": "Mostra als Llocs",
+ "@chipActionGoToPlacePage": {},
+ "chipActionGoToTagPage": "Mostra a les etiquetes",
+ "@chipActionGoToTagPage": {},
+ "chipActionLock": "Bloquejar",
+ "@chipActionLock": {},
+ "chipActionPin": "Ancora a dalt",
+ "@chipActionPin": {},
+ "chipActionUnpin": "Desancora de dalt",
+ "@chipActionUnpin": {},
+ "chipActionSetCover": "Escollir coberta",
+ "@chipActionSetCover": {},
+ "chipActionShowCountryStates": "Mostrar estats",
+ "@chipActionShowCountryStates": {},
+ "chipActionCreateAlbum": "Crear àlbum",
+ "@chipActionCreateAlbum": {},
+ "chipActionCreateVault": "Crear una caixa forta",
+ "@chipActionCreateVault": {},
+ "entryActionCopyToClipboard": "Copiar al porta-retalls",
+ "@entryActionCopyToClipboard": {},
+ "chipActionRename": "Canviar nom",
+ "@chipActionRename": {},
+ "entryActionConvert": "Convertir",
+ "@entryActionConvert": {},
+ "entryActionInfo": "Informació",
+ "@entryActionInfo": {},
+ "entryActionRename": "Canviar nom",
+ "@entryActionRename": {},
+ "entryActionRestore": "Restaurar",
+ "@entryActionRestore": {},
+ "entryActionFlip": "Voltar horitzontalment",
+ "@entryActionFlip": {},
+ "entryActionPrint": "Imprimir",
+ "@entryActionPrint": {},
+ "entryActionShare": "Compartir",
+ "@entryActionShare": {},
+ "entryActionShareImageOnly": "Compartir només imatge",
+ "@entryActionShareImageOnly": {},
+ "entryActionShareVideoOnly": "Compartir només vídeo",
+ "@entryActionShareVideoOnly": {},
+ "entryActionViewSource": "Veure font",
+ "@entryActionViewSource": {},
+ "entryActionViewMotionPhotoVideo": "Obrir vídeo",
+ "@entryActionViewMotionPhotoVideo": {},
+ "entryActionEdit": "Editar",
+ "@entryActionEdit": {},
+ "entryActionOpenMap": "Mostrar en aplicació de mapes",
+ "@entryActionOpenMap": {},
+ "entryActionRotateScreen": "Girar pantalla",
+ "@entryActionRotateScreen": {},
+ "entryActionAddFavourite": "Afegir a preferits",
+ "@entryActionAddFavourite": {},
+ "entryActionRemoveFavourite": "Treure de preferits",
+ "@entryActionRemoveFavourite": {},
+ "videoActionCaptureFrame": "Capturar fotograma",
+ "@videoActionCaptureFrame": {},
+ "videoActionUnmute": "Activar so",
+ "@videoActionUnmute": {},
+ "videoActionPlay": "Reproduir",
+ "@videoActionPlay": {},
+ "videoActionReplay10": "Retrocedeix 10 segons",
+ "@videoActionReplay10": {},
+ "videoActionSkip10": "Avança 10 segons",
+ "@videoActionSkip10": {},
+ "videoActionSelectStreams": "Seleccionar pista",
+ "@videoActionSelectStreams": {},
+ "videoActionSetSpeed": "Velocitat de reproducció",
+ "@videoActionSetSpeed": {},
+ "viewerActionSettings": "Configuració",
+ "@viewerActionSettings": {},
+ "viewerActionUnlock": "Desbloquejar visor",
+ "@viewerActionUnlock": {},
+ "entryInfoActionRemoveLocation": "Esborrar localització",
+ "@entryInfoActionRemoveLocation": {},
+ "editorActionTransform": "Transformar",
+ "@editorActionTransform": {},
+ "editorTransformCrop": "Retallar",
+ "@editorTransformCrop": {},
+ "editorTransformRotate": "Girar",
+ "@editorTransformRotate": {},
+ "cropAspectRatioFree": "Lliure",
+ "@cropAspectRatioFree": {},
+ "cropAspectRatioOriginal": "Original",
+ "@cropAspectRatioOriginal": {},
+ "cropAspectRatioSquare": "Quadrat",
+ "@cropAspectRatioSquare": {},
+ "filterAspectRatioLandscapeLabel": "Paisatge",
+ "@filterAspectRatioLandscapeLabel": {},
+ "filterAspectRatioPortraitLabel": "Retrat",
+ "@filterAspectRatioPortraitLabel": {},
+ "filterBinLabel": "Paperera de reciclatge",
+ "@filterBinLabel": {},
+ "filterFavouriteLabel": "Preferida",
+ "@filterFavouriteLabel": {},
+ "filterNoDateLabel": "Sense data",
+ "@filterNoDateLabel": {},
+ "filterNoAddressLabel": "Sense adreça",
+ "@filterNoAddressLabel": {},
+ "filterLocatedLabel": "Localitzat",
+ "@filterLocatedLabel": {},
+ "filterNoLocationLabel": "Deslocalitzat",
+ "@filterNoLocationLabel": {},
+ "filterNoRatingLabel": "Sense valoració",
+ "@filterNoRatingLabel": {},
+ "filterNoTitleLabel": "Sense títol",
+ "@filterNoTitleLabel": {},
+ "filterOnThisDayLabel": "D’avui",
+ "@filterOnThisDayLabel": {},
+ "filterRecentlyAddedLabel": "Afegit recentment",
+ "@filterRecentlyAddedLabel": {},
+ "filterTypeMotionPhotoLabel": "Foto en moviment",
+ "@filterTypeMotionPhotoLabel": {},
+ "filterTypePanoramaLabel": "Panoràmica",
+ "@filterTypePanoramaLabel": {},
+ "filterTypeGeotiffLabel": "GeoTIFF",
+ "@filterTypeGeotiffLabel": {},
+ "accessibilityAnimationsRemove": "Evitar efectes de pantalla",
+ "@accessibilityAnimationsRemove": {},
+ "albumTierNew": "Nou",
+ "@albumTierNew": {},
+ "albumTierPinned": "Fixat",
+ "@albumTierPinned": {},
+ "albumTierSpecial": "Comú",
+ "@albumTierSpecial": {},
+ "coordinateDms": "{coordinate} {direction}",
+ "@coordinateDms": {
+ "placeholders": {
+ "coordinate": {
+ "type": "String",
+ "example": "38° 41′ 47.72″"
+ },
+ "direction": {
+ "type": "String",
+ "example": "S"
+ }
+ }
+ },
+ "coordinateDmsNorth": "N",
+ "@coordinateDmsNorth": {},
+ "coordinateDmsSouth": "S",
+ "@coordinateDmsSouth": {},
+ "coordinateDmsEast": "E",
+ "@coordinateDmsEast": {},
+ "coordinateDmsWest": "O",
+ "@coordinateDmsWest": {},
+ "displayRefreshRatePreferHighest": "Taxa més alta",
+ "@displayRefreshRatePreferHighest": {},
+ "displayRefreshRatePreferLowest": "Taxa més baixa",
+ "@displayRefreshRatePreferLowest": {},
+ "keepScreenOnNever": "Mai",
+ "@keepScreenOnNever": {},
+ "keepScreenOnVideoPlayback": "Durant la reproducció de vídeo",
+ "@keepScreenOnVideoPlayback": {},
+ "keepScreenOnViewerOnly": "Només la pàgina del visor",
+ "@keepScreenOnViewerOnly": {},
+ "keepScreenOnAlways": "Sempre",
+ "@keepScreenOnAlways": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "mapStyleGoogleNormal": "Google Maps",
+ "@mapStyleGoogleNormal": {},
+ "mapStyleGoogleHybrid": "Google Maps (Híbrid)",
+ "@mapStyleGoogleHybrid": {},
+ "mapStyleGoogleTerrain": "Google Maps (Terreny)",
+ "@mapStyleGoogleTerrain": {},
+ "mapStyleHuaweiTerrain": "Petal Maps (Terreny)",
+ "@mapStyleHuaweiTerrain": {},
+ "mapStyleOsmHot": "Humanitarian OSM",
+ "@mapStyleOsmHot": {},
+ "mapStyleStamenWatercolor": "Stamen Watercolor",
+ "@mapStyleStamenWatercolor": {},
+ "maxBrightnessNever": "Mai",
+ "@maxBrightnessNever": {},
+ "nameConflictStrategyRename": "Canviar nom",
+ "@nameConflictStrategyRename": {},
+ "overlayHistogramNone": "Cap",
+ "@overlayHistogramNone": {},
+ "overlayHistogramRGB": "RGB",
+ "@overlayHistogramRGB": {},
+ "subtitlePositionTop": "A dalt",
+ "@subtitlePositionTop": {},
+ "unitSystemMetric": "Mètric",
+ "@unitSystemMetric": {},
+ "unitSystemImperial": "Imperial",
+ "@unitSystemImperial": {},
+ "vaultLockTypePattern": "Patró",
+ "@vaultLockTypePattern": {},
+ "vaultLockTypePin": "PIN",
+ "@vaultLockTypePin": {},
+ "vaultLockTypePassword": "Contrasenya",
+ "@vaultLockTypePassword": {},
+ "settingsVideoEnablePip": "Imatge-en-imatge",
+ "@settingsVideoEnablePip": {},
+ "videoControlsPlay": "Reproduir",
+ "@videoControlsPlay": {},
+ "videoControlsPlaySeek": "Reproduir i retrocedeix/avança",
+ "@videoControlsPlaySeek": {},
+ "videoControlsNone": "Cap",
+ "@videoControlsNone": {},
+ "viewerTransitionZoomIn": "Ampliar",
+ "@viewerTransitionZoomIn": {},
+ "viewerTransitionNone": "Cap",
+ "@viewerTransitionNone": {},
+ "wallpaperTargetHome": "Pàgina principal",
+ "@wallpaperTargetHome": {},
+ "widgetDisplayedItemRandom": "Aleatori",
+ "@widgetDisplayedItemRandom": {},
+ "widgetDisplayedItemMostRecent": "Més recent",
+ "@widgetDisplayedItemMostRecent": {},
+ "storageVolumeDescriptionFallbackPrimary": "Espai intern",
+ "@storageVolumeDescriptionFallbackPrimary": {},
+ "storageVolumeDescriptionFallbackNonPrimary": "Targeta SD",
+ "@storageVolumeDescriptionFallbackNonPrimary": {},
+ "rootDirectoryDescription": "Directori arrel",
+ "@rootDirectoryDescription": {},
+ "otherDirectoryDescription": "«{name}» directori",
+ "@otherDirectoryDescription": {
+ "placeholders": {
+ "name": {
+ "type": "String",
+ "example": "Pictures",
+ "description": "the name of a specific directory"
+ }
+ }
+ },
+ "storageAccessDialogMessage": "Si us plau, selecciona el {directory} a «{volume}» a la següent pantalla per donar-li accés a aquesta aplicació.",
+ "@storageAccessDialogMessage": {
+ "placeholders": {
+ "directory": {
+ "type": "String",
+ "description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`"
+ },
+ "volume": {
+ "type": "String",
+ "example": "SD card",
+ "description": "the name of a storage volume"
+ }
+ }
+ },
+ "missingSystemFilePickerDialogMessage": "Falta el selector de fitxers del sistema o està desactivat. Si us plau, activeu-lo i torneu-ho a provar.",
+ "@missingSystemFilePickerDialogMessage": {},
+ "nameConflictDialogSingleSourceMessage": "Alguns fitxers de la carpeta de destinació tenen el mateix nom.",
+ "@nameConflictDialogSingleSourceMessage": {},
+ "addShortcutButtonLabel": "AFEGEIX",
+ "@addShortcutButtonLabel": {},
+ "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Esborrar aquest element?} other{Esborrar aquests {count} elements?}}",
+ "@deleteEntriesConfirmationDialogMessage": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "moveUndatedConfirmationDialogMessage": "Desar les dates dels elements abans de continuar?",
+ "@moveUndatedConfirmationDialogMessage": {},
+ "moveUndatedConfirmationDialogSetDate": "Guardar dates",
+ "@moveUndatedConfirmationDialogSetDate": {},
+ "videoResumeDialogMessage": "Vols continuar reproduint a {time}?",
+ "@videoResumeDialogMessage": {
+ "placeholders": {
+ "time": {
+ "type": "String",
+ "example": "13:37"
+ }
+ }
+ },
+ "videoStartOverButtonLabel": "TORNAR A COMENÇAR",
+ "@videoStartOverButtonLabel": {},
+ "videoResumeButtonLabel": "REPRENDRE",
+ "@videoResumeButtonLabel": {},
+ "setCoverDialogLatest": "Últim element",
+ "@setCoverDialogLatest": {},
+ "setCoverDialogAuto": "Automàtic",
+ "@setCoverDialogAuto": {},
+ "setCoverDialogCustom": "Personalitzat",
+ "@setCoverDialogCustom": {},
+ "hideFilterConfirmationDialogMessage": "Les fotos i els vídeos coincidents s’amagaran de la teva coŀlecció. Podeu tornar-los a mostrar des de la configuració de «Privadesa».\n\nEsteu segur que voleu amagar-los?",
+ "@hideFilterConfirmationDialogMessage": {},
+ "newAlbumDialogTitle": "Àlbum nou",
+ "@newAlbumDialogTitle": {},
+ "newAlbumDialogNameLabel": "Nom de l’Àlbum",
+ "@newAlbumDialogNameLabel": {},
+ "newAlbumDialogStorageLabel": "Emmagatzematge:",
+ "@newAlbumDialogStorageLabel": {},
+ "newVaultDialogTitle": "Caixa forta nova",
+ "@newVaultDialogTitle": {},
+ "configureVaultDialogTitle": "Configura caixa forta",
+ "@configureVaultDialogTitle": {},
+ "patternDialogEnter": "Introdueix patró",
+ "@patternDialogEnter": {},
+ "passwordDialogEnter": "Introdueix contrasenya",
+ "@passwordDialogEnter": {},
+ "passwordDialogConfirm": "Confirma contrasenya",
+ "@passwordDialogConfirm": {},
+ "authenticateToUnlockVault": "Autentifica per desbloquejar caixa forta",
+ "@authenticateToUnlockVault": {},
+ "renameAlbumDialogLabel": "Nom nou",
+ "@renameAlbumDialogLabel": {},
+ "renameAlbumDialogLabelAlreadyExistsHelper": "El directori ja existeix",
+ "@renameAlbumDialogLabelAlreadyExistsHelper": {},
+ "renameProcessorCounter": "Comptador",
+ "@renameProcessorCounter": {},
+ "renameProcessorName": "Nom",
+ "@renameProcessorName": {},
+ "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Eliminar aquest àlbum i l’element que conté?} other{Eliminar aquests àlbums i els {count} elements que contenen?}}",
+ "@deleteMultiAlbumConfirmationDialogMessage": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "exportEntryDialogQuality": "Qualitat",
+ "@exportEntryDialogQuality": {},
+ "editEntryDialogCopyFromItem": "Copiar d’un altre element",
+ "@editEntryDialogCopyFromItem": {},
+ "editEntryDialogTargetFieldsHeader": "Camps a modificar",
+ "@editEntryDialogTargetFieldsHeader": {},
+ "editEntryDateDialogSetCustom": "Defineix data personalitzada",
+ "@editEntryDateDialogSetCustom": {},
+ "editEntryDateDialogCopyField": "Copiar d’una altra data",
+ "@editEntryDateDialogCopyField": {},
+ "editEntryDateDialogExtractFromTitle": "Extreu del títol",
+ "@editEntryDateDialogExtractFromTitle": {},
+ "editEntryDateDialogShift": "Desplaça",
+ "@editEntryDateDialogShift": {},
+ "editEntryDateDialogSourceFileModifiedDate": "Data del fitxer modificat",
+ "@editEntryDateDialogSourceFileModifiedDate": {},
+ "durationDialogMinutes": "Minuts",
+ "@durationDialogMinutes": {},
+ "editEntryLocationDialogTitle": "Localització",
+ "@editEntryLocationDialogTitle": {},
+ "editEntryLocationDialogSetCustom": "Defineix localització personalitzada",
+ "@editEntryLocationDialogSetCustom": {},
+ "editEntryLocationDialogChooseOnMap": "Escull al mapa",
+ "@editEntryLocationDialogChooseOnMap": {},
+ "editEntryLocationDialogLongitude": "Longitud",
+ "@editEntryLocationDialogLongitude": {},
+ "locationPickerUseThisLocationButton": "Utilitza aquesta localització",
+ "@locationPickerUseThisLocationButton": {},
+ "removeEntryMetadataDialogMore": "Més",
+ "@removeEntryMetadataDialogMore": {},
+ "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Es necessita XMP per reproduir el vídeo dins una foto en moviment.\n\nEstàs segur que ho vols esborrar?",
+ "@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
+ "videoSpeedDialogLabel": "Velocitat de reproducció",
+ "@videoSpeedDialogLabel": {},
+ "videoStreamSelectionDialogAudio": "Àudio",
+ "@videoStreamSelectionDialogAudio": {},
+ "videoStreamSelectionDialogText": "Subtítols",
+ "@videoStreamSelectionDialogText": {},
+ "videoStreamSelectionDialogOff": "Desactivat",
+ "@videoStreamSelectionDialogOff": {},
+ "genericSuccessFeedback": "Fet!",
+ "@genericSuccessFeedback": {},
+ "genericFailureFeedback": "Fallit",
+ "@genericFailureFeedback": {},
+ "tooManyItemsErrorDialogMessage": "Torna-ho a provar amb menys elements.",
+ "@tooManyItemsErrorDialogMessage": {},
+ "menuActionSelectAll": "Selecciona tots",
+ "@menuActionSelectAll": {},
+ "menuActionSelectNone": "Selecciona’n cap",
+ "@menuActionSelectNone": {},
+ "menuActionSlideshow": "Presentació de diapositives",
+ "@menuActionSlideshow": {},
+ "viewDialogSortSectionTitle": "Ordena",
+ "@viewDialogSortSectionTitle": {},
+ "viewDialogLayoutSectionTitle": "Disposició",
+ "@viewDialogLayoutSectionTitle": {},
+ "tileLayoutMosaic": "Mosaic",
+ "@tileLayoutMosaic": {},
+ "tileLayoutGrid": "Quadrícula",
+ "@tileLayoutGrid": {},
+ "tileLayoutList": "Llista",
+ "@tileLayoutList": {},
+ "castDialogTitle": "Dispositius d’emissió",
+ "@castDialogTitle": {},
+ "coverDialogTabCover": "Coberta",
+ "@coverDialogTabCover": {},
+ "coverDialogTabApp": "Aplicació",
+ "@coverDialogTabApp": {},
+ "appPickDialogTitle": "Tria l’aplicació",
+ "@appPickDialogTitle": {},
+ "aboutPageTitle": "Sobre",
+ "@aboutPageTitle": {},
+ "aboutLinkLicense": "Llicència",
+ "@aboutLinkLicense": {},
+ "aboutBugSectionTitle": "Informe d’error",
+ "@aboutBugSectionTitle": {},
+ "aboutBugSaveLogInstruction": "Desa els registres de l’aplicació en un fitxer",
+ "@aboutBugSaveLogInstruction": {},
+ "aboutBugCopyInfoInstruction": "Copia la informació del sistema",
+ "@aboutBugCopyInfoInstruction": {},
+ "aboutBugCopyInfoButton": "Copia",
+ "@aboutBugCopyInfoButton": {},
+ "aboutBugReportInstruction": "Reporta a GitHub amb els logs i la informació del sistema",
+ "@aboutBugReportInstruction": {},
+ "aboutBugReportButton": "Informe",
+ "@aboutBugReportButton": {},
+ "aboutDataUsageSectionTitle": "Ús de dades",
+ "@aboutDataUsageSectionTitle": {},
+ "aboutLinkPolicy": "Política de Privacitat",
+ "@aboutLinkPolicy": {},
+ "aboutDataUsageCache": "Cache",
+ "@aboutDataUsageCache": {},
+ "aboutDataUsageDatabase": "Base de dades",
+ "@aboutDataUsageDatabase": {},
+ "aboutDataUsageInternal": "Intern",
+ "@aboutDataUsageInternal": {},
+ "aboutDataUsageExternal": "Extern",
+ "@aboutDataUsageExternal": {},
+ "aboutDataUsageClearCache": "Netejar Cache",
+ "@aboutDataUsageClearCache": {},
+ "aboutCreditsWorldAtlas1": "Aquesta aplicació utilitza el format de fitxers TopoJSON",
+ "@aboutCreditsWorldAtlas1": {},
+ "aboutCreditsWorldAtlas2": "sota llicència ISC.",
+ "@aboutCreditsWorldAtlas2": {},
+ "aboutTranslatorsSectionTitle": "Traductors",
+ "@aboutTranslatorsSectionTitle": {},
+ "aboutLicensesSectionTitle": "Llicències de codi obert",
+ "@aboutLicensesSectionTitle": {},
+ "aboutLicensesBanner": "Aquesta aplicació utilitza els següents paquets i biblioteques de codi obert.",
+ "@aboutLicensesBanner": {},
+ "aboutLicensesAndroidLibrariesSectionTitle": "Llibreries Android",
+ "@aboutLicensesAndroidLibrariesSectionTitle": {},
+ "aboutLicensesFlutterPluginsSectionTitle": "Plugins de Flutter",
+ "@aboutLicensesFlutterPluginsSectionTitle": {},
+ "aboutLicensesDartPackagesSectionTitle": "Paquets de Dart",
+ "@aboutLicensesDartPackagesSectionTitle": {},
+ "aboutLicensesShowAllButtonLabel": "Mostra Totes les Llicències",
+ "@aboutLicensesShowAllButtonLabel": {},
+ "policyPageTitle": "Política de Privacitat",
+ "@policyPageTitle": {},
+ "collectionPageTitle": "Coŀlecció",
+ "@collectionPageTitle": {},
+ "timeSeconds": "{seconds, plural, =1{1 segon} other{{seconds} segons}}",
+ "@timeSeconds": {
+ "placeholders": {
+ "seconds": {}
+ }
+ },
+ "timeMinutes": "{minutes, plural, =1{1 minut} other{{minutes} minuts}}",
+ "@timeMinutes": {
+ "placeholders": {
+ "minutes": {}
+ }
+ },
+ "timeDays": "{days, plural, =1{1 dia} other{{days} dies}}",
+ "@timeDays": {
+ "placeholders": {
+ "days": {}
+ }
+ },
+ "focalLength": "{length} mm",
+ "@focalLength": {
+ "placeholders": {
+ "length": {
+ "type": "String",
+ "example": "5.4"
+ }
+ }
+ },
+ "deleteButtonLabel": "SUPRIMEIX",
+ "@deleteButtonLabel": {},
+ "applyButtonLabel": "APLICAR",
+ "@applyButtonLabel": {},
+ "nextButtonLabel": "SEGÜENT",
+ "@nextButtonLabel": {},
+ "showButtonLabel": "MOSTRAR",
+ "@showButtonLabel": {},
+ "hideButtonLabel": "AMAGAR",
+ "@hideButtonLabel": {},
+ "continueButtonLabel": "CONTINUAR",
+ "@continueButtonLabel": {},
+ "saveCopyButtonLabel": "GUARDAR CÒPIA",
+ "@saveCopyButtonLabel": {},
+ "applyTooltip": "Aplicar",
+ "@applyTooltip": {},
+ "clearTooltip": "Netejar",
+ "@clearTooltip": {},
+ "previousTooltip": "Anterior",
+ "@previousTooltip": {},
+ "nextTooltip": "Següent",
+ "@nextTooltip": {},
+ "hideTooltip": "Amagar",
+ "@hideTooltip": {},
+ "showTooltip": "Mostrar",
+ "@showTooltip": {},
+ "chipActionFilterOut": "Filtrar",
+ "@chipActionFilterOut": {},
+ "chipActionGoToCountryPage": "Mostra als Països",
+ "@chipActionGoToCountryPage": {},
+ "chipActionFilterIn": "Filtrar",
+ "@chipActionFilterIn": {},
+ "chipActionHide": "Amagar",
+ "@chipActionHide": {},
+ "entryActionDelete": "Esborrar",
+ "@entryActionDelete": {},
+ "entryActionExport": "Exportar",
+ "@entryActionExport": {},
+ "chipActionConfigureVault": "Configurar caixa forta",
+ "@chipActionConfigureVault": {},
+ "entryActionRotateCCW": "Girar en sentit antihorari",
+ "@entryActionRotateCCW": {},
+ "entryActionShowGeoTiffOnMap": "Mostra com a mapa superposat",
+ "@entryActionShowGeoTiffOnMap": {},
+ "entryActionConvertMotionPhotoToStillImage": "Convertir a imatge fixa",
+ "@entryActionConvertMotionPhotoToStillImage": {},
+ "entryActionRotateCW": "Girar en sentit horari",
+ "@entryActionRotateCW": {},
+ "entryActionOpen": "Obrir amb",
+ "@entryActionOpen": {},
+ "videoActionMute": "Silencia",
+ "@videoActionMute": {},
+ "videoActionPause": "Pausa",
+ "@videoActionPause": {},
+ "entryActionSetAs": "Definir com",
+ "@entryActionSetAs": {},
+ "viewerActionLock": "Bloquejar visor",
+ "@viewerActionLock": {},
+ "slideshowActionResume": "Reprèn",
+ "@slideshowActionResume": {},
+ "slideshowActionShowInCollection": "Mostrar a Coŀlecció",
+ "@slideshowActionShowInCollection": {},
+ "entryInfoActionEditDate": "Edita la data i l’hora",
+ "@entryInfoActionEditDate": {},
+ "entryInfoActionEditLocation": "Editar localització",
+ "@entryInfoActionEditLocation": {},
+ "entryInfoActionEditRating": "Editar valoració",
+ "@entryInfoActionEditRating": {},
+ "entryInfoActionEditTags": "Editar etiquetes",
+ "@entryInfoActionEditTags": {},
+ "entryInfoActionRemoveMetadata": "Esborrar metadades",
+ "@entryInfoActionRemoveMetadata": {},
+ "entryInfoActionEditTitleDescription": "Editar títol i descripció",
+ "@entryInfoActionEditTitleDescription": {},
+ "entryInfoActionExportMetadata": "Exportar metadades",
+ "@entryInfoActionExportMetadata": {},
+ "unsupportedTypeDialogMessage": "{count, plural, =1{Aquesta operació no és compatible amb elements del següent format: {types}.} other{Aquesta operació no és compatible amb elements dels següents formats: {types}.}}",
+ "@unsupportedTypeDialogMessage": {
+ "placeholders": {
+ "count": {},
+ "types": {
+ "type": "String",
+ "example": "GIF, TIFF, MP4",
+ "description": "a list of unsupported types"
+ }
+ }
+ },
+ "nameConflictDialogMultipleSourceMessage": "Alguns fitxers tenen el mateix nom.",
+ "@nameConflictDialogMultipleSourceMessage": {},
+ "addShortcutDialogLabel": "Etiqueta de la drecera",
+ "@addShortcutDialogLabel": {},
+ "noMatchingAppDialogMessage": "No hi ha aplicacions que puguin gestionar-ho.",
+ "@noMatchingAppDialogMessage": {},
+ "binEntriesConfirmationDialogMessage": "{count, plural, =1{Moure aquest element a la paperera de reciclatge?} other{Moure aquests {count} elements a la paperera de reciclatge?}}",
+ "@binEntriesConfirmationDialogMessage": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "newAlbumDialogNameLabelAlreadyExistsHelper": "El directori ja existeix",
+ "@newAlbumDialogNameLabelAlreadyExistsHelper": {},
+ "newVaultWarningDialogMessage": "Els elements en caixes fortes només son disponibles des d’aquesta aplicació.\n\nSi desinstaŀles aquesta aplicació o en borres les dades, perdràs aquests elements.",
+ "@newVaultWarningDialogMessage": {},
+ "vaultDialogLockModeWhenScreenOff": "Bloqueja quan la pantalla s’apagui",
+ "@vaultDialogLockModeWhenScreenOff": {},
+ "patternDialogConfirm": "Confirma patró",
+ "@patternDialogConfirm": {},
+ "pinDialogEnter": "Introdueix PIN",
+ "@pinDialogEnter": {},
+ "pinDialogConfirm": "Confirma PIN",
+ "@pinDialogConfirm": {},
+ "authenticateToConfigureVault": "Autentifica la configuració de la caixa forta",
+ "@authenticateToConfigureVault": {},
+ "renameEntrySetPagePatternFieldLabel": "Patró de nomenament",
+ "@renameEntrySetPagePatternFieldLabel": {},
+ "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Eliminar aquest àlbum i l’element que conté?} other{Eliminar aquest àlbum i els {count} elements que conté?}}",
+ "@deleteSingleAlbumConfirmationDialogMessage": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "vaultBinUsageDialogMessage": "Algunes caixes fortes fan servir la paperera de reciclatge.",
+ "@vaultBinUsageDialogMessage": {},
+ "renameEntrySetPageTitle": "Canviar nom",
+ "@renameEntrySetPageTitle": {},
+ "renameEntrySetPageInsertTooltip": "Insereix camp",
+ "@renameEntrySetPageInsertTooltip": {},
+ "renameEntrySetPagePreviewSectionTitle": "Previsualitza",
+ "@renameEntrySetPagePreviewSectionTitle": {},
+ "exportEntryDialogFormat": "Format:",
+ "@exportEntryDialogFormat": {},
+ "exportEntryDialogWidth": "Amplada",
+ "@exportEntryDialogWidth": {},
+ "exportEntryDialogHeight": "Alçada",
+ "@exportEntryDialogHeight": {},
+ "exportEntryDialogWriteMetadata": "Escriu metadades",
+ "@exportEntryDialogWriteMetadata": {},
+ "renameEntryDialogLabel": "Nom nou",
+ "@renameEntryDialogLabel": {},
+ "editEntryDateDialogTitle": "Data i Hora",
+ "@editEntryDateDialogTitle": {},
+ "durationDialogHours": "Hores",
+ "@durationDialogHours": {},
+ "durationDialogSeconds": "Segons",
+ "@durationDialogSeconds": {},
+ "editEntryRatingDialogTitle": "Valoració",
+ "@editEntryRatingDialogTitle": {},
+ "editEntryLocationDialogLatitude": "Latitud",
+ "@editEntryLocationDialogLatitude": {},
+ "removeEntryMetadataDialogTitle": "Esborra metadades",
+ "@removeEntryMetadataDialogTitle": {},
+ "genericDangerWarningDialogMessage": "Estàs segur?",
+ "@genericDangerWarningDialogMessage": {},
+ "videoStreamSelectionDialogVideo": "Vídeo",
+ "@videoStreamSelectionDialogVideo": {},
+ "videoStreamSelectionDialogTrack": "Pista",
+ "@videoStreamSelectionDialogTrack": {},
+ "videoStreamSelectionDialogNoSelection": "No hi ha altres pistes.",
+ "@videoStreamSelectionDialogNoSelection": {},
+ "menuActionConfigureView": "Vista",
+ "@menuActionConfigureView": {},
+ "menuActionSelect": "Selecciona",
+ "@menuActionSelect": {},
+ "menuActionMap": "Mapa",
+ "@menuActionMap": {},
+ "viewDialogGroupSectionTitle": "Agrupa",
+ "@viewDialogGroupSectionTitle": {},
+ "menuActionStats": "Estadístiques",
+ "@menuActionStats": {},
+ "viewDialogReverseSortOrder": "Inverteix l’ordre",
+ "@viewDialogReverseSortOrder": {},
+ "coverDialogTabColor": "Color",
+ "@coverDialogTabColor": {},
+ "appPickDialogNone": "Cap",
+ "@appPickDialogNone": {},
+ "aboutLicensesFlutterPackagesSectionTitle": "Paquets de Flutter",
+ "@aboutLicensesFlutterPackagesSectionTitle": {},
+ "aboutDataUsageData": "Dades",
+ "@aboutDataUsageData": {},
+ "aboutDataUsageMisc": "Misc",
+ "@aboutDataUsageMisc": {},
+ "aboutCreditsSectionTitle": "Crèdits",
+ "@aboutCreditsSectionTitle": {},
+ "filterTaggedLabel": "Etiquetat",
+ "@filterTaggedLabel": {},
+ "albumTierApps": "Aplicacions",
+ "@albumTierApps": {},
+ "coordinateFormatDms": "DMS",
+ "@coordinateFormatDms": {},
+ "filterNoTagLabel": "Sense etiqueta",
+ "@filterNoTagLabel": {},
+ "albumTierVaults": "Caixes fortes",
+ "@albumTierVaults": {},
+ "coordinateFormatDecimal": "Graus decimals",
+ "@coordinateFormatDecimal": {},
+ "albumTierRegular": "Altres",
+ "@albumTierRegular": {},
+ "filterRatingRejectedLabel": "Rebutjat",
+ "@filterRatingRejectedLabel": {},
+ "vaultDialogLockTypeLabel": "Tipus de bloqueig",
+ "@vaultDialogLockTypeLabel": {},
+ "filterTypeAnimatedLabel": "Animat",
+ "@filterTypeAnimatedLabel": {},
+ "filterTypeRawLabel": "Raw",
+ "@filterTypeRawLabel": {},
+ "filterTypeSphericalVideoLabel": "360° Vídeo",
+ "@filterTypeSphericalVideoLabel": {},
+ "filterMimeImageLabel": "Imatge",
+ "@filterMimeImageLabel": {},
+ "mapStyleHuaweiNormal": "Petal Maps",
+ "@mapStyleHuaweiNormal": {},
+ "filterMimeVideoLabel": "Vídeo",
+ "@filterMimeVideoLabel": {},
+ "maxBrightnessAlways": "Sempre",
+ "@maxBrightnessAlways": {},
+ "accessibilityAnimationsKeep": "Mantenir efectes de pantalla",
+ "@accessibilityAnimationsKeep": {},
+ "nameConflictStrategyReplace": "Substituir",
+ "@nameConflictStrategyReplace": {},
+ "overlayHistogramLuminance": "Lluminància",
+ "@overlayHistogramLuminance": {},
+ "videoControlsPlayOutside": "Obrir amb un altre reproductor",
+ "@videoControlsPlayOutside": {},
+ "videoLoopModeNever": "Mai",
+ "@videoLoopModeNever": {},
+ "videoLoopModeShortOnly": "Només vídeos curts",
+ "@videoLoopModeShortOnly": {},
+ "videoPlaybackSkip": "Saltar",
+ "@videoPlaybackSkip": {},
+ "videoResumptionModeNever": "Mai",
+ "@videoResumptionModeNever": {},
+ "nameConflictStrategySkip": "Saltar",
+ "@nameConflictStrategySkip": {},
+ "viewerTransitionSlide": "Lliscar",
+ "@viewerTransitionSlide": {},
+ "viewerTransitionFade": "Esvair",
+ "@viewerTransitionFade": {},
+ "wallpaperTargetLock": "Pàgina de bloqueig",
+ "@wallpaperTargetLock": {},
+ "wallpaperTargetHomeLock": "Pàgina principal i de bloqueig",
+ "@wallpaperTargetHomeLock": {},
+ "widgetOpenPageHome": "Obrir casa",
+ "@widgetOpenPageHome": {},
+ "widgetOpenPageViewer": "Obrir visor",
+ "@widgetOpenPageViewer": {},
+ "widgetTapUpdateWidget": "Actualitzar giny",
+ "@widgetTapUpdateWidget": {},
+ "restrictedAccessDialogMessage": "Aquesta aplicació no té permís per modificar arxius de {directory} a «{volume}».\n\nSi us plau, feu servir un gestor d’arxius o l’aplicació de galeria preinstaŀlada per moure els elements a un altre directori.",
+ "@restrictedAccessDialogMessage": {
+ "placeholders": {
+ "directory": {
+ "type": "String",
+ "description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`"
+ },
+ "volume": {
+ "type": "String",
+ "example": "SD card",
+ "description": "the name of a storage volume"
+ }
+ }
+ },
+ "notEnoughSpaceDialogMessage": "Aquesta operació necessita {neededSize} d’espai lliure a «{volume}» per completar-se, però només hi ha {freeSize} lliure.",
+ "@notEnoughSpaceDialogMessage": {
+ "placeholders": {
+ "neededSize": {
+ "type": "String",
+ "example": "314 MB"
+ },
+ "freeSize": {
+ "type": "String",
+ "example": "123 MB"
+ },
+ "volume": {
+ "type": "String",
+ "example": "SD card",
+ "description": "the name of a storage volume"
+ }
+ }
+ },
+ "subtitlePositionBottom": "A baix",
+ "@subtitlePositionBottom": {},
+ "videoPlaybackMuted": "Reproduir sense so",
+ "@videoPlaybackMuted": {},
+ "videoPlaybackWithSound": "Reproduir amb so",
+ "@videoPlaybackWithSound": {},
+ "videoResumptionModeAlways": "Sempre",
+ "@videoResumptionModeAlways": {},
+ "viewerTransitionParallax": "Paraŀlaxi",
+ "@viewerTransitionParallax": {},
+ "widgetOpenPageCollection": "Obrir coŀlecció",
+ "@widgetOpenPageCollection": {},
+ "themeBrightnessLight": "Llum",
+ "@themeBrightnessLight": {},
+ "videoLoopModeAlways": "Sempre",
+ "@videoLoopModeAlways": {},
+ "themeBrightnessDark": "Fosc",
+ "@themeBrightnessDark": {},
+ "themeBrightnessBlack": "Negre",
+ "@themeBrightnessBlack": {},
+ "entryActionCast": "Transmetre",
+ "@entryActionCast": {},
+ "collectionPickPageTitle": "Escollir",
+ "@collectionPickPageTitle": {},
+ "collectionSelectPageTitle": "Escollir elements",
+ "@collectionSelectPageTitle": {},
+ "collectionActionShowTitleSearch": "Mostra filtres de títol",
+ "@collectionActionShowTitleSearch": {},
+ "collectionActionHideTitleSearch": "Amaga filtres de títol",
+ "@collectionActionHideTitleSearch": {},
+ "collectionActionAddShortcut": "Afegeix drecera",
+ "@collectionActionAddShortcut": {},
+ "collectionActionSetHome": "Defineix com inici",
+ "@collectionActionSetHome": {},
+ "collectionActionEmptyBin": "Buidar paperera",
+ "@collectionActionEmptyBin": {},
+ "collectionActionCopy": "Copiar a àlbum",
+ "@collectionActionCopy": {},
+ "collectionActionMove": "Moure a àlbum",
+ "@collectionActionMove": {},
+ "collectionActionRescan": "Tornar a buscar",
+ "@collectionActionRescan": {},
+ "collectionActionEdit": "Editar",
+ "@collectionActionEdit": {},
+ "collectionSearchTitlesHintText": "Buscar títols",
+ "@collectionSearchTitlesHintText": {},
+ "collectionGroupAlbum": "Per àlbum",
+ "@collectionGroupAlbum": {},
+ "collectionGroupMonth": "Per mes",
+ "@collectionGroupMonth": {},
+ "collectionGroupNone": "No per grup",
+ "@collectionGroupNone": {},
+ "collectionGroupDay": "Per dia",
+ "@collectionGroupDay": {},
+ "dateToday": "Avui",
+ "@dateToday": {},
+ "collectionDeleteFailureFeedback": "{count, plural, =1{Error en esborrar 1 element} other{Error en esborrar {count} elements}}",
+ "@collectionDeleteFailureFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionCopyFailureFeedback": "{count, plural, =1{Error en copiar 1 element} other{Error en copiar {count} elements}}",
+ "@collectionCopyFailureFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionRenameFailureFeedback": "{count, plural, =1{Error en canviar el nom a 1 element} other{Error en canviar el nom a {count} elements}}",
+ "@collectionRenameFailureFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionEditFailureFeedback": "{count, plural, =1{Error en editar 1 element} other{Error en editar {count} elements}}",
+ "@collectionEditFailureFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionCopySuccessFeedback": "{count, plural, =1{Un element copiat} other{{count} elements copiats}}",
+ "@collectionCopySuccessFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionMoveSuccessFeedback": "{count, plural, =1{Un element mogut} other{{count} elements moguts}}",
+ "@collectionMoveSuccessFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionEmptyFavourites": "Sense preferits",
+ "@collectionEmptyFavourites": {},
+ "collectionEmptyImages": "Sense imatges",
+ "@collectionEmptyImages": {},
+ "collectionDeselectSectionTooltip": "Desseleccionar secció",
+ "@collectionDeselectSectionTooltip": {},
+ "drawerCollectionMotionPhotos": "Fotos en moviment",
+ "@drawerCollectionMotionPhotos": {},
+ "drawerCollectionPanoramas": "Panoràmiques",
+ "@drawerCollectionPanoramas": {},
+ "drawerCollectionRaws": "Fotos Raw",
+ "@drawerCollectionRaws": {},
+ "drawerCollectionSphericalVideos": "Vídeos 360º",
+ "@drawerCollectionSphericalVideos": {},
+ "drawerAlbumPage": "Àlbums",
+ "@drawerAlbumPage": {},
+ "drawerCountryPage": "Països",
+ "@drawerCountryPage": {},
+ "drawerPlacePage": "Llocs",
+ "@drawerPlacePage": {},
+ "drawerTagPage": "Etiquetes",
+ "@drawerTagPage": {},
+ "sortByDate": "Per data",
+ "@sortByDate": {},
+ "sortByName": "Per nom",
+ "@sortByName": {},
+ "sortByItemCount": "Per nombre d’elements",
+ "@sortByItemCount": {},
+ "sortBySize": "Per mida",
+ "@sortBySize": {},
+ "sortByAlbumFileName": "Per àlbum i nom d’arxiu",
+ "@sortByAlbumFileName": {},
+ "sortByRating": "Per valoració",
+ "@sortByRating": {},
+ "sortOrderNewestFirst": "Primer el més nou",
+ "@sortOrderNewestFirst": {},
+ "sortOrderOldestFirst": "Primer el més antic",
+ "@sortOrderOldestFirst": {},
+ "sortOrderAtoZ": "De A a Z",
+ "@sortOrderAtoZ": {},
+ "sortOrderZtoA": "De Z a A",
+ "@sortOrderZtoA": {},
+ "sortOrderHighestFirst": "Primer el més alt",
+ "@sortOrderHighestFirst": {},
+ "sortOrderLowestFirst": "Primer el més baix",
+ "@sortOrderLowestFirst": {},
+ "sortOrderLargestFirst": "Primer el més gran",
+ "@sortOrderLargestFirst": {},
+ "sortOrderSmallestFirst": "Primer el més petit",
+ "@sortOrderSmallestFirst": {},
+ "albumGroupTier": "Per nivell",
+ "@albumGroupTier": {},
+ "albumGroupType": "Per tipus",
+ "@albumGroupType": {},
+ "albumGroupVolume": "Per volum d’emmagatzematge",
+ "@albumGroupVolume": {},
+ "albumGroupNone": "No agrupar",
+ "@albumGroupNone": {},
+ "albumMimeTypeMixed": "Barrejat",
+ "@albumMimeTypeMixed": {},
+ "albumPickPageTitleCopy": "Copiar a Àlbum",
+ "@albumPickPageTitleCopy": {},
+ "albumPickPageTitleExport": "Exportar a Àlbum",
+ "@albumPickPageTitleExport": {},
+ "albumPickPageTitleMove": "Moure a Àlbum",
+ "@albumPickPageTitleMove": {},
+ "albumPickPageTitlePick": "Escollir Àlbum",
+ "@albumPickPageTitlePick": {},
+ "albumDownload": "Baixades",
+ "@albumDownload": {},
+ "albumScreenshots": "Captures de pantalla",
+ "@albumScreenshots": {},
+ "albumScreenRecordings": "Gravacions de pantalla",
+ "@albumScreenRecordings": {},
+ "albumVideoCaptures": "Captures de Vídeo",
+ "@albumVideoCaptures": {},
+ "albumPageTitle": "Àlbums",
+ "@albumPageTitle": {},
+ "createAlbumButtonLabel": "CREAR",
+ "@createAlbumButtonLabel": {},
+ "countryPageTitle": "Països",
+ "@countryPageTitle": {},
+ "statePageTitle": "Estats",
+ "@statePageTitle": {},
+ "stateEmpty": "Sense estats",
+ "@stateEmpty": {},
+ "placePageTitle": "Llocs",
+ "@placePageTitle": {},
+ "placeEmpty": "Sense llocs",
+ "@placeEmpty": {},
+ "tagPageTitle": "Etiquetes",
+ "@tagPageTitle": {},
+ "tagEmpty": "Sense etiquetes",
+ "@tagEmpty": {},
+ "binPageTitle": "Paperera de Reciclatge",
+ "@binPageTitle": {},
+ "searchCollectionFieldHint": "Buscar a coŀlecció",
+ "@searchCollectionFieldHint": {},
+ "searchRecentSectionTitle": "Recent",
+ "@searchRecentSectionTitle": {},
+ "settingsNavigationSectionTitle": "Navegació",
+ "@settingsNavigationSectionTitle": {},
+ "settingsHomeTile": "Inici",
+ "@settingsHomeTile": {},
+ "settingsHomeDialogTitle": "Inici",
+ "@settingsHomeDialogTitle": {},
+ "settingsShowBottomNavigationBar": "Mostra barra de navegació inferior",
+ "@settingsShowBottomNavigationBar": {},
+ "settingsKeepScreenOnTile": "Mantenir pantalla encesa",
+ "@settingsKeepScreenOnTile": {},
+ "settingsKeepScreenOnDialogTitle": "Mantenir la Pantalla Encesa",
+ "@settingsKeepScreenOnDialogTitle": {},
+ "settingsDoubleBackExit": "Toqueu «enrere» dues vegades per sortir",
+ "@settingsDoubleBackExit": {},
+ "settingsConfirmationTile": "Diàlegs de confirmació",
+ "@settingsConfirmationTile": {},
+ "settingsConfirmationDialogTitle": "Diàlegs de Confirmació",
+ "@settingsConfirmationDialogTitle": {},
+ "settingsConfirmationBeforeDeleteItems": "Pregunta abans d’esborrar elements per sempre",
+ "@settingsConfirmationBeforeDeleteItems": {},
+ "settingsConfirmationBeforeMoveUndatedItems": "Pregunta abans de moure elements sense data",
+ "@settingsConfirmationBeforeMoveUndatedItems": {},
+ "settingsConfirmationAfterMoveToBinItems": "Mostra missatge després de moure elements a la paperera de reciclatge",
+ "@settingsConfirmationAfterMoveToBinItems": {},
+ "settingsConfirmationVaultDataLoss": "Mostra l’avís de pèrdua de dades d’una caixa forta",
+ "@settingsConfirmationVaultDataLoss": {},
+ "settingsNavigationDrawerTile": "Menú de navegació",
+ "@settingsNavigationDrawerTile": {},
+ "settingsNavigationDrawerEditorPageTitle": "Menú de Navegació",
+ "@settingsNavigationDrawerEditorPageTitle": {},
+ "settingsThumbnailOverlayTile": "Incrustacions",
+ "@settingsThumbnailOverlayTile": {},
+ "settingsThumbnailOverlayPageTitle": "Incrustacions",
+ "@settingsThumbnailOverlayPageTitle": {},
+ "settingsThumbnailShowHdrIcon": "Mostra icona de HDR",
+ "@settingsThumbnailShowHdrIcon": {},
+ "settingsThumbnailShowFavouriteIcon": "Mostra icona de preferits",
+ "@settingsThumbnailShowFavouriteIcon": {},
+ "settingsThumbnailShowTagIcon": "Mostra icona d’etiqueta",
+ "@settingsThumbnailShowTagIcon": {},
+ "settingsThumbnailShowLocationIcon": "Mostra icona de localització",
+ "@settingsThumbnailShowLocationIcon": {},
+ "settingsThumbnailShowMotionPhotoIcon": "Mostra icona de foto en moviment",
+ "@settingsThumbnailShowMotionPhotoIcon": {},
+ "settingsThumbnailShowRating": "Mostra valoració",
+ "@settingsThumbnailShowRating": {},
+ "settingsThumbnailShowRawIcon": "Mostra icona raw",
+ "@settingsThumbnailShowRawIcon": {},
+ "settingsThumbnailShowVideoDuration": "Mostra duració de vídeo",
+ "@settingsThumbnailShowVideoDuration": {},
+ "settingsCollectionQuickActionsTile": "Accions ràpides",
+ "@settingsCollectionQuickActionsTile": {},
+ "settingsCollectionQuickActionEditorPageTitle": "Accions Ràpides",
+ "@settingsCollectionQuickActionEditorPageTitle": {},
+ "settingsCollectionQuickActionTabSelecting": "Selecció",
+ "@settingsCollectionQuickActionTabSelecting": {},
+ "settingsCollectionBrowsingQuickActionEditorBanner": "Manté premut per moure botons i seleccionar quines accions es mostren en els articles de navegació.",
+ "@settingsCollectionBrowsingQuickActionEditorBanner": {},
+ "settingsCollectionQuickActionTabBrowsing": "Navegació",
+ "@settingsCollectionQuickActionTabBrowsing": {},
+ "settingsCollectionSelectionQuickActionEditorBanner": "Manté premut per moure botons i seleccionar quines accions es mostren en seleccionar elements.",
+ "@settingsCollectionSelectionQuickActionEditorBanner": {},
+ "settingsCollectionBurstPatternsTile": "Patrons de ràfega",
+ "@settingsCollectionBurstPatternsTile": {},
+ "settingsCollectionBurstPatternsNone": "Cap",
+ "@settingsCollectionBurstPatternsNone": {},
+ "settingsViewerGestureSideTapNext": "Toqueu les vores de la pantalla per mostrar l’anterior/el següent element",
+ "@settingsViewerGestureSideTapNext": {},
+ "settingsViewerUseCutout": "Utilitzeu l’àrea de tall",
+ "@settingsViewerUseCutout": {},
+ "settingsViewerMaximumBrightness": "Brillantor màxima",
+ "@settingsViewerMaximumBrightness": {},
+ "settingsMotionPhotoAutoPlay": "Reprodueix automàticament fotos en moviment",
+ "@settingsMotionPhotoAutoPlay": {},
+ "settingsViewerQuickActionsTile": "Accions ràpides",
+ "@settingsViewerQuickActionsTile": {},
+ "settingsViewerQuickActionEditorPageTitle": "Accions Ràpides",
+ "@settingsViewerQuickActionEditorPageTitle": {},
+ "settingsViewerQuickActionEditorBanner": "Manté premut per moure botons i seleccionar quines accions es mostren al visor.",
+ "@settingsViewerQuickActionEditorBanner": {},
+ "settingsViewerQuickActionEditorDisplayedButtonsSectionTitle": "Botons Mostrats",
+ "@settingsViewerQuickActionEditorDisplayedButtonsSectionTitle": {},
+ "settingsViewerShowMinimap": "Mostra mapa en miniatura",
+ "@settingsViewerShowMinimap": {},
+ "settingsViewerShowInformation": "Mostra informació",
+ "@settingsViewerShowInformation": {},
+ "settingsViewerShowInformationSubtitle": "Mostra títol, data, localització, etc.",
+ "@settingsViewerShowInformationSubtitle": {},
+ "settingsViewerShowRatingTags": "Mostra valoració i etiquetes",
+ "@settingsViewerShowRatingTags": {},
+ "settingsViewerShowShootingDetails": "Mostra detalls de la foto",
+ "@settingsViewerShowShootingDetails": {},
+ "settingsViewerShowDescription": "Mostra descripció",
+ "@settingsViewerShowDescription": {},
+ "settingsViewerShowOverlayThumbnails": "Mostra miniatures",
+ "@settingsViewerShowOverlayThumbnails": {},
+ "settingsViewerEnableOverlayBlurEffect": "Efecte borrós",
+ "@settingsViewerEnableOverlayBlurEffect": {},
+ "settingsViewerSlideshowTile": "Presentació de diapositives",
+ "@settingsViewerSlideshowTile": {},
+ "settingsViewerSlideshowPageTitle": "Presentació de diapositives",
+ "@settingsViewerSlideshowPageTitle": {},
+ "settingsSlideshowRepeat": "Repeteix",
+ "@settingsSlideshowRepeat": {},
+ "settingsSlideshowShuffle": "Barreja",
+ "@settingsSlideshowShuffle": {},
+ "settingsSlideshowFillScreen": "Emplenar pantalla",
+ "@settingsSlideshowFillScreen": {},
+ "settingsSlideshowAnimatedZoomEffect": "Efecte de zoom animat",
+ "@settingsSlideshowAnimatedZoomEffect": {},
+ "settingsSlideshowTransitionTile": "Transició",
+ "@settingsSlideshowTransitionTile": {},
+ "settingsSlideshowIntervalTile": "Interval",
+ "@settingsSlideshowIntervalTile": {},
+ "settingsSlideshowVideoPlaybackTile": "Reproducció de vídeo",
+ "@settingsSlideshowVideoPlaybackTile": {},
+ "settingsSlideshowVideoPlaybackDialogTitle": "Reproducció de Vídeo",
+ "@settingsSlideshowVideoPlaybackDialogTitle": {},
+ "settingsVideoPageTitle": "Configuració de Vídeo",
+ "@settingsVideoPageTitle": {},
+ "settingsVideoSectionTitle": "Vídeo",
+ "@settingsVideoSectionTitle": {},
+ "settingsVideoShowVideos": "Mostra vídeos",
+ "@settingsVideoShowVideos": {},
+ "settingsVideoPlaybackTile": "Reproducció",
+ "@settingsVideoPlaybackTile": {},
+ "settingsVideoAutoPlay": "Reproducció automàtica",
+ "@settingsVideoAutoPlay": {},
+ "settingsVideoLoopModeTile": "Mode en bucle",
+ "@settingsVideoLoopModeTile": {},
+ "settingsVideoLoopModeDialogTitle": "Mode en Bucle",
+ "@settingsVideoLoopModeDialogTitle": {},
+ "settingsVideoResumptionModeTile": "Reprèn reproducció",
+ "@settingsVideoResumptionModeTile": {},
+ "settingsVideoResumptionModeDialogTitle": "Reprèn Reproducció",
+ "@settingsVideoResumptionModeDialogTitle": {},
+ "settingsVideoBackgroundMode": "Mode de fons",
+ "@settingsVideoBackgroundMode": {},
+ "settingsVideoBackgroundModeDialogTitle": "Mode de Fons",
+ "@settingsVideoBackgroundModeDialogTitle": {},
+ "settingsVideoControlsTile": "Controls",
+ "@settingsVideoControlsTile": {},
+ "settingsVideoButtonsTile": "Botons",
+ "@settingsVideoButtonsTile": {},
+ "settingsVideoGestureDoubleTapTogglePlay": "Fes doble toc per reproduir/aturar",
+ "@settingsVideoGestureDoubleTapTogglePlay": {},
+ "settingsVideoGestureSideDoubleTapSeek": "Fes doble toc a la vora de la pantalla per retrocedir/avançar",
+ "@settingsVideoGestureSideDoubleTapSeek": {},
+ "settingsVideoGestureVerticalDragBrightnessVolume": "Llisca cap amunt o cap avall per ajustar la brillantor/volum",
+ "@settingsVideoGestureVerticalDragBrightnessVolume": {},
+ "settingsSubtitleThemeTile": "Subtítols",
+ "@settingsSubtitleThemeTile": {},
+ "settingsSubtitleThemePageTitle": "Subtítols",
+ "@settingsSubtitleThemePageTitle": {},
+ "settingsSubtitleThemeSample": "Això és un exemple.",
+ "@settingsSubtitleThemeSample": {},
+ "settingsSubtitleThemeTextAlignmentTile": "Ajustament de text",
+ "@settingsSubtitleThemeTextAlignmentTile": {},
+ "settingsSubtitleThemeTextPositionTile": "Posició de text",
+ "@settingsSubtitleThemeTextPositionTile": {},
+ "settingsSubtitleThemeTextPositionDialogTitle": "Posició de Text",
+ "@settingsSubtitleThemeTextPositionDialogTitle": {},
+ "settingsSubtitleThemeTextSize": "Mida del text",
+ "@settingsSubtitleThemeTextSize": {},
+ "settingsSubtitleThemeShowOutline": "Mostra el contorn i l’ombra",
+ "@settingsSubtitleThemeShowOutline": {},
+ "settingsSubtitleThemeTextColor": "Color del text",
+ "@settingsSubtitleThemeTextColor": {},
+ "settingsSubtitleThemeTextOpacity": "Opacitat del text",
+ "@settingsSubtitleThemeTextOpacity": {},
+ "settingsSubtitleThemeBackgroundColor": "Color de fons",
+ "@settingsSubtitleThemeBackgroundColor": {},
+ "settingsSubtitleThemeTextAlignmentLeft": "Esquerra",
+ "@settingsSubtitleThemeTextAlignmentLeft": {},
+ "settingsSubtitleThemeTextAlignmentCenter": "Centre",
+ "@settingsSubtitleThemeTextAlignmentCenter": {},
+ "settingsAllowInstalledAppAccess": "Permet accés a la llista d’aplicacions",
+ "@settingsAllowInstalledAppAccess": {},
+ "settingsAllowErrorReporting": "Permet enviar errors anònims",
+ "@settingsAllowErrorReporting": {},
+ "settingsSaveSearchHistory": "Guarda historial de cerca",
+ "@settingsSaveSearchHistory": {},
+ "settingsEnableBin": "Utilitzar paperera de reciclatge",
+ "@settingsEnableBin": {},
+ "settingsEnableBinSubtitle": "Manté elements eliminats durant 30 dies",
+ "@settingsEnableBinSubtitle": {},
+ "settingsDisablingBinWarningDialogMessage": "Els elements de la paperera de reciclatge seran eliminats per sempre.",
+ "@settingsDisablingBinWarningDialogMessage": {},
+ "settingsAllowMediaManagement": "Permet la gestió dels mitjans",
+ "@settingsAllowMediaManagement": {},
+ "settingsHiddenItemsTile": "Elements amagats",
+ "@settingsHiddenItemsTile": {},
+ "settingsHiddenItemsPageTitle": "Elements Amagats",
+ "@settingsHiddenItemsPageTitle": {},
+ "settingsHiddenItemsTabFilters": "Filtres Amagats",
+ "@settingsHiddenItemsTabFilters": {},
+ "settingsHiddenFiltersBanner": "Les fotos i els vídeos que coincideixin amb filtres amagats no apareixeran a la teva coŀlecció.",
+ "@settingsHiddenFiltersBanner": {},
+ "addPathTooltip": "Afegeix ruta",
+ "@addPathTooltip": {},
+ "settingsStorageAccessBanner": "Alguns directoris requereixen permís explícit per modificar-hi fitxers. Podeu revisar aquí els directoris als quals heu donat accés anteriorment.",
+ "@settingsStorageAccessBanner": {},
+ "settingsStorageAccessEmpty": "Cap permís d’accés",
+ "@settingsStorageAccessEmpty": {},
+ "settingsStorageAccessRevokeTooltip": "Revocar",
+ "@settingsStorageAccessRevokeTooltip": {},
+ "settingsAccessibilitySectionTitle": "Accessibilitat",
+ "@settingsAccessibilitySectionTitle": {},
+ "settingsRemoveAnimationsTile": "Treure animacions",
+ "@settingsRemoveAnimationsTile": {},
+ "settingsRemoveAnimationsDialogTitle": "Treure Animacions",
+ "@settingsRemoveAnimationsDialogTitle": {},
+ "settingsTimeToTakeActionTile": "Retard per executar una acció",
+ "@settingsTimeToTakeActionTile": {},
+ "settingsAccessibilityShowPinchGestureAlternatives": "Mostra alternatives amb gestos multitàctils",
+ "@settingsAccessibilityShowPinchGestureAlternatives": {},
+ "settingsDisplaySectionTitle": "Pantalla",
+ "@settingsDisplaySectionTitle": {},
+ "settingsThemeBrightnessTile": "Tema",
+ "@settingsThemeBrightnessTile": {},
+ "settingsThemeBrightnessDialogTitle": "Tema",
+ "@settingsThemeBrightnessDialogTitle": {},
+ "settingsThemeColorHighlights": "Colors per ressaltar",
+ "@settingsThemeColorHighlights": {},
+ "settingsThemeEnableDynamicColor": "Color dinàmic",
+ "@settingsThemeEnableDynamicColor": {},
+ "settingsDisplayRefreshRateModeTile": "Taxa de refresc de pantalla",
+ "@settingsDisplayRefreshRateModeTile": {},
+ "settingsDisplayRefreshRateModeDialogTitle": "Taxa de refresc",
+ "@settingsDisplayRefreshRateModeDialogTitle": {},
+ "settingsDisplayUseTvInterface": "Interfície d’Android TV",
+ "@settingsDisplayUseTvInterface": {},
+ "settingsLanguageSectionTitle": "Llengua i Formats",
+ "@settingsLanguageSectionTitle": {},
+ "settingsLanguageTile": "Idioma",
+ "@settingsLanguageTile": {},
+ "settingsLanguagePageTitle": "Idioma",
+ "@settingsLanguagePageTitle": {},
+ "settingsCoordinateFormatTile": "Format de coordenades",
+ "@settingsCoordinateFormatTile": {},
+ "settingsCoordinateFormatDialogTitle": "Format de Coordenades",
+ "@settingsCoordinateFormatDialogTitle": {},
+ "settingsUnitSystemTile": "Unitats",
+ "@settingsUnitSystemTile": {},
+ "settingsUnitSystemDialogTitle": "Unitats",
+ "@settingsUnitSystemDialogTitle": {},
+ "settingsScreenSaverPageTitle": "Protector de Pantalla",
+ "@settingsScreenSaverPageTitle": {},
+ "settingsWidgetPageTitle": "Marc de Foto",
+ "@settingsWidgetPageTitle": {},
+ "settingsWidgetShowOutline": "Contorn",
+ "@settingsWidgetShowOutline": {},
+ "settingsWidgetOpenPage": "En tocar un giny",
+ "@settingsWidgetOpenPage": {},
+ "settingsWidgetDisplayedItem": "Element mostrat",
+ "@settingsWidgetDisplayedItem": {},
+ "settingsCollectionTile": "Coŀlecció",
+ "@settingsCollectionTile": {},
+ "statsPageTitle": "Estadístiques",
+ "@statsPageTitle": {},
+ "statsWithGps": "{count, plural, =1{1 element amb localització} other{{count} elements amb localització}}",
+ "@statsWithGps": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "statsTopStatesSectionTitle": "Estats Principals",
+ "@statsTopStatesSectionTitle": {},
+ "statsTopCountriesSectionTitle": "Països Principals",
+ "@statsTopCountriesSectionTitle": {},
+ "statsTopTagsSectionTitle": "Etiquetes Principals",
+ "@statsTopTagsSectionTitle": {},
+ "statsTopAlbumsSectionTitle": "Àlbums Principals",
+ "@statsTopAlbumsSectionTitle": {},
+ "viewerOpenPanoramaButtonLabel": "OBRIR PANORÀMICA",
+ "@viewerOpenPanoramaButtonLabel": {},
+ "viewerSetWallpaperButtonLabel": "DEFINEIX FONS DE PANTALLA",
+ "@viewerSetWallpaperButtonLabel": {},
+ "viewerErrorUnknown": "Vaja!",
+ "@viewerErrorUnknown": {},
+ "viewerErrorDoesNotExist": "El fitxer ja no existeix.",
+ "@viewerErrorDoesNotExist": {},
+ "viewerInfoPageTitle": "Informació",
+ "@viewerInfoPageTitle": {},
+ "viewerInfoBackToViewerTooltip": "Torna al visor",
+ "@viewerInfoBackToViewerTooltip": {},
+ "viewerInfoLabelDescription": "Descripció",
+ "@viewerInfoLabelDescription": {},
+ "viewerInfoLabelTitle": "Títol",
+ "@viewerInfoLabelTitle": {},
+ "viewerInfoLabelResolution": "Resolució",
+ "@viewerInfoLabelResolution": {},
+ "viewerInfoLabelSize": "Mida",
+ "@viewerInfoLabelSize": {},
+ "viewerInfoLabelUri": "URI",
+ "@viewerInfoLabelUri": {},
+ "viewerInfoLabelPath": "Ruta",
+ "@viewerInfoLabelPath": {},
+ "viewerInfoLabelDuration": "Duració",
+ "@viewerInfoLabelDuration": {},
+ "viewerInfoLabelOwner": "Propietari",
+ "@viewerInfoLabelOwner": {},
+ "viewerInfoLabelCoordinates": "Coordenades",
+ "@viewerInfoLabelCoordinates": {},
+ "viewerInfoLabelAddress": "Adreça",
+ "@viewerInfoLabelAddress": {},
+ "mapStyleDialogTitle": "Estil de mapa",
+ "@mapStyleDialogTitle": {},
+ "mapStyleTooltip": "Selecciona tipus de mapa",
+ "@mapStyleTooltip": {},
+ "mapZoomInTooltip": "Ampliar",
+ "@mapZoomInTooltip": {},
+ "mapZoomOutTooltip": "Allunyar",
+ "@mapZoomOutTooltip": {},
+ "mapPointNorthUpTooltip": "Apuntar el nord cap amunt",
+ "@mapPointNorthUpTooltip": {},
+ "mapAttributionStamen": "Dades de mapa © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuïdors • Rajoles per [Stamen Design](https://stamen.com), [CC BY 3.0](https://creativecommons.org/licenses/by/3.0)",
+ "@mapAttributionStamen": {},
+ "openMapPageTooltip": "Veure a la pàgina de mapa",
+ "@openMapPageTooltip": {},
+ "mapEmptyRegion": "Sense imatges a aquesta regió",
+ "@mapEmptyRegion": {},
+ "viewerInfoOpenEmbeddedFailureFeedback": "Error en extreure les dades incrustades",
+ "@viewerInfoOpenEmbeddedFailureFeedback": {},
+ "viewerInfoOpenLinkText": "Obrir",
+ "@viewerInfoOpenLinkText": {},
+ "viewerInfoViewXmlLinkText": "Veure XML",
+ "@viewerInfoViewXmlLinkText": {},
+ "viewerInfoSearchFieldLabel": "Buscar metadades",
+ "@viewerInfoSearchFieldLabel": {},
+ "viewerInfoSearchEmpty": "Sense claus coincidents",
+ "@viewerInfoSearchEmpty": {},
+ "viewerInfoSearchSuggestionDate": "Data i hora",
+ "@viewerInfoSearchSuggestionDate": {},
+ "viewerInfoSearchSuggestionResolution": "Resolució",
+ "@viewerInfoSearchSuggestionResolution": {},
+ "viewerInfoSearchSuggestionRights": "Drets",
+ "@viewerInfoSearchSuggestionRights": {},
+ "wallpaperUseScrollEffect": "Utilitza l’efecte de desplaçament a la pantalla d’inici",
+ "@wallpaperUseScrollEffect": {},
+ "tagEditorPageNewTagFieldLabel": "Nova etiqueta",
+ "@tagEditorPageNewTagFieldLabel": {},
+ "tagEditorSectionPlaceholders": "Marcadors de posició",
+ "@tagEditorSectionPlaceholders": {},
+ "tagEditorDiscardDialogMessage": "Vols descartar els canvis?",
+ "@tagEditorDiscardDialogMessage": {},
+ "tagPlaceholderCountry": "País",
+ "@tagPlaceholderCountry": {},
+ "panoramaEnableSensorControl": "Activa el control del sensor",
+ "@panoramaEnableSensorControl": {},
+ "panoramaDisableSensorControl": "Desactiva el control del sensor",
+ "@panoramaDisableSensorControl": {},
+ "sourceViewerPageTitle": "Font",
+ "@sourceViewerPageTitle": {},
+ "filePickerShowHiddenFiles": "Mostra arxius amagats",
+ "@filePickerShowHiddenFiles": {},
+ "filePickerDoNotShowHiddenFiles": "No mostris arxius amagats",
+ "@filePickerDoNotShowHiddenFiles": {},
+ "filePickerOpenFrom": "Obrir des de",
+ "@filePickerOpenFrom": {},
+ "filePickerNoItems": "Sense element",
+ "@filePickerNoItems": {},
+ "filePickerUseThisFolder": "Utilitza aquesta carpeta",
+ "@filePickerUseThisFolder": {},
+ "settingsVideoControlsPageTitle": "Controls",
+ "@settingsVideoControlsPageTitle": {},
+ "settingsSubtitleThemeTextAlignmentDialogTitle": "Ajustament de Text",
+ "@settingsSubtitleThemeTextAlignmentDialogTitle": {},
+ "settingsSubtitleThemeBackgroundOpacity": "Opacitat del fons",
+ "@settingsSubtitleThemeBackgroundOpacity": {},
+ "settingsSubtitleThemeTextAlignmentRight": "Dreta",
+ "@settingsSubtitleThemeTextAlignmentRight": {},
+ "settingsPrivacySectionTitle": "Privacitat",
+ "@settingsPrivacySectionTitle": {},
+ "settingsAllowInstalledAppAccessSubtitle": "Utilitzat per millorar la visualització dels àlbums",
+ "@settingsAllowInstalledAppAccessSubtitle": {},
+ "settingsHiddenFiltersEmpty": "Cap filtre amagat",
+ "@settingsHiddenFiltersEmpty": {},
+ "settingsHiddenItemsTabPaths": "Rutes Amagades",
+ "@settingsHiddenItemsTabPaths": {},
+ "settingsHiddenPathsBanner": "Les fotos i vídeos d’aquestes carpetes, o a les seves subcarpetes, no apareixeran a la teva coŀlecció.",
+ "@settingsHiddenPathsBanner": {},
+ "settingsStorageAccessTile": "Accés d’emmagatzematge",
+ "@settingsStorageAccessTile": {},
+ "settingsStorageAccessPageTitle": "Accés d’Emmagatzematge",
+ "@settingsStorageAccessPageTitle": {},
+ "collectionRenameSuccessFeedback": "{count, plural, =1{Nom canviat a 1 element} other{Nom canviat a {count} elements}}",
+ "@collectionRenameSuccessFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionEditSuccessFeedback": "{count, plural, =1{Un element editat} other{{count} elements editats}}",
+ "@collectionEditSuccessFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionEmptyVideos": "Sense vídeos",
+ "@collectionEmptyVideos": {},
+ "collectionEmptyGrantAccessButtonLabel": "Donar accés",
+ "@collectionEmptyGrantAccessButtonLabel": {},
+ "collectionSelectSectionTooltip": "Seleccionar secció",
+ "@collectionSelectSectionTooltip": {},
+ "drawerAboutButton": "Sobre",
+ "@drawerAboutButton": {},
+ "drawerSettingsButton": "Configuració",
+ "@drawerSettingsButton": {},
+ "drawerCollectionAll": "Tota la coŀlecció",
+ "@drawerCollectionAll": {},
+ "drawerCollectionFavourites": "Preferits",
+ "@drawerCollectionFavourites": {},
+ "drawerCollectionImages": "Imatges",
+ "@drawerCollectionImages": {},
+ "drawerCollectionVideos": "Vídeos",
+ "@drawerCollectionVideos": {},
+ "statsTopPlacesSectionTitle": "Llocs Principals",
+ "@statsTopPlacesSectionTitle": {},
+ "viewerInfoUnknown": "desconegut",
+ "@viewerInfoUnknown": {},
+ "drawerCollectionAnimated": "Animacions",
+ "@drawerCollectionAnimated": {},
+ "viewerInfoLabelDate": "Data",
+ "@viewerInfoLabelDate": {},
+ "albumCamera": "Càmera",
+ "@albumCamera": {},
+ "albumEmpty": "Sense àlbums",
+ "@albumEmpty": {},
+ "newFilterBanner": "nou",
+ "@newFilterBanner": {},
+ "countryEmpty": "Sense països",
+ "@countryEmpty": {},
+ "searchDateSectionTitle": "Data",
+ "@searchDateSectionTitle": {},
+ "searchStatesSectionTitle": "Estats",
+ "@searchStatesSectionTitle": {},
+ "searchAlbumsSectionTitle": "Àlbums",
+ "@searchAlbumsSectionTitle": {},
+ "searchPlacesSectionTitle": "Llocs",
+ "@searchPlacesSectionTitle": {},
+ "searchTagsSectionTitle": "Etiquetes",
+ "@searchTagsSectionTitle": {},
+ "searchCountriesSectionTitle": "Països",
+ "@searchCountriesSectionTitle": {},
+ "searchRatingSectionTitle": "Valoracions",
+ "@searchRatingSectionTitle": {},
+ "searchMetadataSectionTitle": "Metadades",
+ "@searchMetadataSectionTitle": {},
+ "settingsPageTitle": "Configuració",
+ "@settingsPageTitle": {},
+ "settingsSystemDefault": "Per defecte al sistema",
+ "@settingsSystemDefault": {},
+ "settingsDefault": "Per defecte",
+ "@settingsDefault": {},
+ "settingsDisabled": "Desactivat",
+ "@settingsDisabled": {},
+ "settingsAskEverytime": "Preguntar cada vegada",
+ "@settingsAskEverytime": {},
+ "settingsModificationWarningDialogMessage": "Altres configuracions es modificaran.",
+ "@settingsModificationWarningDialogMessage": {},
+ "settingsSearchFieldLabel": "Busca configuracions",
+ "@settingsSearchFieldLabel": {},
+ "settingsActionExport": "Exportar",
+ "@settingsActionExport": {},
+ "settingsSearchEmpty": "Cap coincidència",
+ "@settingsSearchEmpty": {},
+ "settingsActionExportDialogTitle": "Exportar",
+ "@settingsActionExportDialogTitle": {},
+ "appExportSettings": "Configuracions",
+ "@appExportSettings": {},
+ "settingsActionImport": "Importar",
+ "@settingsActionImport": {},
+ "settingsActionImportDialogTitle": "Importar",
+ "@settingsActionImportDialogTitle": {},
+ "appExportCovers": "Cobertes",
+ "@appExportCovers": {},
+ "appExportFavourites": "Preferits",
+ "@appExportFavourites": {},
+ "mapAttributionOsmHot": "Dades de mapa © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuïdors • Rajoles de [HOT](https://www.hotosm.org/) • Allotjat a [OSM France](https://openstreetmap.fr/)",
+ "@mapAttributionOsmHot": {},
+ "viewerInfoSearchSuggestionDescription": "Descripció",
+ "@viewerInfoSearchSuggestionDescription": {},
+ "viewerInfoSearchSuggestionDimensions": "Dimensions",
+ "@viewerInfoSearchSuggestionDimensions": {},
+ "tagEditorPageAddTagTooltip": "Afegir etiqueta",
+ "@tagEditorPageAddTagTooltip": {},
+ "tagEditorPageTitle": "Editar Etiquetes",
+ "@tagEditorPageTitle": {},
+ "tagEditorSectionRecent": "Recent",
+ "@tagEditorSectionRecent": {},
+ "tagPlaceholderState": "Estat",
+ "@tagPlaceholderState": {},
+ "tagPlaceholderPlace": "Lloc",
+ "@tagPlaceholderPlace": {},
+ "setHomeCustomCollection": "Coŀlecció personalitzada",
+ "@setHomeCustomCollection": {},
+ "settingsConfirmationBeforeMoveToBinItems": "Pregunta abans de moure elements a la paperera de reciclatge",
+ "@settingsConfirmationBeforeMoveToBinItems": {},
+ "settingsNavigationDrawerBanner": "Mantén premut per moure i reordenar els elements del menú.",
+ "@settingsNavigationDrawerBanner": {},
+ "settingsNavigationDrawerTabTypes": "Tipus",
+ "@settingsNavigationDrawerTabTypes": {},
+ "settingsNavigationDrawerTabAlbums": "Àlbums",
+ "@settingsNavigationDrawerTabAlbums": {},
+ "settingsNavigationDrawerTabPages": "Pàgines",
+ "@settingsNavigationDrawerTabPages": {},
+ "settingsNavigationDrawerAddAlbum": "Afegeix àlbum",
+ "@settingsNavigationDrawerAddAlbum": {},
+ "settingsThumbnailSectionTitle": "Miniatures",
+ "@settingsThumbnailSectionTitle": {},
+ "settingsViewerSectionTitle": "Visor",
+ "@settingsViewerSectionTitle": {},
+ "settingsViewerQuickActionEditorAvailableButtonsSectionTitle": "Botons Disponibles",
+ "@settingsViewerQuickActionEditorAvailableButtonsSectionTitle": {},
+ "settingsViewerQuickActionEmpty": "Sense botons",
+ "@settingsViewerQuickActionEmpty": {},
+ "settingsImageBackground": "Fons d’imatge",
+ "@settingsImageBackground": {},
+ "settingsViewerOverlayPageTitle": "Incrustacions",
+ "@settingsViewerOverlayPageTitle": {},
+ "settingsViewerOverlayTile": "Incrustacions",
+ "@settingsViewerOverlayTile": {},
+ "settingsViewerShowOverlayOnOpening": "Mostra a l’obrir",
+ "@settingsViewerShowOverlayOnOpening": {},
+ "settingsViewerShowHistogram": "Mostra histograma",
+ "@settingsViewerShowHistogram": {},
+ "settingsVideoPlaybackPageTitle": "Reproducció",
+ "@settingsVideoPlaybackPageTitle": {},
+ "settingsVideoEnableHardwareAcceleration": "Acceleració per maquinari",
+ "@settingsVideoEnableHardwareAcceleration": {},
+ "sectionUnknown": "Desconegut",
+ "@sectionUnknown": {},
+ "dateYesterday": "Ahir",
+ "@dateYesterday": {},
+ "dateThisMonth": "Aquest mes",
+ "@dateThisMonth": {},
+ "collectionMoveFailureFeedback": "{count, plural, =1{Error en moure 1 element} other{Error en moure {count} elements}}",
+ "@collectionMoveFailureFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "collectionExportFailureFeedback": "{count, plural, =1{Error en exportar una pàgina} other{Error en exportar {count} pàgines}}",
+ "@collectionExportFailureFeedback": {
+ "placeholders": {
+ "count": {}
+ }
+ }
+}
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index 071513ba8..6e72e81ab 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -68,10 +68,38 @@
"@previousTooltip": {},
"hideTooltip": "छिपाए",
"@hideTooltip": {},
- "cancelTooltip": "कैंसिल",
+ "cancelTooltip": "रद्द करें",
"@cancelTooltip": {},
"changeTooltip": "बदलें",
"@changeTooltip": {},
"showTooltip": "देखें",
- "@showTooltip": {}
+ "@showTooltip": {},
+ "applyTooltip": "लगाए",
+ "@applyTooltip": {},
+ "chipActionGoToPlacePage": "स्थानों में दिखाएं",
+ "@chipActionGoToPlacePage": {},
+ "saveCopyButtonLabel": "सेव कॉपी",
+ "@saveCopyButtonLabel": {},
+ "doubleBackExitMessage": "बाहर जाने के लिए दोबारा \"पीछे\" पर टैप करें",
+ "@doubleBackExitMessage": {},
+ "sourceStateLoading": "लोड हो रहा है",
+ "@sourceStateLoading": {},
+ "chipActionGoToTagPage": "टैग्स में दिखाएं",
+ "@chipActionGoToTagPage": {},
+ "resetTooltip": "रिसेट",
+ "@resetTooltip": {},
+ "saveTooltip": "सेव करें",
+ "@saveTooltip": {},
+ "pickTooltip": "चुनें",
+ "@pickTooltip": {},
+ "doNotAskAgain": "दोबारा मत पूछो",
+ "@doNotAskAgain": {},
+ "chipActionDelete": "मिटाएं",
+ "@chipActionDelete": {},
+ "chipActionGoToAlbumPage": "एल्बम में दिखाए",
+ "@chipActionGoToAlbumPage": {},
+ "chipActionGoToCountryPage": "देशों में दिखाएं",
+ "@chipActionGoToCountryPage": {},
+ "chipActionHide": "छिपाए",
+ "@chipActionHide": {}
}
diff --git a/lib/l10n/app_ro.arb b/lib/l10n/app_ro.arb
index 1980ac953..78a29d2b4 100644
--- a/lib/l10n/app_ro.arb
+++ b/lib/l10n/app_ro.arb
@@ -1450,5 +1450,79 @@
"searchStatesSectionTitle": "State",
"@searchStatesSectionTitle": {},
"statsTopStatesSectionTitle": "Statele de top",
- "@statsTopStatesSectionTitle": {}
+ "@statsTopStatesSectionTitle": {},
+ "overlayHistogramNone": "Nimic",
+ "@overlayHistogramNone": {},
+ "overlayHistogramLuminance": "Luminanță",
+ "@overlayHistogramLuminance": {},
+ "saveCopyButtonLabel": "SALVEAZĂ COPIA",
+ "@saveCopyButtonLabel": {},
+ "applyTooltip": "Aplică",
+ "@applyTooltip": {},
+ "editorTransformCrop": "Decupare",
+ "@editorTransformCrop": {},
+ "cropAspectRatioFree": "Liber",
+ "@cropAspectRatioFree": {},
+ "cropAspectRatioOriginal": "Original",
+ "@cropAspectRatioOriginal": {},
+ "settingsVideoPlaybackPageTitle": "Redare",
+ "@settingsVideoPlaybackPageTitle": {},
+ "settingsAskEverytime": "Întreabă de fiecare dată",
+ "@settingsAskEverytime": {},
+ "aboutDataUsageCache": "Cache",
+ "@aboutDataUsageCache": {},
+ "aboutDataUsageDatabase": "Bază de date",
+ "@aboutDataUsageDatabase": {},
+ "aboutDataUsageMisc": "Diverse",
+ "@aboutDataUsageMisc": {},
+ "settingsVideoPlaybackTile": "Redare",
+ "@settingsVideoPlaybackTile": {},
+ "overlayHistogramRGB": "RGB",
+ "@overlayHistogramRGB": {},
+ "widgetTapUpdateWidget": "Actualizare widget",
+ "@widgetTapUpdateWidget": {},
+ "exportEntryDialogQuality": "Calitate",
+ "@exportEntryDialogQuality": {},
+ "aboutDataUsageSectionTitle": "Utilizare date",
+ "@aboutDataUsageSectionTitle": {},
+ "aboutDataUsageData": "Date",
+ "@aboutDataUsageData": {},
+ "aboutDataUsageInternal": "Intern",
+ "@aboutDataUsageInternal": {},
+ "aboutDataUsageExternal": "Extern",
+ "@aboutDataUsageExternal": {},
+ "collectionActionSetHome": "Setare ca principal",
+ "@collectionActionSetHome": {},
+ "aboutDataUsageClearCache": "Golește memoria cache",
+ "@aboutDataUsageClearCache": {},
+ "setHomeCustomCollection": "Colecție personalizată",
+ "@setHomeCustomCollection": {},
+ "settingsThumbnailShowHdrIcon": "Afișare pictogramă HDR",
+ "@settingsThumbnailShowHdrIcon": {},
+ "settingsViewerShowHistogram": "Afișare histogramă",
+ "@settingsViewerShowHistogram": {},
+ "settingsVideoResumptionModeTile": "Reluare redare",
+ "@settingsVideoResumptionModeTile": {},
+ "entryActionCast": "Proiectare",
+ "@entryActionCast": {},
+ "settingsVideoResumptionModeDialogTitle": "Reluare redare",
+ "@settingsVideoResumptionModeDialogTitle": {},
+ "editorActionTransform": "Transformă",
+ "@editorActionTransform": {},
+ "tagEditorDiscardDialogMessage": "Dorești să renunți la modificări?",
+ "@tagEditorDiscardDialogMessage": {},
+ "editorTransformRotate": "Rotire",
+ "@editorTransformRotate": {},
+ "cropAspectRatioSquare": "Pătrat",
+ "@cropAspectRatioSquare": {},
+ "videoResumptionModeNever": "Niciodată",
+ "@videoResumptionModeNever": {},
+ "videoResumptionModeAlways": "Mereu",
+ "@videoResumptionModeAlways": {},
+ "castDialogTitle": "Dispozitive de proiectare",
+ "@castDialogTitle": {},
+ "maxBrightnessNever": "Niciodată",
+ "@maxBrightnessNever": {},
+ "maxBrightnessAlways": "Mereu",
+ "@maxBrightnessAlways": {}
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 54ce1fec0..1a15dc7d3 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -71,7 +71,7 @@
"@chipActionGoToCountryPage": {},
"chipActionGoToTagPage": "在标签中显示",
"@chipActionGoToTagPage": {},
- "chipActionFilterOut": "滤除",
+ "chipActionFilterOut": "筛除",
"@chipActionFilterOut": {},
"chipActionFilterIn": "筛选",
"@chipActionFilterIn": {},
@@ -1243,7 +1243,7 @@
"@exportEntryDialogQuality": {},
"placeEmpty": "没有地点",
"@placeEmpty": {},
- "settingsAskEverytime": "每次询问",
+ "settingsAskEverytime": "每次都询问",
"@settingsAskEverytime": {},
"settingsModificationWarningDialogMessage": "其他设置将被修改。",
"@settingsModificationWarningDialogMessage": {},
diff --git a/lib/main_common.dart b/lib/main_common.dart
index 91736a6e7..c5eb15426 100644
--- a/lib/main_common.dart
+++ b/lib/main_common.dart
@@ -38,7 +38,7 @@ void mainCommon(AppFlavor flavor, {Map? debugIntentData}) {
// cf https://docs.flutter.dev/testing/errors
LeakTracking.start();
- MemoryAllocations.instance.addListener(
+ FlutterMemoryAllocations.instance.addListener(
(event) => LeakTracking.dispatchObjectEvent(event.toMap()),
);
runApp(AvesApp(flavor: flavor, debugIntentData: debugIntentData));
diff --git a/lib/model/app/contributors.dart b/lib/model/app/contributors.dart
index a2eed236e..cd464c37c 100644
--- a/lib/model/app/contributors.dart
+++ b/lib/model/app/contributors.dart
@@ -77,6 +77,7 @@ class Contributors {
Contributor('fuzfyy', 'egeozce35@gmail.com'),
Contributor('minh', 'teaminh@skiff.com'),
Contributor('luckris25', 'lk1thebestl@gmail.com'),
+ Contributor('Marc Amorós', 'marquitus99@gmail.com'),
// Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali
// Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese
// Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese
@@ -84,6 +85,7 @@ class Contributors {
// Contributor('Åzze', 'laitinen.jere222@gmail.com'), // Finnish
// Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew
// Contributor('Rohit Burman', 'rohitburman31p@rediffmail.com'), // Hindi
+ // Contributor('AJ07', 'ajaykumarmeena676@gmail.com'), // Hindi
// Contributor('Chethan', 'chethan@users.noreply.hosted.weblate.org'), // Kannada
// Contributor('GoRaN', 'gorangharib.909@gmail.com'), // Kurdish (Central)
// Contributor('Rasti K5', 'rasti.khdhr@gmail.com'), // Kurdish (Central)
diff --git a/lib/model/entry/entry.dart b/lib/model/entry/entry.dart
index 5b8f7c12d..3d2060405 100644
--- a/lib/model/entry/entry.dart
+++ b/lib/model/entry/entry.dart
@@ -72,7 +72,7 @@ class AvesEntry with AvesEntryBase {
this.burstEntries,
}) : id = id ?? 0 {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectCreated(
+ FlutterMemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$AvesEntry',
object: this,
@@ -188,7 +188,7 @@ class AvesEntry with AvesEntryBase {
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectDisposed(object: this);
+ FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
visualChangeNotifier.dispose();
metadataChangeNotifier.dispose();
diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart
index 286dda44d..c987965b9 100644
--- a/lib/model/multipage.dart
+++ b/lib/model/multipage.dart
@@ -18,7 +18,7 @@ class MultiPageInfo {
required List pages,
}) : _pages = pages {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectCreated(
+ FlutterMemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$MultiPageInfo',
object: this,
@@ -44,7 +44,7 @@ class MultiPageInfo {
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectDisposed(object: this);
+ FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
_transientEntries.forEach((entry) => entry.dispose());
}
diff --git a/lib/model/settings/enums/widget_outline.dart b/lib/model/settings/enums/widget_outline.dart
new file mode 100644
index 000000000..0b4f0e13f
--- /dev/null
+++ b/lib/model/settings/enums/widget_outline.dart
@@ -0,0 +1,23 @@
+import 'package:aves_model/aves_model.dart';
+import 'package:dynamic_color/dynamic_color.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+extension ExtraWidgetOutline on WidgetOutline {
+ Future color(Brightness brightness) async {
+ switch (this) {
+ case WidgetOutline.none:
+ return SynchronousFuture(null);
+ case WidgetOutline.black:
+ return SynchronousFuture(Colors.black);
+ case WidgetOutline.white:
+ return SynchronousFuture(Colors.white);
+ case WidgetOutline.systemBlackAndWhite:
+ return SynchronousFuture(brightness == Brightness.dark ? Colors.black : Colors.white);
+ case WidgetOutline.systemDynamic:
+ final corePalette = await DynamicColorPlugin.getCorePalette();
+ final scheme = corePalette?.toColorScheme(brightness: brightness);
+ return scheme?.primary ?? await WidgetOutline.systemBlackAndWhite.color(brightness);
+ }
+ }
+}
diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart
index 693f8d9e7..f8a7325d8 100644
--- a/lib/model/settings/settings.dart
+++ b/lib/model/settings/settings.dart
@@ -274,12 +274,9 @@ class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings
// widget
- Color? getWidgetOutline(int widgetId) {
- final value = getInt('${SettingKeys.widgetOutlinePrefixKey}$widgetId');
- return value != null ? Color(value) : null;
- }
+ WidgetOutline getWidgetOutline(int widgetId) => getEnumOrDefault('${SettingKeys.widgetOutlinePrefixKey}$widgetId', WidgetOutline.none, WidgetOutline.values);
- void setWidgetOutline(int widgetId, Color? newValue) => set('${SettingKeys.widgetOutlinePrefixKey}$widgetId', newValue?.value);
+ void setWidgetOutline(int widgetId, WidgetOutline newValue) => set('${SettingKeys.widgetOutlinePrefixKey}$widgetId', newValue.toString());
WidgetShape getWidgetShape(int widgetId) => getEnumOrDefault('${SettingKeys.widgetShapePrefixKey}$widgetId', SettingsDefaults.widgetShape, WidgetShape.values);
diff --git a/lib/model/source/analysis_controller.dart b/lib/model/source/analysis_controller.dart
index b36c2306e..cf7288180 100644
--- a/lib/model/source/analysis_controller.dart
+++ b/lib/model/source/analysis_controller.dart
@@ -15,7 +15,7 @@ class AnalysisController {
this.progressOffset = 0,
}) {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectCreated(
+ FlutterMemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$AnalysisController',
object: this,
@@ -25,7 +25,7 @@ class AnalysisController {
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectDisposed(object: this);
+ FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
_stopSignal.dispose();
}
diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart
index 75c4f1091..c6a4216b2 100644
--- a/lib/model/source/collection_source.dart
+++ b/lib/model/source/collection_source.dart
@@ -38,6 +38,8 @@ mixin SourceBase {
Map get entryById;
+ Set get allEntries;
+
Set get visibleEntries;
Set get trashedEntries;
@@ -62,7 +64,7 @@ mixin SourceBase {
abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, StateMixin, LocationMixin, TagMixin, TrashMixin {
CollectionSource() {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectCreated(
+ FlutterMemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$CollectionSource',
object: this,
@@ -86,7 +88,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
@mustCallSuper
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectDisposed(object: this);
+ FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
_rawEntries.forEach((v) => v.dispose());
}
@@ -103,6 +105,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
final Set _rawEntries = {};
+ @override
Set get allEntries => Set.unmodifiable(_rawEntries);
Set? _visibleEntries, _trashedEntries;
@@ -261,8 +264,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
}
});
if (entry.trashed) {
- entry.contentId = null;
- entry.uri = 'file://${entry.trashDetails?.path}';
+ final trashPath = entry.trashDetails?.path;
+ if (trashPath != null) {
+ entry.contentId = null;
+ entry.uri = Uri.file(trashPath).toString();
+ } else {
+ debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}');
+ }
}
if (persist) {
diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart
index bacfaffef..d118dd101 100644
--- a/lib/model/source/media_store_source.dart
+++ b/lib/model/source/media_store_source.dart
@@ -148,12 +148,21 @@ class MediaStoreSource extends CollectionSource {
knownDateByContentId[contentId] = 0;
});
+ // items to add to the collection
+ final pendingNewEntries = {};
+
+ // recover untracked trash items
+ debugPrint('$runtimeType refresh ${stopwatch.elapsed} recover untracked entries');
+ if (directory == null) {
+ pendingNewEntries.addAll(await recoverUntrackedTrashItems());
+ }
+
// fetch new & modified entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10;
const refreshCountMax = 1000;
- final allNewEntries = {}, pendingNewEntries = {};
+ final allNewEntries = {};
void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries);
addEntries(pendingNewEntries);
diff --git a/lib/model/source/trash.dart b/lib/model/source/trash.dart
index 5aac6de6e..0249da2d4 100644
--- a/lib/model/source/trash.dart
+++ b/lib/model/source/trash.dart
@@ -1,9 +1,14 @@
import 'dart:async';
+import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/props.dart';
+import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
+import 'package:aves/utils/android_file_utils.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
mixin TrashMixin on SourceBase {
static const Duration binKeepDuration = Duration(days: 30);
@@ -32,4 +37,50 @@ mixin TrashMixin on SourceBase {
);
return await completer.future;
}
+
+ Future> recoverUntrackedTrashItems() async {
+ final newEntries = {};
+
+ final knownPaths = allEntries.map((v) => v.trashDetails?.path).whereNotNull().toSet();
+ final untrackedPaths = await storageService.getUntrackedTrashPaths(knownPaths);
+ if (untrackedPaths.isNotEmpty) {
+ debugPrint('Recovering ${untrackedPaths.length} untracked bin items');
+ final recoveryPath = pContext.join(androidFileUtils.picturesPath, AndroidFileUtils.recoveryDir);
+ await Future.forEach(untrackedPaths, (untrackedPath) async {
+ TrashDetails _buildTrashDetails(int id) => TrashDetails(
+ id: id,
+ path: untrackedPath,
+ dateMillis: DateTime.now().millisecondsSinceEpoch,
+ );
+
+ final uri = Uri.file(untrackedPath).toString();
+ final entry = allEntries.firstWhereOrNull((v) => v.uri == uri);
+ if (entry != null) {
+ // there is already a matching entry
+ // but missing trash details, and possibly not marked as trash
+ final id = entry.id;
+ entry.contentId = null;
+ entry.trashed = true;
+ entry.trashDetails = _buildTrashDetails(id);
+ // persist
+ await metadataDb.updateEntry(id, entry);
+ await metadataDb.updateTrash(id, entry.trashDetails);
+ } else {
+ // there is no matching entry
+ final sourceEntry = await mediaFetchService.getEntry(uri, null);
+ if (sourceEntry != null) {
+ final id = metadataDb.nextId;
+ sourceEntry.id = id;
+ sourceEntry.path = pContext.join(recoveryPath, pContext.basename(untrackedPath));
+ sourceEntry.trashed = true;
+ sourceEntry.trashDetails = _buildTrashDetails(id);
+ newEntries.add(sourceEntry);
+ } else {
+ await reportService.recordError('Failed to recover untracked bin item at uri=$uri', null);
+ }
+ }
+ });
+ }
+ return newEntries;
+ }
}
diff --git a/lib/model/vaults/vaults.dart b/lib/model/vaults/vaults.dart
index da80ec112..90a442dab 100644
--- a/lib/model/vaults/vaults.dart
+++ b/lib/model/vaults/vaults.dart
@@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:io';
+import 'package:aves/model/entry/entry.dart';
+import 'package:aves/model/entry/origins.dart';
+import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/vaults/details.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves_screen_state/aves_screen_state.dart';
import 'package:collection/collection.dart';
-import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+import 'package:provider/provider.dart';
final Vaults vaults = Vaults._private();
@@ -14,6 +18,8 @@ class Vaults extends ChangeNotifier {
Set _rows = {};
final Set _unlockedDirPaths = {};
+ static const _fileScheme = 'file';
+
Vaults._private();
Future init() async {
@@ -118,7 +124,7 @@ class Vaults extends ChangeNotifier {
bool isVaultEntryUri(String uriString) {
final uri = Uri.parse(uriString);
- if (uri.scheme != 'file') return false;
+ if (uri.scheme != _fileScheme) return false;
final path = uri.pathSegments.fold('', (prev, v) => '$prev${pContext.separator}$v');
return vaultDirectories.any(path.startsWith);
@@ -132,13 +138,47 @@ class Vaults extends ChangeNotifier {
_onLockStateChanged();
}
- void unlock(String dirPath) {
+ Future unlock(BuildContext context, String dirPath) async {
if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return;
+ // recover untracked vault items
+ final source = context.read();
+ final newEntries = await recoverUntrackedItems(source, dirPath);
+ if (newEntries.isNotEmpty) {
+ source.addEntries(newEntries);
+ await metadataDb.saveEntries(newEntries);
+ unawaited(source.analyze(null, entries: newEntries));
+ }
+
_unlockedDirPaths.add(dirPath);
_onLockStateChanged();
}
+ Future> recoverUntrackedItems(CollectionSource source, String dirPath) async {
+ final newEntries = {};
+
+ final vaultName = detailsForPath(dirPath)?.name;
+ if (vaultName == null) return newEntries;
+
+ final knownPaths = source.allEntries.where((v) => v.origin == EntryOrigins.vault && v.directory == dirPath).map((v) => v.path).whereNotNull().toSet();
+ final untrackedPaths = await storageService.getUntrackedVaultPaths(vaultName, knownPaths);
+ if (untrackedPaths.isNotEmpty) {
+ debugPrint('Recovering ${untrackedPaths.length} untracked vault items');
+ await Future.forEach(untrackedPaths, (untrackedPath) async {
+ final uri = Uri.file(untrackedPath).toString();
+ final sourceEntry = await mediaFetchService.getEntry(uri, null);
+ if (sourceEntry != null) {
+ sourceEntry.id = metadataDb.nextId;
+ sourceEntry.origin = EntryOrigins.vault;
+ newEntries.add(sourceEntry);
+ } else {
+ await reportService.recordError('Failed to recover untracked vault item at uri=$uri', null);
+ }
+ });
+ }
+ return newEntries;
+ }
+
void _onScreenOff() => lock(all.where((v) => v.autoLockScreenOff).map((v) => v.path).toSet());
void _onLockStateChanged() {
diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart
index d0f1db0cb..1b302917e 100644
--- a/lib/services/analysis_service.dart
+++ b/lib/services/analysis_service.dart
@@ -94,7 +94,7 @@ class Analyzer {
Analyzer() {
debugPrint('$runtimeType create');
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectCreated(
+ FlutterMemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$Analyzer',
object: this,
@@ -107,7 +107,7 @@ class Analyzer {
void dispose() {
debugPrint('$runtimeType dispose');
if (kFlutterMemoryAllocationsEnabled) {
- MemoryAllocations.instance.dispatchObjectDisposed(object: this);
+ FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
_stopUpdateTimer();
_controller?.dispose();
diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart
index 9941df2f3..0964b105d 100644
--- a/lib/services/app_service.dart
+++ b/lib/services/app_service.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:aves/model/apps.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/props.dart';
@@ -7,6 +9,7 @@ import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
+import 'package:streams_channel/streams_channel.dart';
abstract class AppService {
Future> getPackages();
@@ -15,7 +18,7 @@ abstract class AppService {
Future copyToClipboard(String uri, String? label);
- Future edit(String uri, String mimeType);
+ Future