diff --git a/cameraview/build.gradle b/cameraview/build.gradle index 08238ef7..9da69d03 100644 --- a/cameraview/build.gradle +++ b/cameraview/build.gradle @@ -34,18 +34,19 @@ android { dependencies { testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-core:2.23.0' + testImplementation 'org.mockito:mockito-core:2.28.2' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test:rules:1.1.1' - androidTestImplementation 'androidx.test.ext:junit:1.1.0' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test:rules:1.2.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'com.google.dexmaker:dexmaker:1.2' androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' api 'androidx.exifinterface:exifinterface:1.0.0' api 'androidx.lifecycle:lifecycle-common:2.1.0-alpha01' - implementation 'androidx.annotation:annotation:1.0.1' + api 'com.google.android.gms:play-services-tasks:17.0.0' + implementation 'androidx.annotation:annotation:1.1.0' } //endregion diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java index a2363b2c..03ab2945 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java @@ -10,9 +10,10 @@ import android.os.PowerManager; import androidx.test.platform.app.InstrumentationRegistry; +import android.util.Log; import android.view.View; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import org.junit.After; import org.junit.AfterClass; @@ -131,23 +132,23 @@ public class BaseTest { }); } - public static Stubber doEndTask(final Task task, final T response) { + public static Stubber doEndTask(final Op op, final T response) { return doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { - task.end(response); + op.end(response); return null; } }); } - public static Stubber doEndTask(final Task task, final int withReturnArgument) { + public static Stubber doEndTask(final Op op, final int withReturnArgument) { return doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { Object o = invocation.getArguments()[withReturnArgument]; //noinspection unchecked - task.end(o); + op.end(o); return null; } }); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraLoggerTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraLoggerTest.java index 43b562c0..c8ef7b4c 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraLoggerTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraLoggerTest.java @@ -1,7 +1,7 @@ package com.otaliastudios.cameraview; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -109,27 +109,27 @@ public class CameraLoggerTest extends BaseTest { CameraLogger.Logger mock = mock(CameraLogger.Logger.class); CameraLogger.registerLogger(mock); - final Task task = new Task<>(); + final Op op = new Op<>(); doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { Object[] args = invocation.getArguments(); Throwable throwable = (Throwable) args[3]; - task.end(throwable); + op.end(throwable); return null; } }).when(mock).log(anyInt(), anyString(), anyString(), any(Throwable.class)); - task.listen(); + op.listen(); logger.e("Got no error."); - assertNull(task.await(100)); + assertNull(op.await(100)); - task.listen(); + op.listen(); logger.e("Got error:", new RuntimeException("")); - assertNotNull(task.await(100)); + assertNotNull(op.await(100)); - task.listen(); + op.listen(); logger.e("Got", new RuntimeException(""), "while starting"); - assertNotNull(task.await(100)); + assertNotNull(op.await(100)); } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java index 314c874c..d4209374 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java @@ -6,7 +6,7 @@ import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Color; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -36,18 +36,18 @@ public class CameraUtilsTest extends BaseTest { } // Encodes bitmap and decodes again using our utility. - private Task encodeDecodeTask(Bitmap source) { + private Op encodeDecodeTask(Bitmap source) { return encodeDecodeTask(source, 0, 0); } // Encodes bitmap and decodes again using our utility. - private Task encodeDecodeTask(Bitmap source, final int maxWidth, final int maxHeight) { + private Op encodeDecodeTask(Bitmap source, final int maxWidth, final int maxHeight) { 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 Task decode = new Task<>(true); + final Op decode = new Op<>(true); final BitmapCallback callback = new BitmapCallback() { @Override public void onBitmapReady(Bitmap bitmap) { @@ -75,7 +75,7 @@ public class CameraUtilsTest extends BaseTest { Bitmap source = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); source.setPixel(0, 0, color); - Task decode = encodeDecodeTask(source); + Op decode = encodeDecodeTask(source); Bitmap other = decode.await(800); assertNotNull(other); assertEquals(100, w); @@ -93,23 +93,23 @@ public class CameraUtilsTest extends BaseTest { public void testDecodeDownscaledBitmap() { int width = 1000, height = 2000; Bitmap source = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Task task; + Op op; Bitmap other; - task = encodeDecodeTask(source, 100, 100); - other = task.await(800); + op = encodeDecodeTask(source, 100, 100); + other = op.await(800); assertNotNull(other); assertTrue(other.getWidth() <= 100); assertTrue(other.getHeight() <= 100); - task = encodeDecodeTask(source, Integer.MAX_VALUE, Integer.MAX_VALUE); - other = task.await(800); + op = encodeDecodeTask(source, Integer.MAX_VALUE, Integer.MAX_VALUE); + other = op.await(800); assertNotNull(other); assertEquals(other.getWidth(), width); assertEquals(other.getHeight(), height); - task = encodeDecodeTask(source, 6000, 6000); - other = task.await(800); + op = encodeDecodeTask(source, 6000, 6000); + other = op.await(800); assertNotNull(other); assertEquals(other.getWidth(), width); assertEquals(other.getHeight(), height); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java index 2ed8c815..90f03a94 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java @@ -17,7 +17,7 @@ import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameProcessor; import com.otaliastudios.cameraview.gesture.Gesture; import com.otaliastudios.cameraview.gesture.GestureAction; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.engine.MockCameraEngine; import com.otaliastudios.cameraview.markers.AutoFocusMarker; import com.otaliastudios.cameraview.markers.AutoFocusTrigger; @@ -39,6 +39,7 @@ import static org.junit.Assert.assertNull; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyFloat; import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -57,7 +58,7 @@ public class CameraViewCallbacksTest extends BaseTest { private FrameProcessor processor; private MockCameraEngine mockController; private MockCameraPreview mockPreview; - private Task task; + private Op op; @Before public void setUp() { @@ -91,7 +92,7 @@ public class CameraViewCallbacksTest extends BaseTest { camera.doInstantiatePreview(); camera.addCameraListener(listener); camera.addFrameProcessor(processor); - task = new Task<>(true); + op = new Op<>(true); } }); } @@ -104,12 +105,12 @@ public class CameraViewCallbacksTest extends BaseTest { listener = null; } - // Completes our task. + // Completes our op. private Stubber completeTask() { return doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { - task.end(true); + op.end(true); return null; } }); @@ -121,7 +122,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onCameraOpened(null); camera.mCameraCallbacks.dispatchOnCameraOpened(null); - assertNull(task.await(200)); + assertNull(op.await(200)); verify(listener, never()).onCameraOpened(null); } @@ -131,7 +132,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onCameraOpened(null); camera.mCameraCallbacks.dispatchOnCameraOpened(null); - assertNull(task.await(200)); + assertNull(op.await(200)); verify(listener, never()).onCameraOpened(null); } @@ -140,7 +141,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onCameraOpened(null); camera.mCameraCallbacks.dispatchOnCameraOpened(null); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onCameraOpened(null); } @@ -149,7 +150,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onCameraClosed(); camera.mCameraCallbacks.dispatchOnCameraClosed(); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onCameraClosed(); } @@ -159,7 +160,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onVideoTaken(any(VideoResult.class)); camera.mCameraCallbacks.dispatchOnVideoTaken(stub); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onVideoTaken(any(VideoResult.class)); } @@ -168,7 +169,7 @@ public class CameraViewCallbacksTest extends BaseTest { PictureResult.Stub stub = new PictureResult.Stub(); completeTask().when(listener).onPictureTaken(any(PictureResult.class)); camera.mCameraCallbacks.dispatchOnPictureTaken(stub); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onPictureTaken(any(PictureResult.class)); } @@ -177,7 +178,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onZoomChanged(anyFloat(), any(float[].class), any(PointF[].class)); camera.mCameraCallbacks.dispatchOnZoomChanged(0f, null); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onZoomChanged(anyFloat(), any(float[].class), any(PointF[].class)); } @@ -186,7 +187,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onExposureCorrectionChanged(0f, null, null); camera.mCameraCallbacks.dispatchOnExposureCorrectionChanged(0f, null, null); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onExposureCorrectionChanged(0f, null, null); } @@ -204,10 +205,10 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onAutoFocusStart(point); camera.mCameraCallbacks.dispatchOnFocusStart(Gesture.TAP, point); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onAutoFocusStart(point); verify(marker, times(1)).onAutoFocusStart(AutoFocusTrigger.GESTURE, point); - verify(markerLayout, times(1)).onEvent(MarkerLayout.TYPE_AUTOFOCUS, any(PointF[].class)); + verify(markerLayout, times(1)).onEvent(eq(MarkerLayout.TYPE_AUTOFOCUS), any(PointF[].class)); } @Test @@ -223,7 +224,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onAutoFocusEnd(success, point); camera.mCameraCallbacks.dispatchOnFocusEnd(Gesture.TAP, success, point); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onAutoFocusEnd(success, point); verify(marker, times(1)).onAutoFocusEnd(AutoFocusTrigger.GESTURE, success, point); @@ -234,7 +235,7 @@ public class CameraViewCallbacksTest extends BaseTest { public void testOrientationCallbacks() { completeTask().when(listener).onOrientationChanged(anyInt()); camera.mCameraCallbacks.onDeviceOrientationChanged(90); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onOrientationChanged(anyInt()); } @@ -246,7 +247,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(listener).onCameraError(error); camera.mCameraCallbacks.dispatchError(error); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(listener, times(1)).onCameraError(error); } @@ -256,7 +257,7 @@ public class CameraViewCallbacksTest extends BaseTest { completeTask().when(processor).process(mock); camera.mCameraCallbacks.dispatchFrame(mock); - assertNotNull(task.await(200)); + assertNotNull(op.await(200)); verify(processor, times(1)).process(mock); } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java index 57912b7f..13f2419e 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java @@ -32,7 +32,7 @@ import com.otaliastudios.cameraview.gesture.PinchGestureLayout; import com.otaliastudios.cameraview.gesture.ScrollGestureLayout; import com.otaliastudios.cameraview.gesture.TapGestureLayout; import com.otaliastudios.cameraview.engine.MockCameraEngine; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.markers.AutoFocusMarker; import com.otaliastudios.cameraview.markers.DefaultAutoFocusMarker; import com.otaliastudios.cameraview.markers.MarkerLayout; @@ -226,7 +226,7 @@ public class CameraViewTest extends BaseTest { public void testGestureAction_capture() { CameraOptions o = mock(CameraOptions.class); mockController.setMockCameraOptions(o); - mockController.mockStarted(true); + mockController.setMockEngineState(true); MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); ui(new Runnable() { @Override @@ -248,7 +248,7 @@ public class CameraViewTest extends BaseTest { public void testGestureAction_focus() { CameraOptions o = mock(CameraOptions.class); mockController.setMockCameraOptions(o); - mockController.mockStarted(true); + mockController.setMockEngineState(true); MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); ui(new Runnable() { @Override @@ -273,7 +273,7 @@ public class CameraViewTest extends BaseTest { public void testGestureAction_zoom() { CameraOptions o = mock(CameraOptions.class); mockController.setMockCameraOptions(o); - mockController.mockStarted(true); + mockController.setMockEngineState(true); mockController.mZoomChanged = false; MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); final FactorHolder factor = new FactorHolder(); @@ -314,7 +314,7 @@ public class CameraViewTest extends BaseTest { when(o.getExposureCorrectionMinValue()).thenReturn(-10f); when(o.getExposureCorrectionMaxValue()).thenReturn(10f); mockController.setMockCameraOptions(o); - mockController.mockStarted(true); + mockController.setMockEngineState(true); mockController.mExposureCorrectionChanged = false; MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); final FactorHolder factor = new FactorHolder(); @@ -754,16 +754,16 @@ public class CameraViewTest extends BaseTest { cameraView.mMarkerLayout = markerLayout; final PointF point = new PointF(0, 0); final PointF[] points = new PointF[]{ point }; - final Task task = new Task<>(true); + final Op op = new Op<>(true); doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { - task.end(true); + op.end(true); return null; } }).when(markerLayout).onEvent(MarkerLayout.TYPE_AUTOFOCUS, points); cameraView.mCameraCallbacks.dispatchOnFocusStart(Gesture.TAP, point); - assertNotNull(task.await(100)); + assertNotNull(op.await(100)); } //endregion diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java new file mode 100644 index 00000000..4a449d0d --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java @@ -0,0 +1,28 @@ +package com.otaliastudios.cameraview.engine; + +import com.otaliastudios.cameraview.controls.Engine; + +import org.junit.Ignore; +import org.junit.runner.RunWith; + +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +/** + * These tests work great on real devices, and are the only way to test actual CameraEngine + * implementation - we really need to open the camera device. + * Unfortunately they fail unreliably on emulated devices, due to some bug with the + * emulated camera controller. Waiting for it to be fixed. + */ +@RunWith(AndroidJUnit4.class) +@LargeTest +@Ignore +public class Camera1IntegrationTest extends CameraIntegrationTest { + + @NonNull + @Override + protected Engine getEngine() { + return Engine.CAMERA2; + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera2IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera2IntegrationTest.java new file mode 100644 index 00000000..6684badd --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera2IntegrationTest.java @@ -0,0 +1,28 @@ +package com.otaliastudios.cameraview.engine; + +import com.otaliastudios.cameraview.controls.Engine; + +import org.junit.Ignore; +import org.junit.runner.RunWith; + +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +/** + * These tests work great on real devices, and are the only way to test actual CameraEngine + * implementation - we really need to open the camera device. + * Unfortunately they fail unreliably on emulated devices, due to some bug with the + * emulated camera controller. Waiting for it to be fixed. + */ +@RunWith(AndroidJUnit4.class) +@LargeTest +@Ignore +public class Camera2IntegrationTest extends CameraIntegrationTest { + + @NonNull + @Override + protected Engine getEngine() { + return Engine.CAMERA1; + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java similarity index 82% rename from cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java rename to cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java index 639df3e7..2dce655a 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java @@ -5,6 +5,7 @@ import android.graphics.Bitmap; import android.graphics.PointF; import android.hardware.Camera; import android.os.Build; +import android.util.Log; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.CameraListener; @@ -22,13 +23,15 @@ import com.otaliastudios.cameraview.controls.Mode; import com.otaliastudios.cameraview.controls.WhiteBalance; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameProcessor; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.internal.utils.WorkerHandler; import com.otaliastudios.cameraview.size.Size; import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; import androidx.test.filters.MediumTest; +import androidx.test.filters.SmallTest; import androidx.test.rule.ActivityTestRule; import org.junit.After; @@ -48,35 +51,30 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.anyBoolean; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; -/** - * These tests work great on real devices, and are the only way to test actual CameraEngine - * implementation - we really need to open the camera device. - * Unfortunately they fail unreliably on emulated devices, due to some bug with the - * emulated camera controller. Waiting for it to be fixed. - */ -@RunWith(AndroidJUnit4.class) -@MediumTest -@Ignore -public class IntegrationTest extends BaseTest { +public abstract class CameraIntegrationTest extends BaseTest { @Rule public ActivityTestRule rule = new ActivityTestRule<>(TestActivity.class); private CameraView camera; - private Camera1Engine controller; + private CameraEngine controller; private CameraListener listener; - private Task uiExceptionTask; + private Op uiExceptionOp; @BeforeClass public static void grant() { grantPermissions(); } + @NonNull + protected abstract Engine getEngine(); + @Before public void setUp() { WorkerHandler.destroy(); @@ -89,27 +87,26 @@ public class IntegrationTest extends BaseTest { @NonNull @Override protected CameraEngine instantiateCameraEngine(@NonNull Engine engine, @NonNull CameraEngine.Callback callback) { - controller = new Camera1Engine(callback); + controller = super.instantiateCameraEngine(getEngine(), callback); return controller; } }; - listener = mock(CameraListener.class); camera.addCameraListener(listener); rule.getActivity().inflate(camera); - } - }); - // Ensure that controller exceptions are thrown on this thread (not on the UI thread). - uiExceptionTask = new Task<>(true); - WorkerHandler crashThread = WorkerHandler.get("CrashThread"); - crashThread.getThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - uiExceptionTask.end(e); + // Ensure that controller exceptions are thrown on this thread (not on the UI thread). + uiExceptionOp = new Op<>(true); + WorkerHandler crashThread = WorkerHandler.get("CrashThread"); + crashThread.getThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + uiExceptionOp.end(e); + } + }); + controller.mCrashHandler = crashThread.getHandler(); } }); - controller.mCrashHandler = crashThread.getHandler(); } @After @@ -120,13 +117,13 @@ public class IntegrationTest extends BaseTest { } private void waitForUiException() throws Throwable { - Throwable throwable = uiExceptionTask.await(2500); + Throwable throwable = uiExceptionOp.await(2500); if (throwable != null) throw throwable; } private CameraOptions waitForOpen(boolean expectSuccess) { camera.open(); - final Task open = new Task<>(true); + final Op open = new Op<>(true); doEndTask(open, 0).when(listener).onCameraOpened(any(CameraOptions.class)); CameraOptions result = open.await(4000); if (expectSuccess) { @@ -139,7 +136,7 @@ public class IntegrationTest extends BaseTest { private void waitForClose(boolean expectSuccess) { camera.close(); - final Task close = new Task<>(true); + final Op close = new Op<>(true); doEndTask(close, true).when(listener).onCameraClosed(); Boolean result = close.await(4000); if (expectSuccess) { @@ -150,7 +147,7 @@ public class IntegrationTest extends BaseTest { } private void waitForVideoEnd(boolean expectSuccess) { - final Task video = new Task<>(true); + final Op video = new Op<>(true); doEndTask(video, true).when(listener).onVideoTaken(any(VideoResult.class)); Boolean result = video.await(8000); if (expectSuccess) { @@ -161,7 +158,7 @@ public class IntegrationTest extends BaseTest { } private PictureResult waitForPicture(boolean expectSuccess) { - final Task pic = new Task<>(true); + final Op pic = new Op<>(true); doEndTask(pic, 0).when(listener).onPictureTaken(any(PictureResult.class)); PictureResult result = pic.await(5000); if (expectSuccess) { @@ -173,24 +170,24 @@ public class IntegrationTest extends BaseTest { } private void waitForVideoStart() { - controller.mStartVideoTask.listen(); + controller.mStartVideoOp.listen(); File file = new File(context().getFilesDir(), "video.mp4"); camera.takeVideo(file); - controller.mStartVideoTask.await(400); + controller.mStartVideoOp.await(400); } //region test open/close @Test - public void testOpenClose() throws Exception { + public void testOpenClose() { // Starting and stopping are hard to get since they happen on another thread. - assertEquals(controller.getState(), CameraEngine.STATE_STOPPED); + assertEquals(controller.getEngineState(), CameraEngine.STATE_STOPPED); waitForOpen(true); - assertEquals(controller.getState(), CameraEngine.STATE_STARTED); + assertEquals(controller.getEngineState(), CameraEngine.STATE_STARTED); waitForClose(true); - assertEquals(controller.getState(), CameraEngine.STATE_STOPPED); + assertEquals(controller.getEngineState(), CameraEngine.STATE_STOPPED); } @Test @@ -277,11 +274,11 @@ public class IntegrationTest extends BaseTest { public void testSetZoom() { CameraOptions options = waitForOpen(true); - controller.mZoomTask.listen(); + controller.mZoomOp.listen(); float oldValue = camera.getZoom(); float newValue = 0.65f; camera.setZoom(newValue); - controller.mZoomTask.await(500); + controller.mZoomOp.await(500); if (options.isZoomSupported()) { assertEquals(newValue, camera.getZoom(), 0f); @@ -294,11 +291,11 @@ public class IntegrationTest extends BaseTest { public void testSetExposureCorrection() { CameraOptions options = waitForOpen(true); - controller.mExposureCorrectionTask.listen(); + controller.mExposureCorrectionOp.listen(); float oldValue = camera.getExposureCorrection(); float newValue = options.getExposureCorrectionMaxValue(); camera.setExposureCorrection(newValue); - controller.mExposureCorrectionTask.await(300); + controller.mExposureCorrectionOp.await(300); if (options.isExposureCorrectionSupported()) { assertEquals(newValue, camera.getExposureCorrection(), 0f); @@ -313,9 +310,9 @@ public class IntegrationTest extends BaseTest { Flash[] values = Flash.values(); Flash oldValue = camera.getFlash(); for (Flash value : values) { - controller.mFlashTask.listen(); + controller.mFlashOp.listen(); camera.setFlash(value); - controller.mFlashTask.await(300); + controller.mFlashOp.await(300); if (options.supports(value)) { assertEquals(camera.getFlash(), value); oldValue = value; @@ -331,9 +328,9 @@ public class IntegrationTest extends BaseTest { WhiteBalance[] values = WhiteBalance.values(); WhiteBalance oldValue = camera.getWhiteBalance(); for (WhiteBalance value : values) { - controller.mWhiteBalanceTask.listen(); + controller.mWhiteBalanceOp.listen(); camera.setWhiteBalance(value); - controller.mWhiteBalanceTask.await(300); + controller.mWhiteBalanceOp.await(300); if (options.supports(value)) { assertEquals(camera.getWhiteBalance(), value); oldValue = value; @@ -349,9 +346,9 @@ public class IntegrationTest extends BaseTest { Hdr[] values = Hdr.values(); Hdr oldValue = camera.getHdr(); for (Hdr value : values) { - controller.mHdrTask.listen(); + controller.mHdrOp.listen(); camera.setHdr(value); - controller.mHdrTask.await(300); + controller.mHdrOp.await(300); if (options.supports(value)) { assertEquals(camera.getHdr(), value); oldValue = value; @@ -375,9 +372,9 @@ public class IntegrationTest extends BaseTest { @Test public void testSetLocation() { waitForOpen(true); - controller.mLocationTask.listen(); + controller.mLocationOp.listen(); camera.setLocation(10d, 2d); - controller.mLocationTask.await(300); + controller.mLocationOp.await(300); assertNotNull(camera.getLocation()); assertEquals(camera.getLocation().getLatitude(), 10d, 0d); assertEquals(camera.getLocation().getLongitude(), 2d, 0d); @@ -386,21 +383,25 @@ public class IntegrationTest extends BaseTest { @Test public void testSetPlaySounds() { - controller.mPlaySoundsTask.listen(); + controller.mPlaySoundsOp.listen(); boolean oldValue = camera.getPlaySounds(); boolean newValue = !oldValue; camera.setPlaySounds(newValue); - controller.mPlaySoundsTask.await(300); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - - Camera.CameraInfo info = new Camera.CameraInfo(); - Camera.getCameraInfo(controller.mCameraId, info); - if (info.canDisableShutterSound) { - assertEquals(newValue, camera.getPlaySounds()); + controller.mPlaySoundsOp.await(300); + + if (controller instanceof Camera1Engine) { + Camera1Engine camera1Engine = (Camera1Engine) controller; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(camera1Engine.mCameraId, info); + if (info.canDisableShutterSound) { + assertEquals(newValue, camera.getPlaySounds()); + } + } else { + assertEquals(oldValue, camera.getPlaySounds()); } } else { - assertEquals(oldValue, camera.getPlaySounds()); + // TODO do when Camera2 is completed } } @@ -459,13 +460,12 @@ public class IntegrationTest extends BaseTest { //endregion //region startAutoFocus - // TODO: won't test onStopAutoFocus because that is not guaranteed to be called @Test public void testStartAutoFocus() { CameraOptions o = waitForOpen(true); - final Task focus = new Task<>(true); + final Op focus = new Op<>(true); doEndTask(focus, 0).when(listener).onAutoFocusStart(any(PointF.class)); camera.startAutoFocus(1, 1); @@ -478,6 +478,24 @@ public class IntegrationTest extends BaseTest { } } + @Test + public void testStopAutoFocus() { + CameraOptions o = waitForOpen(true); + + final Op focus = new Op<>(true); + doEndTask(focus, 1).when(listener).onAutoFocusEnd(anyBoolean(), any(PointF.class)); + + camera.startAutoFocus(1, 1); + // Stop is not guaranteed to be called, we use a delay. So wait at least the delay time. + PointF point = focus.await(1000 + Camera1Engine.AUTOFOCUS_END_DELAY_MILLIS); + if (o.isAutoFocusSupported()) { + assertNotNull(point); + assertEquals(point, new PointF(1, 1)); + } else { + assertNull(point); + } + } + //endregion //region capture @@ -506,6 +524,8 @@ public class IntegrationTest extends BaseTest { @Test public void testCapturePicture_size() throws Exception { waitForOpen(true); + // PictureSize can still be null after opened. + while (camera.getPictureSize() == null) {} Size size = camera.getPictureSize(); camera.takePicture(); PictureResult result = waitForPicture(true); @@ -550,6 +570,8 @@ public class IntegrationTest extends BaseTest { @Test public void testCaptureSnapshot_size() throws Exception { waitForOpen(true); + // SnapshotSize can still be null after opened. + while (camera.getSnapshotSize() == null) {} Size size = camera.getSnapshotSize(); camera.takePictureSnapshot(); @@ -622,7 +644,6 @@ public class IntegrationTest extends BaseTest { assert30Frames(processor); } - @Test public void testFrameProcessing_freezeRelease() throws Exception { // Ensure that freeze/release cycles do not cause OOMs. diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/MockCameraEngine.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/MockCameraEngine.java index aa101253..bf3b4af0 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/MockCameraEngine.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/MockCameraEngine.java @@ -4,13 +4,14 @@ package com.otaliastudios.cameraview.engine; import android.graphics.PointF; import android.location.Location; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.CameraOptions; import com.otaliastudios.cameraview.PictureResult; import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.controls.Audio; import com.otaliastudios.cameraview.controls.Facing; import com.otaliastudios.cameraview.controls.Flash; -import com.otaliastudios.cameraview.engine.CameraEngine; import com.otaliastudios.cameraview.gesture.Gesture; import com.otaliastudios.cameraview.controls.Hdr; import com.otaliastudios.cameraview.controls.Mode; @@ -21,9 +22,10 @@ import com.otaliastudios.cameraview.size.SizeSelector; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import java.io.File; +import java.util.ArrayList; +import java.util.List; public class MockCameraEngine extends CameraEngine { @@ -36,12 +38,40 @@ public class MockCameraEngine extends CameraEngine { super(callback); } + @NonNull @Override - protected void onStart() { + protected Task onStartEngine() { + return Tasks.forResult(null); } + @NonNull @Override - protected void onStop() { + protected Task onStopEngine() { + return Tasks.forResult(null); + } + + @NonNull + @Override + protected Task onStartBind() { + return Tasks.forResult(null); + } + + @NonNull + @Override + protected Task onStopBind() { + return Tasks.forResult(null); + } + + @NonNull + @Override + protected Task onStartPreview() { + return Tasks.forResult(null); + } + + @NonNull + @Override + protected Task onStopPreview() { + return Tasks.forResult(null); } public void setMockCameraOptions(CameraOptions options) { @@ -52,8 +82,8 @@ public class MockCameraEngine extends CameraEngine { mPreviewStreamSize = size; } - public void mockStarted(boolean started) { - mState = started ? STATE_STARTED : STATE_STOPPED; + public void setMockEngineState(boolean started) { + mEngineStep.setState(started ? STATE_STARTED : STATE_STOPPED); } public int getSnapshotMaxWidth() { @@ -147,21 +177,19 @@ public class MockCameraEngine extends CameraEngine { } @Override - public void startAutoFocus(@Nullable Gesture gesture, @NonNull PointF point) { - mFocusStarted = true; - } + protected void onPreviewStreamSizeChanged() { - @Override - public void onSurfaceChanged() { } + @NonNull @Override - public void onSurfaceAvailable() { + protected List getPreviewStreamAvailableSizes() { + return new ArrayList<>(); } @Override - public void onSurfaceDestroyed() { - + public void startAutoFocus(@Nullable Gesture gesture, @NonNull PointF point) { + mFocusStarted = true; } @Override diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureLayoutTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureLayoutTest.java index 1740c8d6..f7ed7565 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureLayoutTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureLayoutTest.java @@ -12,9 +12,7 @@ import android.view.View; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; -import com.otaliastudios.cameraview.gesture.Gesture; -import com.otaliastudios.cameraview.gesture.GestureLayout; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import org.hamcrest.Matchers; import org.junit.Before; @@ -33,7 +31,7 @@ public abstract class GestureLayoutTest extends BaseTes @SuppressWarnings("WeakerAccess") protected T layout; @SuppressWarnings("WeakerAccess") - protected Task touch; + protected Op touch; @Before public void setUp() { @@ -45,7 +43,7 @@ public abstract class GestureLayoutTest extends BaseTes layout.setActive(true); a.inflate(layout); - touch = new Task<>(); + touch = new Op<>(); layout.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent motionEvent) { diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/GridLinesLayoutTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/GridLinesLayoutTest.java index f6ec2c68..955b970f 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/GridLinesLayoutTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/GridLinesLayoutTest.java @@ -4,7 +4,6 @@ package com.otaliastudios.cameraview.internal; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; import com.otaliastudios.cameraview.controls.Grid; -import com.otaliastudios.cameraview.internal.GridLinesLayout; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.MediumTest; @@ -34,18 +33,18 @@ public class GridLinesLayoutTest extends BaseTest { TestActivity a = rule.getActivity(); layout = new GridLinesLayout(a); layout.setGridMode(Grid.OFF); - layout.drawTask.listen(); + layout.drawOp.listen(); a.getContentView().addView(layout); } }); // Wait for first draw. - layout.drawTask.await(1000); + layout.drawOp.await(1000); } private int setGridAndWait(Grid value) { - layout.drawTask.listen(); + layout.drawOp.listen(); layout.setGridMode(value); - Integer result = layout.drawTask.await(1000); + Integer result = layout.drawOp.await(1000); assertNotNull(result); return result; } 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 1b4b7b35..7fcae66b 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 @@ -2,8 +2,6 @@ package com.otaliastudios.cameraview.internal.utils; import com.otaliastudios.cameraview.BaseTest; -import com.otaliastudios.cameraview.internal.utils.Task; -import com.otaliastudios.cameraview.internal.utils.WorkerHandler; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -28,15 +26,15 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testStaticRun() { - final Task task = new Task<>(true); + final Op op = new Op<>(true); Runnable action = new Runnable() { @Override public void run() { - task.end(true); + op.end(true); } }; WorkerHandler.run(action); - Boolean result = task.await(500); + Boolean result = op.await(500); assertNotNull(result); assertTrue(result); } 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 4980f9a9..03ec7c01 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java @@ -3,29 +3,19 @@ package com.otaliastudios.cameraview.markers; import android.annotation.TargetApi; import android.content.Context; -import android.view.MotionEvent; -import android.view.View; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; -import com.otaliastudios.cameraview.gesture.Gesture; -import com.otaliastudios.cameraview.gesture.GestureLayout; -import com.otaliastudios.cameraview.internal.utils.Task; -import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; -import org.mockito.Mock; import org.mockito.Mockito; -import androidx.test.espresso.ViewInteraction; -import androidx.test.espresso.matcher.RootMatchers; +import androidx.test.annotation.UiThreadTest; import androidx.test.rule.ActivityTestRule; -import static androidx.test.espresso.Espresso.onView; - @TargetApi(17) public class MarkerLayoutTest extends BaseTest { @@ -65,6 +55,7 @@ public class MarkerLayoutTest extends BaseTest { } @Test + @UiThreadTest public void testOnMarker_removesView() { markerLayout.onMarker(MarkerLayout.TYPE_AUTOFOCUS, autoFocusMarker); Assert.assertEquals(markerLayout.getChildCount(), 1); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/CameraPreviewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/CameraPreviewTest.java index ad1501e2..d99da33d 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/CameraPreviewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/CameraPreviewTest.java @@ -7,8 +7,7 @@ import android.view.ViewGroup; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; -import com.otaliastudios.cameraview.internal.utils.Task; -import com.otaliastudios.cameraview.preview.CameraPreview; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.size.AspectRatio; import com.otaliastudios.cameraview.size.Size; @@ -37,13 +36,13 @@ public abstract class CameraPreviewTest extends BaseTest { protected Size surfaceSize; private CameraPreview.SurfaceCallback callback; - private Task available; - private Task destroyed; + private Op available; + private Op destroyed; @Before public void setUp() { - available = new Task<>(true); - destroyed = new Task<>(true); + available = new Op<>(true); + destroyed = new Op<>(true); ui(new Runnable() { @Override @@ -152,17 +151,17 @@ public abstract class CameraPreviewTest extends BaseTest { // Since desired is 'desired', let's fake a new view size that is consistent with it. // Ensure crop is not happening anymore. - preview.mCropTask.listen(); + preview.mCropOp.listen(); preview.dispatchOnSurfaceSizeChanged((int) (50f * desired), 50); // Wait... - preview.mCropTask.await(); + preview.mCropOp.await(); assertEquals(desired, getViewAspectRatioWithScale(), 0.01f); assertFalse(preview.isCropping()); } private void setDesiredAspectRatio(float desiredAspectRatio) { - preview.mCropTask.listen(); + preview.mCropOp.listen(); preview.setStreamSize((int) (10f * desiredAspectRatio), 10); // Wait... - preview.mCropTask.await(); + preview.mCropOp.await(); assertEquals(desiredAspectRatio, getViewAspectRatioWithScale(), 0.01f); } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/GlCameraPreviewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/GlCameraPreviewTest.java index f7617f99..059bf268 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/GlCameraPreviewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/GlCameraPreviewTest.java @@ -7,10 +7,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; import android.view.ViewGroup; -import com.otaliastudios.cameraview.preview.CameraPreview; -import com.otaliastudios.cameraview.preview.CameraPreviewTest; -import com.otaliastudios.cameraview.preview.GlCameraPreview; - import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) @@ -24,11 +20,11 @@ public class GlCameraPreviewTest extends CameraPreviewTest { @Override protected float getCropScaleY() { - return 1F / ((GlCameraPreview) preview).mScaleY; + return 1F / ((GlCameraPreview) preview).mCropScaleY; } @Override protected float getCropScaleX() { - return 1F / ((GlCameraPreview) preview).mScaleX; + return 1F / ((GlCameraPreview) preview).mCropScaleX; } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java index 2e35ca39..023ec2b4 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java @@ -74,7 +74,7 @@ public class CameraException extends RuntimeException { /** * Whether this error is unrecoverable. If this function returns true, - * the Camera has been closed and it is likely showing a black preview. + * the Camera has been closed (or will be soon) and it is likely showing a black preview. * This is the right moment to show an error dialog to the user. * * @return true if this error is unrecoverable diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index fa2b0fc8..3892d6c2 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -597,11 +597,11 @@ public class CameraView extends FrameLayout implements LifecycleObserver { * @return whether the camera has started */ public boolean isOpened() { - return mCameraEngine.getState() >= CameraEngine.STATE_STARTED; + return mCameraEngine.getEngineState() >= CameraEngine.STATE_STARTED; } private boolean isClosed() { - return mCameraEngine.getState() == CameraEngine.STATE_STOPPED; + return mCameraEngine.getEngineState() == CameraEngine.STATE_STOPPED; } /** diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java index 8259a221..639e90c5 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java @@ -15,6 +15,8 @@ import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import android.view.SurfaceHolder; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.CameraException; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.CameraOptions; @@ -30,7 +32,7 @@ import com.otaliastudios.cameraview.controls.Hdr; import com.otaliastudios.cameraview.controls.Mode; import com.otaliastudios.cameraview.controls.WhiteBalance; import com.otaliastudios.cameraview.internal.utils.CropHelper; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.picture.FullPictureRecorder; import com.otaliastudios.cameraview.picture.PictureRecorder; import com.otaliastudios.cameraview.picture.SnapshotPictureRecorder; @@ -54,11 +56,10 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac private static final String TAG = Camera1Engine.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); - private static final int AUTOFOCUS_END_DELAY_MILLIS = 2500; + @VisibleForTesting static final int AUTOFOCUS_END_DELAY_MILLIS = 2500; private Camera mCamera; @VisibleForTesting int mCameraId; - private boolean mIsBound = false; private Runnable mFocusEndRunnable; private final Runnable mFocusResetRunnable = new Runnable() { @@ -81,87 +82,62 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac mMapper = Mapper.get(Engine.CAMERA1); } - private void schedule(@Nullable final Task task, final boolean ensureAvailable, final Runnable action) { + private boolean isCameraAvailable() { + return getEngineState() == STATE_STARTED; + } + + private void schedule(@Nullable final Op op, final boolean ensureAvailable, final Runnable action) { mHandler.post(new Runnable() { @Override public void run() { if (ensureAvailable && !isCameraAvailable()) { - if (task != null) task.end(null); + if (op != null) op.end(null); } else { action.run(); - if (task != null) task.end(null); + if (op != null) op.end(null); } } }); } - /** - * Preview surface is now available. If camera is open, set up. - * At this point we are sure that mPreview is not null. - */ + @NonNull + @WorkerThread @Override - public void onSurfaceAvailable() { - LOG.i("onSurfaceAvailable:", "Size is", getPreviewSurfaceSize(REF_VIEW)); - schedule(null, false, new Runnable() { - @Override - public void run() { - LOG.i("onSurfaceAvailable:", "Inside handler. About to bind."); - if (shouldBindToSurface()) bindToSurface(); - if (shouldStartPreview()) startPreview("onSurfaceAvailable"); + protected Task onStartEngine() { + if (collectCameraId()) { + try { + mCamera = Camera.open(mCameraId); + } catch (Exception e) { + LOG.e("onStartEngine:", "Failed to connect. Maybe in use by another app?"); + throw new CameraException(e, CameraException.REASON_FAILED_TO_CONNECT); } - }); - } - - /** - * Preview surface did change its size. Compute a new preview size. - * This requires stopping and restarting the preview. - * At this point we are sure that mPreview is not null. - */ - @Override - public void onSurfaceChanged() { - LOG.i("onSurfaceChanged, size is", getPreviewSurfaceSize(REF_VIEW)); - schedule(null, true, new Runnable() { - @Override - public void run() { - if (!mIsBound) return; - - // Compute a new camera preview size. - Size newSize = computePreviewStreamSize(sizesFromList(mCamera.getParameters().getSupportedPreviewSizes())); - if (newSize.equals(mPreviewStreamSize)) return; + mCamera.setErrorCallback(this); - // Apply. - LOG.i("onSurfaceChanged:", "Computed a new preview size. Going on."); - mPreviewStreamSize = newSize; - stopPreview(); - startPreview("onSurfaceChanged:"); - } - }); + // Set parameters that might have been set before the camera was opened. + LOG.i("onStartEngine:", "Applying default parameters."); + Camera.Parameters params = mCamera.getParameters(); + mCameraOptions = new CameraOptions(params, flip(REF_SENSOR, REF_VIEW)); + applyDefaultFocus(params); + applyFlash(params, Flash.OFF); + applyLocation(params, null); + applyWhiteBalance(params, WhiteBalance.AUTO); + applyHdr(params, Hdr.OFF); + applyPlaySounds(mPlaySounds); + params.setRecordingHint(mMode == Mode.VIDEO); + mCamera.setParameters(params); + mCamera.setDisplayOrientation(offset(REF_SENSOR, REF_VIEW)); // <- not allowed during preview + LOG.i("onStartEngine:", "Ended"); + return Tasks.forResult(null); + } else { + LOG.e("onStartEngine:", "No camera available for facing", mFacing); + throw new CameraException(CameraException.REASON_NO_CAMERA); + } } + @NonNull @Override - public void onSurfaceDestroyed() { - LOG.i("onSurfaceDestroyed"); - schedule(null, true, new Runnable() { - @Override - public void run() { - stopPreview(); - if (mIsBound) unbindFromSurface(); - } - }); - } - - private boolean shouldBindToSurface() { - return isCameraAvailable() && mPreview != null && mPreview.hasSurface() && !mIsBound; - } - - /** - * The act of binding an "open" camera to a "ready" preview. - * These can happen at different times but we want to end up here. - * At this point we are sure that mPreview is not null. - */ - @WorkerThread - private void bindToSurface() { - LOG.i("bindToSurface:", "Started"); + protected Task onStartBind() { + LOG.i("onStartBind:", "Started"); Object output = mPreview.getOutput(); try { if (output instanceof SurfaceHolder) { @@ -172,40 +148,19 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac throw new RuntimeException("Unknown CameraPreview output class."); } } catch (IOException e) { - LOG.e("bindToSurface:", "Failed to bind.", e); + LOG.e("onStartBind:", "Failed to bind.", e); throw new CameraException(e, CameraException.REASON_FAILED_TO_START_PREVIEW); } mCaptureSize = computeCaptureSize(); - mPreviewStreamSize = computePreviewStreamSize(sizesFromList(mCamera.getParameters().getSupportedPreviewSizes())); - mIsBound = true; - } - - @WorkerThread - private void unbindFromSurface() { - mIsBound = false; - mPreviewStreamSize = null; - mCaptureSize = null; - try { - if (mPreview.getOutputClass() == SurfaceHolder.class) { - mCamera.setPreviewDisplay(null); - } else if (mPreview.getOutputClass() == SurfaceTexture.class) { - mCamera.setPreviewTexture(null); - } else { - throw new RuntimeException("Unknown CameraPreview output class."); - } - } catch (IOException e) { - LOG.e("unbindFromSurface", "Could not release surface", e); - } + mPreviewStreamSize = computePreviewStreamSize(); + return Tasks.forResult(null); } - private boolean shouldStartPreview() { - return isCameraAvailable() && mIsBound; - } - - // To be called when the preview size is setup or changed. - private void startPreview(String log) { - LOG.i(log, "Dispatching onCameraPreviewStreamSizeChanged."); + @NonNull + @Override + protected Task onStartPreview() { + LOG.i("onStartPreview", "Dispatching onCameraPreviewStreamSizeChanged."); mCallback.onCameraPreviewStreamSizeChanged(); Size previewSize = getPreviewStreamSize(REF_VIEW); @@ -231,106 +186,87 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac mCamera.setPreviewCallbackWithBuffer(null); // Release anything left mCamera.setPreviewCallbackWithBuffer(this); // Add ourselves - mFrameManager.allocateBuffers(ImageFormat.getBitsPerPixel(mPreviewStreamFormat), mPreviewStreamSize); + getFrameManager().setUp(ImageFormat.getBitsPerPixel(mPreviewStreamFormat), mPreviewStreamSize); - LOG.i(log, "Starting preview with startPreview()."); + LOG.i("onStartPreview", "Starting preview with startPreview()."); try { mCamera.startPreview(); } catch (Exception e) { - LOG.e(log, "Failed to start preview.", e); + LOG.e("onStartPreview", "Failed to start preview.", e); throw new CameraException(e, CameraException.REASON_FAILED_TO_START_PREVIEW); } - LOG.i(log, "Started preview."); + LOG.i("onStartPreview", "Started preview."); + return Tasks.forResult(null); } - private void stopPreview() { + @NonNull + @Override + protected Task onStopPreview() { + if (mVideoRecorder != null) { + mVideoRecorder.stop(); + mVideoRecorder = null; + } mPreviewStreamFormat = 0; - mFrameManager.release(); + getFrameManager().release(); mCamera.setPreviewCallbackWithBuffer(null); // Release anything left try { mCamera.stopPreview(); } catch (Exception e) { LOG.e("stopPreview", "Could not stop preview", e); } + return Tasks.forResult(null); } - private void createCamera() { - try { - mCamera = Camera.open(mCameraId); - } catch (Exception e) { - LOG.e("createCamera:", "Failed to connect. Maybe in use by another app?"); - throw new CameraException(e, CameraException.REASON_FAILED_TO_CONNECT); - } - mCamera.setErrorCallback(this); - - // Set parameters that might have been set before the camera was opened. - LOG.i("createCamera:", "Applying default parameters."); - Camera.Parameters params = mCamera.getParameters(); - mCameraOptions = new CameraOptions(params, flip(REF_SENSOR, REF_VIEW)); - applyDefaultFocus(params); - applyFlash(params, Flash.OFF); - applyLocation(params, null); - applyWhiteBalance(params, WhiteBalance.AUTO); - applyHdr(params, Hdr.OFF); - applyPlaySounds(mPlaySounds); - params.setRecordingHint(mMode == Mode.VIDEO); - mCamera.setParameters(params); - mCamera.setDisplayOrientation(offset(REF_SENSOR, REF_VIEW)); // <- not allowed during preview - } - - private void destroyCamera() { - try { - LOG.i("destroyCamera:", "Clean up.", "Releasing camera."); - mCamera.release(); - LOG.i("destroyCamera:", "Clean up.", "Released camera."); - } catch (Exception e) { - LOG.w("destroyCamera:", "Clean up.", "Exception while releasing camera.", e); - } - mCamera = null; - mCameraOptions = null; - } - @WorkerThread + @NonNull @Override - protected void onStart() { - if (isCameraAvailable()) { - LOG.w("onStart:", "Camera not available. Should not happen."); - onStop(); // Should not happen. - } - if (collectCameraId()) { - createCamera(); - if (shouldBindToSurface()) bindToSurface(); - if (shouldStartPreview()) startPreview("onStart"); - LOG.i("onStart:", "Ended"); - } else { - LOG.e("onStart:", "No camera available for facing", mFacing); - throw new CameraException(CameraException.REASON_NO_CAMERA); + protected Task onStopBind() { + mPreviewStreamSize = null; + mCaptureSize = null; + try { + if (mPreview.getOutputClass() == SurfaceHolder.class) { + mCamera.setPreviewDisplay(null); + } else if (mPreview.getOutputClass() == SurfaceTexture.class) { + mCamera.setPreviewTexture(null); + } else { + throw new RuntimeException("Unknown CameraPreview output class."); + } + } catch (IOException e) { + LOG.e("unbindFromSurface", "Could not release surface", e); } + return Tasks.forResult(null); } + @NonNull @WorkerThread @Override - protected void onStop() { - LOG.i("onStop:", "About to clean up."); + protected Task onStopEngine() { + LOG.i("onStopEngine:", "About to clean up."); mHandler.remove(mFocusResetRunnable); if (mFocusEndRunnable != null) { mHandler.remove(mFocusEndRunnable); } - if (mVideoRecorder != null) { - mVideoRecorder.stop(); - mVideoRecorder = null; - } if (mCamera != null) { - stopPreview(); - if (mIsBound) unbindFromSurface(); - destroyCamera(); + try { + LOG.i("onStopEngine:", "Clean up.", "Releasing camera."); + mCamera.release(); + LOG.i("onStopEngine:", "Clean up.", "Released camera."); + } catch (Exception e) { + LOG.w("onStopEngine:", "Clean up.", "Exception while releasing camera.", e); + } + mCamera = null; + mCameraOptions = null; } mCameraOptions = null; mCamera = null; - mPreviewStreamSize = null; - mCaptureSize = null; - mIsBound = false; - LOG.w("onStop:", "Clean up.", "Returning."); + LOG.w("onStopEngine:", "Clean up.", "Returning."); + return Tasks.forResult(null); + } + + @WorkerThread + @Override + protected void onPreviewStreamSizeChanged() { + restartPreview(); } private boolean collectCameraId() { @@ -361,8 +297,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac if (error == Camera.CAMERA_ERROR_SERVER_DIED) { // Looks like this is recoverable. LOG.w("Recoverable error inside the onError callback.", "CAMERA_ERROR_SERVER_DIED"); - stopNow(); - start(); + restart(); return; } @@ -394,7 +329,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac public void setLocation(@Nullable Location location) { final Location oldLocation = mLocation; mLocation = location; - schedule(mLocationTask, true, new Runnable() { + schedule(mLocationOp, true, new Runnable() { @Override public void run() { Camera.Parameters params = mCamera.getParameters(); @@ -437,7 +372,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac public void setWhiteBalance(@NonNull WhiteBalance whiteBalance) { final WhiteBalance old = mWhiteBalance; mWhiteBalance = whiteBalance; - schedule(mWhiteBalanceTask, true, new Runnable() { + schedule(mWhiteBalanceOp, true, new Runnable() { @Override public void run() { Camera.Parameters params = mCamera.getParameters(); @@ -459,7 +394,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac public void setHdr(@NonNull Hdr hdr) { final Hdr old = mHdr; mHdr = hdr; - schedule(mHdrTask, true, new Runnable() { + schedule(mHdrOp, true, new Runnable() { @Override public void run() { Camera.Parameters params = mCamera.getParameters(); @@ -515,7 +450,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac public void setFlash(@NonNull Flash flash) { final Flash old = mFlash; mFlash = flash; - schedule(mFlashTask, true, new Runnable() { + schedule(mFlashOp, true, new Runnable() { @Override public void run() { Camera.Parameters params = mCamera.getParameters(); @@ -640,7 +575,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @Override public void onPreviewFrame(@NonNull byte[] data, Camera camera) { - Frame frame = mFrameManager.getFrame(data, + Frame frame = getFrameManager().getFrame(data, System.currentTimeMillis(), offset(REF_SENSOR, REF_OUTPUT), mPreviewStreamSize, @@ -648,26 +583,6 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac mCallback.dispatchFrame(frame); } - private boolean isCameraAvailable() { - switch (getState()) { - // If we are stopped, don't. - case STATE_STOPPED: - return false; - // If we are going to be closed, don't act on camera. - // Even if mCamera != null, it might have been released. - case STATE_STOPPING: - return false; - // If we are started, mCamera should never be null. - case STATE_STARTED: - return true; - // If we are starting, theoretically we could act. - // Just check that camera is available. - case STATE_STARTING: - return mCamera != null; - } - return false; - } - // ----------------- // Video recording stuff. @@ -685,7 +600,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @Override public void takeVideo(final @NonNull VideoResult.Stub stub, @NonNull final File videoFile) { - schedule(mStartVideoTask, true, new Runnable() { + schedule(mStartVideoOp, true, new Runnable() { @Override public void run() { if (mMode == Mode.PICTURE) { @@ -737,7 +652,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { throw new IllegalStateException("Video snapshots are only supported starting from API 18."); } - schedule(mStartVideoTask, true, new Runnable() { + schedule(mStartVideoOp, true, new Runnable() { @Override public void run() { if (isTakingVideo()) return; @@ -798,11 +713,6 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac outputSize = new Size(outputCrop.width(), outputCrop.height()); stub.size = outputSize; stub.rotation = offset(REF_VIEW, REF_OUTPUT); - // LOG.e("ROTBUG_video", "aspectRatio (REF_VIEW):", viewAspectRatio); - // LOG.e("ROTBUG_video", "aspectRatio (REF_OUTPUT):", outputRatio); - // LOG.e("ROTBUG_video", "sizeUncropped (REF_OUTPUT):", outputSize); - // LOG.e("ROTBUG_video", "sizeCropped (REF_OUTPUT):", videoResult.size); - // LOG.e("ROTBUG_video", "rotation:", videoResult.rotation); // Reset facing and start. mFacing = realFacing; @@ -834,7 +744,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @Override public void setZoom(final float zoom, @Nullable final PointF[] points, final boolean notify) { - schedule(mZoomTask, true, new Runnable() { + schedule(mZoomOp, true, new Runnable() { @Override public void run() { if (!mCameraOptions.isZoomSupported()) return; @@ -855,7 +765,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @Override public void setExposureCorrection(final float EVvalue, @NonNull final float[] bounds, @Nullable final PointF[] points, final boolean notify) { - schedule(mExposureCorrectionTask, true, new Runnable() { + schedule(mExposureCorrectionOp, true, new Runnable() { @Override public void run() { if (!mCameraOptions.isExposureCorrectionSupported()) return; @@ -998,13 +908,15 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @NonNull - private List sizesFromList(@NonNull List sizes) { + @Override + protected List getPreviewStreamAvailableSizes() { + List sizes = mCamera.getParameters().getSupportedPreviewSizes(); List result = new ArrayList<>(sizes.size()); for (Camera.Size size : sizes) { Size add = new Size(size.width, size.height); if (!result.contains(add)) result.add(add); } - LOG.i("size:", "sizesFromList:", result); + LOG.i("getPreviewStreamAvailableSizes:", result); return result; } @@ -1012,7 +924,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac public void setPlaySounds(boolean playSounds) { final boolean old = mPlaySounds; mPlaySounds = playSounds; - schedule(mPlaySoundsTask, true, new Runnable() { + schedule(mPlaySoundsOp, true, new Runnable() { @Override public void run() { applyPlaySounds(old); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java index 7767c83d..da4831fa 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java @@ -4,7 +4,6 @@ import android.annotation.SuppressLint; import android.content.Context; import android.graphics.PointF; import android.graphics.SurfaceTexture; -import android.hardware.Camera; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCaptureSession; import android.hardware.camera2.CameraCharacteristics; @@ -17,6 +16,9 @@ import android.os.Build; import android.view.Surface; import android.view.SurfaceHolder; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.CameraException; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.CameraOptions; @@ -30,15 +32,16 @@ import com.otaliastudios.cameraview.controls.Hdr; import com.otaliastudios.cameraview.controls.Mode; import com.otaliastudios.cameraview.controls.WhiteBalance; import com.otaliastudios.cameraview.gesture.Gesture; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.size.AspectRatio; import com.otaliastudios.cameraview.size.Size; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -55,10 +58,9 @@ public class Camera2Engine extends CameraEngine { private final CameraManager mManager; private String mCameraId; private CameraDevice mCamera; - private CameraCaptureSession mSession; // TODO must be released and nulled - private CaptureRequest.Builder mPreviewStreamRequestBuilder; // TODO must be nulled - private CaptureRequest mPreviewStreamRequest; // TODO must be nulled - private boolean mIsBound = false; + private CameraCaptureSession mSession; + private CaptureRequest.Builder mPreviewStreamRequestBuilder; + private CaptureRequest mPreviewStreamRequest; public Camera2Engine(Callback callback) { super(callback); @@ -66,15 +68,19 @@ public class Camera2Engine extends CameraEngine { mManager = (CameraManager) mCallback.getContext().getSystemService(Context.CAMERA_SERVICE); } - private void schedule(@Nullable final Task task, final boolean ensureAvailable, final Runnable action) { + private boolean isCameraAvailable() { + return getEngineState() == STATE_STARTED; + } + + private void schedule(@Nullable final Op op, final boolean ensureAvailable, final Runnable action) { mHandler.post(new Runnable() { @Override public void run() { if (ensureAvailable && !isCameraAvailable()) { - if (task != null) task.end(null); + if (op != null) op.end(null); } else { action.run(); - if (task != null) task.end(null); + if (op != null) op.end(null); } } }); @@ -89,339 +95,309 @@ public class Camera2Engine extends CameraEngine { } @NonNull - private Size computePreviewStreamSize() throws CameraAccessException { - CameraCharacteristics characteristics = mManager.getCameraCharacteristics(mCameraId); - StreamConfigurationMap streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); - if (streamMap == null) throw new RuntimeException("StreamConfigurationMap is null. Should not happen."); - // This works because our previews return either a SurfaceTexture or a SurfaceHolder, which are - // accepted class types by the getOutputSizes method. - android.util.Size[] sizes = streamMap.getOutputSizes(mPreview.getOutputClass()); - List candidates = new ArrayList<>(sizes.length); - for (android.util.Size size : sizes) { - Size add = new Size(size.getWidth(), size.getHeight()); - if (!candidates.contains(add)) candidates.add(add); + private CameraException createCameraException(@NonNull CameraAccessException exception) { + int reason; + switch (exception.getReason()) { + case CameraAccessException.CAMERA_DISABLED: reason = CameraException.REASON_FAILED_TO_CONNECT; break; + case CameraAccessException.CAMERA_ERROR: reason = CameraException.REASON_DISCONNECTED; break; + case CameraAccessException.CAMERA_DISCONNECTED: reason = CameraException.REASON_DISCONNECTED; break; + case CameraAccessException.CAMERA_IN_USE: reason = CameraException.REASON_FAILED_TO_CONNECT; break; + case CameraAccessException.MAX_CAMERAS_IN_USE: reason = CameraException.REASON_FAILED_TO_CONNECT; break; + default: reason = CameraException.REASON_UNKNOWN; break; } - return computePreviewStreamSize(candidates); - } - - private boolean collectCameraId() throws CameraAccessException { - int internalFacing = mMapper.map(mFacing); - int cameras = mManager.getCameraIdList().length; - LOG.i("collectCameraId", "Facing:", mFacing, "Internal:", internalFacing, "Cameras:", cameras); - for (String cameraId : mManager.getCameraIdList()) { - CameraCharacteristics characteristics = mManager.getCameraCharacteristics(cameraId); - if (internalFacing == readCharacteristic(characteristics, CameraCharacteristics.LENS_FACING, -99)) { - mCameraId = cameraId; - mSensorOffset = readCharacteristic(characteristics, CameraCharacteristics.SENSOR_ORIENTATION, 0); - return true; - } - } - return false; + return new CameraException(exception, reason); } - private boolean isCameraAvailable() { - switch (getState()) { - // If we are stopped, don't. - case STATE_STOPPED: - return false; - // If we are going to be closed, don't act on camera. - // Even if mCamera != null, it might have been released. - case STATE_STOPPING: - return false; - // If we are started, mCamera should never be null. - case STATE_STARTED: - return true; - // If we are starting, theoretically we could act. - // Just check that camera is available. - case STATE_STARTING: - return mCamera != null; + @NonNull + private CameraException createCameraException(int stateCallbackError) { + int reason; + switch (stateCallbackError) { + case CameraDevice.StateCallback.ERROR_CAMERA_DISABLED: reason = CameraException.REASON_FAILED_TO_CONNECT; break; // Device policy + case CameraDevice.StateCallback.ERROR_CAMERA_DEVICE: reason = CameraException.REASON_FAILED_TO_CONNECT; break; // Fatal error + case CameraDevice.StateCallback.ERROR_CAMERA_SERVICE: reason = CameraException.REASON_FAILED_TO_CONNECT; break; // Fatal error, device might have to be restarted + case CameraDevice.StateCallback.ERROR_CAMERA_IN_USE: reason = CameraException.REASON_FAILED_TO_CONNECT; break; + case CameraDevice.StateCallback.ERROR_MAX_CAMERAS_IN_USE: reason = CameraException.REASON_FAILED_TO_CONNECT; break; + default: reason = CameraException.REASON_UNKNOWN; break; } - return false; + return new CameraException(reason); } + @NonNull @Override - protected void onStart() { - if (isCameraAvailable()) { - LOG.w("onStart:", "Camera not available. Should not happen."); - onStop(); // Should not happen. - } + protected List getPreviewStreamAvailableSizes() { try { - if (collectCameraId()) { - createCamera(); - LOG.i("onStart:", "Ended"); - } else { - LOG.e("onStart:", "No camera available for facing", mFacing); - throw new CameraException(CameraException.REASON_NO_CAMERA); + CameraCharacteristics characteristics = mManager.getCameraCharacteristics(mCameraId); + StreamConfigurationMap streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (streamMap == null) + throw new RuntimeException("StreamConfigurationMap is null. Should not happen."); + // This works because our previews return either a SurfaceTexture or a SurfaceHolder, which are + // accepted class types by the getOutputSizes method. + android.util.Size[] sizes = streamMap.getOutputSizes(mPreview.getOutputClass()); + List candidates = new ArrayList<>(sizes.length); + for (android.util.Size size : sizes) { + Size add = new Size(size.getWidth(), size.getHeight()); + if (!candidates.contains(add)) candidates.add(add); } + return candidates; } catch (CameraAccessException e) { - // TODO + throw createCameraException(e); } } - @SuppressLint("MissingPermission") - private void createCamera() throws CameraAccessException { - mManager.openCamera(mCameraId, new CameraDevice.StateCallback() { - @Override - public void onOpened(@NonNull CameraDevice camera) { - mCamera = camera; - - // TODO Set parameters that might have been set before the camera was opened. - try { - LOG.i("createCamera:", "Applying default parameters."); - CameraCharacteristics characteristics = mManager.getCameraCharacteristics(mCameraId); - mCameraOptions = new CameraOptions(mManager, characteristics, flip(REF_SENSOR, REF_VIEW)); - // applyDefaultFocus(params); - // applyFlash(params, Flash.OFF); - // applyLocation(params, null); - // applyWhiteBalance(params, WhiteBalance.AUTO); - // applyHdr(params, Hdr.OFF); - // applyPlaySounds(mPlaySounds); - // params.setRecordingHint(mMode == Mode.VIDEO); - // mCamera.setParameters(params); - } catch (CameraAccessException e) { - // TODO - throw new RuntimeException(e); + private boolean collectCameraId() { + int internalFacing = mMapper.map(mFacing); + String[] cameraIds = null; + try { + cameraIds = mManager.getCameraIdList(); + } catch (CameraAccessException e) { + // This should never happen, I don't see how it could crash here. + // However, let's launch an unrecoverable exception. + throw createCameraException(e); + } + LOG.i("collectCameraId", "Facing:", mFacing, "Internal:", internalFacing, "Cameras:", cameraIds.length); + for (String cameraId : cameraIds) { + try { + CameraCharacteristics characteristics = mManager.getCameraCharacteristics(cameraId); + if (internalFacing == readCharacteristic(characteristics, CameraCharacteristics.LENS_FACING, -99)) { + mCameraId = cameraId; + mSensorOffset = readCharacteristic(characteristics, CameraCharacteristics.SENSOR_ORIENTATION, 0); + return true; } + } catch (CameraAccessException ignore) { + // This specific camera has been disconnected. + // Keep searching in other camerIds. + } + } + return false; + } - // Set display orientation, not allowed during preview - // TODO not needed anymore? mCamera.setDisplayOrientation(offset(REF_SENSOR, REF_VIEW)); - try { - if (shouldBindToSurface()) bindToSurface("onStart"); - } catch (CameraAccessException e) { - // TODO - throw new RuntimeException(e); - } + @SuppressLint("MissingPermission") + @NonNull + @Override + protected Task onStartEngine() { + final TaskCompletionSource task = new TaskCompletionSource<>(); + try { + boolean hasCamera = collectCameraId(); + if (!hasCamera) { + LOG.e("onStartEngine:", "No camera available for facing", mFacing); + throw new CameraException(CameraException.REASON_NO_CAMERA); } - @Override - public void onDisconnected(@NonNull CameraDevice camera) { - // TODO not sure what to do here. maybe stop(). Read docs. + // We have a valid camera for this Facing. Go on. + mManager.openCamera(mCameraId, new CameraDevice.StateCallback() { + @Override + public void onOpened(@NonNull CameraDevice camera) { + mCamera = camera; - } + // Set parameters that might have been set before the camera was opened. + try { + LOG.i("createCamera:", "Applying default parameters."); + CameraCharacteristics characteristics = mManager.getCameraCharacteristics(mCameraId); + mCameraOptions = new CameraOptions(mManager, characteristics, flip(REF_SENSOR, REF_VIEW)); + // applyDefaultFocus(params); TODO + // applyFlash(params, Flash.OFF); + // applyLocation(params, null); + // applyWhiteBalance(params, WhiteBalance.AUTO); + // applyHdr(params, Hdr.OFF); + // applyPlaySounds(mPlaySounds); + // params.setRecordingHint(mMode == Mode.VIDEO); + // mCamera.setParameters(params); + } catch (CameraAccessException e) { + task.trySetException(createCameraException(e)); + return; + } + task.trySetResult(null); + } - @Override - public void onError(@NonNull CameraDevice camera, int error) { - // TODO - } - }, null); - } + @Override + public void onDisconnected(@NonNull CameraDevice camera) { + // Not sure if this is called INSTEAD of onOpened() or can be called after as well. + // However, using trySetException should address this problem - it will only trigger + // if the task has no result. + // + // Docs say to release this camera instance, however, since we throw an unrecoverable CameraException, + // this will trigger a stop() through the exception handler. + task.trySetException(new CameraException(CameraException.REASON_DISCONNECTED)); + } - private boolean shouldBindToSurface() { - return isCameraAvailable() && mPreview != null && mPreview.hasSurface() && !mIsBound; + @Override + public void onError(@NonNull CameraDevice camera, int error) { + task.trySetException(createCameraException(error)); + } + }, null); + } catch (CameraAccessException e) { + throw createCameraException(e); + } + return task.getTask(); } - /** - * The act of binding an "open" camera to a "ready" preview. - * These can happen at different times but we want to end up here. - * At this point we are sure that mPreview is not null. - */ - @SuppressLint("Recycle") - @WorkerThread - private void bindToSurface(final @NonNull String trigger) throws CameraAccessException { - LOG.i("bindToSurface:", "Started"); - Object output = mPreview.getOutput(); + @NonNull + @Override + protected Task onStartBind() { + LOG.i("onStartBind:", "Started"); + final TaskCompletionSource task = new TaskCompletionSource<>(); + + // Compute sizes. + mCaptureSize = computeCaptureSize(); + mPreviewStreamSize = computePreviewStreamSize(); + + // Create a preview surface with the correct size.In Camera2, instead of applying it to + // the camera params object, we must resize our own surfaces. + final Object output = mPreview.getOutput(); Surface previewSurface; if (output instanceof SurfaceHolder) { + try { + // This must be called from the UI thread... + Tasks.await(Tasks.call(new Callable() { + @Override + public Void call() { + ((SurfaceHolder) output).setFixedSize(mPreviewStreamSize.getWidth(), mPreviewStreamSize.getHeight()); + return null; + } + })); + } catch (ExecutionException | InterruptedException e) { + throw new CameraException(e, CameraException.REASON_FAILED_TO_CONNECT); + } previewSurface = ((SurfaceHolder) output).getSurface(); } else if (output instanceof SurfaceTexture) { + ((SurfaceTexture) output).setDefaultBufferSize(mPreviewStreamSize.getWidth(), mPreviewStreamSize.getHeight()); previewSurface = new Surface((SurfaceTexture) output); } else { throw new RuntimeException("Unknown CameraPreview output class."); } + + // TODO: captureSize + /* if (mMode == Mode.PICTURE) { + params.setPictureSize(mCaptureSize.getWidth(), mCaptureSize.getHeight()); // <- allowed + } else { + // mCaptureSize in this case is a video size. The available video sizes are not necessarily + // a subset of the picture sizes, so we can't use the mCaptureSize value: it might crash. + // However, the setPictureSize() passed here is useless : we don't allow HQ pictures in video mode. + // While this might be lifted in the future, for now, just use a picture capture size. + Size pictureSize = computeCaptureSize(Mode.PICTURE); + params.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight()); + } */ + //noinspection ArraysAsListWithZeroOrOneArgument List outputSurfaces = Arrays.asList(previewSurface); + try { + mPreviewStreamRequestBuilder = mCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); + mPreviewStreamRequestBuilder.addTarget(previewSurface); - mPreviewStreamRequestBuilder = mCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); - mPreviewStreamRequestBuilder.addTarget(previewSurface); - - // null handler means using the current looper which is totally ok. - mCamera.createCaptureSession(outputSurfaces, new CameraCaptureSession.StateCallback() { - @Override - public void onConfigured(@NonNull CameraCaptureSession session) { - try { - mCaptureSize = computeCaptureSize(); - mPreviewStreamSize = computePreviewStreamSize(); + // null handler means using the current looper which is totally ok. + mCamera.createCaptureSession(outputSurfaces, new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(@NonNull CameraCaptureSession session) { mSession = session; - mIsBound = true; - if (shouldStartPreview()) startPreview(trigger); - } catch (CameraAccessException e) { - // TODO - throw new RuntimeException(e); + task.trySetResult(null); } - } - @Override - public void onConfigureFailed(@NonNull CameraCaptureSession session) { - // TODO - } - - }, null); - } + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession session) { + // I would say this should be a library error and as such we throw a Runtime Exception. + String message = LOG.e("onConfigureFailed! Session", session); + throw new RuntimeException(message); + } - private boolean shouldStartPreview() { - return isCameraAvailable() && mIsBound; + }, null); + } catch (CameraAccessException e) { + throw createCameraException(e); + } + return task.getTask(); } - /** - * To be called when the preview size is setup or changed. - * @param trigger a log helper - */ - private void startPreview(@NonNull String trigger) { - LOG.i(trigger, "Dispatching onCameraPreviewStreamSizeChanged."); + @NonNull + @Override + protected Task onStartPreview() { + LOG.i("onStartPreview", "Dispatching onCameraPreviewStreamSizeChanged."); mCallback.onCameraPreviewStreamSizeChanged(); - Size previewSize = getPreviewStreamSize(REF_VIEW); - if (previewSize == null) { + Size previewSizeForView = getPreviewStreamSize(REF_VIEW); + if (previewSizeForView == null) { throw new IllegalStateException("previewStreamSize should not be null at this point."); } - mPreview.setStreamSize(previewSize.getWidth(), previewSize.getHeight()); + mPreview.setStreamSize(previewSizeForView.getWidth(), previewSizeForView.getHeight()); - // TODO mPreviewStreamFormat = params.getPreviewFormat(); - - // TODO: previewSize and captureSize - /* params.setPreviewSize(mPreviewStreamSize.getWidth(), mPreviewStreamSize.getHeight()); // <- not allowed during preview - if (mMode == Mode.PICTURE) { - params.setPictureSize(mCaptureSize.getWidth(), mCaptureSize.getHeight()); // <- allowed - } else { - // mCaptureSize in this case is a video size. The available video sizes are not necessarily - // a subset of the picture sizes, so we can't use the mCaptureSize value: it might crash. - // However, the setPictureSize() passed here is useless : we don't allow HQ pictures in video mode. - // While this might be lifted in the future, for now, just use a picture capture size. - Size pictureSize = computeCaptureSize(Mode.PICTURE); - params.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight()); - } */ + // Set the preview rotation. + mPreview.setDrawRotation(mDisplayOffset); + // TODO mPreviewStreamFormat = params.getPreviewFormat(); // TODO mCamera.setPreviewCallbackWithBuffer(null); // Release anything left // TODO mCamera.setPreviewCallbackWithBuffer(this); // Add ourselves - // TODO mFrameManager.allocateBuffers(ImageFormat.getBitsPerPixel(mPreviewStreamFormat), mPreviewStreamSize); + // TODO mFrameManager.setUp(ImageFormat.getBitsPerPixel(mPreviewStreamFormat), mPreviewStreamSize); - LOG.i(trigger, "Starting preview with startPreview()."); + LOG.i("onStartPreview", "Starting preview with startPreview()."); try { mPreviewStreamRequest = mPreviewStreamRequestBuilder.build(); mSession.setRepeatingRequest(mPreviewStreamRequest, null, null); } catch (Exception e) { - LOG.e(trigger, "Failed to start preview.", e); + // This is an unrecoverable exception that will stop everything. + LOG.e("onStartPreview", "Failed to start preview.", e); throw new CameraException(e, CameraException.REASON_FAILED_TO_START_PREVIEW); } - LOG.i(trigger, "Started preview."); + LOG.i("onStartPreview", "Started preview."); + return Tasks.forResult(null); } + @NonNull @Override - protected void onStop() { - LOG.i("onStop:", "About to clean up."); + protected Task onStopPreview() { if (mVideoRecorder != null) { mVideoRecorder.stop(); mVideoRecorder = null; } - if (mCamera != null) { - stopPreview(); - if (mIsBound) unbindFromSurface(); - destroyCamera(); - } - mCameraOptions = null; - mCamera = null; - mPreviewStreamSize = null; - mCaptureSize = null; - mIsBound = false; - LOG.w("onStop:", "Clean up.", "Returning."); - } - - private void stopPreview() { mPreviewStreamFormat = 0; - mFrameManager.release(); + getFrameManager().release(); // TODO mCamera.setPreviewCallbackWithBuffer(null); // Release anything left try { + // NOTE: should we wait for onReady() like docs say? + // Leaving this synchronous for now. mSession.stopRepeating(); - // TODO should wait for onReady? } catch (CameraAccessException e) { + // This tells us that we should stop everything. It's better to throw an unrecoverable + // exception rather than just swallow this, so everything gets stopped. LOG.w("stopRepeating failed!", e); + throw createCameraException(e); } mPreviewStreamRequest = null; + return Tasks.forResult(null); } - private void unbindFromSurface() { - mIsBound = false; + + @NonNull + @Override + protected Task onStopBind() { mPreviewStreamRequestBuilder = null; mPreviewStreamSize = null; mCaptureSize = null; mSession.close(); mSession = null; + return Tasks.forResult(null); } - private void destroyCamera() { + + @NonNull + @Override + protected Task onStopEngine() { + LOG.i("onStopEngine:", "About to clean up."); try { - LOG.i("destroyCamera:", "Clean up.", "Releasing camera."); + LOG.i("onStopEngine:", "Clean up.", "Releasing camera."); mCamera.close(); - LOG.i("destroyCamera:", "Clean up.", "Released camera."); + LOG.i("onStopEngine:", "Clean up.", "Released camera."); } catch (Exception e) { - LOG.w("destroyCamera:", "Clean up.", "Exception while releasing camera.", e); + LOG.w("onStopEngine:", "Clean up.", "Exception while releasing camera.", e); } mCamera = null; mCameraOptions = null; + LOG.w("onStopEngine:", "Returning."); + return Tasks.forResult(null); } - - /** - * Preview surface is now available. If camera is open, set up. - * At this point we are sure that mPreview is not null. - */ - @Override - public void onSurfaceAvailable() { - LOG.i("onSurfaceAvailable:", "Size is", getPreviewSurfaceSize(REF_VIEW)); - schedule(null, false, new Runnable() { - @Override - public void run() { - LOG.i("onSurfaceAvailable:", "Inside handler. About to bind."); - try { - if (shouldBindToSurface()) bindToSurface("onSurfaceAvailable"); - } catch (CameraAccessException e) { - // TODO - throw new RuntimeException(e); - } - } - }); - } - - /** - * Preview surface did change its size. Compute a new preview size. - * This requires stopping and restarting the preview. - * At this point we are sure that mPreview is not null. - */ - @Override - public void onSurfaceChanged() { - LOG.i("onSurfaceChanged, size is", getPreviewSurfaceSize(REF_VIEW)); - schedule(null, true, new Runnable() { - @Override - public void run() { - if (!mIsBound) return; - - // Compute a new camera preview size and apply. - try { - Size newSize = computePreviewStreamSize(); - if (newSize.equals(mPreviewStreamSize)) return; - LOG.i("onSurfaceChanged:", "Computed a new preview size. Going on."); - mPreviewStreamSize = newSize; - } catch (CameraAccessException e) { - // TODO - throw new RuntimeException(e); - } - stopPreview(); - startPreview("onSurfaceChanged:"); - } - }); - } - + @WorkerThread @Override - public void onSurfaceDestroyed() { - LOG.i("onSurfaceDestroyed"); - schedule(null, true, new Runnable() { - @Override - public void run() { - stopPreview(); - if (mIsBound) unbindFromSurface(); - } - }); + protected void onPreviewStreamSizeChanged() { + restartBind(); } @@ -436,7 +412,6 @@ public class Camera2Engine extends CameraEngine { - @Override public void onBufferAvailable(@NonNull byte[] buffer) { @@ -455,13 +430,7 @@ public class Camera2Engine extends CameraEngine { schedule(null, true, new Runnable() { @Override public void run() { - boolean success; - try { - success = collectCameraId(); - } catch (CameraAccessException e) { - success = false; - } - if (success) { + if (collectCameraId()) { restart(); } else { mFacing = old; diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java index f5258d4d..e9082593 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java @@ -8,6 +8,13 @@ import android.location.Location; import android.os.Handler; import android.os.Looper; +import com.google.android.gms.tasks.Continuation; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.SuccessContinuation; +import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.CameraException; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.CameraOptions; @@ -15,7 +22,7 @@ import com.otaliastudios.cameraview.PictureResult; import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameManager; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.internal.utils.WorkerHandler; import com.otaliastudios.cameraview.picture.PictureRecorder; import com.otaliastudios.cameraview.preview.CameraPreview; @@ -42,30 +49,43 @@ import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; /** * PROCESS * Setting up the Camera is usually a 4 steps process: - * 1. Setting up the Surface (done by {@link CameraPreview} - * 2. Opening the camera (done by us) - * 3. Binding the camera to the surface (done by us) - * 4. Starting the camera preview (done by us) + * 1. Setting up the Surface. Done by {@link CameraPreview}. + * 2. Starting the camera. Done by us. See {@link #startEngine()}, {@link #onStartEngine()}. + * 3. Binding the camera to the surface. Done by us. See {@link #startBind()}, {@link #onStartBind()} + * 4. Streaming the camera preview. Done by us. See {@link #startPreview()}, {@link #onStartPreview()} * * The first two steps can actually happen at the same time, anyway - * the order is not guaranteed. + * the order is not guaranteed, we just get a callback from the Preview when 1 happens. * So at the end of both step 1 and 2, the engine should check if both have * been performed and trigger the steps 3 and 4. * + * We use an abstraction for each step called {@link CameraEngineStep} that manages the state of + * each step and ensures that start and stop operations, for each step, are never called if the + * previous one has not ended. + * * * STATE - * In the {@link CameraEngine} notation, - * - START [Async] means doing step 2 (which will eventually trigger 3 and 4) - * - STOP [Async] means undoing 4 (if needed), undoing 3 (if needed), then undoing 2. - * - RESTART [Async] means completing a STOP then a START - * - DESTROY [Sync] means performing a silent and synchronous STOP, ignoring all exceptions. This can make the engine unusable. + * We only expose generic {@link #start()} and {@link #stop()} calls to the outside. + * The external users of this class are most likely interested in whether we have completed step 2 + * or not, since that tells us if we can act on the camera or not, rather than knowing about steps 3 and 4. + * + * So in the {@link CameraEngine} notation, + * - {@link #start()}: ASYNC - starts the engine (S2). When possible, at a later time, S3 and S4 are also performed. + * - {@link #stop()}: ASYNC - stops everything: undoes S4, then S3, then S2. + * - {@link #restart()}: ASYNC - completes a stop then a start. + * - {@link #destroy()}: ASYNC - performs a {@link #stop()} that will go on no matter the exceptions, without throwing. + * Makes the engine unusable and clears resources. * - * So the engine state will be: + * For example, we expose the engine (S2) state through {@link #getEngineState()}. It will be: * - {@link #STATE_STARTING} if we're into step 2 * - {@link #STATE_STARTED} if we've completed step 2. No clue about 3 or 4. * - {@link #STATE_STOPPING} if we're undoing steps 4, 3 and 2. @@ -74,9 +94,24 @@ import java.util.List; * * THREADING * Subclasses should always execute code on the thread given by {@link #mHandler}. - * This thread has a special {@link Thread.UncaughtExceptionHandler} that handles exceptions - * and dispatches error to the callback (instead of crashing the app). - * This lets subclasses run code safely and directly throw {@link CameraException}s when needed. + * For convenience, all the setup and tear down methods are called on this engine thread: + * {@link #onStartEngine()}, {@link #onStartBind()}, {@link #onStartPreview()} to setup and + * {@link #onStopEngine()}, {@link #onStopBind()}, {@link #onStopPreview()} to tear down. + * However, these methods are not forced to be synchronous and then can simply return a Google's + * {@link Task}. + * + * Other setters are executed on the callers thread so subclasses should make sure they post + * to the engine handler before acting on themselves. + * + * + * ERROR HANDLING + * THe {@link #mHandler} thread has a special {@link Thread.UncaughtExceptionHandler} that handles exceptions + * and dispatches error to the callback (instead of crashing the app). This lets subclasses run code + * safely and directly throw {@link CameraException}s when needed. + * + * For convenience, the two main method {@link #onStartEngine()} and {@link #onStopEngine()} are already + * called on the engine thread, but they can still be asynchronous by returning a Google's + * {@link com.google.android.gms.tasks.Task}. */ public abstract class CameraEngine implements CameraPreview.SurfaceCallback, @@ -101,17 +136,19 @@ public abstract class CameraEngine implements private static final String TAG = CameraEngine.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); - static final int STATE_STOPPING = -1; // Camera is about to be stopped. - public static final int STATE_STOPPED = 0; // Camera is stopped. - static final int STATE_STARTING = 1; // Camera is about to start. - public static final int STATE_STARTED = 2; // Camera is available and we can set parameters. + @SuppressWarnings("WeakerAccess") + public static final int STATE_STOPPING = CameraEngineStep.STATE_STOPPING; + public static final int STATE_STOPPED = CameraEngineStep.STATE_STOPPED; + @SuppressWarnings("WeakerAccess") + public static final int STATE_STARTING = CameraEngineStep.STATE_STARTING; + public static final int STATE_STARTED = CameraEngineStep.STATE_STARTED; public static final int REF_SENSOR = 0; public static final int REF_VIEW = 1; public static final int REF_OUTPUT = 2; protected final Callback mCallback; - protected final FrameManager mFrameManager; + private final FrameManager mFrameManager; protected CameraPreview mPreview; protected WorkerHandler mHandler; @VisibleForTesting Handler mCrashHandler; @@ -149,21 +186,37 @@ public abstract class CameraEngine implements protected long mAutoFocusResetDelayMillis; protected int mSensorOffset; - private int mDisplayOffset; - private int mDeviceOrientation; + protected int mDisplayOffset; + protected int mDeviceOrientation; - // Subclasses should not change this. Use getState() instead. - @VisibleForTesting int mState = STATE_STOPPED; + private final CameraEngineStep.Callback mStepCallback = new CameraEngineStep.Callback() { + @Override @NonNull public Executor getExecutor() { return mHandler.getExecutor(); } + @Override public void handleException(@NonNull Exception exception) { + CameraEngine.this.handleException(Thread.currentThread(), exception, false); + } + }; + @VisibleForTesting CameraEngineStep mEngineStep = new CameraEngineStep("engine", mStepCallback); + private CameraEngineStep mBindStep = new CameraEngineStep("bind", mStepCallback); + private CameraEngineStep mPreviewStep = new CameraEngineStep("preview", mStepCallback); + private CameraEngineStep mAllStep = new CameraEngineStep("all", mStepCallback); // Used for testing. - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mZoomTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mExposureCorrectionTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mFlashTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mWhiteBalanceTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mHdrTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mLocationTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mStartVideoTask = new Task<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mPlaySoundsTask = new Task<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mZoomOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mExposureCorrectionOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mFlashOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mWhiteBalanceOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mHdrOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mLocationOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mStartVideoOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + Op mPlaySoundsOp = new Op<>(); protected CameraEngine(Callback callback) { mCallback = callback; @@ -188,41 +241,7 @@ public abstract class CameraEngine implements private class CrashExceptionHandler implements Thread.UncaughtExceptionHandler { @Override public void uncaughtException(final Thread thread, final Throwable throwable) { - // Something went wrong. Thread is terminated (about to?). - // Move to other thread and release resources. - if (!(throwable instanceof CameraException)) { - // This is unexpected, either a bug or something the developer should know. - // Release and crash the UI thread so we get bug reports. - LOG.e("uncaughtException:", "Unexpected exception:", throwable); - destroy(); - mCrashHandler.post(new Runnable() { - @Override - public void run() { - RuntimeException exception; - if (throwable instanceof RuntimeException) { - exception = (RuntimeException) throwable; - } else { - exception = new RuntimeException(throwable); - } - throw exception; - } - }); - } else { - final CameraException error = (CameraException) throwable; - LOG.e("uncaughtException:", "Interrupting thread with state:", ss(), "due to CameraException:", error); - final boolean unrecoverable = error.isUnrecoverable(); - thread.interrupt(); - // Restart handler. - mHandler = WorkerHandler.get("CameraViewEngine"); - mHandler.getThread().setUncaughtExceptionHandler(new CrashExceptionHandler()); - mHandler.post(new Runnable() { - @Override - public void run() { - if (unrecoverable) stopNow(); - mCallback.dispatchError(error); - } - }); - } + handleException(thread, throwable, true); } } @@ -238,126 +257,510 @@ public abstract class CameraEngine implements } /** - * Not final due to mockito requirements, but this is basically - * it, nothing more to do. + * Handles exceptions coming from either runtime errors on the {@link #mHandler} code that is + * not caught (using the {@link CrashExceptionHandler}), as might happen during standard mHandler.post() + * operations that subclasses might do, OR for errors caught by tasks and continuations that + * we launch here. + * + * In the first case, the thread is about to be terminated. In the second case, + * we can actually keep using it. + * + * @param thread the thread + * @param throwable the throwable + * @param fromExceptionHandler true if coming from exception handler */ - public void destroy() { - LOG.i("destroy:", "state:", ss()); - // Prevent CameraEngine leaks. Don't set to null, or exceptions - // inside the standard stop() method might crash the main thread. - mHandler.getThread().setUncaughtExceptionHandler(new NoOpExceptionHandler()); - // Stop if needed. - stopNow(); + private void handleException(@NonNull Thread thread, final @NonNull Throwable throwable, final boolean fromExceptionHandler) { + if (!(throwable instanceof CameraException)) { + // This is unexpected, either a bug or something the developer should know. + // Release and crash the UI thread so we get bug reports. + LOG.e("uncaughtException:", "Unexpected exception:", throwable); + mCrashHandler.post(new Runnable() { + @Override + public void run() { + destroy(); + // Throws an unchecked exception without unnecessary wrapping. + if (throwable instanceof RuntimeException) { + throw (RuntimeException) throwable; + } else { + throw new RuntimeException(throwable); + } + } + }); + return; + } + + final CameraException cameraException = (CameraException) throwable; + LOG.e("uncaughtException:", "Got CameraException:", cameraException, "on engine state:", getEngineStateName()); + if (fromExceptionHandler) { + // Got to restart the handler. + thread.interrupt(); + mHandler = WorkerHandler.get("CameraViewEngine"); + mHandler.getThread().setUncaughtExceptionHandler(new CrashExceptionHandler()); + } + + mCallback.dispatchError(cameraException); + if (cameraException.isUnrecoverable()) { + // Stop everything (if needed) without notifying teardown errors. + stop(true); + } + } + + //endregion + + //region states and steps + + public final int getEngineState() { + return mEngineStep.getState(); + } + + @SuppressWarnings("WeakerAccess") + public final int getBindState() { + return mBindStep.getState(); + } + + @SuppressWarnings("unused") + public final int getPreviewState() { + return mPreviewStep.getState(); + } + + @NonNull + private String getEngineStateName() { + return mEngineStep.getStateName(); + } + + private boolean canStartEngine() { + return mEngineStep.isStoppingOrStopped(); + } + + private boolean needsStopEngine() { + return mEngineStep.isStartedOrStarting(); + } + + private boolean canStartBind() { + return mEngineStep.isStarted() + && mPreview != null + && mPreview.hasSurface() + && mBindStep.isStoppingOrStopped(); + } + + private boolean needsStopBind() { + return mBindStep.isStartedOrStarting(); } + private boolean canStartPreview() { + return mEngineStep.isStarted() + && mBindStep.isStarted() + && mPreviewStep.isStoppingOrStopped(); + } + + private boolean needsStopPreview() { + return mPreviewStep.isStartedOrStarting(); + } + + //endregion + + //region Start & Stop the engine + + @NonNull + @WorkerThread + private Task startEngine() { + if (canStartEngine()) { + mEngineStep.doStart(false, new Callable>() { + @Override + public Task call() { + return onStartEngine(); + } + }, new Runnable() { + @Override + public void run() { + mCallback.dispatchOnCameraOpened(mCameraOptions); + } + }); + } + return mEngineStep.getTask(); + } + + @NonNull + @WorkerThread + private Task stopEngine(boolean swallowExceptions) { + if (needsStopEngine()) { + mEngineStep.doStop(swallowExceptions, new Callable>() { + @Override + public Task call() { + return onStopEngine(); + } + }, new Runnable() { + @Override + public void run() { + mCallback.dispatchOnCameraClosed(); + } + }); + } + return mEngineStep.getTask(); + } + + /** + * Starts the engine. + * @return a task + */ + @NonNull + @WorkerThread + protected abstract Task onStartEngine(); + + /** + * Stops the engine. + * Stop events should generally not throw exceptions. We + * want to release resources either way. + * @return a task + */ + @NonNull + @WorkerThread + protected abstract Task onStopEngine(); + //endregion - //region Start&Stop + //region Start & Stop binding + + @NonNull + @WorkerThread + private Task startBind() { + if (canStartBind()) { + mBindStep.doStart(false, new Callable>() { + @Override + public Task call() { + return onStartBind(); + } + }); + } + return mBindStep.getTask(); + } @NonNull - private String ss() { - switch (mState) { - case STATE_STOPPING: return "STATE_STOPPING"; - case STATE_STOPPED: return "STATE_STOPPED"; - case STATE_STARTING: return "STATE_STARTING"; - case STATE_STARTED: return "STATE_STARTED"; + @WorkerThread + private Task stopBind(boolean swallowExceptions) { + if (needsStopBind()) { + mBindStep.doStop(swallowExceptions, new Callable>() { + @Override + public Task call() { + return onStopBind(); + } + }); } - return "null"; + return mBindStep.getTask(); } - // Starts the preview asynchronously. - public final void start() { - LOG.i("Start:", "posting runnable. State:", ss()); + /** + * Starts the binding process. + * @return a task + */ + @NonNull + @WorkerThread + protected abstract Task onStartBind(); + + /** + * Stops the binding process. + * Stop events should generally not throw exceptions. We + * want to release resources either way. + * @return a task + */ + @NonNull + @WorkerThread + protected abstract Task onStopBind(); + + @SuppressWarnings("WeakerAccess") + protected void restartBind() { + LOG.i("restartPreviewAndBind", "posting."); mHandler.post(new Runnable() { @Override public void run() { - LOG.i("Start:", "executing. State:", ss()); - if (mState >= STATE_STARTING) return; - mState = STATE_STARTING; - LOG.i("Start:", "about to call onStart()", ss()); - onStart(); - LOG.i("Start:", "returned from onStart().", "Dispatching.", ss()); - mState = STATE_STARTED; - mCallback.dispatchOnCameraOpened(mCameraOptions); + LOG.i("restartPreviewAndBind", "executing."); + stopPreview(false).continueWithTask(mHandler.getExecutor(), new Continuation>() { + @Override + public Task then(@NonNull Task task) { + return stopBind(false); + } + }).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + return startBind(); + } + }).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + return startPreview(); + } + }); + } + }); + } + + //endregion + + //region Start & Stop preview + + @NonNull + @WorkerThread + private Task startPreview() { + LOG.i("startPreview", "canStartPreview:", canStartPreview()); + if (canStartPreview()) { + mPreviewStep.doStart(false, new Callable>() { + @Override + public Task call() { + return onStartPreview(); + } + }); + } + return mPreviewStep.getTask(); + } + + @NonNull + @WorkerThread + private Task stopPreview(boolean swallowExceptions) { + LOG.i("stopPreview", "needsStopPreview:", needsStopPreview(), "swallowExceptions:", swallowExceptions); + if (needsStopPreview()) { + mPreviewStep.doStop(swallowExceptions, new Callable>() { + @Override + public Task call() { + return onStopPreview(); + } + }); + } + return mPreviewStep.getTask(); + } + + @SuppressWarnings("WeakerAccess") + protected void restartPreview() { + LOG.i("restartPreview", "posting."); + mHandler.post(new Runnable() { + @Override + public void run() { + LOG.i("restartPreview", "executing."); + stopPreview(false); + startPreview(); } }); } - // Stops the preview asynchronously. - // Public & not final so we can verify with mockito in CameraViewTest - public void stop() { - LOG.i("Stop:", "posting runnable. State:", ss()); + /** + * Starts the preview streaming. + * @return a task + */ + @NonNull + @WorkerThread + protected abstract Task onStartPreview(); + + /** + * Stops the preview streaming. + * Stop events should generally not throw exceptions. We + * want to release resources either way. + * @return a task + */ + @NonNull + @WorkerThread + protected abstract Task onStopPreview(); + + //endregion + + //region Surface callbacks + + /** + * The surface is now available, which means that step 1 has completed. + * If we have also completed step 2, go on with binding and streaming. + */ + @Override + public final void onSurfaceAvailable() { + LOG.i("onSurfaceAvailable:", "Size is", getPreviewSurfaceSize(REF_VIEW)); mHandler.post(new Runnable() { @Override public void run() { - LOG.i("Stop:", "executing. State:", ss()); - if (mState <= STATE_STOPPED) return; - mState = STATE_STOPPING; - LOG.i("Stop:", "about to call onStop()"); - onStop(); - LOG.i("Stop:", "returned from onStop().", "Dispatching."); - mState = STATE_STOPPED; - mCallback.dispatchOnCameraClosed(); + startBind().onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + return startPreview(); + } + }); } }); } - // Stops the preview synchronously, ensuring no exceptions are thrown. - final void stopNow() { + @Override + public final void onSurfaceChanged() { + LOG.i("onSurfaceChanged:", "Size is", getPreviewSurfaceSize(REF_VIEW), "Posting."); + mHandler.post(new Runnable() { + @Override + public void run() { + LOG.i("onSurfaceChanged:", + "Engine started?", mEngineStep.isStarted(), + "Bind started?", mBindStep.isStarted()); + if (!mEngineStep.isStarted()) return; // Too early + if (!mBindStep.isStarted()) return; // Too early + + // Compute a new camera preview size and apply. + Size newSize = computePreviewStreamSize(); + if (newSize.equals(mPreviewStreamSize)) return; + LOG.i("onSurfaceChanged:", "Computed a new preview size. Going on."); + mPreviewStreamSize = newSize; + onPreviewStreamSizeChanged(); + } + }); + } + + /** + * The preview stream size has changed. At this point, some engine might want to + * simply call {@link #restartPreview()}, others to {@link #restartBind()}. + * + * It basically depends on the step at which the preview stream size is actually used. + */ + @WorkerThread + protected abstract void onPreviewStreamSizeChanged(); + + @Override + public final void onSurfaceDestroyed() { + LOG.i("onSurfaceDestroyed"); + mHandler.post(new Runnable() { + @Override + public void run() { + stopPreview(false).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + return stopBind(false); + } + }); + } + }); + } + + //endregion + + //region Start & Stop all + + /** + * Not final due to mockito requirements, but this is basically + * it, nothing more to do. + * + * NOTE: Should not be called on the {@link #mHandler} thread! I think + * that would cause deadlocks due to us awaiting for {@link #stop()} to return. + */ + public void destroy() { + LOG.i("destroy:", "state:", getEngineStateName()); + // Prevent CameraEngine leaks. Don't set to null, or exceptions + // inside the standard stop() method might crash the main thread. + mHandler.getThread().setUncaughtExceptionHandler(new NoOpExceptionHandler()); + // Stop if needed, synchronously and silently. + // Cannot use Tasks.await() because we might be on the UI thread. + final CountDownLatch latch = new CountDownLatch(1); + stop(true).addOnCompleteListener(mHandler.getExecutor(), new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + latch.countDown(); + } + }); try { - // Don't check, try stop again. - LOG.i("stopNow:", "State was:", ss()); - if (mState == STATE_STOPPED) return; - mState = STATE_STOPPING; - onStop(); - mState = STATE_STOPPED; - LOG.i("stopNow:", "Stopped. State is:", ss()); - } catch (Exception e) { - // Do nothing. - LOG.i("stopNow:", "Swallowing exception while stopping.", e); - mState = STATE_STOPPED; - } + latch.await(); + } catch (InterruptedException ignore) {} } - // Forces a restart. @SuppressWarnings("WeakerAccess") protected final void restart() { - LOG.i("Restart:", "posting runnable"); + LOG.i("Restart:", "calling stop and start"); + stop(); + start(); + } + + @NonNull + public Task start() { + LOG.i("Start:", "posting runnable. State:", getEngineStateName()); + final TaskCompletionSource outTask = new TaskCompletionSource<>(); mHandler.post(new Runnable() { @Override public void run() { - LOG.i("Restart:", "executing. Needs stopping:", mState > STATE_STOPPED, ss()); - // Don't stop if stopped. - if (mState > STATE_STOPPED) { - mState = STATE_STOPPING; - onStop(); - mState = STATE_STOPPED; - LOG.i("Restart:", "stopped. Dispatching.", ss()); - mCallback.dispatchOnCameraClosed(); + LOG.i("Start:", "executing runnable. State:", getEngineStateName()); + if (mAllStep.isStoppingOrStopped()) { + mAllStep.doStart(false, new Callable>() { + @Override + public Task call() { + return startEngine().addOnFailureListener(mHandler.getExecutor(), new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + outTask.trySetException(e); + } + }).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + outTask.trySetResult(null); + return startBind(); + } + }).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + return startPreview(); + } + }); + } + }); + } else { + // NOTE: this returns early if we were STARTING. + outTask.trySetResult(null); } - - LOG.i("Restart: about to start. State:", ss()); - mState = STATE_STARTING; - onStart(); - mState = STATE_STARTED; - LOG.i("Restart: returned from start. Dispatching. State:", ss()); - mCallback.dispatchOnCameraOpened(mCameraOptions); } }); + return outTask.getTask(); } - // Starts the preview. - // At the end of this method camera must be available, e.g. for setting parameters. - @WorkerThread - protected abstract void onStart(); - - // Stops the preview. - @WorkerThread - protected abstract void onStop(); + @NonNull + public Task stop() { + return stop(false); + } - // Returns current state. - public final int getState() { - return mState; + @NonNull + private Task stop(final boolean swallowExceptions) { + LOG.i("Stop:", "posting runnable. State:", getEngineStateName()); + final TaskCompletionSource outTask = new TaskCompletionSource<>(); + mHandler.post(new Runnable() { + @Override + public void run() { + LOG.i("Stop:", "executing runnable. State:", getEngineStateName()); + if (mAllStep.isStartedOrStarting()) { + mAllStep.doStop(swallowExceptions, new Callable>() { + @Override + public Task call() { + return stopPreview(swallowExceptions).continueWithTask(mHandler.getExecutor(), new Continuation>() { + @Override + public Task then(@NonNull Task task) { + return stopBind(swallowExceptions); + } + }).continueWithTask(mHandler.getExecutor(), new Continuation>() { + @Override + public Task then(@NonNull Task task) { + return stopEngine(swallowExceptions); + } + }).continueWithTask(mHandler.getExecutor(), new Continuation>() { + @Override + public Task then(@NonNull Task task) { + if (task.isSuccessful()) { + outTask.trySetResult(null); + } else { + //noinspection ConstantConditions + outTask.trySetException(task.getException()); + } + return task; + } + }); + } + }); + } else { + // NOTE: this returns early if we were STOPPING. + outTask.trySetResult(null); + } + } + }); + return outTask.getTask(); } //endregion @@ -595,6 +998,9 @@ public abstract class CameraEngine implements } } + // o(S, V) - o(S, O) + // displayOffset - deviceOrientation + // Returns the offset between two reference systems. final int offset(int fromReference, int toReference) { if (fromReference == toReference) return 0; @@ -637,7 +1043,7 @@ public abstract class CameraEngine implements @SuppressWarnings("SameParameterValue") @Nullable - final Size getPreviewSurfaceSize(int reference) { + private Size getPreviewSurfaceSize(int reference) { if (mPreview == null) return null; return flip(REF_VIEW, reference) ? mPreview.getSurfaceSize().flip() : mPreview.getSurfaceSize(); } @@ -727,9 +1133,19 @@ public abstract class CameraEngine implements return result; } + /** + * This is called anytime {@link #computePreviewStreamSize()} is called. + * This means that it should be called during the binding process, when + * we can be sure that the camera is available (engineState == STARTED). + * @return a list of available sizes for preview + */ + @NonNull + protected abstract List getPreviewStreamAvailableSizes(); + @NonNull @SuppressWarnings("WeakerAccess") - protected final Size computePreviewStreamSize(@NonNull List previewSizes) { + protected final Size computePreviewStreamSize() { + @NonNull List previewSizes = getPreviewStreamAvailableSizes(); // These sizes come in REF_SENSOR. Since there is an external selector involved, // we must convert all of them to REF_VIEW, then flip back when returning. boolean flip = flip(REF_SENSOR, REF_VIEW); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngineStep.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngineStep.java new file mode 100644 index 00000000..0744aef6 --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngineStep.java @@ -0,0 +1,170 @@ +package com.otaliastudios.cameraview.engine; + +import com.google.android.gms.tasks.Continuation; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.SuccessContinuation; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; +import com.otaliastudios.cameraview.CameraLogger; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +/** + * Represents one of the steps in the {@link CameraEngine} setup: for example, the engine step, + * the bind-to-surface step, and the preview step. + * + * A step is something that can be setup (started) or torn down (stopped), and + * steps can of course depend onto each other. + * + * The purpose of this class is to manage the step state (stopping, stopped, starting or started) + * and, more importantly, to perform START and STOP operations in such a way that they do not + * overlap. For example, if we're stopping, we're wait for stop to finish before starting again. + * + * This is an important condition for simplifying the engine code. + * Since Camera1, the only requirement was basically to use a single thread. + * Since Camera2, which has an asynchronous API, further care must be used. + * + * For this reason, we use Google's {@link Task} abstraction and only start new operations + * once the previous one has ended. + * + * This class is NOT thread safe! + */ +class CameraEngineStep { + + private static final String TAG = CameraEngineStep.class.getSimpleName(); + private static final CameraLogger LOG = CameraLogger.create(TAG); + + interface Callback { + @NonNull + Executor getExecutor(); + void handleException(@NonNull Exception exception); + } + + static final int STATE_STOPPING = -1; + static final int STATE_STOPPED = 0; + static final int STATE_STARTING = 1; + static final int STATE_STARTED = 2; + + private int state = STATE_STOPPED; + + // To avoid dirty scenarios (e.g. calling stopXXX while XXX is starting), + // and since every operation can be asynchronous, we use some tasks for each step. + private Task task = Tasks.forResult(null); + + private final String name; + private final Callback callback; + + CameraEngineStep(@NonNull String name, @NonNull Callback callback) { + this.name = name.toUpperCase(); + this.callback = callback; + } + + int getState() { + return state; + } + + @VisibleForTesting void setState(int newState) { + state = newState; + } + + @NonNull + String getStateName() { + switch (state) { + case STATE_STOPPING: return name + "_STATE_STOPPING"; + case STATE_STOPPED: return name + "_STATE_STOPPED"; + case STATE_STARTING: return name + "_STATE_STARTING"; + case STATE_STARTED: return name + "_STATE_STARTED"; + } + return "null"; + } + + boolean isStoppingOrStopped() { + return state == STATE_STOPPING || state == STATE_STOPPED; + } + + boolean isStartedOrStarting() { + return state == STATE_STARTING || state == STATE_STARTED; + } + + boolean isStarted() { + return state == STATE_STARTED; + } + + @NonNull + Task getTask() { + return task; + } + + @SuppressWarnings({"SameParameterValue", "UnusedReturnValue"}) + Task doStart(final boolean swallowExceptions, final @NonNull Callable> op) { + return doStart(swallowExceptions, op, null); + } + + Task doStart(final boolean swallowExceptions, final @NonNull Callable> op, final @Nullable Runnable onStarted) { + LOG.i(name, "doStart", "Called. Enqueuing."); + task = task.continueWithTask(callback.getExecutor(), new Continuation>() { + @Override + public Task then(@NonNull Task task) throws Exception { + LOG.i(name, "doStart", "About to start. Setting state to STARTING"); + setState(STATE_STARTING); + return op.call().addOnFailureListener(callback.getExecutor(), new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + LOG.w(name, "doStart", "Failed with error", e, "Setting state to STOPPED"); + setState(STATE_STOPPED); + if (!swallowExceptions) callback.handleException(e); + } + }); + } + }).onSuccessTask(callback.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + LOG.i(name, "doStart", "Succeeded! Setting state to STARTED"); + setState(STATE_STARTED); + if (onStarted != null) onStarted.run(); + return Tasks.forResult(null); + } + }); + return task; + } + + @SuppressWarnings("UnusedReturnValue") + Task doStop(final boolean swallowExceptions, final @NonNull Callable> op) { + return doStop(swallowExceptions, op, null); + } + + Task doStop(final boolean swallowExceptions, final @NonNull Callable> op, final @Nullable Runnable onStopped) { + LOG.i(name, "doStop", "Called. Enqueuing."); + task = task.continueWithTask(callback.getExecutor(), new Continuation>() { + @Override + public Task then(@NonNull Task task) throws Exception { + LOG.i(name, "doStop", "About to stop. Setting state to STOPPING"); + state = STATE_STOPPING; + return op.call().addOnFailureListener(callback.getExecutor(), new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + LOG.w(name, "doStop", "Failed with error", e, "Setting state to STOPPED"); + state = STATE_STOPPED; + if (!swallowExceptions) callback.handleException(e); + } + }); + } + }).onSuccessTask(callback.getExecutor(), new SuccessContinuation() { + @NonNull + @Override + public Task then(@Nullable Void aVoid) { + LOG.i(name, "doStop", "Succeeded! Setting state to STOPPED"); + state = STATE_STOPPED; + if (onStopped != null) onStopped.run(); + return Tasks.forResult(null); + } + }); + return task; + } +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/frame/FrameManager.java b/cameraview/src/main/java/com/otaliastudios/cameraview/frame/FrameManager.java index 5bf7c3f6..6c18068e 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/frame/FrameManager.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/frame/FrameManager.java @@ -40,7 +40,7 @@ public class FrameManager { /** * Construct a new frame manager. - * The construction must be followed by an {@link #allocateBuffers(int, Size)} call + * The construction must be followed by an {@link #setUp(int, Size)} call * as soon as the parameters are known. * * @param poolSize the size of the backing pool. @@ -63,7 +63,7 @@ public class FrameManager { * @param previewSize the preview size * @return the buffer size */ - public int allocateBuffers(int bitsPerPixel, @NonNull Size previewSize) { + public int setUp(int bitsPerPixel, @NonNull Size previewSize) { // TODO throw if called twice without release? mBufferSize = getBufferSize(bitsPerPixel, previewSize); for (int i = 0; i < mPoolSize; i++) { @@ -104,8 +104,8 @@ public class FrameManager { /** * Returns a new Frame for the given data. This must be called - * - after {@link #allocateBuffers(int, Size)}, which sets the buffer size - * - after the byte buffer given by allocateBuffers() has been filled. + * - after {@link #setUp(int, Size)}, which sets the buffer size + * - after the byte buffer given by setUp() has been filled. * If this is called X times in a row without releasing frames, it will allocate * X frames and that's bad. Callers must wait for the preview buffer to be available. * diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/GridLinesLayout.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/GridLinesLayout.java index 636d95b2..844cdc6b 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/GridLinesLayout.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/GridLinesLayout.java @@ -15,7 +15,7 @@ import android.util.TypedValue; import android.view.View; import com.otaliastudios.cameraview.controls.Grid; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; /** * A layout overlay that draws grid lines based on the {@link Grid} parameter. @@ -32,7 +32,8 @@ public class GridLinesLayout extends View { private ColorDrawable vert; private final float width; - @VisibleForTesting Task drawTask = new Task<>(); + @VisibleForTesting + Op drawOp = new Op<>(); public GridLinesLayout(@NonNull Context context) { this(context, null); @@ -115,7 +116,7 @@ public class GridLinesLayout extends View { @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); - drawTask.start(); + drawOp.start(); int count = getLineCount(); for (int n = 0; n < count; n++) { float pos = getLinePosition(n); @@ -130,6 +131,6 @@ public class GridLinesLayout extends View { vert.draw(canvas); canvas.translate(- pos * getWidth(), 0); } - drawTask.end(count); + drawOp.end(count); } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Task.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Op.java similarity index 91% rename from cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Task.java rename to cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Op.java index 9e220b44..0b2bd1d4 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Task.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Op.java @@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit; * A naive implementation of {@link java.util.concurrent.CountDownLatch} * to help in testing. */ -public class Task { +public class Op { private CountDownLatch mLatch; private T mResult; @@ -22,17 +22,17 @@ public class Task { * - call {@link #listen()} to notify they are interested in the next action * - call {@link #await()} to know when the action is performed. * - * Task owners should: + * Op owners should: * - call {@link #start()} when task started * - call {@link #end(Object)} when task ends */ - public Task() { } + public Op() { } /** * Creates an empty task and starts listening. * @param startListening whether to call listen */ - public Task(boolean startListening) { + public Op(boolean startListening) { if (startListening) listen(); } @@ -41,14 +41,14 @@ public class Task { } /** - * Task owner method: notifies the action started. + * Op owner method: notifies the action started. */ public void start() { if (!isListening()) mCount++; } /** - * Task owner method: notifies the action ended. + * Op owner method: notifies the action ended. * @param result the action result */ public void end(T result) { 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 12a54d30..7169a83e 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 @@ -81,7 +81,11 @@ public class WorkerHandler { * @param runnable the action */ public void post(@NonNull Runnable runnable) { - mHandler.post(runnable); + if (Thread.currentThread() == getThread()) { + runnable.run(); + } else { + mHandler.post(runnable); + } } /** diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotPictureRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotPictureRecorder.java index 96dad747..aff90d98 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotPictureRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotPictureRecorder.java @@ -219,7 +219,7 @@ public class SnapshotPictureRecorder extends PictureRecorder { // It seems that the buffers are already cleared here, so we need to allocate again. camera.setPreviewCallbackWithBuffer(null); // Release anything left camera.setPreviewCallbackWithBuffer(mEngine1); // Add ourselves - mEngine1.getFrameManager().allocateBuffers(ImageFormat.getBitsPerPixel(mFormat), previewStreamSize); + mEngine1.getFrameManager().setUp(ImageFormat.getBitsPerPixel(mFormat), previewStreamSize); } }); } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java index 04c7f294..21c9e405 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java @@ -10,7 +10,7 @@ import android.view.ViewGroup; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.engine.CameraEngine; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.size.Size; /** @@ -46,7 +46,8 @@ public abstract class CameraPreview { void onSurfaceDestroyed(); } - @VisibleForTesting Task mCropTask = new Task<>(); + @VisibleForTesting + Op mCropOp = new Op<>(); private SurfaceCallback mSurfaceCallback; private T mView; boolean mCropping; @@ -59,6 +60,9 @@ public abstract class CameraPreview { int mInputStreamWidth; int mInputStreamHeight; + // The rotation, if any, to be applied when drawing. + int mDrawRotation; + /** * Creates a new preview. * @param context a context @@ -130,7 +134,6 @@ public abstract class CameraPreview { /** * Called to notify the preview of the input stream size. The width and height must be * rotated before calling this, if needed, to be consistent with the VIEW reference. - * * @param width width of the preview stream, in view coordinates * @param height height of the preview stream, in view coordinates */ @@ -139,7 +142,7 @@ public abstract class CameraPreview { mInputStreamWidth = width; mInputStreamHeight = height; if (mInputStreamWidth > 0 && mInputStreamHeight > 0) { - crop(mCropTask); + crop(mCropOp); } } @@ -181,7 +184,7 @@ public abstract class CameraPreview { mOutputSurfaceWidth = width; mOutputSurfaceHeight = height; if (mOutputSurfaceWidth > 0 && mOutputSurfaceHeight > 0) { - crop(mCropTask); + crop(mCropOp); } mSurfaceCallback.onSurfaceAvailable(); } @@ -198,7 +201,7 @@ public abstract class CameraPreview { mOutputSurfaceWidth = width; mOutputSurfaceHeight = height; if (width > 0 && height > 0) { - crop(mCropTask); + crop(mCropOp); } mSurfaceCallback.onSurfaceChanged(); } @@ -240,10 +243,10 @@ public abstract class CameraPreview { * There might still be some absolute difference (e.g. same ratio but bigger / smaller). * However that should be already managed by the framework. */ - protected void crop(@NonNull Task task) { + protected void crop(@NonNull Op op) { // The base implementation does not support cropping. - task.start(); - task.end(null); + op.start(); + op.end(null); } /** @@ -263,4 +266,23 @@ public abstract class CameraPreview { public boolean isCropping() { return mCropping; } + + + /** + * Should be called after {@link #setStreamSize(int, int)}! + * + * Sets the rotation, if any, to be applied when drawing. + * Sometimes we don't need this: + * - In Camera1, the buffer producer sets our Surface size and rotates it based on the value + * that we pass to {@link android.hardware.Camera.Parameters#setDisplayOrientation(int)}, + * so the stream that comes in is already rotated. + * - In Camera2, for {@link android.view.SurfaceView} based previews, apparently it just works + * out of the box. The producer might be doing something similar. + * + * But in all the other Camera2 cases, we need to apply this rotation when drawing the surface. + * @param drawRotation the rotation in degrees + */ + public void setDrawRotation(int drawRotation) { + mDrawRotation = drawRotation; + } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java index 0fe71ac1..75454c1e 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java @@ -15,7 +15,7 @@ import android.view.ViewGroup; import com.otaliastudios.cameraview.R; import com.otaliastudios.cameraview.internal.egl.EglViewport; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.size.AspectRatio; import java.util.Collections; @@ -65,8 +65,8 @@ public class GlCameraPreview extends CameraPreview mRendererFrameCallbacks = Collections.synchronizedSet(new HashSet()); - @VisibleForTesting float mScaleX = 1F; - @VisibleForTesting float mScaleY = 1F; + @VisibleForTesting float mCropScaleX = 1F; + @VisibleForTesting float mCropScaleY = 1F; private View mRootView; /** @@ -199,6 +199,7 @@ public class GlCameraPreview extends CameraPreview task) { - task.start(); + protected void crop(@NonNull Op op) { + op.start(); if (mInputStreamWidth > 0 && mInputStreamHeight > 0 && mOutputSurfaceWidth > 0 && mOutputSurfaceHeight > 0) { float scaleX = 1f, scaleY = 1f; AspectRatio current = AspectRatio.of(mOutputSurfaceWidth, mOutputSurfaceHeight); @@ -270,10 +279,10 @@ public class GlCameraPreview extends CameraPreview 1.02f || scaleY > 1.02f; - mScaleX = 1F / scaleX; - mScaleY = 1F / scaleY; + mCropScaleX = 1F / scaleX; + mCropScaleY = 1F / scaleY; getView().requestRender(); } - task.end(null); + op.end(null); } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/TextureCameraPreview.java b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/TextureCameraPreview.java index a74ccb88..19833fd0 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/TextureCameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/TextureCameraPreview.java @@ -2,6 +2,10 @@ package com.otaliastudios.cameraview.preview; import android.annotation.TargetApi; import android.content.Context; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; import android.graphics.SurfaceTexture; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -10,10 +14,14 @@ import android.view.TextureView; import android.view.View; import android.view.ViewGroup; +import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.R; -import com.otaliastudios.cameraview.internal.utils.Task; +import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.size.AspectRatio; +import java.util.concurrent.ExecutionException; + /** * A preview implementation based on {@link TextureView}. * Better than {@link SurfaceCameraPreview} but much less powerful than {@link GlCameraPreview}. @@ -76,29 +84,20 @@ public class TextureCameraPreview extends CameraPreview task) { - task.start(); + protected void crop(final @NonNull Op op) { + op.start(); getView().post(new Runnable() { @Override public void run() { if (mInputStreamHeight == 0 || mInputStreamWidth == 0 || mOutputSurfaceHeight == 0 || mOutputSurfaceWidth == 0) { - task.end(null); + op.end(null); return; } float scaleX = 1f, scaleY = 1f; @@ -118,8 +117,34 @@ public class TextureCameraPreview extends CameraPreview 1.02f || scaleY > 1.02f; LOG.i("crop:", "applied scaleX=", scaleX); LOG.i("crop:", "applied scaleY=", scaleY); - task.end(null); + op.end(null); + } + }); + } + + @Override + public void setDrawRotation(final int drawRotation) { + super.setDrawRotation(drawRotation); + final TaskCompletionSource task = new TaskCompletionSource<>(); + getView().post(new Runnable() { + @Override + public void run() { + Matrix matrix = new Matrix(); + // Output surface coordinates + float outputCenterX = mOutputSurfaceWidth / 2F; + float outputCenterY = mOutputSurfaceHeight / 2F; + boolean flip = drawRotation % 180 != 0; + // If dimensions are swapped, we must also do extra work to flip + // the two dimensions, using the view width and height (to support cropping). + if (flip) { + float scaleX = (float) mOutputSurfaceHeight / mOutputSurfaceWidth; + matrix.postScale(scaleX, 1F / scaleX, outputCenterX, outputCenterY); + } + matrix.postRotate((float) -drawRotation, outputCenterX, outputCenterY); + getView().setTransform(matrix); + task.setResult(null); } }); + try { Tasks.await(task.getTask()); } catch (InterruptedException | ExecutionException ignore) { } } } diff --git a/cameraview/src/test/java/com/otaliastudios/cameraview/frame/FrameManagerTest.java b/cameraview/src/test/java/com/otaliastudios/cameraview/frame/FrameManagerTest.java index f5f94fbf..513a05fb 100644 --- a/cameraview/src/test/java/com/otaliastudios/cameraview/frame/FrameManagerTest.java +++ b/cameraview/src/test/java/com/otaliastudios/cameraview/frame/FrameManagerTest.java @@ -33,12 +33,12 @@ public class FrameManagerTest { @Test public void testAllocate() { FrameManager manager = new FrameManager(1, callback); - manager.allocateBuffers(4, new Size(50, 50)); + manager.setUp(4, new Size(50, 50)); verify(callback, times(1)).onBufferAvailable(any(byte[].class)); reset(callback); manager = new FrameManager(5, callback); - manager.allocateBuffers(4, new Size(50, 50)); + manager.setUp(4, new Size(50, 50)); verify(callback, times(5)).onBufferAvailable(any(byte[].class)); } @@ -46,7 +46,7 @@ public class FrameManagerTest { public void testFrameRecycling() { // A 1-pool manager will always recycle the same frame. FrameManager manager = new FrameManager(1, callback); - manager.allocateBuffers(4, new Size(50, 50)); + manager.setUp(4, new Size(50, 50)); Frame first = manager.getFrame(null, 0, 0, null, 0); first.release(); @@ -60,7 +60,7 @@ public class FrameManagerTest { @Test public void testOnFrameReleased_alreadyFull() { FrameManager manager = new FrameManager(1, callback); - int length = manager.allocateBuffers(4, new Size(50, 50)); + int length = manager.setUp(4, new Size(50, 50)); Frame frame1 = manager.getFrame(new byte[length], 0, 0, null, 0); // Since frame1 is already taken and poolSize = 1, a new Frame is created. @@ -77,7 +77,7 @@ public class FrameManagerTest { @Test public void testOnFrameReleased_sameLength() { FrameManager manager = new FrameManager(1, callback); - int length = manager.allocateBuffers(4, new Size(50, 50)); + int length = manager.setUp(4, new Size(50, 50)); // A camera preview frame comes. Request a frame. byte[] picture = new byte[length]; @@ -92,14 +92,14 @@ public class FrameManagerTest { @Test public void testOnFrameReleased_differentLength() { FrameManager manager = new FrameManager(1, callback); - int length = manager.allocateBuffers(4, new Size(50, 50)); + int length = manager.setUp(4, new Size(50, 50)); // A camera preview frame comes. Request a frame. byte[] picture = new byte[length]; Frame frame = manager.getFrame(picture, 0, 0, null, 0); // Don't release the frame. Change the allocation size. - manager.allocateBuffers(2, new Size(15, 15)); + manager.setUp(2, new Size(15, 15)); // Now release the old frame and ensure that onBufferAvailable is NOT called, // because the released data has wrong length. @@ -111,7 +111,7 @@ public class FrameManagerTest { @Test public void testRelease() { FrameManager manager = new FrameManager(1, callback); - int length = manager.allocateBuffers(4, new Size(50, 50)); + int length = manager.setUp(4, new Size(50, 50)); Frame first = manager.getFrame(new byte[length], 0, 0, null, 0); first.release(); // Store this frame in the queue. diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 15d6c9ea..18f0871a 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -15,8 +15,7 @@ diff --git a/demo/src/main/res/layout/activity_camera.xml b/demo/src/main/res/layout/activity_camera.xml index 8cc27af5..03b195b7 100644 --- a/demo/src/main/res/layout/activity_camera.xml +++ b/demo/src/main/res/layout/activity_camera.xml @@ -18,6 +18,7 @@ android:keepScreenOn="true" app:cameraExperimental="true" app:cameraEngine="camera2" + app:cameraPreview="glSurface" app:cameraPlaySounds="true" app:cameraGrid="off" app:cameraFlash="off"