JpegBytes2Disk.java

/*
 * Copyright 2022 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.imagecapture;

import static androidx.camera.core.ImageCapture.ERROR_FILE_IO;
import static androidx.camera.core.ImageCapture.ERROR_UNKNOWN;

import static java.util.Objects.requireNonNull;

import android.content.ContentResolver;
import android.content.ContentValues;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCaptureException;
import androidx.camera.core.impl.utils.Exif;
import androidx.camera.core.processing.Operation;
import androidx.camera.core.processing.Packet;

import com.google.auto.value.AutoValue;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.UUID;

/**
 * Saves JPEG bytes to disk.
 */
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
class JpegBytes2Disk implements Operation<JpegBytes2Disk.In, ImageCapture.OutputFileResults> {

    private static final String TEMP_FILE_PREFIX = "CameraX";
    private static final String TEMP_FILE_SUFFIX = ".tmp";
    private static final int COPY_BUFFER_SIZE = 1024;
    private static final int PENDING = 1;
    private static final int NOT_PENDING = 0;

    @NonNull
    @Override
    public ImageCapture.OutputFileResults apply(@NonNull In in) throws ImageCaptureException {
        Packet<byte[]> packet = in.getPacket();
        ImageCapture.OutputFileOptions options = in.getOutputFileOptions();
        File tempFile = createTempFile(options);
        writeBytesToFile(tempFile, packet.getData());
        updateFileExif(tempFile, requireNonNull(packet.getExif()), options,
                packet.getRotationDegrees());
        Uri uri = copyFileToTarget(tempFile, options);
        return new ImageCapture.OutputFileResults(uri);
    }

    /**
     * Creates a temporary JPEG file.
     */
    @NonNull
    private static File createTempFile(@NonNull ImageCapture.OutputFileOptions options)
            throws ImageCaptureException {
        try {
            File appProvidedFile = options.getFile();
            if (appProvidedFile != null) {
                // For saving-to-file case, write to the target folder and rename for better
                // performance.
                return new File(appProvidedFile.getParent(),
                        TEMP_FILE_PREFIX + UUID.randomUUID().toString() + TEMP_FILE_SUFFIX);
            } else {
                return File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX);
            }
        } catch (IOException e) {
            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to create temp file.", e);
        }
    }

    /**
     * Writes byte array to the given {@link File}.
     */
    private static void writeBytesToFile(
            @NonNull File tempFile, @NonNull byte[] bytes) throws ImageCaptureException {
        try (FileOutputStream output = new FileOutputStream(tempFile)) {
            output.write(bytes);
        } catch (IOException e) {
            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to write to temp file", e);
        }
    }

    private static void updateFileExif(
            @NonNull File tempFile,
            @NonNull Exif originalExif,
            @NonNull ImageCapture.OutputFileOptions options,
            int rotationDegrees)
            throws ImageCaptureException {
        try {
            // Create new exif based on the original exif.
            Exif exif = Exif.createFromFile(tempFile);
            originalExif.copyToCroppedImage(exif);

            if (exif.getRotation() == 0 && rotationDegrees != 0) {
                // When the HAL does not handle rotation, exif rotation is 0. In which case we
                // apply the packet rotation.
                // See: EXIF_ROTATION_AVAILABILITY
                exif.rotate(rotationDegrees);
            }

            // Overwrite exif based on metadata.
            ImageCapture.Metadata metadata = options.getMetadata();
            if (metadata.isReversedHorizontal()) {
                exif.flipHorizontally();
            }
            if (metadata.isReversedVertical()) {
                exif.flipVertically();
            }
            if (metadata.getLocation() != null) {
                exif.attachLocation(metadata.getLocation());
            }
            exif.save();
        } catch (IOException e) {
            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to update Exif data", e);
        }
    }

    /**
     * Copies the file to target and returns the {@link Uri}.
     *
     * @return null if the target is {@link OutputStream}.
     */
    @Nullable
    private static Uri copyFileToTarget(
            @NonNull File file, @NonNull ImageCapture.OutputFileOptions options)
            throws ImageCaptureException {
        if (isSaveToMediaStore(options)) {
            return copyFileToMediaStore(file, options);
        } else if (isSaveToOutputStream(options)) {
            try {
                copyFileToOutputStream(file, requireNonNull(options.getOutputStream()));
                return null;
            } catch (IOException e) {
                throw new ImageCaptureException(
                        ERROR_FILE_IO, "Failed to write to OutputStream.", null);
            }
        } else if (isSaveToFile(options)) {
            return copyFileToFile(file, requireNonNull(options.getFile()));
        } else {
            throw new ImageCaptureException(ERROR_UNKNOWN, "Invalid OutputFileOptions", null);
        }
    }

    private static Uri copyFileToMediaStore(
            @NonNull File file,
            @NonNull ImageCapture.OutputFileOptions options)
            throws ImageCaptureException {
        ContentResolver contentResolver = requireNonNull(options.getContentResolver());
        ContentValues values = options.getContentValues() != null
                ? new ContentValues(options.getContentValues())
                : new ContentValues();
        setContentValuePendingFlag(values, PENDING);
        Uri uri = null;
        try {
            uri = contentResolver.insert(options.getSaveCollection(), values);
            if (uri == null) {
                throw new ImageCaptureException(
                        ERROR_FILE_IO, "Failed to insert a MediaStore URI.", null);
            }
            copyTempFileToUri(file, uri, contentResolver);
        } catch (IOException | SecurityException e) {
            throw new ImageCaptureException(
                    ERROR_FILE_IO, "Failed to write to MediaStore URI: " + uri, e);
        } finally {
            if (uri != null) {
                updateUriPendingStatus(uri, contentResolver, NOT_PENDING);
            }
        }
        return uri;
    }

    private static Uri copyFileToFile(@NonNull File source, @NonNull File target)
            throws ImageCaptureException {
        // Normally File#renameTo will overwrite the targetFile even if it already exists.
        // Just in case of unexpected behavior on certain platforms or devices, delete the
        // target file before renaming.
        if (target.exists()) {
            target.delete();
        }
        if (!source.renameTo(target)) {
            throw new ImageCaptureException(
                    ERROR_FILE_IO,
                    "Failed to overwrite the file: " + target.getAbsolutePath(),
                    null);
        }
        return Uri.fromFile(target);
    }

    /**
     * Copies temp file to {@link Uri}.
     */
    private static void copyTempFileToUri(
            @NonNull File tempFile,
            @NonNull Uri uri,
            @NonNull ContentResolver contentResolver) throws IOException {
        try (OutputStream outputStream = contentResolver.openOutputStream(uri)) {
            if (outputStream == null) {
                throw new FileNotFoundException(uri + " cannot be resolved.");
            }
            copyFileToOutputStream(tempFile, outputStream);
        }
    }

    @SuppressWarnings("IOStreamConstructor")
    private static void copyFileToOutputStream(@NonNull File file,
            @NonNull OutputStream outputStream)
            throws IOException {
        try (InputStream in = new FileInputStream(file)) {
            byte[] buf = new byte[COPY_BUFFER_SIZE];
            int len;
            while ((len = in.read(buf)) > 0) {
                outputStream.write(buf, 0, len);
            }
        }
    }

    /**
     * Removes IS_PENDING flag during the writing to {@link Uri}.
     */
    private static void updateUriPendingStatus(@NonNull Uri outputUri,
            @NonNull ContentResolver contentResolver, int isPending) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            ContentValues values = new ContentValues();
            setContentValuePendingFlag(values, isPending);
            contentResolver.update(outputUri, values, null, null);
        }
    }

    /** Set IS_PENDING flag to {@link ContentValues}. */
    private static void setContentValuePendingFlag(@NonNull ContentValues values, int isPending) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            values.put(MediaStore.Images.Media.IS_PENDING, isPending);
        }
    }

    private static boolean isSaveToMediaStore(ImageCapture.OutputFileOptions outputFileOptions) {
        return outputFileOptions.getSaveCollection() != null
                && outputFileOptions.getContentResolver() != null
                && outputFileOptions.getContentValues() != null;
    }

    private static boolean isSaveToFile(ImageCapture.OutputFileOptions outputFileOptions) {
        return outputFileOptions.getFile() != null;
    }

    private static boolean isSaveToOutputStream(ImageCapture.OutputFileOptions outputFileOptions) {
        return outputFileOptions.getOutputStream() != null;
    }

    /**
     * Input packet.
     */
    @AutoValue
    abstract static class In {

        @NonNull
        abstract Packet<byte[]> getPacket();

        @NonNull
        abstract ImageCapture.OutputFileOptions getOutputFileOptions();

        @NonNull
        static In of(@NonNull Packet<byte[]> jpegBytes,
                @NonNull ImageCapture.OutputFileOptions outputFileOptions) {
            return new AutoValue_JpegBytes2Disk_In(jpegBytes, outputFileOptions);
        }
    }
}