diff --git a/cameraview/build.gradle b/cameraview/build.gradle index 8ec12527..409e3ce7 100644 --- a/cameraview/build.gradle +++ b/cameraview/build.gradle @@ -17,7 +17,7 @@ android { versionCode 1 versionName project.version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArgument "filter", "com.otaliastudios.cameraview.runner.SdkExcludeFilter" + testInstrumentationRunnerArgument "filter", "com.otaliastudios.cameraview.tools.SdkExcludeFilter" } buildTypes { @@ -42,8 +42,8 @@ dependencies { androidTestImplementation 'org.mockito:mockito-android:2.28.2' 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' + api 'androidx.exifinterface:exifinterface:1.1.0' + api 'androidx.lifecycle:lifecycle-common:2.1.0' api 'com.google.android.gms:play-services-tasks:17.0.0' implementation 'androidx.annotation:annotation:1.1.0' } @@ -250,6 +250,7 @@ task mergeCoverageReports(type: JacocoReport) { classFilter.add('**/com/otaliastudios/cameraview/picture/**.*') classFilter.add('**/com/otaliastudios/cameraview/video/**.*') // TODO these below could be easily testable ALSO outside of the integration tests + classFilter.add('**/com/otaliastudios/cameraview/orchestrator/**.*') classFilter.add('**/com/otaliastudios/cameraview/video/encoding/**.*') } // We don't test OpenGL filters. diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java index 081a6fd5..494f03f8 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java @@ -11,7 +11,7 @@ import android.os.PowerManager; import androidx.annotation.NonNull; import androidx.test.platform.app.InstrumentationRegistry; -import com.otaliastudios.cameraview.internal.utils.Op; +import com.otaliastudios.cameraview.tools.Op; import org.junit.After; import org.junit.AfterClass; @@ -103,7 +103,7 @@ public class BaseTest { } @NonNull - protected static Stubber doCountDown(final CountDownLatch latch) { + protected static Stubber doCountDown(@NonNull final CountDownLatch latch) { return doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) { @@ -118,7 +118,7 @@ public class BaseTest { return doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) { - op.end(response); + op.controller().end(response); return null; } }); @@ -126,14 +126,6 @@ public class BaseTest { @NonNull protected static Stubber doEndOp(final Op op, final int withReturnArgument) { - return doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) { - //noinspection unchecked - T o = (T) invocation.getArguments()[withReturnArgument]; - op.end(o); - return null; - } - }); + return op.controller().from(withReturnArgument); } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraLoggerTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraLoggerTest.java index e26c0bff..d3d985a4 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.Op; +import com.otaliastudios.cameraview.tools.Op; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -109,7 +109,7 @@ public class CameraLoggerTest extends BaseTest { CameraLogger.Logger mock = mock(CameraLogger.Logger.class); CameraLogger.registerLogger(mock); - final Op op = new Op<>(); + final Op op = new Op<>(false); doEndOp(op, 3) .when(mock) .log(anyInt(), anyString(), anyString(), any(Throwable.class)); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java index dd22d5c5..8da82853 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java @@ -6,11 +6,10 @@ import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Color; -import com.otaliastudios.cameraview.internal.utils.Op; +import com.otaliastudios.cameraview.tools.Op; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.test.annotation.UiThreadTest; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -20,7 +19,6 @@ import org.junit.runner.RunWith; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.Charset; @@ -51,21 +49,21 @@ public class CameraUtilsTest extends BaseTest { private Op writeAndReadString(@NonNull String data) { final File file = new File(getContext().getFilesDir(), "string.txt"); final byte[] bytes = data.getBytes(Charset.forName("UTF-8")); - final Op result = new Op<>(true); + final Op result = new Op<>(); final FileCallback callback = new FileCallback() { @Override public void onFileReady(@Nullable File file) { if (file == null) { - result.end(null); + result.controller().end(null); } else { // Read back the file. try { FileInputStream stream = new FileInputStream(file); byte[] bytes = new byte[stream.available()]; stream.read(bytes); - result.end(new String(bytes, Charset.forName("UTF-8"))); + result.controller().end(new String(bytes, Charset.forName("UTF-8"))); } catch (IOException e) { - result.end(null); + result.controller().end(null); } } } @@ -94,12 +92,12 @@ public class CameraUtilsTest extends BaseTest { source.compress(Bitmap.CompressFormat.PNG, 100, os); final byte[] data = os.toByteArray(); - final Op decode = new Op<>(true); + final Op decode = new Op<>(); if (async) { final BitmapCallback callback = new BitmapCallback() { @Override public void onBitmapReady(Bitmap bitmap) { - decode.end(bitmap); + decode.controller().end(bitmap); } }; @@ -121,7 +119,7 @@ public class CameraUtilsTest extends BaseTest { } else { result = CameraUtils.decodeBitmap(data); } - decode.end(result); + decode.controller().end(result); } return decode; } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java index 935ec3d5..6439ee01 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.Op; +import com.otaliastudios.cameraview.tools.Op; import com.otaliastudios.cameraview.engine.MockCameraEngine; import com.otaliastudios.cameraview.markers.AutoFocusMarker; import com.otaliastudios.cameraview.markers.AutoFocusTrigger; @@ -90,7 +90,7 @@ public class CameraViewCallbacksTest extends BaseTest { camera.doInstantiatePreview(); camera.addCameraListener(listener); camera.addFrameProcessor(processor); - op = new Op<>(true); + op = new Op<>(); } }); } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java index fb634090..7bdc790c 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java @@ -23,9 +23,9 @@ import com.otaliastudios.cameraview.controls.Flash; import com.otaliastudios.cameraview.controls.PictureFormat; import com.otaliastudios.cameraview.controls.Preview; import com.otaliastudios.cameraview.engine.CameraEngine; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; import com.otaliastudios.cameraview.filter.Filter; import com.otaliastudios.cameraview.filter.Filters; -import com.otaliastudios.cameraview.filter.NoFilter; import com.otaliastudios.cameraview.filters.DuotoneFilter; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameProcessor; @@ -41,7 +41,7 @@ import com.otaliastudios.cameraview.gesture.PinchGestureFinder; import com.otaliastudios.cameraview.gesture.ScrollGestureFinder; import com.otaliastudios.cameraview.gesture.TapGestureFinder; import com.otaliastudios.cameraview.engine.MockCameraEngine; -import com.otaliastudios.cameraview.internal.utils.Op; +import com.otaliastudios.cameraview.tools.Op; import com.otaliastudios.cameraview.markers.AutoFocusMarker; import com.otaliastudios.cameraview.markers.DefaultAutoFocusMarker; import com.otaliastudios.cameraview.markers.MarkerLayout; @@ -128,7 +128,7 @@ public class CameraViewTest extends BaseTest { public void testClose() { cameraView.close(); verify(mockPreview, times(1)).onPause(); - verify(mockController, times(1)).stop(); + verify(mockController, times(1)).stop(false); } @Test @@ -239,7 +239,7 @@ public class CameraViewTest extends BaseTest { public void testGestureAction_capture() { CameraOptions o = mock(CameraOptions.class); mockController.setMockCameraOptions(o); - mockController.setMockEngineState(true); + mockController.setMockState(CameraState.PREVIEW); MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); uiSync(new Runnable() { @Override @@ -261,7 +261,7 @@ public class CameraViewTest extends BaseTest { public void testGestureAction_focus() { CameraOptions o = mock(CameraOptions.class); mockController.setMockCameraOptions(o); - mockController.setMockEngineState(true); + mockController.setMockState(CameraState.PREVIEW); MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); uiSync(new Runnable() { @Override @@ -286,7 +286,7 @@ public class CameraViewTest extends BaseTest { public void testGestureAction_zoom() { CameraOptions o = mock(CameraOptions.class); mockController.setMockCameraOptions(o); - mockController.setMockEngineState(true); + mockController.setMockState(CameraState.PREVIEW); mockController.mZoomChanged = false; MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); final FactorHolder factor = new FactorHolder(); @@ -327,7 +327,7 @@ public class CameraViewTest extends BaseTest { o.exposureCorrectionMaxValue = 10F; o.exposureCorrectionMinValue = -10F; mockController.setMockCameraOptions(o); - mockController.setMockEngineState(true); + mockController.setMockState(CameraState.PREVIEW); mockController.mExposureCorrectionChanged = false; MotionEvent event = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0); final FactorHolder factor = new FactorHolder(); @@ -363,7 +363,7 @@ public class CameraViewTest extends BaseTest { @Test public void testGestureAction_filterControl1() { - mockController.setMockEngineState(true); + mockController.setMockState(CameraState.PREVIEW); mockController.setMockCameraOptions(mock(CameraOptions.class)); DuotoneFilter filter = new DuotoneFilter(); // supports two parameters filter.setParameter1(0F); @@ -405,7 +405,7 @@ public class CameraViewTest extends BaseTest { @Test public void testGestureAction_filterControl2() { - mockController.setMockEngineState(true); + mockController.setMockState(CameraState.PREVIEW); mockController.setMockCameraOptions(mock(CameraOptions.class)); DuotoneFilter filter = new DuotoneFilter(); // supports two parameters filter.setParameter2(0F); @@ -891,7 +891,7 @@ public class CameraViewTest extends BaseTest { cameraView.mMarkerLayout = markerLayout; final PointF point = new PointF(0, 0); final PointF[] points = new PointF[]{ point }; - final Op op = new Op<>(true); + final Op op = new Op<>(); doEndOp(op, true).when(markerLayout).onEvent(MarkerLayout.TYPE_AUTOFOCUS, points); cameraView.mCameraCallbacks.dispatchOnFocusStart(Gesture.TAP, point); assertNotNull(op.await(100)); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java index ee417387..af89d544 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/Camera1IntegrationTest.java @@ -2,6 +2,7 @@ package com.otaliastudios.cameraview.engine; import com.otaliastudios.cameraview.controls.Engine; +import org.junit.Test; import org.junit.runner.RunWith; import androidx.annotation.NonNull; diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java index 5563aba2..6a0bfc3c 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java @@ -25,9 +25,10 @@ import com.otaliastudios.cameraview.controls.Hdr; import com.otaliastudios.cameraview.controls.Mode; import com.otaliastudios.cameraview.controls.PictureFormat; import com.otaliastudios.cameraview.controls.WhiteBalance; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameProcessor; -import com.otaliastudios.cameraview.internal.utils.Op; +import com.otaliastudios.cameraview.tools.Op; import com.otaliastudios.cameraview.internal.utils.WorkerHandler; import com.otaliastudios.cameraview.overlay.Overlay; import com.otaliastudios.cameraview.size.Size; @@ -110,12 +111,12 @@ public abstract class CameraIntegrationTest extends BaseTest { // Ensure that controller exceptions are thrown on this thread (not on the UI thread). // TODO this makes debugging for wrong tests very hard, as we don't get the exception // unless waitForUiException() is called. - uiExceptionOp = new Op<>(true); + uiExceptionOp = new Op<>(); WorkerHandler crashThread = WorkerHandler.get("CrashThread"); crashThread.getThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { - uiExceptionOp.end(e); + uiExceptionOp.controller().end(e); } }); controller.mCrashHandler = crashThread.getHandler(); @@ -139,7 +140,7 @@ public abstract class CameraIntegrationTest extends BaseTest { private CameraOptions openSync(boolean expectSuccess) { camera.open(); - final Op open = new Op<>(true); + final Op open = new Op<>(); doEndOp(open, 0).when(listener).onCameraOpened(any(CameraOptions.class)); CameraOptions result = open.await(DELAY); if (expectSuccess) { @@ -156,13 +157,12 @@ public abstract class CameraIntegrationTest extends BaseTest { // Extra wait for the bind and preview state, so we run tests in a fully operational // state. If we didn't do so, we could have null values, for example, in getPictureSize // or in getSnapshotSize. - while (controller.getBindState() != CameraEngine.STATE_STARTED) {} - while (controller.getPreviewState() != CameraEngine.STATE_STARTED) {} + while (controller.getState() != CameraState.PREVIEW) {} } private void closeSync(boolean expectSuccess) { camera.close(); - final Op close = new Op<>(true); + final Op close = new Op<>(); doEndOp(close, true).when(listener).onCameraClosed(); Boolean result = close.await(DELAY); if (expectSuccess) { @@ -180,7 +180,7 @@ public abstract class CameraIntegrationTest extends BaseTest { doCountDown(onVideoRecordingEnd).when(listener).onVideoRecordingEnd(); // Op for onVideoTaken. - final Op video = new Op<>(true); + final Op video = new Op<>(); doEndOp(video, 0).when(listener).onVideoTaken(any(VideoResult.class)); doEndOp(video, null).when(listener).onCameraError(argThat(new ArgumentMatcher() { @Override @@ -218,7 +218,7 @@ public abstract class CameraIntegrationTest extends BaseTest { @Nullable private PictureResult waitForPictureResult(boolean expectSuccess) { - final Op pic = new Op<>(true); + final Op pic = new Op<>(); doEndOp(pic, 0).when(listener).onPictureTaken(any(PictureResult.class)); doEndOp(pic, null).when(listener).onCameraError(argThat(new ArgumentMatcher() { @Override @@ -240,7 +240,7 @@ public abstract class CameraIntegrationTest extends BaseTest { } private void takeVideoSync(boolean expectSuccess, int duration) { - final Op op = new Op<>(true); + final Op op = new Op<>(); doEndOp(op, true).when(listener).onVideoRecordingStart(); doEndOp(op, false).when(listener).onCameraError(argThat(new ArgumentMatcher() { @Override @@ -269,7 +269,7 @@ public abstract class CameraIntegrationTest extends BaseTest { } private void takeVideoSnapshotSync(boolean expectSuccess, int duration) { - final Op op = new Op<>(true); + final Op op = new Op<>(); doEndOp(op, true).when(listener).onVideoRecordingStart(); doEndOp(op, false).when(listener).onCameraError(argThat(new ArgumentMatcher() { @Override @@ -296,14 +296,11 @@ public abstract class CameraIntegrationTest extends BaseTest { @Test public void testOpenClose() { - // Starting and stopping are hard to get since they happen on another thread. - assertEquals(controller.getEngineState(), CameraEngine.STATE_STOPPED); - + assertEquals(controller.getState(), CameraState.OFF); openSync(true); - assertEquals(controller.getEngineState(), CameraEngine.STATE_STARTED); - + assertTrue(controller.getState().isAtLeast(CameraState.ENGINE)); closeSync(true); - assertEquals(controller.getEngineState(), CameraEngine.STATE_STOPPED); + assertEquals(controller.getState(), CameraState.OFF); } @Test @@ -389,12 +386,11 @@ public abstract class CameraIntegrationTest extends BaseTest { @Test public void testSetZoom() { CameraOptions options = openSync(true); - - controller.mZoomOp.listen(); float oldValue = camera.getZoom(); float newValue = 0.65f; camera.setZoom(newValue); - controller.mZoomOp.await(500); + Op op = new Op<>(controller.mZoomTask); + op.await(500); if (options.isZoomSupported()) { assertEquals(newValue, camera.getZoom(), 0f); @@ -406,12 +402,11 @@ public abstract class CameraIntegrationTest extends BaseTest { @Test public void testSetExposureCorrection() { CameraOptions options = openSync(true); - - controller.mExposureCorrectionOp.listen(); float oldValue = camera.getExposureCorrection(); float newValue = options.getExposureCorrectionMaxValue(); camera.setExposureCorrection(newValue); - controller.mExposureCorrectionOp.await(300); + Op op = new Op<>(controller.mExposureCorrectionTask); + op.await(300); if (options.isExposureCorrectionSupported()) { assertEquals(newValue, camera.getExposureCorrection(), 0f); @@ -426,9 +421,10 @@ public abstract class CameraIntegrationTest extends BaseTest { Flash[] values = Flash.values(); Flash oldValue = camera.getFlash(); for (Flash value : values) { - controller.mFlashOp.listen(); + camera.setFlash(value); - controller.mFlashOp.await(300); + Op op = new Op<>(controller.mFlashTask); + op.await(300); if (options.supports(value)) { assertEquals(camera.getFlash(), value); oldValue = value; @@ -444,9 +440,9 @@ public abstract class CameraIntegrationTest extends BaseTest { WhiteBalance[] values = WhiteBalance.values(); WhiteBalance oldValue = camera.getWhiteBalance(); for (WhiteBalance value : values) { - controller.mWhiteBalanceOp.listen(); camera.setWhiteBalance(value); - controller.mWhiteBalanceOp.await(300); + Op op = new Op<>(controller.mWhiteBalanceTask); + op.await(300); if (options.supports(value)) { assertEquals(camera.getWhiteBalance(), value); oldValue = value; @@ -462,9 +458,9 @@ public abstract class CameraIntegrationTest extends BaseTest { Hdr[] values = Hdr.values(); Hdr oldValue = camera.getHdr(); for (Hdr value : values) { - controller.mHdrOp.listen(); camera.setHdr(value); - controller.mHdrOp.await(300); + Op op = new Op<>(controller.mHdrTask); + op.await(300); if (options.supports(value)) { assertEquals(camera.getHdr(), value); oldValue = value; @@ -487,9 +483,9 @@ public abstract class CameraIntegrationTest extends BaseTest { @Test public void testSetLocation() { openSync(true); - controller.mLocationOp.listen(); camera.setLocation(10d, 2d); - controller.mLocationOp.await(300); + Op op = new Op<>(controller.mLocationTask); + op.await(300); assertNotNull(camera.getLocation()); assertEquals(camera.getLocation().getLatitude(), 10d, 0d); assertEquals(camera.getLocation().getLongitude(), 2d, 0d); @@ -499,19 +495,19 @@ public abstract class CameraIntegrationTest extends BaseTest { @Test public void testSetPreviewFrameRate() { openSync(true); - controller.mPreviewFrameRateOp.listen(); camera.setPreviewFrameRate(30); - controller.mPreviewFrameRateOp.await(300); + Op op = new Op<>(controller.mPreviewFrameRateTask); + op.await(300); assertEquals(camera.getPreviewFrameRate(), 30, 0); } @Test public void testSetPlaySounds() { - controller.mPlaySoundsOp.listen(); boolean oldValue = camera.getPlaySounds(); boolean newValue = !oldValue; camera.setPlaySounds(newValue); - controller.mPlaySoundsOp.await(300); + Op op = new Op<>(controller.mPlaySoundsTask); + op.await(300); if (controller instanceof Camera1Engine) { Camera1Engine camera1Engine = (Camera1Engine) controller; @@ -647,7 +643,7 @@ public abstract class CameraIntegrationTest extends BaseTest { public void testStartAutoFocus() { CameraOptions o = openSync(true); - final Op focus = new Op<>(true); + final Op focus = new Op<>(); doEndOp(focus, 0).when(listener).onAutoFocusStart(any(PointF.class)); camera.startAutoFocus(1, 1); @@ -664,7 +660,7 @@ public abstract class CameraIntegrationTest extends BaseTest { public void testStopAutoFocus() { CameraOptions o = openSync(true); - final Op focus = new Op<>(true); + final Op focus = new Op<>(); doEndOp(focus, 1).when(listener).onAutoFocusEnd(anyBoolean(), any(PointF.class)); camera.startAutoFocus(1, 1); @@ -809,25 +805,24 @@ public abstract class CameraIntegrationTest extends BaseTest { //region Picture Formats - // TODO this fails because setPictureFormat triggers a restart() and takePicture can be called - // in the middle of the restart, failing because the engine is not properly set up. To fix this - // we would have to change the whole CameraEngine threading scheme. - // @Test + @SuppressWarnings("ConstantConditions") + @Test public void testPictureFormat_DNG() { openSync(true); if (camera.getCameraOptions().supports(PictureFormat.DNG)) { + Op op = new Op<>(); + doEndOp(op, true).when(listener).onCameraOpened(any(CameraOptions.class)); camera.setPictureFormat(PictureFormat.DNG); + assertNotNull(op.await(2000)); camera.takePicture(); PictureResult result = waitForPictureResult(true); // assert that result.getData() is a DNG file: // We can use the first 4 bytes assuming they are the same as a TIFF file - // https://en.wikipedia.org/wiki/List_of_file_signatures + // https://en.wikipedia.org/wiki/List_of_file_signatures 73, 73, 42, 0 byte[] b = result.getData(); - boolean isII = b[0] == 'I' && b[1] == 'I' && b[2] == '*' && b[3] == '.'; - boolean isMM = b[0] == 'M' && b[1] == 'M' && b[2] == '.' && b[3] == '*'; - if (!isII && !isMM) { - throw new RuntimeException("Not a DNG file."); - } + boolean isII = b[0] == 'I' && b[1] == 'I' && b[2] == '*' && b[3] == 0; + boolean isMM = b[0] == 'M' && b[1] == 'M' && b[2] == 0 && b[3] == '*'; + assertTrue(isII || isMM); } } 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 6bbd97d1..0c0b50d6 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/MockCameraEngine.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/MockCameraEngine.java @@ -12,6 +12,7 @@ import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.controls.Facing; import com.otaliastudios.cameraview.controls.Flash; import com.otaliastudios.cameraview.controls.PictureFormat; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; import com.otaliastudios.cameraview.frame.FrameManager; import com.otaliastudios.cameraview.gesture.Gesture; import com.otaliastudios.cameraview.controls.Hdr; @@ -24,6 +25,7 @@ import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.Callable; public class MockCameraEngine extends CameraEngine { @@ -80,8 +82,19 @@ public class MockCameraEngine extends CameraEngine { mPreviewStreamSize = size; } - public void setMockEngineState(boolean started) { - mEngineStep.setState(started ? STATE_STARTED : STATE_STOPPED); + public void setMockState(@NonNull CameraState state) { + Task change = mOrchestrator.scheduleStateChange(getState(), + state, + false, + new Callable>() { + @Override + public Task call() { + return Tasks.forResult(null); + } + }); + try { + Tasks.await(change); + } catch (Exception ignore) {} } @Override diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureFinderTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureFinderTest.java index f7c04d43..cdcc7e8f 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureFinderTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/GestureFinderTest.java @@ -15,7 +15,7 @@ import android.widget.FrameLayout; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; -import com.otaliastudios.cameraview.internal.utils.Op; +import com.otaliastudios.cameraview.tools.Op; import org.hamcrest.Matchers; import org.junit.Before; @@ -49,12 +49,12 @@ public abstract class GestureFinderTest extends BaseTes finder.setActive(true); a.inflate(layout); - touchOp = new Op<>(); + touchOp = new Op<>(false); layout.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent motionEvent) { boolean found = finder.onTouchEvent(motionEvent); - if (found) touchOp.end(finder.getGesture()); + if (found) touchOp.controller().end(finder.getGesture()); return true; } }); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/PinchGestureFinderTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/PinchGestureFinderTest.java index 74cc7252..58bc5865 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/PinchGestureFinderTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/gesture/PinchGestureFinderTest.java @@ -6,7 +6,7 @@ import androidx.test.espresso.ViewAction; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; -import com.otaliastudios.cameraview.runner.SdkExclude; +import com.otaliastudios.cameraview.tools.SdkExclude; import org.junit.Test; import org.junit.runner.RunWith; @@ -52,7 +52,7 @@ public class PinchGestureFinderTest extends GestureFinderTest { @Test public void testTap() { touchOp.listen(); - touchOp.start(); + touchOp.controller().start(); GeneralClickAction a = new GeneralClickAction( Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY); @@ -62,7 +62,7 @@ public class TapGestureFinderTest extends GestureFinderTest { public void testTapWhileDisabled() { finder.setActive(false); touchOp.listen(); - touchOp.start(); + touchOp.controller().start(); onLayout().perform(click()); Gesture found = touchOp.await(500); assertNull(found); @@ -71,7 +71,7 @@ public class TapGestureFinderTest extends GestureFinderTest { @Test public void testLongTap() { touchOp.listen(); - touchOp.start(); + touchOp.controller().start(); GeneralClickAction a = new GeneralClickAction( Tap.LONG, GeneralLocation.CENTER, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY); 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 5323d66f..389b8a88 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,9 @@ 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.tools.Op; +import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.MediumTest; import androidx.test.rule.ActivityTestRule; @@ -15,6 +17,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.*; +import static org.mockito.Mockito.*; @RunWith(AndroidJUnit4.class) @MediumTest @@ -25,6 +28,14 @@ public class GridLinesLayoutTest extends BaseTest { private GridLinesLayout layout; + @NonNull + private Op getDrawOp() { + final Op op = new Op<>(); + layout.callback = mock(GridLinesLayout.DrawCallback.class); + doEndOp(op, 0).when(layout.callback).onDraw(anyInt()); + return op; + } + @Before public void setUp() { uiSync(new Runnable() { @@ -33,18 +44,17 @@ public class GridLinesLayoutTest extends BaseTest { TestActivity a = rule.getActivity(); layout = new GridLinesLayout(a); layout.setGridMode(Grid.OFF); - layout.drawOp.listen(); + Op op = getDrawOp(); a.getContentView().addView(layout); + op.await(1000); } }); - // Wait for first draw. - layout.drawOp.await(1000); } private int setGridAndWait(Grid value) { - layout.drawOp.listen(); layout.setGridMode(value); - Integer result = layout.drawOp.await(1000); + Op op = getDrawOp(); + Integer result = op.await(1000); assertNotNull(result); return result; } @@ -52,25 +62,25 @@ public class GridLinesLayoutTest extends BaseTest { @Test public void testOff() { int linesDrawn = setGridAndWait(Grid.OFF); - assertEquals(linesDrawn, 0); + assertEquals(0, linesDrawn); } @Test public void test3x3() { int linesDrawn = setGridAndWait(Grid.DRAW_3X3); - assertEquals(linesDrawn, 2); + assertEquals(2, linesDrawn); } @Test public void testPhi() { int linesDrawn = setGridAndWait(Grid.DRAW_PHI); - assertEquals(linesDrawn, 2); + assertEquals(2, linesDrawn); } @Test public void test4x4() { int linesDrawn = setGridAndWait(Grid.DRAW_4X4); - assertEquals(linesDrawn, 3); + assertEquals(3, linesDrawn); } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java index e8960e5b..169e1c48 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/CamcorderProfilesTest.java @@ -8,7 +8,7 @@ import androidx.test.filters.SmallTest; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.CameraUtils; -import com.otaliastudios.cameraview.runner.SdkExclude; +import com.otaliastudios.cameraview.tools.SdkExclude; import com.otaliastudios.cameraview.size.Size; import org.junit.Test; diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java index f884a467..a75f8438 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/internal/utils/ImageHelperTest.java @@ -18,11 +18,11 @@ import android.view.Surface; import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.SdkSuppress; import androidx.test.filters.SmallTest; import com.otaliastudios.cameraview.BaseTest; -import com.otaliastudios.cameraview.runner.SdkExclude; +import com.otaliastudios.cameraview.tools.Op; +import com.otaliastudios.cameraview.tools.SdkExclude; import org.junit.Test; import org.junit.runner.RunWith; @@ -45,12 +45,12 @@ public class ImageHelperTest extends BaseTest { private Image getImage() { ImageReader reader = ImageReader.newInstance(100, 100, ImageFormat.YUV_420_888, 1); Surface readerSurface = reader.getSurface(); - final Op imageOp = new Op<>(true); + final Op imageOp = new Op<>(); reader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { @Override public void onImageAvailable(ImageReader reader) { Image image = reader.acquireLatestImage(); - if (image != null) imageOp.end(image); + if (image != null) imageOp.controller().end(image); } }, new Handler(Looper.getMainLooper())); 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 fd5349be..5a733217 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 @@ -4,6 +4,7 @@ package com.otaliastudios.cameraview.internal.utils; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.tools.Op; import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -13,7 +14,6 @@ import org.junit.Test; import org.junit.runner.RunWith; import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; @@ -43,7 +43,7 @@ public class WorkerHandlerTest extends BaseTest { return new Runnable() { @Override public void run() { - op.end(true); + op.controller().end(true); } }; } @@ -53,7 +53,7 @@ public class WorkerHandlerTest extends BaseTest { return new Callable() { @Override public Boolean call() { - op.end(true); + op.controller().end(true); return true; } }; @@ -77,7 +77,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testFallbackExecute() { - final Op op = new Op<>(true); + final Op op = new Op<>(); WorkerHandler.execute(getRunnableForOp(op)); waitOp(op); } @@ -85,7 +85,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testPostRunnable() { WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.post(getRunnableForOp(op)); waitOp(op); } @@ -93,7 +93,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testPostCallable() { WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.post(getCallableForOp(op)); waitOp(op); } @@ -110,7 +110,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testRunRunnable_background() { WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.run(getRunnableForOp(op)); waitOp(op); } @@ -118,14 +118,14 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testRunRunnable_sameThread() { final WorkerHandler handler = WorkerHandler.get("handler"); - final Op op1 = new Op<>(true); - final Op op2 = new Op<>(true); + final Op op1 = new Op<>(); + final Op op2 = new Op<>(); handler.post(new Runnable() { @Override public void run() { handler.run(getRunnableForOp(op2)); assertTrue(op2.await(0)); // Do not wait. - op1.end(true); + op1.controller().end(true); } }); waitOp(op1); @@ -134,7 +134,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testRunCallable_background() { WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.run(getCallableForOp(op)); waitOp(op); } @@ -142,14 +142,14 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testRunCallable_sameThread() { final WorkerHandler handler = WorkerHandler.get("handler"); - final Op op1 = new Op<>(true); - final Op op2 = new Op<>(true); + final Op op1 = new Op<>(); + final Op op2 = new Op<>(); handler.post(new Runnable() { @Override public void run() { handler.run(getCallableForOp(op2)); assertTrue(op2.await(0)); // Do not wait. - op1.end(true); + op1.controller().end(true); } }); waitOp(op1); @@ -158,14 +158,14 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testRunCallable_sameThread_throws() { final WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.post(new Runnable() { @Override public void run() { Task task = handler.run(getThrowCallable()); assertTrue(task.isComplete()); // Already complete assertFalse(task.isSuccessful()); - op.end(true); + op.controller().end(true); } }); waitOp(op); @@ -174,7 +174,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testPostDelayed_tooEarly() { final WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.post(1000, getRunnableForOp(op)); assertNull(op.await(500)); } @@ -182,7 +182,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testPostDelayed() { final WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); handler.post(1000, getRunnableForOp(op)); assertNotNull(op.await(2000)); } @@ -190,7 +190,7 @@ public class WorkerHandlerTest extends BaseTest { @Test public void testRemove() { final WorkerHandler handler = WorkerHandler.get("handler"); - final Op op = new Op<>(true); + final Op op = new Op<>(); Runnable runnable = getRunnableForOp(op); handler.post(1000, runnable); handler.remove(runnable); @@ -210,7 +210,7 @@ public class WorkerHandlerTest extends BaseTest { public void testExecutor() { final WorkerHandler handler = WorkerHandler.get("handler"); Executor executor = handler.getExecutor(); - final Op op = new Op<>(true); + final Op op = new Op<>(); executor.execute(getRunnableForOp(op)); waitOp(op); } 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 c203932f..9220a938 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/markers/MarkerLayoutTest.java @@ -9,7 +9,7 @@ import android.view.ViewGroup; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; -import com.otaliastudios.cameraview.runner.SdkExclude; +import com.otaliastudios.cameraview.tools.SdkExclude; import org.junit.Assert; import org.junit.Before; 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 2142eae9..b2d3d064 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/CameraPreviewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/preview/CameraPreviewTest.java @@ -7,7 +7,7 @@ import android.view.ViewGroup; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.TestActivity; -import com.otaliastudios.cameraview.internal.utils.Op; +import com.otaliastudios.cameraview.tools.Op; import com.otaliastudios.cameraview.size.AspectRatio; import com.otaliastudios.cameraview.size.Size; @@ -42,8 +42,8 @@ public abstract class CameraPreviewTest extends BaseTes @Before public void setUp() { - available = new Op<>(true); - destroyed = new Op<>(true); + available = new Op<>(); + destroyed = new Op<>(); uiSync(new Runnable() { @Override @@ -55,7 +55,7 @@ public abstract class CameraPreviewTest extends BaseTes doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) { - if (available != null) available.end(true); + if (available != null) available.controller().end(true); return null; } }).when(callback).onSurfaceAvailable(); @@ -63,7 +63,7 @@ public abstract class CameraPreviewTest extends BaseTes doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) { - if (destroyed != null) destroyed.end(true); + if (destroyed != null) destroyed.controller().end(true); return null; } }).when(callback).onSurfaceDestroyed(); @@ -153,17 +153,23 @@ public abstract class CameraPreviewTest extends BaseTes // Since desired is 'desired', let's fake a new view size that is consistent with it. // Ensure crop is not happening anymore. - preview.mCropOp.listen(); - preview.dispatchOnSurfaceSizeChanged((int) (50f * desired), 50); // Wait... - preview.mCropOp.await(); + preview.mCropCallback = mock(CameraPreview.CropCallback.class); + Op op = new Op<>(); + doEndOp(op, null).when(preview.mCropCallback).onCrop(); + preview.dispatchOnSurfaceSizeChanged((int) (50f * desired), 50); + + op.await(); // Wait... assertEquals(desired, getViewAspectRatioWithScale(), 0.01f); assertFalse(preview.isCropping()); } private void setDesiredAspectRatio(float desiredAspectRatio) { - preview.mCropOp.listen(); - preview.setStreamSize((int) (10f * desiredAspectRatio), 10); // Wait... - preview.mCropOp.await(); + preview.mCropCallback = mock(CameraPreview.CropCallback.class); + Op op = new Op<>(); + doEndOp(op, null).when(preview.mCropCallback).onCrop(); + preview.setStreamSize((int) (10f * desiredAspectRatio), 10); + + op.await(); // Wait... assertEquals(desiredAspectRatio, getViewAspectRatioWithScale(), 0.01f); } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/Op.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/Op.java new file mode 100644 index 00000000..2d81c55f --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/Op.java @@ -0,0 +1,149 @@ +package com.otaliastudios.cameraview.tools; + +import androidx.annotation.NonNull; + +import com.google.android.gms.tasks.OnSuccessListener; +import com.google.android.gms.tasks.Task; +import com.otaliastudios.cameraview.controls.Control; + +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.mockito.stubbing.Stubber; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * A naive implementation of {@link java.util.concurrent.CountDownLatch} + * to help in testing. + */ +public class Op { + + public class Controller { + private int mToBeIgnored; + + private Controller() { } + + /** Op owner method: notifies the action started. */ + public void start() { + if (!isListening()) mToBeIgnored++; + } + + /** Op owner method: notifies the action ended. */ + public void end(T result) { + if (mToBeIgnored > 0) { + mToBeIgnored--; + return; + } + + if (isListening()) { // Should be always true. + mResult = result; + mLatch.countDown(); + } + } + + public void from(@NonNull Task task) { + start(); + task.addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(T result) { + end(result); + } + }); + } + + @NonNull + public Stubber from(final int invocationArgument) { + return Mockito.doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + //noinspection unchecked + T o = (T) invocation.getArguments()[invocationArgument]; + start(); + end(o); + return null; + } + }); + } + } + + private CountDownLatch mLatch; + private Controller mController = new Controller(); + private T mResult; + + /** + * Listeners should: + * - call {@link #listen()} to notify they are interested in the next action + * - call {@link #await()} to know when the action is performed. + * + * Op owners should: + * - call {@link Controller#start()} when task started + * - call {@link Controller#end(Object)} when task ends + */ + public Op() { + this(true); + } + + public Op(boolean startListening) { + if (startListening) listen(); + } + + public Op(@NonNull Task task) { + listen(); + controller().from(task); + } + + private boolean isListening() { + return mLatch != null; + } + + /** + * Listener method: notifies we are interested in the next action. + */ + public void listen() { + if (isListening()) throw new RuntimeException("Should not happen."); + mResult = null; + mLatch = new CountDownLatch(1); + } + + /** + * Listener method: waits for next task action to end. + * @param millis milliseconds + * @return the action result + */ + public T await(long millis) { + return await(millis, TimeUnit.MILLISECONDS); + } + + /** + * Listener method: waits 1 minute for next task action to end. + * @return the action result + */ + public T await() { + return await(1, TimeUnit.MINUTES); + } + + /** + * Listener method: waits for next task action to end. + * @param time time + * @param unit the time unit + * @return the action result + */ + private T await(long time, @NonNull TimeUnit unit) { + try { + mLatch.await(time, unit); + } catch (Exception e) { + e.printStackTrace(); + } + T result = mResult; + mResult = null; + mLatch = null; + return result; + } + + @NonNull + public Controller controller() { + return mController; + } +} diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/runner/SdkExclude.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/SdkExclude.java similarity index 92% rename from cameraview/src/androidTest/java/com/otaliastudios/cameraview/runner/SdkExclude.java rename to cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/SdkExclude.java index 7a119a9b..23a2d86e 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/runner/SdkExclude.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/SdkExclude.java @@ -1,4 +1,4 @@ -package com.otaliastudios.cameraview.runner; +package com.otaliastudios.cameraview.tools; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/runner/SdkExcludeFilter.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/SdkExcludeFilter.java similarity index 96% rename from cameraview/src/androidTest/java/com/otaliastudios/cameraview/runner/SdkExcludeFilter.java rename to cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/SdkExcludeFilter.java index 8dc014ad..4978e74e 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/runner/SdkExcludeFilter.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/tools/SdkExcludeFilter.java @@ -1,4 +1,4 @@ -package com.otaliastudios.cameraview.runner; +package com.otaliastudios.cameraview.tools; import android.os.Build; diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index d8eb83c9..154f8c9f 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -48,6 +48,7 @@ import com.otaliastudios.cameraview.engine.Camera1Engine; import com.otaliastudios.cameraview.engine.Camera2Engine; import com.otaliastudios.cameraview.engine.CameraEngine; import com.otaliastudios.cameraview.engine.offset.Reference; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; import com.otaliastudios.cameraview.filter.Filter; import com.otaliastudios.cameraview.filter.FilterParser; import com.otaliastudios.cameraview.filter.Filters; @@ -693,15 +694,17 @@ public class CameraView extends FrameLayout implements LifecycleObserver { //region Lifecycle APIs /** - * Returns whether the camera has started showing its preview. + * Returns whether the camera engine has started. * @return whether the camera has started */ public boolean isOpened() { - return mCameraEngine.getEngineState() >= CameraEngine.STATE_STARTED; + return mCameraEngine.getState().isAtLeast(CameraState.ENGINE) + && mCameraEngine.getTargetState().isAtLeast(CameraState.ENGINE); } private boolean isClosed() { - return mCameraEngine.getEngineState() == CameraEngine.STATE_STOPPED; + return mCameraEngine.getState() == CameraState.OFF + && !mCameraEngine.isChangingState(); } /** @@ -792,7 +795,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) public void close() { if (mInEditor) return; - mCameraEngine.stop(); + mCameraEngine.stop(false); if (mCameraPreview != null) mCameraPreview.onPause(); } @@ -2006,7 +2009,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { } @Override - public void dispatchOnCameraOpened(final CameraOptions options) { + public void dispatchOnCameraOpened(@NonNull final CameraOptions options) { mLogger.i("dispatchOnCameraOpened", options); mUiHandler.post(new Runnable() { @Override @@ -2054,7 +2057,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { } @Override - public void dispatchOnPictureTaken(final PictureResult.Stub stub) { + public void dispatchOnPictureTaken(@NonNull final PictureResult.Stub stub) { mLogger.i("dispatchOnPictureTaken", stub); mUiHandler.post(new Runnable() { @Override @@ -2068,7 +2071,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { } @Override - public void dispatchOnVideoTaken(final VideoResult.Stub stub) { + public void dispatchOnVideoTaken(@NonNull final VideoResult.Stub stub) { mLogger.i("dispatchOnVideoTaken", stub); mUiHandler.post(new Runnable() { @Override 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 0dbb7b4a..59163449 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java @@ -24,6 +24,7 @@ import com.otaliastudios.cameraview.engine.mappers.Camera1Mapper; import com.otaliastudios.cameraview.engine.offset.Axis; import com.otaliastudios.cameraview.engine.offset.Reference; import com.otaliastudios.cameraview.engine.options.Camera1Options; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.PictureResult; import com.otaliastudios.cameraview.VideoResult; @@ -49,7 +50,6 @@ import java.util.ArrayList; import java.util.List; -@SuppressWarnings("deprecation") public class Camera1Engine extends CameraEngine implements Camera.PreviewCallback, Camera.ErrorCallback, @@ -58,13 +58,15 @@ public class Camera1Engine extends CameraEngine implements private static final String TAG = Camera1Engine.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); + private static final String JOB_FOCUS_RESET = "focus reset"; + private static final String JOB_FOCUS_END = "focus end"; + private static final int PREVIEW_FORMAT = ImageFormat.NV21; @VisibleForTesting static final int AUTOFOCUS_END_DELAY_MILLIS = 2500; private final Camera1Mapper mMapper = Camera1Mapper.get(); private Camera mCamera; @VisibleForTesting int mCameraId; - private Runnable mFocusEndRunnable; public Camera1Engine(@NonNull Callback callback) { super(callback); @@ -286,10 +288,8 @@ public class Camera1Engine extends CameraEngine implements @Override protected Task onStopEngine() { LOG.i("onStopEngine:", "About to clean up."); - mHandler.remove(mFocusResetRunnable); - if (mFocusEndRunnable != null) { - mHandler.remove(mFocusEndRunnable); - } + mOrchestrator.remove(JOB_FOCUS_RESET); + mOrchestrator.remove(JOB_FOCUS_END); if (mCamera != null) { try { LOG.i("onStopEngine:", "Clean up.", "Releasing camera."); @@ -457,14 +457,13 @@ public class Camera1Engine extends CameraEngine implements public void setFlash(@NonNull Flash flash) { final Flash old = mFlash; mFlash = flash; - mHandler.run(new Runnable() { + mFlashTask = mOrchestrator.scheduleStateful("flash", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyFlash(params, old)) mCamera.setParameters(params); - } - mFlashOp.end(null); + Camera.Parameters params = mCamera.getParameters(); + if (applyFlash(params, old)) mCamera.setParameters(params); } }); } @@ -482,14 +481,13 @@ public class Camera1Engine extends CameraEngine implements public void setLocation(@Nullable Location location) { final Location oldLocation = mLocation; mLocation = location; - mHandler.run(new Runnable() { + mLocationTask = mOrchestrator.scheduleStateful("location", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyLocation(params, oldLocation)) mCamera.setParameters(params); - } - mLocationOp.end(null); + Camera.Parameters params = mCamera.getParameters(); + if (applyLocation(params, oldLocation)) mCamera.setParameters(params); } }); } @@ -510,14 +508,13 @@ public class Camera1Engine extends CameraEngine implements public void setWhiteBalance(@NonNull WhiteBalance whiteBalance) { final WhiteBalance old = mWhiteBalance; mWhiteBalance = whiteBalance; - mHandler.run(new Runnable() { + mWhiteBalanceTask = mOrchestrator.scheduleStateful("white balance", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyWhiteBalance(params, old)) mCamera.setParameters(params); - } - mWhiteBalanceOp.end(null); + Camera.Parameters params = mCamera.getParameters(); + if (applyWhiteBalance(params, old)) mCamera.setParameters(params); } }); } @@ -536,14 +533,13 @@ public class Camera1Engine extends CameraEngine implements public void setHdr(@NonNull Hdr hdr) { final Hdr old = mHdr; mHdr = hdr; - mHandler.run(new Runnable() { + mHdrTask = mOrchestrator.scheduleStateful("hdr", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyHdr(params, old)) mCamera.setParameters(params); - } - mHdrOp.end(null); + Camera.Parameters params = mCamera.getParameters(); + if (applyHdr(params, old)) mCamera.setParameters(params); } }); } @@ -561,19 +557,18 @@ public class Camera1Engine extends CameraEngine implements public void setZoom(final float zoom, @Nullable final PointF[] points, final boolean notify) { final float old = mZoomValue; mZoomValue = zoom; - mHandler.run(new Runnable() { + mZoomTask = mOrchestrator.scheduleStateful("zoom", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyZoom(params, old)) { - mCamera.setParameters(params); - if (notify) { - mCallback.dispatchOnZoomChanged(mZoomValue, points); - } + Camera.Parameters params = mCamera.getParameters(); + if (applyZoom(params, old)) { + mCamera.setParameters(params); + if (notify) { + mCallback.dispatchOnZoomChanged(mZoomValue, points); } } - mZoomOp.end(null); } }); } @@ -594,20 +589,19 @@ public class Camera1Engine extends CameraEngine implements @Nullable final PointF[] points, final boolean notify) { final float old = mExposureCorrectionValue; mExposureCorrectionValue = EVvalue; - mHandler.run(new Runnable() { + mExposureCorrectionTask = mOrchestrator.scheduleStateful("exposure correction", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyExposureCorrection(params, old)) { - mCamera.setParameters(params); - if (notify) { - mCallback.dispatchOnExposureCorrectionChanged(mExposureCorrectionValue, - bounds, points); - } + Camera.Parameters params = mCamera.getParameters(); + if (applyExposureCorrection(params, old)) { + mCamera.setParameters(params); + if (notify) { + mCallback.dispatchOnExposureCorrectionChanged(mExposureCorrectionValue, + bounds, points); } } - mExposureCorrectionOp.end(null); } }); } @@ -635,13 +629,12 @@ public class Camera1Engine extends CameraEngine implements public void setPlaySounds(boolean playSounds) { final boolean old = mPlaySounds; mPlaySounds = playSounds; - mHandler.run(new Runnable() { + mPlaySoundsTask = mOrchestrator.scheduleStateful("play sounds", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - applyPlaySounds(old); - } - mPlaySoundsOp.end(null); + applyPlaySounds(old); } }); } @@ -672,14 +665,13 @@ public class Camera1Engine extends CameraEngine implements public void setPreviewFrameRate(float previewFrameRate) { final float old = previewFrameRate; mPreviewFrameRate = previewFrameRate; - mHandler.run(new Runnable() { + mPreviewFrameRateTask = mOrchestrator.scheduleStateful("preview fps", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - Camera.Parameters params = mCamera.getParameters(); - if (applyPreviewFrameRate(params, old)) mCamera.setParameters(params); - } - mPreviewFrameRateOp.end(null); + Camera.Parameters params = mCamera.getParameters(); + if (applyPreviewFrameRate(params, old)) mCamera.setParameters(params); } }); } @@ -737,7 +729,8 @@ public class Camera1Engine extends CameraEngine implements @Override public void onBufferAvailable(@NonNull byte[] buffer) { - if (getEngineState() == STATE_STARTED) { + if (getState().isAtLeast(CameraState.ENGINE) + && getTargetState().isAtLeast(CameraState.ENGINE)) { mCamera.addCallbackBuffer(buffer); } } @@ -770,10 +763,9 @@ public class Camera1Engine extends CameraEngine implements } final int viewWidthF = viewWidth; final int viewHeightF = viewHeight; - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("auto focus", CameraState.ENGINE, new Runnable() { @Override public void run() { - if (getEngineState() < STATE_STARTED) return; if (!mCameraOptions.isAutoFocusSupported()) return; final PointF p = new PointF(point.x, point.y); // copy. int offset = getAngles().offset(Reference.SENSOR, Reference.VIEW, Axis.ABSOLUTE); @@ -794,14 +786,14 @@ public class Camera1Engine extends CameraEngine implements // The auto focus callback is not guaranteed to be called, but we really want it // to be. So we remove the old runnable if still present and post a new one. - if (mFocusEndRunnable != null) mHandler.remove(mFocusEndRunnable); - mFocusEndRunnable = new Runnable() { + mOrchestrator.remove(JOB_FOCUS_END); + mOrchestrator.scheduleDelayed(JOB_FOCUS_END, AUTOFOCUS_END_DELAY_MILLIS, + new Runnable() { @Override public void run() { mCallback.dispatchOnFocusEnd(gesture, false, p); } - }; - mHandler.post(AUTOFOCUS_END_DELAY_MILLIS, mFocusEndRunnable); + }); // Wrapping autoFocus in a try catch to handle some device specific exceptions, // see See https://github.com/natario1/CameraView/issues/181. @@ -809,14 +801,27 @@ public class Camera1Engine extends CameraEngine implements mCamera.autoFocus(new Camera.AutoFocusCallback() { @Override public void onAutoFocus(boolean success, Camera camera) { - if (mFocusEndRunnable != null) { - mHandler.remove(mFocusEndRunnable); - mFocusEndRunnable = null; - } + mOrchestrator.remove(JOB_FOCUS_END); + mOrchestrator.remove(JOB_FOCUS_RESET); mCallback.dispatchOnFocusEnd(gesture, success, p); - mHandler.remove(mFocusResetRunnable); if (shouldResetAutoFocus()) { - mHandler.post(getAutoFocusResetDelay(), mFocusResetRunnable); + mOrchestrator.scheduleStatefulDelayed( + JOB_FOCUS_RESET, + CameraState.ENGINE, + getAutoFocusResetDelay(), + new Runnable() { + @Override + public void run() { + mCamera.cancelAutoFocus(); + Camera.Parameters params = mCamera.getParameters(); + int maxAF = params.getMaxNumFocusAreas(); + int maxAE = params.getMaxNumMeteringAreas(); + if (maxAF > 0) params.setFocusAreas(null); + if (maxAE > 0) params.setMeteringAreas(null); + applyDefaultFocus(params); // Revert to internal focus. + mCamera.setParameters(params); + } + }); } } }); @@ -825,7 +830,6 @@ public class Camera1Engine extends CameraEngine implements // Let the mFocusEndRunnable do its job. (could remove it and quickly dispatch // onFocusEnd here, but let's make it simpler). } - } }); } @@ -876,21 +880,6 @@ public class Camera1Engine extends CameraEngine implements return new Rect(left, top, right, bottom); } - private final Runnable mFocusResetRunnable = new Runnable() { - @Override - public void run() { - if (getEngineState() < STATE_STARTED) return; - mCamera.cancelAutoFocus(); - Camera.Parameters params = mCamera.getParameters(); - int maxAF = params.getMaxNumFocusAreas(); - int maxAE = params.getMaxNumMeteringAreas(); - if (maxAF > 0) params.setFocusAreas(null); - if (maxAE > 0) params.setMeteringAreas(null); - applyDefaultFocus(params); // Revert to internal focus. - mCamera.setParameters(params); - } - }; - //endregion } 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 3f51f481..80dfab0b 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java @@ -48,12 +48,14 @@ import com.otaliastudios.cameraview.engine.action.ActionHolder; import com.otaliastudios.cameraview.engine.action.Actions; import com.otaliastudios.cameraview.engine.action.BaseAction; import com.otaliastudios.cameraview.engine.action.CompletionCallback; +import com.otaliastudios.cameraview.engine.action.LogAction; import com.otaliastudios.cameraview.engine.mappers.Camera2Mapper; import com.otaliastudios.cameraview.engine.meter.MeterAction; import com.otaliastudios.cameraview.engine.meter.MeterResetAction; import com.otaliastudios.cameraview.engine.offset.Axis; import com.otaliastudios.cameraview.engine.offset.Reference; import com.otaliastudios.cameraview.engine.options.Camera2Options; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameManager; import com.otaliastudios.cameraview.gesture.Gesture; @@ -243,7 +245,7 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv } private void applyRepeatingRequestBuilder(boolean checkStarted, int errorReason) { - if (getPreviewState() == STATE_STARTED || !checkStarted) { + if ((getState() == CameraState.PREVIEW && !isChangingState()) || !checkStarted) { try { mSession.setRepeatingRequest(mRepeatingRequestBuilder.build(), mRepeatingRequestCallback, null); @@ -256,9 +258,8 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv LOG.e("applyRepeatingRequestBuilder: session is invalid!", e, "checkStarted:", checkStarted, "currentThread:", Thread.currentThread().getName(), - "previewState:", getPreviewState(), - "bindState:", getBindState(), - "engineState:", getEngineState()); + "state:", getState(), + "targetState:", getTargetState()); throw new CameraException(CameraException.REASON_DISCONNECTED); } } @@ -588,13 +589,12 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv if (mFullVideoPendingStub != null) { // Do not call takeVideo/onTakeVideo. It will reset some stub parameters that // the recorder sets. Also we are posting so that doTakeVideo sees a started preview. - LOG.i("onStartPreview", "Posting doTakeVideo call."); final VideoResult.Stub stub = mFullVideoPendingStub; mFullVideoPendingStub = null; - mHandler.post(new Runnable() { + mOrchestrator.scheduleStateful("do take video", CameraState.PREVIEW, + new Runnable() { @Override public void run() { - LOG.i("onStartPreview", "Executing doTakeVideo call."); doTakeVideo(stub); } }); @@ -895,10 +895,10 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv // SnapshotRecorder will invoke this on its own thread, so let's post in our own thread // and check camera state before trying to restore the preview. Engine might have been // torn down in the engine thread while this was still being called. - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("restore preview template", CameraState.BIND, + new Runnable() { @Override public void run() { - if (getBindState() < STATE_STARTED) return; maybeRestorePreviewTemplateAfterVideo(); } }); @@ -1028,33 +1028,32 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv public void setFlash(@NonNull final Flash flash) { final Flash old = mFlash; mFlash = flash; - mHandler.run(new Runnable() { + mFlashTask = mOrchestrator.scheduleStateful("flash (" + flash + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - boolean shouldApply = applyFlash(mRepeatingRequestBuilder, old); - boolean needsWorkaround = getPreviewState() == STATE_STARTED; - if (needsWorkaround) { - // Runtime changes to the flash value are not correctly handled by the - // driver. See https://stackoverflow.com/q/53003383/4288782 for example. - // For this reason, we go back to OFF, capture once, then go to the new one. - mFlash = Flash.OFF; - applyFlash(mRepeatingRequestBuilder, old); - try { - mSession.capture(mRepeatingRequestBuilder.build(), null, - null); - } catch (CameraAccessException e) { - throw createCameraException(e); - } - mFlash = flash; - applyFlash(mRepeatingRequestBuilder, old); - applyRepeatingRequestBuilder(); - - } else if (shouldApply) { - applyRepeatingRequestBuilder(); + boolean shouldApply = applyFlash(mRepeatingRequestBuilder, old); + boolean needsWorkaround = getState() == CameraState.PREVIEW; + if (needsWorkaround) { + // Runtime changes to the flash value are not correctly handled by the + // driver. See https://stackoverflow.com/q/53003383/4288782 for example. + // For this reason, we go back to OFF, capture once, then go to the new one. + mFlash = Flash.OFF; + applyFlash(mRepeatingRequestBuilder, old); + try { + mSession.capture(mRepeatingRequestBuilder.build(), null, + null); + } catch (CameraAccessException e) { + throw createCameraException(e); } + mFlash = flash; + applyFlash(mRepeatingRequestBuilder, old); + applyRepeatingRequestBuilder(); + + } else if (shouldApply) { + applyRepeatingRequestBuilder(); } - mFlashOp.end(null); } }); } @@ -1104,15 +1103,14 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv public void setLocation(@Nullable Location location) { final Location old = mLocation; mLocation = location; - mHandler.run(new Runnable() { + mLocationTask = mOrchestrator.scheduleStateful("location", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - if (applyLocation(mRepeatingRequestBuilder, old)) { - applyRepeatingRequestBuilder(); - } + if (applyLocation(mRepeatingRequestBuilder, old)) { + applyRepeatingRequestBuilder(); } - mLocationOp.end(null); } }); } @@ -1130,15 +1128,15 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv public void setWhiteBalance(@NonNull WhiteBalance whiteBalance) { final WhiteBalance old = mWhiteBalance; mWhiteBalance = whiteBalance; - mHandler.run(new Runnable() { + mWhiteBalanceTask = mOrchestrator.scheduleStateful( + "white balance (" + whiteBalance + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - if (applyWhiteBalance(mRepeatingRequestBuilder, old)) { - applyRepeatingRequestBuilder(); - } + if (applyWhiteBalance(mRepeatingRequestBuilder, old)) { + applyRepeatingRequestBuilder(); } - mWhiteBalanceOp.end(null); } }); } @@ -1159,15 +1157,14 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv public void setHdr(@NonNull Hdr hdr) { final Hdr old = mHdr; mHdr = hdr; - mHandler.run(new Runnable() { + mHdrTask = mOrchestrator.scheduleStateful("hdr (" + hdr + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - if (applyHdr(mRepeatingRequestBuilder, old)) { - applyRepeatingRequestBuilder(); - } + if (applyHdr(mRepeatingRequestBuilder, old)) { + applyRepeatingRequestBuilder(); } - mHdrOp.end(null); } }); } @@ -1187,18 +1184,18 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv public void setZoom(final float zoom, final @Nullable PointF[] points, final boolean notify) { final float old = mZoomValue; mZoomValue = zoom; - mHandler.run(new Runnable() { + mZoomTask = mOrchestrator.scheduleStateful( + "zoom (" + zoom + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - if (applyZoom(mRepeatingRequestBuilder, old)) { - applyRepeatingRequestBuilder(); - if (notify) { - mCallback.dispatchOnZoomChanged(zoom, points); - } + if (applyZoom(mRepeatingRequestBuilder, old)) { + applyRepeatingRequestBuilder(); + if (notify) { + mCallback.dispatchOnZoomChanged(zoom, points); } } - mZoomOp.end(null); } }); } @@ -1243,18 +1240,18 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv final boolean notify) { final float old = mExposureCorrectionValue; mExposureCorrectionValue = EVvalue; - mHandler.run(new Runnable() { + mExposureCorrectionTask = mOrchestrator.scheduleStateful( + "exposure correction (" + EVvalue + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - if (applyExposureCorrection(mRepeatingRequestBuilder, old)) { - applyRepeatingRequestBuilder(); - if (notify) { - mCallback.dispatchOnExposureCorrectionChanged(EVvalue, bounds, points); - } + if (applyExposureCorrection(mRepeatingRequestBuilder, old)) { + applyRepeatingRequestBuilder(); + if (notify) { + mCallback.dispatchOnExposureCorrectionChanged(EVvalue, bounds, points); } } - mExposureCorrectionOp.end(null); } }); } @@ -1278,21 +1275,22 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv @Override public void setPlaySounds(boolean playSounds) { mPlaySounds = playSounds; - mPlaySoundsOp.end(null); + mPlaySoundsTask = Tasks.forResult(null); } - @Override public void setPreviewFrameRate(float previewFrameRate) { + @Override + public void setPreviewFrameRate(float previewFrameRate) { final float oldPreviewFrameRate = mPreviewFrameRate; mPreviewFrameRate = previewFrameRate; - mHandler.run(new Runnable() { + mPreviewFrameRateTask = mOrchestrator.scheduleStateful( + "preview fps (" + previewFrameRate + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - if (applyPreviewFrameRate(mRepeatingRequestBuilder, oldPreviewFrameRate)) { - applyRepeatingRequestBuilder(); - } + if (applyPreviewFrameRate(mRepeatingRequestBuilder, oldPreviewFrameRate)) { + applyRepeatingRequestBuilder(); } - mPreviewFrameRateOp.end(null); } }); } @@ -1334,19 +1332,12 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv public void setPictureFormat(final @NonNull PictureFormat pictureFormat) { if (pictureFormat != mPictureFormat) { mPictureFormat = pictureFormat; - LOG.i("setPictureFormat", "changing to", pictureFormat, "posting."); - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("picture format (" + pictureFormat + ")", + CameraState.ENGINE, + new Runnable() { @Override public void run() { - LOG.i("setPictureFormat", "changing to", pictureFormat, - "executing. EngineState:", getEngineState(), - "BindState:", getBindState()); - if (getEngineState() == STATE_STOPPED) { - LOG.i("setPictureFormat", "not started so won't restart."); - } else { - LOG.i("setPictureFormat", "started or starting. Calling restart()"); - restart(); - } + restart(); } }); } @@ -1391,7 +1382,7 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv return; } image.close(); - if (getPreviewState() == STATE_STARTED) { + if (getState() == CameraState.PREVIEW && !isChangingState()) { // After preview, the frame manager is correctly set up Frame frame = getFrameManager().getFrame(data, System.currentTimeMillis(), @@ -1405,35 +1396,23 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv @Override public void setHasFrameProcessors(final boolean hasFrameProcessors) { - LOG.i("setHasFrameProcessors", "changing to", hasFrameProcessors, "posting."); - Camera2Engine.super.setHasFrameProcessors(hasFrameProcessors); - mHandler.run(new Runnable() { + // Frame processing is set up partially when binding and partially when starting + // the preview. If the value is changed between the two, the preview step can crash. + mOrchestrator.schedule("has frame processors (" + hasFrameProcessors + ")", + true, new Runnable() { @Override public void run() { - LOG.i("setHasFrameProcessors", "changing to", hasFrameProcessors, - "executing. BindState:", getBindState(), - "PreviewState:", getPreviewState()); - - // Frame processing is set up partially when binding and partially when starting - // the preview. We don't want to only check bind state or startPreview can fail. - if (getBindState() == STATE_STOPPED) { - LOG.i("setHasFrameProcessors", "not bound so won't restart."); - } else if (getPreviewState() == STATE_STARTED) { - // This needs a restartBind(). NOTE: if taking video, this stops it. - LOG.i("setHasFrameProcessors", "bound with preview.", - "Calling restartBind()."); + if (getState().isAtLeast(CameraState.BIND) && isChangingState()) { + // Extremely rare case in which this was called in between startBind and + // startPreview. This can cause issues. Try later. + setHasFrameProcessors(hasFrameProcessors); + } else if (getState().isAtLeast(CameraState.BIND)) { + // Apply and restart. + Camera2Engine.super.setHasFrameProcessors(hasFrameProcessors); restartBind(); } else { - // Bind+Preview is not completely started yet not completely stopped. - // This can happen if the user adds a frame processor in onCameraOpened(). - // Supporting this would add lot of complexity to this class, and - // this should be discouraged anyway since changing the frame processor number - // at this time requires restarting the camera when it was just opened. - // For these reasons, let's throw. - throw new IllegalStateException("Added/removed a FrameProcessor at illegal " + - "time. These operations should be done before opening the camera, or " + - "before closing it - NOT when it just opened, for example during the " + - "onCameraOpened() callback."); + // Just apply. + Camera2Engine.super.setHasFrameProcessors(hasFrameProcessors); } } }); @@ -1445,16 +1424,14 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv @Override public void startAutoFocus(@Nullable final Gesture gesture, @NonNull final PointF point) { - LOG.i("startAutoFocus", "dispatching. Gesture:", gesture); - mHandler.run(new Runnable() { + // This will only work when we have a preview, since it launches the preview + // in the end. Even without this it would need the bind state at least, + // since we need the preview size. + mOrchestrator.scheduleStateful("autofocus (" + gesture + ")", + CameraState.PREVIEW, + new Runnable() { @Override public void run() { - LOG.i("startAutoFocus", "executing. Preview state:", getPreviewState()); - // This will only work when we have a preview, since it launches the preview - // in the end. Even without this it would need the bind state at least, - // since we need the preview size. - if (getPreviewState() < STATE_STARTED) return; - // The camera options API still has the auto focus API but it really // refers to "3A metering to a specific point". Since we have a point, check. if (!mCameraOptions.isAutoFocusSupported()) return; @@ -1468,10 +1445,16 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv @Override protected void onActionCompleted(@NonNull Action a) { mCallback.dispatchOnFocusEnd(gesture, action.isSuccessful(), point); - mHandler.remove(mUnlockAndResetMeteringRunnable); + mOrchestrator.remove("reset metering"); if (shouldResetAutoFocus()) { - mHandler.post(getAutoFocusResetDelay(), - mUnlockAndResetMeteringRunnable); + mOrchestrator.scheduleDelayed("reset metering", + getAutoFocusResetDelay(), + new Runnable() { + @Override + public void run() { + unlockAndResetMetering(); + } + }); } } }); @@ -1495,15 +1478,8 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv return mMeterAction; } - private final Runnable mUnlockAndResetMeteringRunnable = new Runnable() { - @Override - public void run() { - unlockAndResetMetering(); - } - }; - private void unlockAndResetMetering() { - if (getEngineState() == STATE_STARTED) { + if (getState() == CameraState.PREVIEW && !isChangingState()) { Actions.sequence( new BaseAction() { @Override @@ -1566,7 +1542,7 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv @Override public void applyBuilder(@NonNull Action source, @NonNull CaptureRequest.Builder builder) throws CameraAccessException { - if (getPreviewState() == STATE_STARTED) { + if (getState() == CameraState.PREVIEW && !isChangingState()) { mSession.capture(builder.build(), mRepeatingRequestCallback, null); } } 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 d4345e00..b5019108 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java @@ -8,24 +8,24 @@ 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.OnSuccessListener; 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; import com.otaliastudios.cameraview.PictureResult; import com.otaliastudios.cameraview.controls.PictureFormat; +import com.otaliastudios.cameraview.engine.orchestrator.CameraOrchestrator; +import com.otaliastudios.cameraview.engine.orchestrator.CameraState; +import com.otaliastudios.cameraview.engine.orchestrator.CameraStateOrchestrator; import com.otaliastudios.cameraview.overlay.Overlay; import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.engine.offset.Angles; import com.otaliastudios.cameraview.engine.offset.Reference; import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameManager; -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; @@ -54,7 +54,6 @@ import java.util.Collection; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; @@ -73,13 +72,8 @@ import java.util.concurrent.TimeUnit; * 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 Step} 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 - * We only expose generic {@link #start()} and {@link #stop()} calls to the outside. + * We only expose generic {@link #start()} and {@link #stop(boolean)} 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. @@ -87,18 +81,11 @@ import java.util.concurrent.TimeUnit; * 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 #stop(boolean)}: ASYNC - stops everything: undoes S4, then S3, then S2. * - {@link #restart()}: ASYNC - completes a stop then a start. - * - {@link #destroy()}: SYNC - performs a {@link #stop()} that will go on no matter the exceptions, + * - {@link #destroy()}: SYNC - performs a {@link #stop(boolean)} that will go on no matter what, * without throwing. Makes the engine unusable and clears resources. * - * 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. - * - {@link #STATE_STOPPED} if we have undone steps 4, 3 and 2 (or they never started at all). - * - * * THREADING * Subclasses should always execute code on the thread given by {@link #mHandler}. * For convenience, all the setup and tear down methods are called on this engine thread: @@ -127,17 +114,16 @@ public abstract class CameraEngine implements public interface Callback { @NonNull Context getContext(); - void dispatchOnCameraOpened(CameraOptions options); + void dispatchOnCameraOpened(@NonNull CameraOptions options); void dispatchOnCameraClosed(); void onCameraPreviewStreamSizeChanged(); void onShutter(boolean shouldPlaySound); - void dispatchOnVideoTaken(VideoResult.Stub stub); - void dispatchOnPictureTaken(PictureResult.Stub stub); + void dispatchOnVideoTaken(@NonNull VideoResult.Stub stub); + void dispatchOnPictureTaken(@NonNull PictureResult.Stub stub); void dispatchOnFocusStart(@Nullable Gesture trigger, @NonNull PointF where); void dispatchOnFocusEnd(@Nullable Gesture trigger, boolean success, @NonNull PointF where); void dispatchOnZoomChanged(final float newValue, @Nullable final PointF[] fingers); - void dispatchOnExposureCorrectionChanged(float newValue, - @NonNull float[] bounds, + void dispatchOnExposureCorrectionChanged(float newValue, @NonNull float[] bounds, @Nullable PointF[] fingers); void dispatchFrame(@NonNull Frame frame); void dispatchError(CameraException exception); @@ -148,15 +134,7 @@ public abstract class CameraEngine implements private static final String TAG = CameraEngine.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); - @SuppressWarnings({"WeakerAccess", "unused"}) - public static final int STATE_STOPPING = Step.STATE_STOPPING; - public static final int STATE_STOPPED = Step.STATE_STOPPED; - @SuppressWarnings({"WeakerAccess", "unused"}) - public static final int STATE_STARTING = Step.STATE_STARTING; - public static final int STATE_STARTED = Step.STATE_STARTED; - // Need to be protected - @SuppressWarnings("WeakerAccess") protected WorkerHandler mHandler; @SuppressWarnings("WeakerAccess") protected final Callback mCallback; @SuppressWarnings("WeakerAccess") protected CameraPreview mPreview; @SuppressWarnings("WeakerAccess") protected CameraOptions mCameraOptions; @@ -177,7 +155,7 @@ public abstract class CameraEngine implements @SuppressWarnings("WeakerAccess") protected boolean mPictureSnapshotMetering; @SuppressWarnings("WeakerAccess") protected float mPreviewFrameRate; - // Can be private + private WorkerHandler mHandler; @VisibleForTesting Handler mCrashHandler; private final FrameManager mFrameManager; private final Angles mAngles; @@ -193,36 +171,42 @@ public abstract class CameraEngine implements private int mAudioBitRate; private boolean mHasFrameProcessors; private long mAutoFocusResetDelayMillis; - // in REF_VIEW, for consistency with SizeSelectors - private int mSnapshotMaxWidth = Integer.MAX_VALUE; - // in REF_VIEW, for consistency with SizeSelectors - private int mSnapshotMaxHeight = Integer.MAX_VALUE; + private int mSnapshotMaxWidth = Integer.MAX_VALUE; // in REF_VIEW like SizeSelectors + private int mSnapshotMaxHeight = Integer.MAX_VALUE; // in REF_VIEW like SizeSelectors private Overlay overlay; - // Steps - private final Step.Callback mStepCallback = new Step.Callback() { - @Override @NonNull public Executor getExecutor() { return mHandler.getExecutor(); } - @Override public void handleException(@NonNull Exception exception) { - CameraEngine.this.handleException(Thread.currentThread(), exception, false); + @SuppressWarnings("WeakerAccess") + protected final CameraStateOrchestrator mOrchestrator + = new CameraStateOrchestrator(new CameraOrchestrator.Callback() { + @Override + @NonNull + public WorkerHandler getJobWorker(@NonNull String job) { + return mHandler; + } + + @Override + public void handleJobException(@NonNull String job, @NonNull Exception exception) { + handleException(Thread.currentThread(), exception, false); } - }; - @VisibleForTesting - Step mEngineStep = new Step("engine", mStepCallback); - private Step mBindStep = new Step("bind", mStepCallback); - private Step mPreviewStep = new Step("preview", mStepCallback); - private Step mAllStep = new Step("all", mStepCallback); + }); // Ops used for testing. - @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 mPlaySoundsOp = new Op<>(); - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Op mPreviewFrameRateOp = new Op<>(); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mZoomTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mExposureCorrectionTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mFlashTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mWhiteBalanceTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mHdrTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mLocationTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mPlaySoundsTask + = Tasks.forResult(null); + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mPreviewFrameRateTask + = Tasks.forResult(null); protected CameraEngine(@NonNull Callback callback) { mCallback = callback; @@ -252,7 +236,7 @@ public abstract class CameraEngine implements */ private class CrashExceptionHandler implements Thread.UncaughtExceptionHandler { @Override - public void uncaughtException(final Thread thread, final Throwable throwable) { + public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { handleException(thread, throwable, true); } } @@ -263,7 +247,7 @@ public abstract class CameraEngine implements */ private static class NoOpExceptionHandler implements Thread.UncaughtExceptionHandler { @Override - public void uncaughtException(Thread t, Throwable e) { + public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { // No-op. } } @@ -305,7 +289,7 @@ public abstract class CameraEngine implements final CameraException cameraException = (CameraException) throwable; LOG.e("uncaughtException:", "Got CameraException:", cameraException, - "on engine state:", getEngineState()); + "on state:", getState()); if (fromExceptionHandler) { // Got to restart the handler. thread.interrupt(); @@ -322,49 +306,95 @@ public abstract class CameraEngine implements //endregion - //region states and steps + //region State management - public final int getEngineState() { - return mEngineStep.getState(); + @NonNull + public final CameraState getState() { + return mOrchestrator.getCurrentState(); } - @SuppressWarnings("WeakerAccess") - public final int getBindState() { - return mBindStep.getState(); + @NonNull + public final CameraState getTargetState() { + return mOrchestrator.getTargetState(); } - @SuppressWarnings({"unused", "WeakerAccess"}) - public final int getPreviewState() { - return mPreviewStep.getState(); + public boolean isChangingState() { + return mOrchestrator.hasPendingStateChange(); } - private boolean canStartEngine() { - return mEngineStep.isStoppingOrStopped(); + /** + * Calls {@link #stop(boolean)} and waits for it. + * Not final due to mockito requirements. + * + * NOTE: Should not be called on the orchestrator thread! This would cause deadlocks due to us + * awaiting for {@link #stop(boolean)} to return. + */ + public void destroy() { + LOG.i("DESTROY:", "state:", getState(), "thread:", Thread.currentThread()); + // 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( + WorkerHandler.get().getExecutor(), + new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + latch.countDown(); + } + }); + try { + boolean success = latch.await(3, TimeUnit.SECONDS); + if (!success) { + LOG.e("Probably some deadlock in destroy.", + "Current thread:", Thread.currentThread(), + "Handler thread: ", mHandler.getThread()); + } + } catch (InterruptedException ignore) {} } - private boolean needsStopEngine() { - return mEngineStep.isStartedOrStarting(); + @SuppressWarnings("WeakerAccess") + public void restart() { + LOG.i("RESTART:", "scheduled. State:", getState()); + stop(false); + start(); } - private boolean canStartBind() { - return mEngineStep.isStarted() - && mPreview != null - && mPreview.hasSurface() - && mBindStep.isStoppingOrStopped(); + @NonNull + public Task start() { + LOG.i("START:", "scheduled. State:", getState()); + Task engine = startEngine(); + startBind(); + startPreview(); + return engine; } - private boolean needsStopBind() { - return mBindStep.isStartedOrStarting(); + @NonNull + public Task stop(final boolean swallowExceptions) { + LOG.i("STOP:", "scheduled. State:", getState()); + stopPreview(swallowExceptions); + stopBind(swallowExceptions); + return stopEngine(swallowExceptions); } - private boolean canStartPreview() { - return mEngineStep.isStarted() - && mBindStep.isStarted() - && mPreviewStep.isStoppingOrStopped(); + @SuppressWarnings("WeakerAccess") + @NonNull + protected Task restartBind() { + LOG.i("RESTART BIND:", "scheduled. State:", getState()); + stopPreview(false); + stopBind(false); + startBind(); + return startPreview(); } - private boolean needsStopPreview() { - return mPreviewStep.isStartedOrStarting(); + @SuppressWarnings("WeakerAccess") + @NonNull + protected Task restartPreview() { + LOG.i("RESTART PREVIEW:", "scheduled. State:", getState()); + stopPreview(false); + return startPreview(); } //endregion @@ -374,43 +404,41 @@ public abstract class CameraEngine implements @NonNull @EngineThread private Task startEngine() { - if (canStartEngine()) { - mEngineStep.doStart(false, new Callable>() { - @Override - public Task call() { - if (!collectCameraInfo(mFacing)) { - LOG.e("onStartEngine:", "No camera available for facing", mFacing); - throw new CameraException(CameraException.REASON_NO_CAMERA); - } - return onStartEngine(); - } - }, new Runnable() { - @Override - public void run() { - mCallback.dispatchOnCameraOpened(mCameraOptions); + return mOrchestrator.scheduleStateChange(CameraState.OFF, CameraState.ENGINE, + true, + new Callable>() { + @Override + public Task call() { + if (!collectCameraInfo(mFacing)) { + LOG.e("onStartEngine:", "No camera available for facing", mFacing); + throw new CameraException(CameraException.REASON_NO_CAMERA); } - }); - } - return mEngineStep.getTask(); + return onStartEngine(); + } + }).addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Void aVoid) { + mCallback.dispatchOnCameraOpened(mCameraOptions); + } + }); } @NonNull @EngineThread 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(); + return mOrchestrator.scheduleStateChange(CameraState.ENGINE, CameraState.OFF, + !swallowExceptions, + new Callable>() { + @Override + public Task call() { + return onStopEngine(); + } + }).addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Void aVoid) { + mCallback.dispatchOnCameraClosed(); + } + }); } /** @@ -438,29 +466,32 @@ public abstract class CameraEngine implements @NonNull @EngineThread private Task startBind() { - if (canStartBind()) { - mBindStep.doStart(false, new Callable>() { - @Override - public Task call() { + return mOrchestrator.scheduleStateChange(CameraState.ENGINE, CameraState.BIND, + true, + new Callable>() { + @Override + public Task call() { + if (mPreview != null && mPreview.hasSurface()) { return onStartBind(); + } else { + return Tasks.forCanceled(); } - }); - } - return mBindStep.getTask(); + } + }); } + @SuppressWarnings("UnusedReturnValue") @NonNull @EngineThread private Task stopBind(boolean swallowExceptions) { - if (needsStopBind()) { - mBindStep.doStop(swallowExceptions, new Callable>() { - @Override - public Task call() { - return onStopBind(); - } - }); - } - return mBindStep.getTask(); + return mOrchestrator.scheduleStateChange(CameraState.BIND, CameraState.ENGINE, + !swallowExceptions, + new Callable>() { + @Override + public Task call() { + return onStopBind(); + } + }); } /** @@ -481,39 +512,6 @@ public abstract class CameraEngine implements @EngineThread protected abstract Task onStopBind(); - @SuppressWarnings("WeakerAccess") - protected void restartBind() { - LOG.i("restartBind", "posting."); - mHandler.run(new Runnable() { - @Override - public void run() { - LOG.w("restartBind", "executing stopPreview."); - stopPreview(false).continueWithTask(mHandler.getExecutor(), - new Continuation>() { - @Override - public Task then(@NonNull Task task) { - LOG.w("restartBind", "executing stopBind."); - return stopBind(false); - } - }).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { - @NonNull - @Override - public Task then(@Nullable Void aVoid) { - LOG.w("restartBind", "executing startBind."); - return startBind(); - } - }).onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { - @NonNull - @Override - public Task then(@Nullable Void aVoid) { - LOG.w("restartBind", "executing startPreview."); - return startPreview(); - } - }); - } - }); - } - //endregion //region Start & Stop preview @@ -521,44 +519,26 @@ public abstract class CameraEngine implements @NonNull @EngineThread private Task startPreview() { - LOG.i("startPreview", "canStartPreview:", canStartPreview()); - if (canStartPreview()) { - mPreviewStep.doStart(false, new Callable>() { - @Override - public Task call() { - return onStartPreview(); - } - }); - } - return mPreviewStep.getTask(); + return mOrchestrator.scheduleStateChange(CameraState.BIND, CameraState.PREVIEW, + true, + new Callable>() { + @Override + public Task call() { + return onStartPreview(); + } + }); } + @SuppressWarnings("UnusedReturnValue") @NonNull @EngineThread 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.run(new Runnable() { + return mOrchestrator.scheduleStateChange(CameraState.PREVIEW, CameraState.BIND, + !swallowExceptions, + new Callable>() { @Override - public void run() { - LOG.i("restartPreview", "executing."); - stopPreview(false); - startPreview(); + public Task call() { + return onStopPreview(); } }); } @@ -592,33 +572,24 @@ public abstract class CameraEngine implements @Override public final void onSurfaceAvailable() { LOG.i("onSurfaceAvailable:", "Size is", getPreviewSurfaceSize(Reference.VIEW)); - mHandler.run(new Runnable() { - @Override - public void run() { - startBind().onSuccessTask(mHandler.getExecutor(), new SuccessContinuation() { - @NonNull - @Override - public Task then(@Nullable Void aVoid) { - return startPreview(); - } - }); - } - }); + startBind(); + startPreview(); + } + + @Override + public final void onSurfaceDestroyed() { + LOG.i("onSurfaceDestroyed"); + stopPreview(false); + stopBind(false); } @Override public final void onSurfaceChanged() { - LOG.i("onSurfaceChanged:", "Size is", getPreviewSurfaceSize(Reference.VIEW), - "Posting."); - mHandler.run(new Runnable() { + LOG.i("onSurfaceChanged:", "Size is", getPreviewSurfaceSize(Reference.VIEW)); + mOrchestrator.scheduleStateful("surface changed", CameraState.BIND, + 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)) { @@ -643,176 +614,6 @@ public abstract class CameraEngine implements @EngineThread protected abstract void onPreviewStreamSizeChanged(); - @Override - public final void onSurfaceDestroyed() { - LOG.i("onSurfaceDestroyed"); - mHandler.run(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:", getEngineState(), "thread:", Thread.currentThread()); - // 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 { - boolean success = latch.await(3, TimeUnit.SECONDS); - if (!success) { - // TODO seems like this is always the case? - LOG.e("Probably some deadlock in destroy.", - "Current thread:", Thread.currentThread(), - "Handler thread: ", mHandler.getThread()); - } - } catch (InterruptedException ignore) {} - } - - @SuppressWarnings("WeakerAccess") - protected final void restart() { - LOG.i("Restart:", "calling stop and start"); - stop(); - start(); - } - - @NonNull - public Task start() { - LOG.i("Start:", "posting runnable. State:", getEngineState()); - final TaskCompletionSource outTask = new TaskCompletionSource<>(); - mHandler.run(new Runnable() { - @Override - public void run() { - LOG.w("Start:", "executing runnable. AllState is", mAllStep.getState()); - // It's better to schedule anyway. allStep might be STARTING and we might be - // tempted to early return here, but the truth is that there might be a stop - // already scheduled when the STARTING op ends. - // if (mAllStep.isStoppingOrStopped()) { - // LOG.i("Start:", "executing runnable. AllState is STOPPING or STOPPED, - // so we schedule a start."); - 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. - // LOG.i("Start:", - // "executing runnable. AllState is STARTING or STARTED, so we return early."); - // outTask.trySetResult(null); - // } - } - }); - return outTask.getTask(); - } - - @NonNull - public Task stop() { - return stop(false); - } - - @NonNull - private Task stop(final boolean swallowExceptions) { - LOG.i("Stop:", "posting runnable. State:", getEngineState()); - final TaskCompletionSource outTask = new TaskCompletionSource<>(); - mHandler.run(new Runnable() { - @Override - public void run() { - LOG.w("Stop:", "executing runnable. AllState is", mAllStep.getState()); - // It's better to schedule anyway. allStep might be STOPPING and we might be - // tempted to early return here, but the truth is that there might be a start - // already scheduled when the STOPPING op ends. - // if (mAllStep.isStartedOrStarting()) { - // LOG.i("Stop:", "executing runnable. AllState is STARTING or STARTED, - // so we schedule a stop."); - 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. - // LOG.i("Stop:", "executing runnable. - // AllState is STOPPING or STOPPED, so we return early."); - // outTask.trySetResult(null); - // } - } - }); - return outTask.getTask(); - } - //endregion //region Final setters and getters @@ -922,7 +723,7 @@ public abstract class CameraEngine implements } /** - * Sets a new facing value. This will restart the session (if there's any) + * Sets a new facing value. This will restart the engine session (if there's any) * so that we can open the new facing camera. * @param facing facing */ @@ -930,10 +731,10 @@ public abstract class CameraEngine implements final Facing old = mFacing; if (facing != old) { mFacing = facing; - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("facing", CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() < STATE_STARTED) return; if (collectCameraInfo(facing)) { restart(); } else { @@ -975,12 +776,11 @@ public abstract class CameraEngine implements public final void setMode(@NonNull Mode mode) { if (mode != mMode) { mMode = mode; - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("mode", CameraState.ENGINE, + new Runnable() { @Override public void run() { - if (getEngineState() == STATE_STARTED) { - restart(); - } + restart(); } }); } @@ -1134,22 +934,22 @@ public abstract class CameraEngine implements /* not final for tests */ public void takePicture(final @NonNull PictureResult.Stub stub) { - LOG.i("takePicture", "scheduling"); - mHandler.run(new Runnable() { + // Save boolean before scheduling! See how Camera2Engine calls this with a temp value. + final boolean metering = mPictureMetering; + mOrchestrator.scheduleStateful("take picture", CameraState.BIND, + new Runnable() { @Override public void run() { - LOG.i("takePicture", "performing. BindState:", getBindState(), - "isTakingPicture:", isTakingPicture()); + LOG.i("takePicture:", "running. isTakingPicture:", isTakingPicture()); + if (isTakingPicture()) return; if (mMode == Mode.VIDEO) { throw new IllegalStateException("Can't take hq pictures while in VIDEO mode"); } - if (getBindState() < STATE_STARTED) return; - if (isTakingPicture()) return; stub.isSnapshot = false; stub.location = mLocation; stub.facing = mFacing; stub.format = mPictureFormat; - onTakePicture(stub, mPictureMetering); + onTakePicture(stub, metering); } }); } @@ -1160,13 +960,13 @@ public abstract class CameraEngine implements * @param stub a picture stub */ public final void takePictureSnapshot(final @NonNull PictureResult.Stub stub) { - LOG.i("takePictureSnapshot", "scheduling"); - mHandler.run(new Runnable() { + // Save boolean before scheduling! See how Camera2Engine calls this with a temp value. + final boolean metering = mPictureSnapshotMetering; + mOrchestrator.scheduleStateful("take picture snapshot", CameraState.BIND, + new Runnable() { @Override public void run() { - LOG.i("takePictureSnapshot", "performing. BindState:", - getBindState(), "isTakingPicture:", isTakingPicture()); - if (getBindState() < STATE_STARTED) return; + LOG.i("takePictureSnapshot:", "running. isTakingPicture:", isTakingPicture()); if (isTakingPicture()) return; stub.location = mLocation; stub.isSnapshot = true; @@ -1175,7 +975,7 @@ public abstract class CameraEngine implements // Leave the other parameters to subclasses. //noinspection ConstantConditions AspectRatio ratio = AspectRatio.of(getPreviewSurfaceSize(Reference.OUTPUT)); - onTakePictureSnapshot(stub, ratio, mPictureSnapshotMetering); + onTakePictureSnapshot(stub, ratio, metering); } }); } @@ -1202,13 +1002,10 @@ public abstract class CameraEngine implements } public final void takeVideo(final @NonNull VideoResult.Stub stub, final @NonNull File file) { - LOG.i("takeVideo", "scheduling"); - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("take video", CameraState.BIND, new Runnable() { @Override public void run() { - LOG.i("takeVideo", "performing. BindState:", getBindState(), - "isTakingVideo:", isTakingVideo()); - if (getBindState() < STATE_STARTED) return; + LOG.i("takeVideo:", "running. isTakingVideo:", isTakingVideo()); if (isTakingVideo()) return; if (mMode == Mode.PICTURE) { throw new IllegalStateException("Can't record video while in PICTURE mode"); @@ -1234,14 +1031,11 @@ public abstract class CameraEngine implements */ public final void takeVideoSnapshot(@NonNull final VideoResult.Stub stub, @NonNull final File file) { - LOG.i("takeVideoSnapshot", "scheduling"); - mHandler.run(new Runnable() { + mOrchestrator.scheduleStateful("take video snapshot", CameraState.BIND, + new Runnable() { @Override public void run() { - LOG.i("takeVideoSnapshot", "performing. BindState:", getBindState(), - "isTakingVideo:", isTakingVideo()); - if (getBindState() < STATE_STARTED) return; - if (isTakingVideo()) return; + LOG.i("takeVideoSnapshot:", "running. isTakingVideo:", isTakingVideo()); stub.file = file; stub.isSnapshot = true; stub.videoCodec = mVideoCodec; @@ -1260,11 +1054,10 @@ public abstract class CameraEngine implements } public final void stopVideo() { - LOG.i("stopVideo", "posting"); - mHandler.run(new Runnable() { + mOrchestrator.schedule("stop video", true, new Runnable() { @Override public void run() { - LOG.i("stopVideo", "executing.", "isTakingVideo?", isTakingVideo()); + LOG.i("stopVideo", "running. isTakingVideo?", isTakingVideo()); onStopVideo(); } }); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Step.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Step.java deleted file mode 100644 index 5769e7c4..00000000 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Step.java +++ /dev/null @@ -1,178 +0,0 @@ -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 Step { - - private static final String TAG = Step.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; - - Step(@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/engine/action/BaseAction.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/action/BaseAction.java index 08b02e3a..5e16e612 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/action/BaseAction.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/action/BaseAction.java @@ -39,6 +39,7 @@ public abstract class BaseAction implements Action { @Override public final void start(@NonNull ActionHolder holder) { + this.holder = holder; holder.addAction(this); if (holder.getLastResult(this) != null) { onStart(holder); @@ -64,6 +65,8 @@ public abstract class BaseAction implements Action { */ @CallSuper protected void onStart(@NonNull ActionHolder holder) { + // Repeating holder assignment here (already in start()) because we NEED it in start() + // but some special actions will not call start() at all for their children. this.holder = holder; // Overrideable } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/LogAction.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/action/LogAction.java similarity index 92% rename from cameraview/src/main/java/com/otaliastudios/cameraview/engine/LogAction.java rename to cameraview/src/main/java/com/otaliastudios/cameraview/engine/action/LogAction.java index 8c20a83f..1cb53316 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/LogAction.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/action/LogAction.java @@ -1,4 +1,4 @@ -package com.otaliastudios.cameraview.engine; +package com.otaliastudios.cameraview.engine.action; import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.CaptureResult; @@ -9,11 +9,12 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import com.otaliastudios.cameraview.CameraLogger; +import com.otaliastudios.cameraview.engine.Camera2Engine; import com.otaliastudios.cameraview.engine.action.ActionHolder; import com.otaliastudios.cameraview.engine.action.BaseAction; @RequiresApi(Build.VERSION_CODES.LOLLIPOP) -class LogAction extends BaseAction { +public class LogAction extends BaseAction { private final static CameraLogger LOG = CameraLogger.create(Camera2Engine.class.getSimpleName()); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraOrchestrator.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraOrchestrator.java new file mode 100644 index 00000000..0b7977c3 --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraOrchestrator.java @@ -0,0 +1,183 @@ +package com.otaliastudios.cameraview.engine.orchestrator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.tasks.OnCompleteListener; +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.CameraLogger; +import com.otaliastudios.cameraview.internal.utils.WorkerHandler; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; + +/** + * Schedules {@link com.otaliastudios.cameraview.engine.CameraEngine} actions, + * so that they always run on the same thread. + * + * We need to be extra careful (not as easy as posting on a Handler) because the engine + * has different states, and some actions will modify the engine state - turn it on or + * tear it down. Other actions might need a specific state to be executed. + * And most importantly, some actions will finish asynchronously, so subsequent actions + * should wait for the previous to finish, but without blocking the thread. + */ +@SuppressWarnings("WeakerAccess") +public class CameraOrchestrator { + + protected static final String TAG = CameraOrchestrator.class.getSimpleName(); + protected static final CameraLogger LOG = CameraLogger.create(TAG); + + public interface Callback { + @NonNull + WorkerHandler getJobWorker(@NonNull String job); + void handleJobException(@NonNull String job, @NonNull Exception exception); + } + + protected static class Token { + public final String name; + public final Task task; + + private Token(@NonNull String name, @NonNull Task task) { + this.name = name; + this.task = task; + } + + @Override + public boolean equals(@Nullable Object obj) { + return obj instanceof Token && ((Token) obj).name.equals(name); + } + } + + protected final Callback mCallback; + protected final ArrayDeque mJobs = new ArrayDeque<>(); + protected final Object mLock = new Object(); + private final Map mDelayedJobs = new HashMap<>(); + + public CameraOrchestrator(@NonNull Callback callback) { + mCallback = callback; + ensureToken(); + } + + @NonNull + public Task schedule(@NonNull String name, + boolean dispatchExceptions, + @NonNull final Runnable job) { + return schedule(name, dispatchExceptions, new Callable>() { + @Override + public Task call() { + job.run(); + return Tasks.forResult(null); + } + }); + } + + @NonNull + public Task schedule(@NonNull final String name, + final boolean dispatchExceptions, + @NonNull final Callable> job) { + LOG.i(name.toUpperCase(), "- Scheduling."); + final TaskCompletionSource source = new TaskCompletionSource<>(); + final WorkerHandler handler = mCallback.getJobWorker(name); + synchronized (mLock) { + applyCompletionListener(mJobs.getLast().task, handler, + new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + synchronized (mLock) { + mJobs.removeFirst(); + ensureToken(); + } + try { + LOG.i(name.toUpperCase(), "- Executing."); + Task inner = job.call(); + applyCompletionListener(inner, handler, new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + Exception e = task.getException(); + LOG.i(name.toUpperCase(), "- Finished.", e); + if (e != null) { + if (dispatchExceptions) { + mCallback.handleJobException(name, e); + } + source.trySetException(e); + } else if (task.isCanceled()) { + source.trySetException(new CancellationException()); + } else { + source.trySetResult(null); + } + } + }); + } catch (Exception e) { + LOG.i(name.toUpperCase(), "- Finished.", e); + if (dispatchExceptions) mCallback.handleJobException(name, e); + source.trySetException(e); + } + } + }); + mJobs.addLast(new Token(name, source.getTask())); + } + return source.getTask(); + } + + public void scheduleDelayed(@NonNull final String name, + long minDelay, + @NonNull final Runnable runnable) { + Runnable wrapper = new Runnable() { + @Override + public void run() { + schedule(name, true, runnable); + synchronized (mLock) { + if (mDelayedJobs.containsValue(this)) { + mDelayedJobs.remove(name); + } + } + } + }; + synchronized (mLock) { + mDelayedJobs.put(name, wrapper); + mCallback.getJobWorker(name).post(minDelay, wrapper); + } + } + + public void remove(@NonNull String name) { + synchronized (mLock) { + if (mDelayedJobs.get(name) != null) { + //noinspection ConstantConditions + mCallback.getJobWorker(name).remove(mDelayedJobs.get(name)); + mDelayedJobs.remove(name); + } + Token token = new Token(name, Tasks.forResult(null)); + //noinspection StatementWithEmptyBody + while (mJobs.remove(token)) { /* do nothing */ } + ensureToken(); + } + } + + private void ensureToken() { + synchronized (mLock) { + if (mJobs.isEmpty()) { + mJobs.add(new Token("BASE", Tasks.forResult(null))); + } + } + } + + private static void applyCompletionListener(@NonNull final Task task, + @NonNull WorkerHandler handler, + @NonNull final OnCompleteListener listener) { + if (task.isComplete()) { + handler.run(new Runnable() { + @Override + public void run() { + listener.onComplete(task); + } + }); + } else { + task.addOnCompleteListener(handler.getExecutor(), listener); + } + } +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraState.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraState.java new file mode 100644 index 00000000..1f7e66c6 --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraState.java @@ -0,0 +1,17 @@ +package com.otaliastudios.cameraview.engine.orchestrator; + +import androidx.annotation.NonNull; + +public enum CameraState { + OFF(0), ENGINE(1), BIND(2), PREVIEW(3); + + private int mState; + + CameraState(int state) { + mState = state; + } + + public boolean isAtLeast(@NonNull CameraState reference) { + return mState >= reference.mState; + } +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraStateOrchestrator.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraStateOrchestrator.java new file mode 100644 index 00000000..d5b06fd9 --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/orchestrator/CameraStateOrchestrator.java @@ -0,0 +1,117 @@ +package com.otaliastudios.cameraview.engine.orchestrator; + +import androidx.annotation.NonNull; + +import com.google.android.gms.tasks.Continuation; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; + +/** + * A special {@link CameraOrchestrator} with special methods that deal with the + * {@link CameraState}. + */ +public class CameraStateOrchestrator extends CameraOrchestrator { + + private CameraState mCurrentState = CameraState.OFF; + private CameraState mTargetState = CameraState.OFF; + private int mStateChangeCount = 0; + + public CameraStateOrchestrator(@NonNull Callback callback) { + super(callback); + } + + @NonNull + public CameraState getCurrentState() { + return mCurrentState; + } + + @NonNull + public CameraState getTargetState() { + return mTargetState; + } + + public boolean hasPendingStateChange() { + synchronized (mLock) { + for (Token token : mJobs) { + if (token.name.contains(" > ") && !token.task.isComplete()) { + return true; + } + } + return false; + } + } + + @NonNull + public Task scheduleStateChange(@NonNull final CameraState fromState, + @NonNull final CameraState toState, + boolean dispatchExceptions, + @NonNull final Callable> stateChange) { + final int changeCount = ++mStateChangeCount; + mTargetState = toState; + + final boolean isTearDown = !toState.isAtLeast(fromState); + final String changeName = fromState.name() + " > " + toState.name(); + return schedule(changeName, dispatchExceptions, new Callable>() { + @Override + public Task call() throws Exception { + if (getCurrentState() != fromState) { + LOG.w(changeName.toUpperCase(), "- State mismatch, aborting. current:", + getCurrentState(), "from:", fromState, "to:", toState); + return Tasks.forCanceled(); + } else { + Executor executor = mCallback.getJobWorker(changeName).getExecutor(); + return stateChange.call().continueWithTask(executor, + new Continuation>() { + @Override + public Task then(@NonNull Task task) { + if (task.isSuccessful() || isTearDown) { + mCurrentState = toState; + } + return task; + } + }); + } + } + }).addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + if (changeCount == mStateChangeCount) { + mTargetState = mCurrentState; + } + } + }); + } + + @SuppressWarnings("UnusedReturnValue") + @NonNull + public Task scheduleStateful(@NonNull String name, + @NonNull final CameraState atLeast, + @NonNull final Runnable job) { + return schedule(name, true, new Runnable() { + @Override + public void run() { + if (getCurrentState().isAtLeast(atLeast)) { + job.run(); + } + } + }); + } + + public void scheduleStatefulDelayed(@NonNull String name, + @NonNull final CameraState atLeast, + long delay, + @NonNull final Runnable job) { + scheduleDelayed(name, delay, new Runnable() { + @Override + public void run() { + if (getCurrentState().isAtLeast(atLeast)) { + job.run(); + } + } + }); + } +} 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 8743858f..3c070464 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,6 @@ import android.util.TypedValue; import android.view.View; import com.otaliastudios.cameraview.controls.Grid; -import com.otaliastudios.cameraview.internal.utils.Op; /** * A layout overlay that draws grid lines based on the {@link Grid} parameter. @@ -32,8 +31,11 @@ public class GridLinesLayout extends View { private ColorDrawable vert; private final float width; - @VisibleForTesting - Op drawOp = new Op<>(); + interface DrawCallback { + void onDraw(int lines); + } + + @VisibleForTesting DrawCallback callback; public GridLinesLayout(@NonNull Context context) { this(context, null); @@ -117,7 +119,6 @@ public class GridLinesLayout extends View { @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); - drawOp.start(); int count = getLineCount(); for (int n = 0; n < count; n++) { float pos = getLinePosition(n); @@ -132,6 +133,8 @@ public class GridLinesLayout extends View { vert.draw(canvas); canvas.translate(- pos * getWidth(), 0); } - drawOp.end(count); + if (callback != null) { + callback.onDraw(count); + } } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Op.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Op.java deleted file mode 100644 index 0b2bd1d4..00000000 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Op.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.otaliastudios.cameraview.internal.utils; - -import androidx.annotation.NonNull; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * A naive implementation of {@link java.util.concurrent.CountDownLatch} - * to help in testing. - */ -public class Op { - - private CountDownLatch mLatch; - private T mResult; - private int mCount; - - /** - * Creates an empty task. - * - * Listeners should: - * - call {@link #listen()} to notify they are interested in the next action - * - call {@link #await()} to know when the action is performed. - * - * Op owners should: - * - call {@link #start()} when task started - * - call {@link #end(Object)} when task ends - */ - public Op() { } - - /** - * Creates an empty task and starts listening. - * @param startListening whether to call listen - */ - public Op(boolean startListening) { - if (startListening) listen(); - } - - private boolean isListening() { - return mLatch != null; - } - - /** - * Op owner method: notifies the action started. - */ - public void start() { - if (!isListening()) mCount++; - } - - /** - * Op owner method: notifies the action ended. - * @param result the action result - */ - public void end(T result) { - if (mCount > 0) { - mCount--; - return; - } - - if (isListening()) { // Should be always true. - mResult = result; - mLatch.countDown(); - } - } - - /** - * Listener method: notifies we are interested in the next action. - */ - public void listen() { - if (isListening()) throw new RuntimeException("Should not happen."); - mResult = null; - mLatch = new CountDownLatch(1); - } - - /** - * Listener method: waits for next task action to end. - * @param millis milliseconds - * @return the action result - */ - public T await(long millis) { - return await(millis, TimeUnit.MILLISECONDS); - } - - /** - * Listener method: waits 1 minute for next task action to end. - * @return the action result - */ - public T await() { - return await(1, TimeUnit.MINUTES); - } - - /** - * Listener method: waits for next task action to end. - * @param time time - * @param unit the time unit - * @return the action result - */ - private T await(long time, @NonNull TimeUnit unit) { - try { - mLatch.await(time, unit); - } catch (Exception e) { - e.printStackTrace(); - } - T result = mResult; - mResult = null; - mLatch = null; - return result; - } - - -} 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 29df062d..deaa9002 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java @@ -18,7 +18,6 @@ import com.google.android.gms.tasks.TaskCompletionSource; import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.engine.CameraEngine; -import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.size.Size; /** @@ -56,8 +55,11 @@ public abstract class CameraPreview { void onSurfaceDestroyed(); } - @VisibleForTesting - Op mCropOp = new Op<>(); + protected interface CropCallback { + void onCrop(); + } + + @VisibleForTesting CropCallback mCropCallback; private SurfaceCallback mSurfaceCallback; private T mView; boolean mCropping; @@ -152,7 +154,7 @@ public abstract class CameraPreview { mInputStreamWidth = width; mInputStreamHeight = height; if (mInputStreamWidth > 0 && mInputStreamHeight > 0) { - crop(mCropOp); + crop(mCropCallback); } } @@ -194,7 +196,7 @@ public abstract class CameraPreview { mOutputSurfaceWidth = width; mOutputSurfaceHeight = height; if (mOutputSurfaceWidth > 0 && mOutputSurfaceHeight > 0) { - crop(mCropOp); + crop(mCropCallback); } if (mSurfaceCallback != null) { mSurfaceCallback.onSurfaceAvailable(); @@ -213,7 +215,7 @@ public abstract class CameraPreview { mOutputSurfaceWidth = width; mOutputSurfaceHeight = height; if (width > 0 && height > 0) { - crop(mCropOp); + crop(mCropCallback); } if (mSurfaceCallback != null) { mSurfaceCallback.onSurfaceChanged(); @@ -291,12 +293,11 @@ 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. * - * @param op the op + * @param callback the callback */ - protected void crop(@NonNull Op op) { + protected void crop(@Nullable CropCallback callback) { // The base implementation does not support cropping. - op.start(); - op.end(null); + if (callback != null) callback.onCrop(); } /** 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 ffabc8d4..361113f1 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java @@ -5,6 +5,7 @@ import android.graphics.SurfaceTexture; import android.opengl.GLSurfaceView; import android.opengl.Matrix; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import android.view.LayoutInflater; @@ -14,7 +15,6 @@ import android.view.ViewGroup; import com.otaliastudios.cameraview.R; import com.otaliastudios.cameraview.internal.egl.EglViewport; -import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.filter.Filter; import com.otaliastudios.cameraview.filter.NoFilter; import com.otaliastudios.cameraview.size.AspectRatio; @@ -270,8 +270,7 @@ public class GlCameraPreview extends FilterCameraPreview op) { - op.start(); + protected void crop(@Nullable final CropCallback callback) { if (mInputStreamWidth > 0 && mInputStreamHeight > 0 && mOutputSurfaceWidth > 0 && mOutputSurfaceHeight > 0) { float scaleX = 1f, scaleY = 1f; @@ -289,7 +288,7 @@ public class GlCameraPreview extends FilterCameraPreview op) { - op.start(); + protected void crop(@Nullable final CropCallback callback) { getView().post(new Runnable() { @Override public void run() { if (mInputStreamHeight == 0 || mInputStreamWidth == 0 || mOutputSurfaceHeight == 0 || mOutputSurfaceWidth == 0) { - op.end(null); + if (callback != null) callback.onCrop(); return; } float scaleX = 1f, scaleY = 1f; @@ -118,7 +116,7 @@ public class TextureCameraPreview extends CameraPreview 1.02f || scaleY > 1.02f; LOG.i("crop:", "applied scaleX=", scaleX); LOG.i("crop:", "applied scaleY=", scaleY); - op.end(null); + if (callback != null) callback.onCrop(); } }); }