/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.camera.core;
import android.content.ContentValues;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.camera.core.impl.utils.Exif;
import androidx.camera.core.internal.compat.workaround.ExifRotationAvailability;
import androidx.camera.core.internal.utils.ImageUtil;
import androidx.camera.core.internal.utils.ImageUtil.CodecFailedException;
import androidx.core.util.Preconditions;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.UUID;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
final class ImageSaver implements Runnable {
private static final String TAG = "ImageSaver";
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;
// The image that was captured
private final ImageProxy mImage;
// The orientation of the image
private final int mOrientation;
// The target location to save the image to.
@NonNull
private final ImageCapture.OutputFileOptions mOutputFileOptions;
// The executor to call back on
private final Executor mUserCallbackExecutor;
// The callback to call on completion
private final OnImageSavedCallback mCallback;
private final Executor mSequentialIoExecutor;
ImageSaver(
ImageProxy image,
@NonNull ImageCapture.OutputFileOptions outputFileOptions,
int orientation,
Executor userCallbackExecutor,
Executor sequentialIoExecutor,
OnImageSavedCallback callback) {
mImage = image;
mOutputFileOptions = outputFileOptions;
mOrientation = orientation;
mCallback = callback;
mUserCallbackExecutor = userCallbackExecutor;
mSequentialIoExecutor = sequentialIoExecutor;
}
@Override
public void run() {
// Save the image to a temp file first. This is necessary because ExifInterface only
// supports saving to File.
File tempFile = saveImageToTempFile();
if (tempFile != null) {
// Post copying on a sequential executor. If the user provided saving destination maps
// to a specific file on disk, accessing the file from multiple threads is not safe.
mSequentialIoExecutor.execute(() -> copyTempFileToDestination(tempFile));
}
}
/**
* Saves the {@link #mImage} to a temp file.
*
* <p> It also crops the image and update Exif if necessary. Returns null if saving failed.
*/
@Nullable
private File saveImageToTempFile() {
File tempFile;
try {
if (isSaveToFile()) {
// For saving to file, write to the target folder and rename for better performance.
tempFile = new File(mOutputFileOptions.getFile().getParent(),
TEMP_FILE_PREFIX + UUID.randomUUID().toString() + TEMP_FILE_SUFFIX);
} else {
tempFile = File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX);
}
} catch (IOException e) {
postError(SaveError.FILE_IO_FAILED, "Failed to create temp file", e);
return null;
}
SaveError saveError = null;
String errorMessage = null;
Exception exception = null;
try (ImageProxy imageToClose = mImage;
FileOutputStream output = new FileOutputStream(tempFile)) {
byte[] bytes = ImageUtil.imageToJpegByteArray(mImage);
output.write(bytes);
Exif exif = Exif.createFromFile(tempFile);
exif.attachTimestamp();
// Use exif for orientation (contains rotation only) from the original image if JPEG,
// because imageToJpegByteArray removes EXIF in certain conditions. See b/124280392.
// Retrieve the orientation value from the embedded EXIF data in the captured image
// only if it is available.
if (new ExifRotationAvailability().shouldUseExifOrientation(mImage)) {
ByteBuffer buffer = mImage.getPlanes()[0].getBuffer();
// Rewind to make sure it is at the beginning of the buffer
buffer.rewind();
byte[] data = new byte[buffer.capacity()];
buffer.get(data);
InputStream inputStream = new ByteArrayInputStream(data);
Exif originalExif = Exif.createFromInputStream(inputStream);
exif.setOrientation(originalExif.getOrientation());
} else {
exif.rotate(mOrientation);
}
ImageCapture.Metadata metadata = mOutputFileOptions.getMetadata();
if (metadata.isReversedHorizontal()) {
exif.flipHorizontally();
}
if (metadata.isReversedVertical()) {
exif.flipVertically();
}
if (metadata.getLocation() != null) {
exif.attachLocation(mOutputFileOptions.getMetadata().getLocation());
}
exif.save();
} catch (IOException | IllegalArgumentException e) {
saveError = SaveError.FILE_IO_FAILED;
errorMessage = "Failed to write temp file";
exception = e;
} catch (CodecFailedException e) {
switch (e.getFailureType()) {
case ENCODE_FAILED:
saveError = SaveError.ENCODE_FAILED;
errorMessage = "Failed to encode mImage";
break;
case DECODE_FAILED:
saveError = SaveError.CROP_FAILED;
errorMessage = "Failed to crop mImage";
break;
case UNKNOWN:
default:
saveError = SaveError.UNKNOWN;
errorMessage = "Failed to transcode mImage";
break;
}
exception = e;
}
if (saveError != null) {
postError(saveError, errorMessage, exception);
tempFile.delete();
return null;
}
return tempFile;
}
/**
* Copy the temp file to user specified destination.
*
* <p> The temp file will be deleted afterwards.
*/
void copyTempFileToDestination(@NonNull File tempFile) {
Preconditions.checkNotNull(tempFile);
SaveError saveError = null;
String errorMessage = null;
Exception exception = null;
Uri outputUri = null;
try {
if (isSaveToMediaStore()) {
ContentValues values = mOutputFileOptions.getContentValues() != null
? new ContentValues(mOutputFileOptions.getContentValues())
: new ContentValues();
setContentValuePending(values, PENDING);
outputUri = mOutputFileOptions.getContentResolver().insert(
mOutputFileOptions.getSaveCollection(),
values);
if (outputUri == null) {
saveError = SaveError.FILE_IO_FAILED;
errorMessage = "Failed to insert URI.";
} else {
if (!copyTempFileToUri(tempFile, outputUri)) {
saveError = SaveError.FILE_IO_FAILED;
errorMessage = "Failed to save to URI.";
}
setUriNotPending(outputUri);
}
} else if (isSaveToOutputStream()) {
copyTempFileToOutputStream(tempFile, mOutputFileOptions.getOutputStream());
} else if (isSaveToFile()) {
File targetFile = mOutputFileOptions.getFile();
// 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 (targetFile.exists()) {
targetFile.delete();
}
if (!tempFile.renameTo(targetFile)) {
saveError = SaveError.FILE_IO_FAILED;
errorMessage = "Failed to rename file.";
}
}
} catch (IOException | IllegalArgumentException e) {
saveError = SaveError.FILE_IO_FAILED;
errorMessage = "Failed to write destination file.";
exception = e;
} finally {
tempFile.delete();
}
if (saveError != null) {
postError(saveError, errorMessage, exception);
} else {
postSuccess(outputUri);
}
}
private boolean isSaveToMediaStore() {
return mOutputFileOptions.getSaveCollection() != null
&& mOutputFileOptions.getContentResolver() != null
&& mOutputFileOptions.getContentValues() != null;
}
private boolean isSaveToFile() {
return mOutputFileOptions.getFile() != null;
}
private boolean isSaveToOutputStream() {
return mOutputFileOptions.getOutputStream() != null;
}
/**
* Removes IS_PENDING flag during the writing to {@link Uri}.
*/
private void setUriNotPending(@NonNull Uri outputUri) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentValues values = new ContentValues();
setContentValuePending(values, NOT_PENDING);
mOutputFileOptions.getContentResolver().update(outputUri, values, null, null);
}
}
/** Set IS_PENDING flag to {@link ContentValues}. */
private void setContentValuePending(@NonNull ContentValues values, int isPending) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.Images.Media.IS_PENDING, isPending);
}
}
/**
* Copies temp file to {@link Uri}.
*
* @return false if the {@link Uri} is not writable.
*/
private boolean copyTempFileToUri(@NonNull File tempFile, @NonNull Uri uri) throws IOException {
try (OutputStream outputStream =
mOutputFileOptions.getContentResolver().openOutputStream(uri)) {
if (outputStream == null) {
// The URI is not writable.
return false;
}
copyTempFileToOutputStream(tempFile, outputStream);
}
return true;
}
private void copyTempFileToOutputStream(@NonNull File tempFile,
@NonNull OutputStream outputStream) throws IOException {
try (InputStream in = new FileInputStream(tempFile)) {
byte[] buf = new byte[COPY_BUFFER_SIZE];
int len;
while ((len = in.read(buf)) > 0) {
outputStream.write(buf, 0, len);
}
}
}
private void postSuccess(@Nullable Uri outputUri) {
try {
mUserCallbackExecutor.execute(
() -> mCallback.onImageSaved(new ImageCapture.OutputFileResults(outputUri)));
} catch (RejectedExecutionException e) {
Logger.e(TAG,
"Application executor rejected executing OnImageSavedCallback.onImageSaved "
+ "callback. Skipping.");
}
}
private void postError(SaveError saveError, final String message,
@Nullable final Throwable cause) {
try {
mUserCallbackExecutor.execute(() -> mCallback.onError(saveError, message, cause));
} catch (RejectedExecutionException e) {
Logger.e(TAG, "Application executor rejected executing OnImageSavedCallback.onError "
+ "callback. Skipping.");
}
}
/** Type of error that occurred during save */
public enum SaveError {
/** Failed to write to or close the file */
FILE_IO_FAILED,
/** Failure when attempting to encode image */
ENCODE_FAILED,
/** Failure when attempting to crop image */
CROP_FAILED,
UNKNOWN
}
public interface OnImageSavedCallback {
void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults);
void onError(@NonNull SaveError saveError, @NonNull String message,
@Nullable Throwable cause);
}
}