From 1318b7d10b691f1782194f96978528f65304ab4e Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Thu, 18 Jul 2019 12:20:27 -0300 Subject: [PATCH] Improve coverage (#507) * Do not use ViewCompat * Remove TODOs, improve logging * SizeSelectorParser and CameraUtils tests * DefaultAutoFOcusMarkerTest * MarkerParserTest and improve MarkerLayoutTest * Add MediaEncoderEngine comments * Add ExifHelper test * ImageHelper test * More internal/utils tests --- cameraview/build.gradle | 1 + .../cameraview/CameraUtilsTest.java | 128 ++++++++--- .../internal/utils/CamcorderProfilesTest.java | 55 +++++ .../internal/utils/ImageHelperTest.java | 102 +++++++++ .../internal/utils/RotationHelperTest.java | 52 +++++ .../internal/utils/WorkerHandlerTest.java | 208 ++++++++++++++++- .../markers/DefaultAutoFocusMarkerTest.java | 104 +++++++++ .../cameraview/markers/MarkerLayoutTest.java | 46 +++- .../cameraview/markers/MarkerParserTest.java | 79 +++++++ .../size/SizeSelectorParserTest.java | 211 ++++++++++++++++++ .../otaliastudios/cameraview/CameraView.java | 15 +- .../internal/utils/ImageHelper.java | 13 +- .../internal/utils/WorkerHandler.java | 5 +- .../markers/DefaultAutoFocusMarker.java | 5 +- .../video/encoding/AudioMediaEncoder.java | 5 +- .../video/encoding/MediaEncoder.java | 10 +- .../video/encoding/MediaEncoderEngine.java | 11 + .../internal/utils/ExifHelperTest.java | 41 ++++ .../cameraview/demo/CameraActivity.java | 52 ++++- demo/src/main/res/layout/activity_camera.xml | 1 + 20 files changed, 1073 insertions(+), 71 deletions(-) create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/RotationHelperTest.java create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarkerTest.java create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerParserTest.java create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/size/SizeSelectorParserTest.java create mode 100644 cameraview/src/test/java/com/otaliastudios/cameraview/internal/utils/ExifHelperTest.java diff --git a/cameraview/build.gradle b/cameraview/build.gradle index 3272c5db..410862b8 100644 --- a/cameraview/build.gradle +++ b/cameraview/build.gradle @@ -223,6 +223,7 @@ task mergedCoverageReport(type: JacocoReport) { classFilter.add('**/com/otaliastudios/cameraview/engine/Camera2Engine**.*') classFilter.add('**/com/otaliastudios/cameraview/picture/**.*') classFilter.add('**/com/otaliastudios/cameraview/video/**.*') + // TODO these below could be testable ALSO outside of the integration tests classFilter.add('**/com/otaliastudios/cameraview/video/encoding/**.*') } classDirectories = fileTree(dir: classDir, excludes: classFilter); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java index b7477105..dd22d5c5 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java @@ -8,6 +8,9 @@ import android.graphics.Color; import com.otaliastudios.cameraview.internal.utils.Op; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.annotation.UiThreadTest; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -15,6 +18,11 @@ import org.junit.Test; import org.junit.runner.RunWith; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.Charset; import static org.mockito.Mockito.*; import static org.junit.Assert.*; @@ -28,44 +36,93 @@ public class CameraUtilsTest extends BaseTest { Context context = mock(Context.class); PackageManager pm = mock(PackageManager.class); when(context.getPackageManager()).thenReturn(pm); - when(pm.hasSystemFeature(anyString())).thenReturn(true); + when(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA)).thenReturn(true); + when(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT)).thenReturn(true); assertTrue(CameraUtils.hasCameras(context)); - - when(pm.hasSystemFeature(anyString())).thenReturn(false); + when(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA)).thenReturn(false); + when(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT)).thenReturn(true); + assertTrue(CameraUtils.hasCameras(context)); + when(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA)).thenReturn(false); + when(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT)).thenReturn(false); assertFalse(CameraUtils.hasCameras(context)); } - // Encodes bitmap and decodes again using our utility. - private Op encodeDecodeTask(Bitmap source) { - return encodeDecodeTask(source, 0, 0); + @NonNull + private Op writeAndReadString(@NonNull String data) { + final File file = new File(getContext().getFilesDir(), "string.txt"); + final byte[] bytes = data.getBytes(Charset.forName("UTF-8")); + final Op result = new Op<>(true); + final FileCallback callback = new FileCallback() { + @Override + public void onFileReady(@Nullable File file) { + if (file == null) { + result.end(null); + } else { + // Read back the file. + try { + FileInputStream stream = new FileInputStream(file); + byte[] bytes = new byte[stream.available()]; + stream.read(bytes); + result.end(new String(bytes, Charset.forName("UTF-8"))); + } catch (IOException e) { + result.end(null); + } + } + } + }; + uiSync(new Runnable() { + @Override + public void run() { + CameraUtils.writeToFile(bytes, file, callback); + } + }); + return result; + } + + @Test + public void testWriteToFile() { + Op op = writeAndReadString("testString"); + String result = op.await(2000); + assertEquals("testString", result); } + // Encodes bitmap and decodes again using our utility. - private Op encodeDecodeTask(Bitmap source, final int maxWidth, final int maxHeight) { + private Op encodeDecodeTask(@NonNull Bitmap source, final int maxWidth, final int maxHeight, boolean async) { final ByteArrayOutputStream os = new ByteArrayOutputStream(); // Using lossy JPG we can't have strict comparison of values after compression. source.compress(Bitmap.CompressFormat.PNG, 100, os); final byte[] data = os.toByteArray(); final Op decode = new Op<>(true); - final BitmapCallback callback = new BitmapCallback() { - @Override - public void onBitmapReady(Bitmap bitmap) { - decode.end(bitmap); - } - }; - - // Run on ui because it involves handlers. - uiSync(new Runnable() { - @Override - public void run() { - if (maxWidth > 0 && maxHeight > 0) { - CameraUtils.decodeBitmap(data, maxWidth, maxHeight, callback); - } else { - CameraUtils.decodeBitmap(data, callback); + if (async) { + final BitmapCallback callback = new BitmapCallback() { + @Override + public void onBitmapReady(Bitmap bitmap) { + decode.end(bitmap); + } + }; + + // Run on ui because it involves handlers. + uiSync(new Runnable() { + @Override + public void run() { + if (maxWidth > 0 && maxHeight > 0) { + CameraUtils.decodeBitmap(data, maxWidth, maxHeight, callback); + } else { + CameraUtils.decodeBitmap(data, callback); + } } + }); + } else { + Bitmap result; + if (maxWidth > 0 && maxHeight > 0) { + result = CameraUtils.decodeBitmap(data, maxWidth, maxHeight); + } else { + result = CameraUtils.decodeBitmap(data); } - }); + decode.end(result); + } return decode; } @@ -75,7 +132,24 @@ public class CameraUtilsTest extends BaseTest { Bitmap source = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); source.setPixel(0, 0, color); - Op decode = encodeDecodeTask(source); + Op decode = encodeDecodeTask(source, 0, 0, true); + Bitmap other = decode.await(800); + assertNotNull(other); + assertEquals(100, w); + assertEquals(200, h); + assertEquals(color, other.getPixel(0, 0)); + assertEquals(0, other.getPixel(0, h-1)); + assertEquals(0, other.getPixel(w-1, 0)); + assertEquals(0, other.getPixel(w-1, h-1)); + } + + @Test + public void testDecodeBitmapSync() { + int w = 100, h = 200, color = Color.WHITE; + Bitmap source = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + source.setPixel(0, 0, color); + + Op decode = encodeDecodeTask(source, 0, 0, false); Bitmap other = decode.await(800); assertNotNull(other); assertEquals(100, w); @@ -94,19 +168,19 @@ public class CameraUtilsTest extends BaseTest { Op op; Bitmap other; - op = encodeDecodeTask(source, 100, 100); + op = encodeDecodeTask(source, 100, 100, true); other = op.await(800); assertNotNull(other); assertTrue(other.getWidth() <= 100); assertTrue(other.getHeight() <= 100); - op = encodeDecodeTask(source, Integer.MAX_VALUE, Integer.MAX_VALUE); + op = encodeDecodeTask(source, Integer.MAX_VALUE, Integer.MAX_VALUE, true); other = op.await(800); assertNotNull(other); assertEquals(other.getWidth(), width); assertEquals(other.getHeight(), height); - op = encodeDecodeTask(source, 6000, 6000); + op = encodeDecodeTask(source, 6000, 6000, true); other = op.await(800); assertNotNull(other); assertEquals(other.getWidth(), width); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java new file mode 100644 index 00000000..a2fef905 --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java @@ -0,0 +1,55 @@ +package com.otaliastudios.cameraview.internal.utils; + + +import android.media.CamcorderProfile; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.CameraUtils; +import com.otaliastudios.cameraview.size.Size; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class CamcorderProfilesTest extends BaseTest { + + private String getCameraId() { + if (CameraUtils.hasCameras(getContext())) { + return "0"; + } + return null; + } + + @Test + public void testInvalidCameraReturnsLowest() { + CamcorderProfile invalid = CamcorderProfiles.get("invalid", new Size(100, 100)); + CamcorderProfile lowest = CamcorderProfile.get(CamcorderProfile.QUALITY_LOW); + assertEquals(lowest.videoFrameWidth, invalid.videoFrameWidth); + assertEquals(lowest.videoFrameHeight, invalid.videoFrameHeight); + } + + @Test + public void testGet() { + String cameraId = getCameraId(); + if (cameraId == null) return; + int cameraIdInt = Integer.parseInt(cameraId); + + // Not much we can test. Let's just ask for lowest and highest. + CamcorderProfile low = CamcorderProfiles.get(cameraId, new Size(1, 1)); + CamcorderProfile high = CamcorderProfiles.get(cameraId, new Size(Integer.MAX_VALUE, Integer.MAX_VALUE)); + + // Compare with lowest + CamcorderProfile lowest = CamcorderProfile.get(cameraIdInt, CamcorderProfile.QUALITY_LOW); + CamcorderProfile highest = CamcorderProfile.get(cameraIdInt, CamcorderProfile.QUALITY_HIGH); + assertEquals(lowest.videoFrameWidth, low.videoFrameWidth); + assertEquals(lowest.videoFrameHeight, low.videoFrameHeight); + assertEquals(highest.videoFrameWidth, high.videoFrameWidth); + assertEquals(highest.videoFrameHeight, high.videoFrameHeight); + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java new file mode 100644 index 00000000..48fc0c87 --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java @@ -0,0 +1,102 @@ +package com.otaliastudios.cameraview.internal.utils; + + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ImageFormat; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.media.Image; +import android.media.ImageReader; +import android.opengl.GLES20; +import android.os.Handler; +import android.os.Looper; +import android.view.Surface; + +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.internal.egl.EglCore; +import com.otaliastudios.cameraview.internal.egl.EglViewport; +import com.otaliastudios.cameraview.internal.egl.EglWindowSurface; +import com.otaliastudios.cameraview.size.AspectRatio; +import com.otaliastudios.cameraview.size.Size; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ImageHelperTest extends BaseTest { + + @NonNull + private Image getImage() { + ImageReader reader = ImageReader.newInstance(100, 100, ImageFormat.YUV_420_888, 1); + Surface readerSurface = reader.getSurface(); + final Op imageOp = new Op<>(true); + reader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { + @Override + public void onImageAvailable(ImageReader reader) { + Image image = reader.acquireLatestImage(); + if (image != null) imageOp.end(image); + } + }, new Handler(Looper.getMainLooper())); + + // Write on reader surface. + Canvas readerCanvas = readerSurface.lockCanvas(null); + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(Color.RED); + readerCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.MULTIPLY); + readerCanvas.drawCircle(50, 50, 50, paint); + readerSurface.unlockCanvasAndPost(readerCanvas); + + // Wait + Image image = imageOp.await(5000); + assertNotNull(image); + return image; + } + + @Test + public void testImage() { + Image image = getImage(); + int width = image.getWidth(); + int height = image.getHeight(); + int bitsPerPixel = ImageFormat.getBitsPerPixel(ImageFormat.NV21); + int sizeBits = width * height * bitsPerPixel; + int sizeBytes = (int) Math.ceil(sizeBits / 8.0d); + byte[] bytes = new byte[sizeBytes]; + ImageHelper.convertToNV21(image, bytes); + image.close(); + + // Read the image + YuvImage yuvImage = new YuvImage(bytes, ImageFormat.NV21, width, height, null); + ByteArrayOutputStream jpegStream = new ByteArrayOutputStream(); + yuvImage.compressToJpeg(new Rect(0, 0, width, height), 100, jpegStream); + byte[] jpegByteArray = jpegStream.toByteArray(); + Bitmap bitmap = BitmapFactory.decodeByteArray(jpegByteArray, 0, jpegByteArray.length); + assertNotNull(bitmap); + + // Wanted to do assertions on the color here but it doesn't work. There must be an issue + // with how we are drawing the image in this test, since in real camera, the algorithm works well. + // So for now let's just test that nothing crashes during this process. + // int color = bitmap.getPixel(bitmap.getWidth() - 1, bitmap.getHeight() - 1); + // assertEquals(Color.red(color), 255, 5); + // assertEquals(Color.green(color), 0, 5); + // assertEquals(Color.blue(color), 0, 5); + // assertEquals(Color.alpha(color), 0, 5); + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/RotationHelperTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/RotationHelperTest.java new file mode 100644 index 00000000..b2be77bc --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/RotationHelperTest.java @@ -0,0 +1,52 @@ +package com.otaliastudios.cameraview.internal.utils; + + +import android.graphics.ImageFormat; +import android.graphics.YuvImage; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.size.Size; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RotationHelperTest extends BaseTest { + + @Test(expected = IllegalArgumentException.class) + public void testInvalidRotation1() { + RotationHelper.rotate(new byte[10], new Size(1, 1), -1); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidRotation2() { + RotationHelper.rotate(new byte[10], new Size(1, 1), -90); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidRotation3() { + RotationHelper.rotate(new byte[10], new Size(1, 1), 360); + } + + @Test + public void testRotate() { + // Just test that nothing happens. + Size inputSize = new Size(160, 90); + int inputSizeBits = inputSize.getWidth() * inputSize.getHeight() * ImageFormat.getBitsPerPixel(ImageFormat.NV21); + int inputSizeBytes = (int) Math.ceil(inputSizeBits / 8.0d); + byte[] input = new byte[inputSizeBytes]; + byte[] output = RotationHelper.rotate(input, inputSize, 90); + assertEquals(input.length, output.length); + + Size outputSize = inputSize.flip(); + YuvImage image = new YuvImage(output, ImageFormat.NV21, outputSize.getWidth(), outputSize.getHeight(), null); + assertNotNull(image); + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/WorkerHandlerTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/WorkerHandlerTest.java index 1dd96c52..acf53a49 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/WorkerHandlerTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/WorkerHandlerTest.java @@ -1,41 +1,229 @@ package com.otaliastudios.cameraview.internal.utils; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.BaseTest; +import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + import static org.junit.Assert.*; +import static org.junit.Assert.assertNotNull; @RunWith(AndroidJUnit4.class) @SmallTest public class WorkerHandlerTest extends BaseTest { @Test - public void testCache() { - WorkerHandler w1 = WorkerHandler.get("handler1"); - WorkerHandler w1a = WorkerHandler.get("handler1"); - WorkerHandler w2 = WorkerHandler.get("handler2"); - assertSame(w1, w1a); - assertNotSame(w1, w2); + public void testGetFromCache() { + WorkerHandler first = WorkerHandler.get("first"); + WorkerHandler second = WorkerHandler.get("first"); + assertSame(first, second); } @Test - public void testStaticRun() { - final Op op = new Op<>(true); - Runnable action = new Runnable() { + public void testGetAnother() { + WorkerHandler first = WorkerHandler.get("first"); + WorkerHandler second = WorkerHandler.get("second"); + assertNotSame(first, second); + } + + @NonNull + private Runnable getRunnableForOp(final @NonNull Op op) { + return new Runnable() { @Override public void run() { op.end(true); } }; - WorkerHandler.execute(action); + } + + @NonNull + private Callable getCallableForOp(final @NonNull Op op) { + return new Callable() { + @Override + public Boolean call() { + op.end(true); + return true; + } + }; + } + + @NonNull + private Callable getThrowCallable() { + return new Callable() { + @Override + public Void call() { + throw new RuntimeException("Fake error"); + } + }; + } + + private void waitOp(@NonNull Op op) { Boolean result = op.await(500); assertNotNull(result); assertTrue(result); } + + @Test + public void testFallbackExecute() { + final Op op = new Op<>(true); + WorkerHandler.execute(getRunnableForOp(op)); + waitOp(op); + } + + @Test + public void testPostRunnable() { + WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.post(getRunnableForOp(op)); + waitOp(op); + } + + @Test + public void testPostCallable() { + WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.post(getCallableForOp(op)); + waitOp(op); + } + + @Test + public void testPostCallable_throws() { + WorkerHandler handler = WorkerHandler.get("handler"); + Task task = handler.post(getThrowCallable()); + try { Tasks.await(task); } catch (ExecutionException | InterruptedException ignore) {} + assertTrue(task.isComplete()); + assertFalse(task.isSuccessful()); + } + + @Test + public void testRunRunnable_background() { + WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.run(getRunnableForOp(op)); + waitOp(op); + } + + @Test + public void testRunRunnable_sameThread() { + final WorkerHandler handler = WorkerHandler.get("handler"); + final Op op1 = new Op<>(true); + final Op op2 = new Op<>(true); + handler.post(new Runnable() { + @Override + public void run() { + handler.run(getRunnableForOp(op2)); + assertTrue(op2.await(0)); // Do not wait. + op1.end(true); + } + }); + waitOp(op1); + } + + @Test + public void testRunCallable_background() { + WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.run(getCallableForOp(op)); + waitOp(op); + } + + @Test + public void testRunCallable_sameThread() { + final WorkerHandler handler = WorkerHandler.get("handler"); + final Op op1 = new Op<>(true); + final Op op2 = new Op<>(true); + handler.post(new Runnable() { + @Override + public void run() { + handler.run(getCallableForOp(op2)); + assertTrue(op2.await(0)); // Do not wait. + op1.end(true); + } + }); + waitOp(op1); + } + + @Test + public void testRunCallable_sameThread_throws() { + final WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.post(new Runnable() { + @Override + public void run() { + Task task = handler.run(getThrowCallable()); + assertTrue(task.isComplete()); // Already complete + assertFalse(task.isSuccessful()); + op.end(true); + } + }); + waitOp(op); + } + + @Test + public void testPostDelayed_tooEarly() { + final WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.post(1000, getRunnableForOp(op)); + assertNull(op.await(500)); + } + + @Test + public void testPostDelayed() { + final WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + handler.post(1000, getRunnableForOp(op)); + assertNotNull(op.await(2000)); + } + + @Test + public void testRemove() { + final WorkerHandler handler = WorkerHandler.get("handler"); + final Op op = new Op<>(true); + Runnable runnable = getRunnableForOp(op); + handler.post(1000, runnable); + handler.remove(runnable); + assertNull(op.await(2000)); + } + + @Test + public void testGetters() { + final WorkerHandler handler = WorkerHandler.get("handler"); + assertNotNull(handler.getExecutor()); + assertNotNull(handler.getHandler()); + assertNotNull(handler.getLooper()); + assertNotNull(handler.getThread()); + } + + @Test + public void testExecutor() { + final WorkerHandler handler = WorkerHandler.get("handler"); + Executor executor = handler.getExecutor(); + final Op op = new Op<>(true); + executor.execute(getRunnableForOp(op)); + waitOp(op); + } + + @Test + public void testDestroy() { + final WorkerHandler handler = WorkerHandler.get("handler"); + assertTrue(handler.getThread().isAlive()); + WorkerHandler.destroy(); + // Wait for the thread to die. + try { handler.getThread().join(500); } catch (InterruptedException ignore) {} + assertFalse(handler.getThread().isAlive()); + WorkerHandler newHandler = WorkerHandler.get("handler"); + assertNotSame(handler, newHandler); + assertTrue(newHandler.getThread().isAlive()); + } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarkerTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarkerTest.java new file mode 100644 index 00000000..76fe970a --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarkerTest.java @@ -0,0 +1,104 @@ +package com.otaliastudios.cameraview.markers; + + +import android.graphics.PointF; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.test.annotation.UiThreadTest; + +import com.otaliastudios.cameraview.BaseTest; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mockito; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +public class DefaultAutoFocusMarkerTest extends BaseTest { + + private DefaultAutoFocusMarker marker; + + @Before + public void setUp() { + marker = new DefaultAutoFocusMarker(); + } + + @After + public void tearDown() { + marker = null; + } + + @Test + public void testOnAttach() { + assertNull(marker.mContainer); + assertNull(marker.mFill); + ViewGroup container = new FrameLayout(getContext()); + View result = marker.onAttach(getContext(), container); + assertNotNull(result); + assertNotNull(marker.mContainer); + assertNotNull(marker.mFill); + } + + @UiThreadTest + @Test + public void testOnAutoFocusStart() { + View mockContainer = spy(new View(getContext())); + View mockFill = spy(new View(getContext())); + marker.mContainer = mockContainer; + marker.mFill = mockFill; + marker.onAutoFocusStart(AutoFocusTrigger.GESTURE, new PointF()); + verify(mockContainer, atLeastOnce()).clearAnimation(); + verify(mockFill, atLeastOnce()).clearAnimation(); + verify(mockContainer, atLeastOnce()).animate(); + verify(mockFill, atLeastOnce()).animate(); + } + + @UiThreadTest + @Test + public void testOnAutoFocusStart_fromMethod() { + View mockContainer = spy(new View(getContext())); + View mockFill = spy(new View(getContext())); + marker.mContainer = mockContainer; + marker.mFill = mockFill; + marker.onAutoFocusStart(AutoFocusTrigger.METHOD, new PointF()); + verify(mockContainer, never()).clearAnimation(); + verify(mockFill, never()).clearAnimation(); + verify(mockContainer, never()).animate(); + verify(mockFill, never()).animate(); + } + + @UiThreadTest + @Test + public void testOnAutoFocusEnd() { + View mockContainer = spy(new View(getContext())); + View mockFill = spy(new View(getContext())); + marker.mContainer = mockContainer; + marker.mFill = mockFill; + marker.onAutoFocusEnd(AutoFocusTrigger.GESTURE, true, new PointF()); + verify(mockContainer, atLeastOnce()).animate(); + verify(mockFill, atLeastOnce()).animate(); + } + + @UiThreadTest + @Test + public void testOnAutoFocusEnd_fromMethod() { + View mockContainer = spy(new View(getContext())); + View mockFill = spy(new View(getContext())); + marker.mContainer = mockContainer; + marker.mFill = mockFill; + marker.onAutoFocusEnd(AutoFocusTrigger.METHOD, true, new PointF()); + verify(mockContainer, never()).animate(); + verify(mockFill, never()).animate(); + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java index a1dd8367..193c28b3 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java @@ -3,6 +3,9 @@ package com.otaliastudios.cameraview.markers; import android.annotation.TargetApi; import android.content.Context; +import android.graphics.PointF; +import android.view.View; +import android.view.ViewGroup; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; @@ -13,9 +16,20 @@ import org.junit.Rule; import org.junit.Test; import org.mockito.Mockito; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.annotation.UiThreadTest; import androidx.test.rule.ActivityTestRule; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + @TargetApi(17) public class MarkerLayoutTest extends BaseTest { @@ -32,9 +46,9 @@ public class MarkerLayoutTest extends BaseTest { @Override public void run() { TestActivity a = rule.getActivity(); - markerLayout = Mockito.spy(new MarkerLayout(a)); + markerLayout = spy(new MarkerLayout(a)); a.inflate(markerLayout); - autoFocusMarker = Mockito.spy(new DefaultAutoFocusMarker()); + autoFocusMarker = spy(new DefaultAutoFocusMarker()); } }); } @@ -43,7 +57,7 @@ public class MarkerLayoutTest extends BaseTest { @UiThreadTest public void testOnMarker_callsOnAttach() { markerLayout.onMarker(MarkerLayout.TYPE_AUTOFOCUS, autoFocusMarker); - Mockito.verify(autoFocusMarker, Mockito.times(1)).onAttach( + Mockito.verify(autoFocusMarker, times(1)).onAttach( Mockito.any(Context.class), Mockito.eq(markerLayout)); } @@ -66,8 +80,32 @@ public class MarkerLayoutTest extends BaseTest { markerLayout.onMarker(MarkerLayout.TYPE_AUTOFOCUS, null); Assert.assertEquals(markerLayout.getChildCount(), 0); - Mockito.verify(autoFocusMarker, Mockito.times(2)).onAttach( + Mockito.verify(autoFocusMarker, times(2)).onAttach( Mockito.any(Context.class), Mockito.eq(markerLayout)); } + + @Test + @UiThreadTest + public void testOnEvent() { + final View mockView = spy(new View(getContext())); + // These fail, however it's not really needed. + // when(mockView.getWidth()).thenReturn(50); + // when(mockView.getHeight()).thenReturn(50); + AutoFocusMarker mockMarker = new AutoFocusMarker() { + public void onAutoFocusStart(@NonNull AutoFocusTrigger trigger, @NonNull PointF point) { } + public void onAutoFocusEnd(@NonNull AutoFocusTrigger trigger, boolean successful, @NonNull PointF point) { } + + @Override + public View onAttach(@NonNull Context context, @NonNull ViewGroup container) { + return mockView; + } + }; + markerLayout.onMarker(MarkerLayout.TYPE_AUTOFOCUS, mockMarker); + reset(mockView); + markerLayout.onEvent(MarkerLayout.TYPE_AUTOFOCUS, new PointF[]{new PointF(0, 0)}); + verify(mockView, times(1)).clearAnimation(); + verify(mockView, times(1)).setTranslationX(anyFloat()); + verify(mockView, times(1)).setTranslationY(anyFloat()); + } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerParserTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerParserTest.java new file mode 100644 index 00000000..30f71c95 --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerParserTest.java @@ -0,0 +1,79 @@ +package com.otaliastudios.cameraview.markers; + + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.PointF; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.arch.core.util.Function; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.R; +import com.otaliastudios.cameraview.size.Size; +import com.otaliastudios.cameraview.size.SizeSelectorParser; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.List; + +import static junit.framework.TestCase.assertNotNull; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class MarkerParserTest extends BaseTest { + + @Test + public void testNullConstructor() { + TypedArray array = mock(TypedArray.class); + when(array.hasValue(R.styleable.CameraView_cameraAutoFocusMarker)).thenReturn(false); + when(array.getString(R.styleable.CameraView_cameraAutoFocusMarker)).thenReturn(null); + MarkerParser parser = new MarkerParser(array); + assertNull(parser.getAutoFocusMarker()); + } + @Test + public void testConstructor() { + TypedArray array = mock(TypedArray.class); + when(array.hasValue(R.styleable.CameraView_cameraAutoFocusMarker)).thenReturn(true); + when(array.getString(R.styleable.CameraView_cameraAutoFocusMarker)).thenReturn(Marker.class.getName()); + MarkerParser parser = new MarkerParser(array); + assertNotNull(parser.getAutoFocusMarker()); + assertTrue(parser.getAutoFocusMarker() instanceof Marker); + } + + public static class Marker implements AutoFocusMarker { + + public Marker() { } + + @Nullable + @Override + public View onAttach(@NonNull Context context, @NonNull ViewGroup container) { + return null; + } + + @Override + public void onAutoFocusStart(@NonNull AutoFocusTrigger trigger, @NonNull PointF point) { } + + @Override + public void onAutoFocusEnd(@NonNull AutoFocusTrigger trigger, boolean successful, @NonNull PointF point) { } + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/size/SizeSelectorParserTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/size/SizeSelectorParserTest.java new file mode 100644 index 00000000..34047923 --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/size/SizeSelectorParserTest.java @@ -0,0 +1,211 @@ +package com.otaliastudios.cameraview.size; + + +import android.content.res.TypedArray; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleableRes; +import androidx.arch.core.util.Function; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.R; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class SizeSelectorParserTest extends BaseTest { + + private MockTypedArray input; + private List sizes = Arrays.asList( + new Size(100, 200), + new Size(150, 300), + new Size(600, 900), + new Size(600, 600), + new Size(1600, 900), + new Size(30, 40), + new Size(40, 30), + new Size(2000, 4000) + ); + + @Before + public void setUp() { + input = new MockTypedArray(); + } + + @After + public void tearDown() { + input = null; + } + + private void doAssert(@NonNull Function, Void> assertions) { + SizeSelectorParser parser = new SizeSelectorParser(input.array); + assertions.apply(parser.getPictureSizeSelector().select(sizes)); + assertions.apply(parser.getVideoSizeSelector().select(sizes)); + } + + @Test + public void testWidth() { + input.setMinWidth(1500); + input.setMaxWidth(1700); + doAssert(new Function, Void>() { + @Override + public Void apply(List input) { + assertEquals(1, input.size()); + assertEquals(new Size(1600, 900), input.get(0)); + return null; + } + }); + } + + @Test + public void testHeight() { + input.setMinHeight(25); + input.setMaxHeight(35); + doAssert(new Function, Void>() { + @Override + public Void apply(List input) { + assertEquals(1, input.size()); + assertEquals(new Size(40, 30), input.get(0)); + return null; + } + }); + } + + @Test + public void testArea() { + input.setMinArea(30 * 30); + input.setMaxArea(40 * 40); + doAssert(new Function, Void>() { + @Override + public Void apply(List input) { + assertEquals(2, input.size()); + assertTrue(input.contains(new Size(40, 30))); + assertTrue(input.contains(new Size(30, 40))); + return null; + } + }); + } + + @Test + public void testSmallest() { + input.setSmallest(true); + doAssert(new Function, Void>() { + @Override + public Void apply(List input) { + assertEquals(sizes.size(), input.size()); + Size first = input.get(0); + assertEquals(30 * 40, first.getWidth() * first.getHeight()); + return null; + } + }); + } + + @Test + public void testBiggest() { + input.setBiggest(true); + doAssert(new Function, Void>() { + @Override + public Void apply(List input) { + assertEquals(sizes.size(), input.size()); + assertEquals(new Size(2000, 4000), input.get(0)); + return null; + } + }); + } + + @Test + public void testAspectRatio() { + input.setAspectRatio("16:9"); + doAssert(new Function, Void>() { + @Override + public Void apply(List input) { + assertEquals(1, input.size()); + assertEquals(new Size(1600, 900), input.get(0)); + return null; + } + }); + } + + @SuppressWarnings("SameParameterValue") + private class MockTypedArray { + private TypedArray array = mock(TypedArray.class); + + private void setIntValue(@StyleableRes int index, int value) { + when(array.hasValue(index)).thenReturn(true); + when(array.getInteger(eq(index), anyInt())).thenReturn(value); + } + + private void setBooleanValue(@StyleableRes int index, boolean value) { + when(array.hasValue(index)).thenReturn(true); + when(array.getBoolean(eq(index), anyBoolean())).thenReturn(value); + } + + private void setStringValue(@StyleableRes int index, @NonNull String value) { + when(array.hasValue(index)).thenReturn(true); + when(array.getString(index)).thenReturn(value); + } + + private void setMinWidth(int value) { + setIntValue(R.styleable.CameraView_cameraPictureSizeMinWidth, value); + setIntValue(R.styleable.CameraView_cameraVideoSizeMinWidth, value); + } + + private void setMaxWidth(int value) { + setIntValue(R.styleable.CameraView_cameraPictureSizeMaxWidth, value); + setIntValue(R.styleable.CameraView_cameraVideoSizeMaxWidth, value); + } + + private void setMinHeight(int value) { + setIntValue(R.styleable.CameraView_cameraPictureSizeMinHeight, value); + setIntValue(R.styleable.CameraView_cameraVideoSizeMinHeight, value); + } + + private void setMaxHeight(int value) { + setIntValue(R.styleable.CameraView_cameraPictureSizeMaxHeight, value); + setIntValue(R.styleable.CameraView_cameraVideoSizeMaxHeight, value); + } + + private void setMinArea(int value) { + setIntValue(R.styleable.CameraView_cameraPictureSizeMinArea, value); + setIntValue(R.styleable.CameraView_cameraVideoSizeMinArea, value); + } + + private void setMaxArea(int value) { + setIntValue(R.styleable.CameraView_cameraPictureSizeMaxArea, value); + setIntValue(R.styleable.CameraView_cameraVideoSizeMaxArea, value); + } + + private void setSmallest(boolean value) { + setBooleanValue(R.styleable.CameraView_cameraPictureSizeSmallest, value); + setBooleanValue(R.styleable.CameraView_cameraVideoSizeSmallest, value); + } + + private void setBiggest(boolean value) { + setBooleanValue(R.styleable.CameraView_cameraPictureSizeBiggest, value); + setBooleanValue(R.styleable.CameraView_cameraVideoSizeBiggest, value); + } + + private void setAspectRatio(@NonNull String value) { + setStringValue(R.styleable.CameraView_cameraPictureSizeAspectRatio, value); + setStringValue(R.styleable.CameraView_cameraVideoSizeAspectRatio, value); + } + } +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index d7eed69c..e54bfaab 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -6,7 +6,6 @@ import android.annotation.TargetApi; import android.app.Activity; import androidx.annotation.VisibleForTesting; -import androidx.core.view.ViewCompat; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LifecycleOwner; @@ -830,12 +829,14 @@ public class CameraView extends FrameLayout implements LifecycleObserver { */ public void setPreview(@NonNull Preview preview) { boolean isNew = preview != mPreview; - if (!isNew) return; - mPreview = preview; - if (!ViewCompat.isAttachedToWindow(this) && mCameraPreview != null) { - // Null the preview: will create another when re-attaching. - mCameraPreview.onDestroy(); - mCameraPreview = null; + if (isNew) { + mPreview = preview; + boolean isAttachedToWindow = getWindowToken() != null; + if (!isAttachedToWindow && mCameraPreview != null) { + // Null the preview: will create another when re-attaching. + mCameraPreview.onDestroy(); + mCameraPreview = null; + } } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/ImageHelper.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/ImageHelper.java index c92078cd..b099b404 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/ImageHelper.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/ImageHelper.java @@ -1,12 +1,8 @@ package com.otaliastudios.cameraview.internal.utils; import android.graphics.ImageFormat; -import android.graphics.Rect; import android.media.Image; -import com.otaliastudios.cameraview.size.AspectRatio; -import com.otaliastudios.cameraview.size.Size; - import java.nio.ByteBuffer; import androidx.annotation.NonNull; @@ -18,7 +14,14 @@ import androidx.annotation.RequiresApi; @RequiresApi(19) public class ImageHelper { - // https://stackoverflow.com/a/52740776/4288782 + /** + * From https://stackoverflow.com/a/52740776/4288782 . + * The result array should have a size that is at least 3/2 * w * h. + * This is correctly computed by {@link com.otaliastudios.cameraview.frame.FrameManager}. + * + * @param image input image + * @param result output array + */ public static void convertToNV21(@NonNull Image image, @NonNull byte[] result) { if (image.getFormat() != ImageFormat.YUV_420_888) { throw new IllegalStateException("CAn only convert from YUV_420_888."); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java index c2a38843..0b932842 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java @@ -165,6 +165,7 @@ public class WorkerHandler { * Returns the android backing {@link Handler}. * @return the handler */ + @NonNull public Handler getHandler() { return mHandler; } @@ -207,7 +208,9 @@ public class WorkerHandler { WorkerHandler handler = ref.get(); if (handler != null && handler.getThread().isAlive()) { handler.getThread().interrupt(); - // handler.getThread().quit(); + handler.getThread().quit(); + // after quit(), the thread will die at some point in the future. Might take some ms. + // try { handler.getThread().join(); } catch (InterruptedException ignore) {} } ref.clear(); } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarker.java b/cameraview/src/main/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarker.java index ef7b5ffa..5a206957 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarker.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/markers/DefaultAutoFocusMarker.java @@ -12,6 +12,7 @@ import com.otaliastudios.cameraview.R; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; /** * A default implementation of {@link AutoFocusMarker}. @@ -20,8 +21,8 @@ import androidx.annotation.Nullable; */ public class DefaultAutoFocusMarker implements AutoFocusMarker { - private View mContainer; - private View mFill; + @VisibleForTesting View mContainer; + @VisibleForTesting View mFill; @Nullable @Override diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java index e24a6273..070eb82f 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java @@ -28,7 +28,7 @@ public class AudioMediaEncoder extends MediaEncoder { private static final String TAG = AudioMediaEncoder.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); - private static final boolean PERFORMANCE_DEBUG = false; + private static final boolean PERFORMANCE_DEBUG = true; private static final boolean PERFORMANCE_FILL_GAPS = true; private boolean mRequestStop = false; @@ -67,7 +67,7 @@ public class AudioMediaEncoder extends MediaEncoder { mConfig.channels); audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); audioFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, mConfig.audioFormatChannels()); - audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, mConfig.bitRate); // TODO multiply by channels? + audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, mConfig.bitRate); try { mMediaCodec = MediaCodec.createEncoderByType(mConfig.mimeType); } catch (IOException e) { @@ -157,7 +157,6 @@ public class AudioMediaEncoder extends MediaEncoder { @Override public void run() { - mLastTimeUs = System.nanoTime() / 1000L; mAudioRecord.startRecording(); while (!mRequestStop) { read(false); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java index eec9cbee..d3efef0d 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java @@ -125,6 +125,8 @@ public abstract class MediaEncoder { private long mStartTimeUs = Long.MIN_VALUE; // In unknown reference private long mLastTimeUs = 0; + private long mDebugSetStateTimestamp = Long.MIN_VALUE; + /** * Needs a readable name for the thread and for logging. * @param name a name @@ -135,6 +137,12 @@ public abstract class MediaEncoder { } private void setState(int newState) { + if (mDebugSetStateTimestamp == Long.MIN_VALUE) { + mDebugSetStateTimestamp = System.currentTimeMillis(); + } + long millis = System.currentTimeMillis() - mDebugSetStateTimestamp; + mDebugSetStateTimestamp = System.currentTimeMillis(); + String newStateName = null; switch (newState) { case STATE_NONE: newStateName = "NONE"; break; @@ -146,7 +154,7 @@ public abstract class MediaEncoder { case STATE_STOPPING: newStateName = "STOPPING"; break; case STATE_STOPPED: newStateName = "STOPPED"; break; } - LOG.w(mName, "setState:", newStateName); + LOG.w(mName, "setState:", newStateName, "millisSinceLastState:", millis); mState = newState; } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java index 904e668f..6e2725dc 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java @@ -309,6 +309,17 @@ public class MediaEncoderEngine { /** * Writes the given data to the muxer. Should be called after {@link #isStarted()} * returns true. Note: this seems to be thread safe, no lock. + * + * TODO: Skip first frames from encoder A when encoder B reported a firstTimeMillis + * time that is significantly later. This can happen even if we wait for both to start, + * because {@link MediaEncoder#notifyFirstFrameMillis(long)} can be called while the + * muxer is still closed. + * + * The firstFrameMillis still has a value in computing the absolute times, but it is meant + * to be the time of the first frame read, not necessarily a frame that will be written. + * + * This controller should coordinate between firstFrameMillis and skip frames that have + * large differences. */ public void write(@NonNull OutputBufferPool pool, @NonNull OutputBuffer buffer) { if (!mMediaMuxerStarted) { diff --git a/cameraview/src/test/java/com/otaliastudios/cameraview/internal/utils/ExifHelperTest.java b/cameraview/src/test/java/com/otaliastudios/cameraview/internal/utils/ExifHelperTest.java new file mode 100644 index 00000000..4f4ccb7f --- /dev/null +++ b/cameraview/src/test/java/com/otaliastudios/cameraview/internal/utils/ExifHelperTest.java @@ -0,0 +1,41 @@ +package com.otaliastudios.cameraview.internal.utils; + + +import androidx.exifinterface.media.ExifInterface; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static junit.framework.Assert.assertNotNull; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ExifHelperTest { + + @Test + public void testValues() { + assertEquals(0, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_NORMAL)); + assertEquals(0, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_FLIP_HORIZONTAL)); + assertEquals(180, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_ROTATE_180)); + assertEquals(180, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_FLIP_VERTICAL)); + assertEquals(90, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_ROTATE_90)); + assertEquals(90, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_TRANSPOSE)); + assertEquals(270, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_ROTATE_270)); + assertEquals(270, ExifHelper.readExifOrientation(ExifInterface.ORIENTATION_TRANSVERSE)); + } + + @Test + public void testUnknownValues() { + assertEquals(0, ExifHelper.readExifOrientation(-15)); + assertEquals(0, ExifHelper.readExifOrientation(-1)); + assertEquals(0, ExifHelper.readExifOrientation(195)); + assertEquals(0, ExifHelper.readExifOrientation(Integer.MAX_VALUE)); + } + +} diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java index 1b81d370..7fdcfb89 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java @@ -5,6 +5,11 @@ import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Intent; import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; import android.os.Bundle; import androidx.annotation.NonNull; import com.google.android.material.bottomsheet.BottomSheetBehavior; @@ -27,7 +32,10 @@ import com.otaliastudios.cameraview.PictureResult; import com.otaliastudios.cameraview.controls.Mode; import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.controls.Preview; +import com.otaliastudios.cameraview.frame.Frame; +import com.otaliastudios.cameraview.frame.FrameProcessor; +import java.io.ByteArrayOutputStream; import java.io.File; import java.util.Arrays; import java.util.List; @@ -36,6 +44,8 @@ import java.util.List; public class CameraActivity extends AppCompatActivity implements View.OnClickListener, OptionView.Callback { private final static CameraLogger LOG = CameraLogger.create("DemoApp"); + private final static boolean USE_FRAME_PROCESSOR = true; + private final static boolean DECODE_BITMAP = true; private CameraView camera; private ViewGroup controlPanel; @@ -51,16 +61,33 @@ public class CameraActivity extends AppCompatActivity implements View.OnClickLis camera.setLifecycleOwner(this); camera.addCameraListener(new Listener()); - /* camera.addFrameProcessor(new FrameProcessor() { - private long lastTime = System.currentTimeMillis(); - @Override - public void process(@NonNull Frame frame) { - long newTime = frame.getTime(); - long delay = newTime - lastTime; - lastTime = newTime; - Log.e("Frames", "Delay: " + delay + "millis, FPS: " + 1000 / delay); - } - }); */ + if (USE_FRAME_PROCESSOR) { + camera.addFrameProcessor(new FrameProcessor() { + private long lastTime = System.currentTimeMillis(); + + @Override + public void process(@NonNull Frame frame) { + long newTime = frame.getTime(); + long delay = newTime - lastTime; + lastTime = newTime; + LOG.e("Frame delayMillis:", delay, "FPS:", 1000 / delay); + if (DECODE_BITMAP) { + YuvImage yuvImage = new YuvImage(frame.getData(), ImageFormat.NV21, + frame.getSize().getWidth(), + frame.getSize().getHeight(), + null); + ByteArrayOutputStream jpegStream = new ByteArrayOutputStream(); + yuvImage.compressToJpeg(new Rect(0, 0, + frame.getSize().getWidth(), + frame.getSize().getHeight()), 100, jpegStream); + byte[] jpegByteArray = jpegStream.toByteArray(); + Bitmap bitmap = BitmapFactory.decodeByteArray(jpegByteArray, 0, jpegByteArray.length); + //noinspection ResultOfMethodCallIgnored + bitmap.toString(); + } + } + }); + } findViewById(R.id.edit).setOnClickListener(this); findViewById(R.id.capturePicture).setOnClickListener(this); @@ -174,20 +201,23 @@ public class CameraActivity extends AppCompatActivity implements View.OnClickLis // This can happen if picture was taken with a gesture. long callbackTime = System.currentTimeMillis(); if (mCaptureTime == 0) mCaptureTime = callbackTime - 300; + LOG.w("onPictureTaken called! Launching activity. Delay:", callbackTime - mCaptureTime); PicturePreviewActivity.setPictureResult(result); Intent intent = new Intent(CameraActivity.this, PicturePreviewActivity.class); intent.putExtra("delay", callbackTime - mCaptureTime); - LOG.w("Picture delay:", callbackTime - mCaptureTime); startActivity(intent); mCaptureTime = 0; + LOG.w("onPictureTaken called! Launched activity."); } @Override public void onVideoTaken(@NonNull VideoResult result) { super.onVideoTaken(result); + LOG.w("onVideoTaken called! Launching activity."); VideoPreviewActivity.setVideoResult(result); Intent intent = new Intent(CameraActivity.this, VideoPreviewActivity.class); startActivity(intent); + LOG.w("onVideoTaken called! Launched activity."); } @Override diff --git a/demo/src/main/res/layout/activity_camera.xml b/demo/src/main/res/layout/activity_camera.xml index 15c40ade..1f94941c 100644 --- a/demo/src/main/res/layout/activity_camera.xml +++ b/demo/src/main/res/layout/activity_camera.xml @@ -23,6 +23,7 @@ app:cameraGrid="off" app:cameraFlash="off" app:cameraAudio="on" + app:cameraFacing="front" app:cameraGestureTap="autoFocus" app:cameraGestureLongTap="none" app:cameraGesturePinch="zoom"