| /* |
| * 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 static com.google.common.truth.Truth.assertThat; |
| |
| import static org.mockito.ArgumentMatchers.any; |
| import static org.mockito.ArgumentMatchers.anyString; |
| import static org.mockito.ArgumentMatchers.eq; |
| import static org.mockito.Mockito.mock; |
| import static org.mockito.Mockito.verify; |
| import static org.mockito.Mockito.when; |
| |
| import android.Manifest; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.graphics.Bitmap; |
| import android.graphics.BitmapFactory; |
| import android.graphics.ImageFormat; |
| import android.graphics.Rect; |
| import android.net.Uri; |
| import android.os.Environment; |
| import android.os.ParcelFileDescriptor; |
| import android.provider.MediaStore; |
| import android.util.Base64; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.camera.core.ImageSaver.OnImageSavedCallback; |
| import androidx.camera.core.ImageSaver.SaveError; |
| import androidx.test.core.app.ApplicationProvider; |
| import androidx.test.ext.junit.runners.AndroidJUnit4; |
| import androidx.test.filters.MediumTest; |
| import androidx.test.rule.GrantPermissionRule; |
| |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.Rule; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.mockito.ArgumentCaptor; |
| import org.mockito.Mock; |
| |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.OutputStream; |
| import java.nio.ByteBuffer; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.Semaphore; |
| |
| /** |
| * Instrument tests for {@link ImageSaver}. |
| */ |
| @MediumTest |
| @RunWith(AndroidJUnit4.class) |
| public class ImageSaverTest { |
| |
| private static final int WIDTH = 160; |
| private static final int HEIGHT = 120; |
| private static final int CROP_WIDTH = 100; |
| private static final int CROP_HEIGHT = 100; |
| private static final int Y_PIXEL_STRIDE = 1; |
| private static final int Y_ROW_STRIDE = WIDTH; |
| private static final int UV_PIXEL_STRIDE = 1; |
| private static final int UV_ROW_STRIDE = WIDTH / 2; |
| private static final String JPEG_IMAGE_DATA_BASE_64 = |
| "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB" |
| + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEB" |
| + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAB4AKADASIA" |
| + "AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA" |
| + "AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3" |
| + "ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm" |
| + "p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA" |
| + "AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx" |
| + "BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK" |
| + "U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3" |
| + "uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD/AD/6" |
| + "KKK/8/8AP/P/AAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii" |
| + "gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKA" |
| + "CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAK" |
| + "KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoo" |
| + "ooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii" |
| + "gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//9k="; |
| // The image used here has a YUV_420_888 format. |
| |
| private static final String TAG = "ImageSaverTest"; |
| private static final String INVALID_DATA_PATH = "/invalid_path"; |
| |
| @Rule |
| public GrantPermissionRule mStoragePermissionRule = |
| GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE, |
| Manifest.permission.READ_EXTERNAL_STORAGE); |
| |
| @Mock |
| private final ImageProxy mMockYuvImage = mock(ImageProxy.class); |
| @Mock |
| private final ImageProxy.PlaneProxy mYPlane = mock(ImageProxy.PlaneProxy.class); |
| @Mock |
| private final ImageProxy.PlaneProxy mUPlane = mock(ImageProxy.PlaneProxy.class); |
| @Mock |
| private final ImageProxy.PlaneProxy mVPlane = mock(ImageProxy.PlaneProxy.class); |
| private final ByteBuffer mYBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT); |
| private final ByteBuffer mUBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 4); |
| private final ByteBuffer mVBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 4); |
| @Mock |
| private final ImageProxy mMockJpegImage = mock(ImageProxy.class); |
| @Mock |
| private final ImageProxy.PlaneProxy mJpegDataPlane = mock(ImageProxy.PlaneProxy.class); |
| private final ByteBuffer mJpegDataBuffer = |
| ByteBuffer.wrap(Base64.decode(JPEG_IMAGE_DATA_BASE_64, Base64.DEFAULT)); |
| |
| private final Semaphore mSemaphore = new Semaphore(0); |
| private final ImageSaver.OnImageSavedCallback mMockCallback = |
| mock(ImageSaver.OnImageSavedCallback.class); |
| private final ImageSaver.OnImageSavedCallback mSyncCallback = |
| new OnImageSavedCallback() { |
| @Override |
| public void onImageSaved( |
| @NonNull ImageCapture.OutputFileResults outputFileResults) { |
| mMockCallback.onImageSaved(outputFileResults); |
| mSemaphore.release(); |
| } |
| |
| @Override |
| public void onError(SaveError saveError, String message, |
| @Nullable Throwable cause) { |
| Log.d(TAG, message, cause); |
| mMockCallback.onError(saveError, message, cause); |
| mSemaphore.release(); |
| } |
| }; |
| |
| private ExecutorService mBackgroundExecutor; |
| private ContentResolver mContentResolver; |
| |
| @Before |
| public void setup() { |
| createDefaultPictureFolderIfNotExist(); |
| // The YUV image's behavior. |
| when(mMockYuvImage.getFormat()).thenReturn(ImageFormat.YUV_420_888); |
| when(mMockYuvImage.getWidth()).thenReturn(WIDTH); |
| when(mMockYuvImage.getHeight()).thenReturn(HEIGHT); |
| |
| when(mYPlane.getBuffer()).thenReturn(mYBuffer); |
| when(mYPlane.getPixelStride()).thenReturn(Y_PIXEL_STRIDE); |
| when(mYPlane.getRowStride()).thenReturn(Y_ROW_STRIDE); |
| |
| when(mUPlane.getBuffer()).thenReturn(mUBuffer); |
| when(mUPlane.getPixelStride()).thenReturn(UV_PIXEL_STRIDE); |
| when(mUPlane.getRowStride()).thenReturn(UV_ROW_STRIDE); |
| |
| when(mVPlane.getBuffer()).thenReturn(mVBuffer); |
| when(mVPlane.getPixelStride()).thenReturn(UV_PIXEL_STRIDE); |
| when(mVPlane.getRowStride()).thenReturn(UV_ROW_STRIDE); |
| when(mMockYuvImage.getPlanes()) |
| .thenReturn(new ImageProxy.PlaneProxy[]{mYPlane, mUPlane, mVPlane}); |
| when(mMockYuvImage.getCropRect()).thenReturn(new Rect(0, 0, CROP_WIDTH, CROP_HEIGHT)); |
| |
| // The JPEG image's behavior |
| when(mMockJpegImage.getFormat()).thenReturn(ImageFormat.JPEG); |
| when(mMockJpegImage.getWidth()).thenReturn(WIDTH); |
| when(mMockJpegImage.getHeight()).thenReturn(HEIGHT); |
| when(mMockJpegImage.getCropRect()).thenReturn(new Rect(0, 0, CROP_WIDTH, CROP_HEIGHT)); |
| |
| when(mJpegDataPlane.getBuffer()).thenReturn(mJpegDataBuffer); |
| when(mMockJpegImage.getPlanes()).thenReturn(new ImageProxy.PlaneProxy[]{mJpegDataPlane}); |
| |
| // Set up a background executor for callbacks |
| mBackgroundExecutor = Executors.newSingleThreadExecutor(); |
| |
| mContentResolver = ApplicationProvider.getApplicationContext().getContentResolver(); |
| } |
| |
| @After |
| public void tearDown() { |
| mBackgroundExecutor.shutdown(); |
| } |
| |
| private void createDefaultPictureFolderIfNotExist() { |
| File pictureFolder = Environment.getExternalStoragePublicDirectory( |
| Environment.DIRECTORY_PICTURES); |
| if (!pictureFolder.exists()) { |
| pictureFolder.mkdir(); |
| } |
| } |
| |
| private ImageSaver getDefaultImageSaver(ImageProxy image, File file) { |
| return getDefaultImageSaver(image, |
| new ImageCapture.OutputFileOptions.Builder(file).build()); |
| } |
| |
| private ImageSaver getDefaultImageSaver(@NonNull ImageProxy image) { |
| ContentValues contentValues = new ContentValues(); |
| contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg"); |
| return getDefaultImageSaver(image, |
| new ImageCapture.OutputFileOptions.Builder(mContentResolver, |
| MediaStore.Images.Media.EXTERNAL_CONTENT_URI, |
| contentValues).build()); |
| } |
| |
| private ImageSaver getDefaultImageSaver(ImageProxy image, OutputStream outputStream) { |
| return getDefaultImageSaver(image, |
| new ImageCapture.OutputFileOptions.Builder(outputStream).build()); |
| } |
| |
| private ImageSaver getDefaultImageSaver(ImageProxy image, |
| ImageCapture.OutputFileOptions outputFileOptions) { |
| return new ImageSaver( |
| image, |
| outputFileOptions, |
| /*orientation=*/ 0, |
| mBackgroundExecutor, |
| mSyncCallback); |
| } |
| |
| @Test |
| public void canSaveYuvImage() throws InterruptedException, IOException { |
| File saveLocation = File.createTempFile("test", ".jpg"); |
| saveLocation.deleteOnExit(); |
| |
| getDefaultImageSaver(mMockYuvImage, saveLocation).run(); |
| mSemaphore.acquire(); |
| |
| verify(mMockCallback).onImageSaved(any()); |
| } |
| |
| @Test |
| public void saveToUri() throws InterruptedException, FileNotFoundException { |
| // Act. |
| getDefaultImageSaver(mMockYuvImage).run(); |
| mSemaphore.acquire(); |
| |
| // Assert. |
| // Verify success callback is called. |
| ArgumentCaptor<ImageCapture.OutputFileResults> outputFileResultsArgumentCaptor = |
| ArgumentCaptor.forClass(ImageCapture.OutputFileResults.class); |
| verify(mMockCallback).onImageSaved(outputFileResultsArgumentCaptor.capture()); |
| |
| // Verify save location Uri is available. |
| Uri saveLocationUri = outputFileResultsArgumentCaptor.getValue().getSavedUri(); |
| assertThat(saveLocationUri).isNotNull(); |
| |
| // Loads image and verify width and height. |
| ParcelFileDescriptor pfd = mContentResolver.openFileDescriptor(saveLocationUri, "r"); |
| Bitmap bitmap = BitmapFactory.decodeFileDescriptor(pfd.getFileDescriptor()); |
| assertThat(bitmap.getWidth()).isEqualTo(CROP_WIDTH); |
| assertThat(bitmap.getHeight()).isEqualTo(CROP_HEIGHT); |
| |
| // Clean up. |
| mContentResolver.delete(saveLocationUri, null, null); |
| } |
| |
| @Test |
| public void saveToUriWithEmptyCollection_onErrorCalled() throws InterruptedException { |
| // Arrange. |
| ContentValues contentValues = new ContentValues(); |
| contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg"); |
| contentValues.put(MediaStore.MediaColumns.DATA, INVALID_DATA_PATH); |
| ImageSaver imageSaver = getDefaultImageSaver(mMockYuvImage, |
| new ImageCapture.OutputFileOptions.Builder(mContentResolver, |
| Uri.EMPTY, |
| contentValues).build()); |
| |
| // Act. |
| imageSaver.run(); |
| mSemaphore.acquire(); |
| |
| // Assert. |
| verify(mMockCallback).onError(eq(SaveError.FILE_IO_FAILED), any(), any()); |
| } |
| |
| @Test |
| public void saveToOutputStream() throws InterruptedException, IOException { |
| // Arrange. |
| File file = File.createTempFile("test", ".jpg"); |
| file.deleteOnExit(); |
| |
| // Act. |
| try (OutputStream outputStream = new FileOutputStream(file)) { |
| getDefaultImageSaver(mMockYuvImage, outputStream).run(); |
| mSemaphore.acquire(); |
| } |
| |
| // Assert. |
| verify(mMockCallback).onImageSaved(any()); |
| // Loads image and verify width and height. |
| Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath()); |
| assertThat(bitmap.getWidth()).isEqualTo(CROP_WIDTH); |
| assertThat(bitmap.getHeight()).isEqualTo(CROP_HEIGHT); |
| } |
| |
| @Test |
| public void saveToClosedOutputStream_onErrorCalled() throws InterruptedException, |
| IOException { |
| // Arrange. |
| File file = File.createTempFile("test", ".jpg"); |
| file.deleteOnExit(); |
| OutputStream outputStream = new FileOutputStream(file); |
| outputStream.close(); |
| |
| // Act. |
| getDefaultImageSaver(mMockYuvImage, outputStream).run(); |
| mSemaphore.acquire(); |
| |
| // Assert. |
| verify(mMockCallback).onError(eq(SaveError.FILE_IO_FAILED), anyString(), |
| any(Throwable.class)); |
| } |
| |
| @Test |
| public void canSaveJpegImage() throws InterruptedException, IOException { |
| File saveLocation = File.createTempFile("test", ".jpg"); |
| saveLocation.deleteOnExit(); |
| |
| getDefaultImageSaver(mMockJpegImage, saveLocation).run(); |
| mSemaphore.acquire(); |
| |
| verify(mMockCallback).onImageSaved(any()); |
| } |
| |
| @Test |
| public void errorCallbackWillBeCalledOnInvalidPath() throws InterruptedException { |
| // Invalid filename should cause error |
| File saveLocation = new File("/not/a/real/path.jpg"); |
| |
| getDefaultImageSaver(mMockJpegImage, saveLocation).run(); |
| mSemaphore.acquire(); |
| |
| verify(mMockCallback).onError(eq(SaveError.FILE_IO_FAILED), anyString(), |
| any(Throwable.class)); |
| } |
| |
| @Test |
| public void imageIsClosedOnSuccess() throws InterruptedException, IOException { |
| File saveLocation = File.createTempFile("test", ".jpg"); |
| saveLocation.deleteOnExit(); |
| |
| getDefaultImageSaver(mMockJpegImage, saveLocation).run(); |
| |
| mSemaphore.acquire(); |
| |
| verify(mMockJpegImage).close(); |
| } |
| |
| @Test |
| public void imageIsClosedOnError() throws InterruptedException { |
| // Invalid filename should cause error |
| File saveLocation = new File("/not/a/real/path.jpg"); |
| |
| getDefaultImageSaver(mMockJpegImage, saveLocation).run(); |
| mSemaphore.acquire(); |
| |
| verify(mMockJpegImage).close(); |
| } |
| |
| private void imageCanBeCropped(ImageProxy image) throws InterruptedException, IOException { |
| File saveLocation = File.createTempFile("test", ".jpg"); |
| saveLocation.deleteOnExit(); |
| |
| getDefaultImageSaver(image, saveLocation).run(); |
| mSemaphore.acquire(); |
| |
| Bitmap bitmap = BitmapFactory.decodeFile(saveLocation.getPath()); |
| assertThat(bitmap.getWidth()).isEqualTo(CROP_WIDTH); |
| assertThat(bitmap.getHeight()).isEqualTo(CROP_HEIGHT); |
| } |
| |
| @Test |
| public void jpegImageCanBeCropped() throws InterruptedException, IOException { |
| imageCanBeCropped(mMockJpegImage); |
| } |
| |
| @Test |
| public void yuvImageCanBeCropped() throws InterruptedException, IOException { |
| imageCanBeCropped(mMockYuvImage); |
| } |
| } |