ExifOutputStream.java

/*
 * 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.camera.core.impl.utils;

import static androidx.camera.core.impl.utils.ExifAttribute.ASCII;
import static androidx.camera.core.impl.utils.ExifData.Builder.sExifTagMapsForWriting;
import static androidx.camera.core.impl.utils.ExifData.EXIF_POINTER_TAGS;
import static androidx.camera.core.impl.utils.ExifData.EXIF_TAGS;
import static androidx.camera.core.impl.utils.ExifData.IFD_TYPE_EXIF;
import static androidx.camera.core.impl.utils.ExifData.IFD_TYPE_GPS;
import static androidx.camera.core.impl.utils.ExifData.IFD_TYPE_INTEROPERABILITY;
import static androidx.camera.core.impl.utils.ExifData.IFD_TYPE_PRIMARY;

import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.camera.core.Logger;
import androidx.core.util.Preconditions;

import java.io.BufferedOutputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Locale;
import java.util.Map;

/**
 * This class provides a way to replace the Exif header of a JPEG image.
 * <p>
 * Below is an example of writing EXIF data into a file
 *
 * <pre>
 * public static void writeExif(byte[] jpeg, ExifData exif, String path) {
 *     OutputStream os = null;
 *     try {
 *         os = new FileOutputStream(path);
 *         // Set the exif header on the output stream
 *         ExifOutputStream eos = new ExifOutputStream(os, exif);
 *         // Write the original jpeg out, the header will be added into the file.
 *         eos.write(jpeg);
 *     } catch (FileNotFoundException e) {
 *         e.printStackTrace();
 *     } catch (IOException e) {
 *         e.printStackTrace();
 *     } finally {
 *         if (os != null) {
 *             try {
 *                 os.close();
 *             } catch (IOException e) {
 *                 e.printStackTrace();
 *             }
 *         }
 *     }
 * }
 * </pre>
 */
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class ExifOutputStream extends FilterOutputStream {
    private static final String TAG = "ExifOutputStream";
    private static final boolean DEBUG = false;
    private static final int STREAMBUFFER_SIZE = 0x00010000; // 64Kb

    private static final int STATE_SOI = 0;
    private static final int STATE_FRAME_HEADER = 1;
    private static final int STATE_JPEG_DATA = 2;

    // Identifier for EXIF APP1 segment in JPEG
    private static final byte[] IDENTIFIER_EXIF_APP1 = "Exifeb3b9ed0-f11d-0137-cfb9-0ebaa35b92c0eb3b9ed0-f11d-0137-cfb9-0ebaa35b92c0".getBytes(ASCII);

    // Types of Exif byte alignments (see JEITA CP-3451C Section 4.5.2)
    private static final short BYTE_ALIGN_II = 0x4949;  // II: Intel order
    private static final short BYTE_ALIGN_MM = 0x4d4d;  // MM: Motorola order

    // TIFF Header Fixed Constant (see JEITA CP-3451C Section 4.5.2)
    private static final byte START_CODE = 0x2a; // 42
    private static final int IFD_OFFSET = 8;

    private final ExifData mExifData;
    private final byte[] mSingleByteArray = new byte[1];
    private final ByteBuffer mBuffer = ByteBuffer.allocate(4);
    private int mState = STATE_SOI;
    private int mByteToSkip;
    private int mByteToCopy;

    /**
     * Creates an ExifOutputStream that wraps the given {@link OutputStream} and overwrites exif
     * with the provided {@link ExifData}.
     * @param ou OutputStream which will be sent the final output.
     * @param exifData Exif data which will overwrite any exif data sent to this stream.
     */
    public ExifOutputStream(@NonNull OutputStream ou, @NonNull ExifData exifData) {
        super(new BufferedOutputStream(ou, STREAMBUFFER_SIZE));
        mExifData = exifData;
    }

    private int requestByteToBuffer(int requestByteCount, byte[] buffer, int offset, int length) {
        int byteNeeded = requestByteCount - mBuffer.position();
        int byteToRead = Math.min(length, byteNeeded);
        mBuffer.put(buffer, offset, byteToRead);
        return byteToRead;
    }

    /**
     * Writes the image out. The input data should be a valid JPEG format. After
     * writing, it's Exif header will be replaced by the given header.
     */
    @Override
    public void write(@NonNull byte[] buffer, int offset, int length) throws IOException {
        while ((mByteToSkip > 0 || mByteToCopy > 0 || mState != STATE_JPEG_DATA)
                && length > 0) {
            if (mByteToSkip > 0) {
                int byteToProcess = Math.min(length, mByteToSkip);
                length -= byteToProcess;
                mByteToSkip -= byteToProcess;
                offset += byteToProcess;
            }
            if (mByteToCopy > 0) {
                int byteToProcess = Math.min(length, mByteToCopy);
                out.write(buffer, offset, byteToProcess);
                length -= byteToProcess;
                mByteToCopy -= byteToProcess;
                offset += byteToProcess;
            }
            if (length == 0) {
                return;
            }
            switch (mState) {
                case STATE_SOI:
                    int byteRead = requestByteToBuffer(2, buffer, offset, length);
                    offset += byteRead;
                    length -= byteRead;
                    if (mBuffer.position() < 2) {
                        return;
                    }
                    mBuffer.rewind();
                    if (mBuffer.getShort() != JpegHeader.SOI) {
                        throw new IOException("Not a valid jpeg image, cannot write exif");
                    }
                    out.write(mBuffer.array(), 0, 2);
                    mState = STATE_FRAME_HEADER;
                    mBuffer.rewind();
                    ByteOrderedDataOutputStream dataOutputStream =
                            new ByteOrderedDataOutputStream(out, ByteOrder.BIG_ENDIAN);
                    dataOutputStream.writeShort(JpegHeader.APP1);
                    writeExifSegment(dataOutputStream);
                    break;
                case STATE_FRAME_HEADER:
                    // We ignore the APP1 segment and copy all other segments
                    // until SOF tag.
                    byteRead = requestByteToBuffer(4, buffer, offset, length);
                    offset += byteRead;
                    length -= byteRead;
                    // Check if this image data doesn't contain SOF.
                    if (mBuffer.position() == 2) {
                        short tag = mBuffer.getShort();
                        if (tag == JpegHeader.EOI) {
                            out.write(mBuffer.array(), 0, 2);
                            mBuffer.rewind();
                        }
                    }
                    if (mBuffer.position() < 4) {
                        return;
                    }
                    mBuffer.rewind();
                    short marker = mBuffer.getShort();
                    if (marker == JpegHeader.APP1) {
                        mByteToSkip = (mBuffer.getShort() & 0x0000ffff) - 2;
                        mState = STATE_JPEG_DATA;
                    } else if (!JpegHeader.isSofMarker(marker)) {
                        out.write(mBuffer.array(), 0, 4);
                        mByteToCopy = (mBuffer.getShort() & 0x0000ffff) - 2;
                    } else {
                        out.write(mBuffer.array(), 0, 4);
                        mState = STATE_JPEG_DATA;
                    }
                    mBuffer.rewind();
            }
        }
        if (length > 0) {
            out.write(buffer, offset, length);
        }
    }

    /**
     * Writes the one bytes out. The input data should be a valid JPEG format.
     * After writing, it's Exif header will be replaced by the given header.
     */
    @Override
    public void write(int oneByte) throws IOException {
        mSingleByteArray[0] = (byte) (0xff & oneByte);
        write(mSingleByteArray);
    }

    /**
     * Equivalent to calling write(buffer, 0, buffer.length).
     */
    @Override
    public void write(@NonNull byte[] buffer) throws IOException {
        write(buffer, 0, buffer.length);
    }

    // Writes an Exif segment into the given output stream.
    private void writeExifSegment(@NonNull 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) {
            for (int ifdIndex = 0; ifdIndex < EXIF_TAGS.length; ++ifdIndex) {
                mExifData.getAttributes(ifdIndex).remove(tag.name);
            }
        }

        // 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 (!mExifData.getAttributes(IFD_TYPE_EXIF).isEmpty()) {
            mExifData.getAttributes(IFD_TYPE_PRIMARY).put(EXIF_POINTER_TAGS[1].name,
                    ExifAttribute.createULong(0, mExifData.getByteOrder()));
        }
        if (!mExifData.getAttributes(IFD_TYPE_GPS).isEmpty()) {
            mExifData.getAttributes(IFD_TYPE_PRIMARY).put(EXIF_POINTER_TAGS[2].name,
                    ExifAttribute.createULong(0, mExifData.getByteOrder()));
        }
        if (!mExifData.getAttributes(IFD_TYPE_INTEROPERABILITY).isEmpty()) {
            mExifData.getAttributes(IFD_TYPE_EXIF).put(EXIF_POINTER_TAGS[3].name,
                    ExifAttribute.createULong(0, mExifData.getByteOrder()));
        }

        // 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<String, ExifAttribute> entry : mExifData.getAttributes(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 (!mExifData.getAttributes(ifdType).isEmpty()) {
                ifdOffsets[ifdType] = position;
                position += 2 + mExifData.getAttributes(ifdType).size() * 12 + 4
                        + ifdDataSizes[ifdType];
            }
        }

        int totalSize = position;
        // Add 8 bytes for APP1 size and identifier data
        totalSize += 8;
        if (DEBUG) {
            for (int i = 0; i < EXIF_TAGS.length; ++i) {
                Logger.d(TAG, String.format(Locale.US, "index: %d, offsets: %d, tag count: %d, "
                                + "data sizes: %d, total size: %d", i, ifdOffsets[i],
                        mExifData.getAttributes(i).size(),
                        ifdDataSizes[i], totalSize));
            }
        }

        // Update IFD pointer tags with the calculated offsets.
        if (!mExifData.getAttributes(IFD_TYPE_EXIF).isEmpty()) {
            mExifData.getAttributes(IFD_TYPE_PRIMARY).put(EXIF_POINTER_TAGS[1].name,
                    ExifAttribute.createULong(ifdOffsets[IFD_TYPE_EXIF], mExifData.getByteOrder()));
        }
        if (!mExifData.getAttributes(IFD_TYPE_GPS).isEmpty()) {
            mExifData.getAttributes(IFD_TYPE_PRIMARY).put(EXIF_POINTER_TAGS[2].name,
                    ExifAttribute.createULong(ifdOffsets[IFD_TYPE_GPS], mExifData.getByteOrder()));
        }
        if (!mExifData.getAttributes(IFD_TYPE_INTEROPERABILITY).isEmpty()) {
            mExifData.getAttributes(IFD_TYPE_EXIF).put(EXIF_POINTER_TAGS[3].name,
                    ExifAttribute.createULong(
                            ifdOffsets[IFD_TYPE_INTEROPERABILITY], mExifData.getByteOrder()));
        }

        // Write JPEG specific data (APP1 size, APP1 identifier)
        dataOutputStream.writeUnsignedShort(totalSize);
        dataOutputStream.write(IDENTIFIER_EXIF_APP1);

        // Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
        dataOutputStream.writeShort(mExifData.getByteOrder() == ByteOrder.BIG_ENDIAN
                ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
        dataOutputStream.setByteOrder(mExifData.getByteOrder());
        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 (!mExifData.getAttributes(ifdType).isEmpty()) {
                // See JEITA CP-3451C Section 4.6.2: IFD structure.
                // Write entry count
                dataOutputStream.writeUnsignedShort(mExifData.getAttributes(ifdType).size());

                // Write entry info
                int dataOffset = ifdOffsets[ifdType] + 2 + mExifData.getAttributes(ifdType).size()
                        * 12 + 4;
                for (Map.Entry<String, ExifAttribute> entry : mExifData.getAttributes(
                        ifdType).entrySet()) {
                    // Convert tag name to tag number.
                    final ExifTag tag = sExifTagMapsForWriting.get(ifdType).get(entry.getKey());
                    final int tagNumber =
                            Preconditions.checkNotNull(tag,
                                    "Tag not supported: " + entry.getKey() + ". Tag needs to be "
                                            + "ported from ExifInterface to ExifData.").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. Since we aren't handling thumbnails, this is just 0.
                dataOutputStream.writeUnsignedInt(0);

                // Write values of data field exceeding 4 bytes after the next offset.
                for (Map.Entry<String, ExifAttribute> entry : mExifData.getAttributes(
                        ifdType).entrySet()) {
                    ExifAttribute attribute = entry.getValue();

                    if (attribute.bytes.length > 4) {
                        dataOutputStream.write(attribute.bytes, 0, attribute.bytes.length);
                    }
                }
            }
        }

        // Reset the byte order to big endian in order to write remaining parts of the JPEG file.
        dataOutputStream.setByteOrder(ByteOrder.BIG_ENDIAN);
    }

    static final class JpegHeader {
        public static final short SOI =  (short) 0xFFD8;
        public static final short APP1 = (short) 0xFFE1;
        public static final short EOI = (short) 0xFFD9;

        /**
         *  SOF (start of frame). All value between SOF0 and SOF15 is SOF marker except for DHT,
         *  JPG, and DAC marker.
         */
        public static final short SOF0 = (short) 0xFFC0;
        public static final short SOF15 = (short) 0xFFCF;
        public static final short DHT = (short) 0xFFC4;
        public static final short JPG = (short) 0xFFC8;
        public static final short DAC = (short) 0xFFCC;

        public static boolean isSofMarker(short marker) {
            return marker >= SOF0 && marker <= SOF15 && marker != DHT && marker != JPG
                    && marker != DAC;
        }

        private JpegHeader() {}
    }
}