From 519a39bee796363574f5a892aaca75d930272ab3 Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Tue, 14 Nov 2017 00:14:30 +0100 Subject: [PATCH] Add basic error handling --- README.md | 13 +- ...Test.java => CameraViewCallbacksTest.java} | 11 +- .../cameraview/IntegrationTest.java | 127 ++++++------- .../cameraview/MockCameraController.java | 4 +- .../com/otaliastudios/cameraview/Camera1.java | 150 ++++++++------- .../com/otaliastudios/cameraview/Camera2.java | 4 +- .../cameraview/CameraController.java | 179 ++++++++++-------- .../cameraview/CameraException.java | 12 ++ .../cameraview/CameraListener.java | 20 ++ .../otaliastudios/cameraview/CameraView.java | 20 +- .../cameraview/CameraExceptionTest.java | 16 ++ 11 files changed, 335 insertions(+), 221 deletions(-) rename cameraview/src/androidTest/java/com/otaliastudios/cameraview/{CameraCallbacksTest.java => CameraViewCallbacksTest.java} (96%) create mode 100644 cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java create mode 100644 cameraview/src/test/java/com/otaliastudios/cameraview/CameraExceptionTest.java diff --git a/README.md b/README.md index 31dc6e49..8d6b5beb 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ See below for a [list of what was done](#roadmap) and [licensing info](#contribu - Automatically detected orientation tags - Plug in location tags with `setLocation()` API - `CameraUtils` to help with Bitmaps and orientations +- Error handling - **Lightweight**, no dependencies, just support `ExifInterface` - Works down to API level 15 @@ -169,6 +170,16 @@ camera.addCameraListener(new CameraListener() { @Override public void onCameraClosed() {} + /** + * Notifies about an error during the camera setup or configuration. + * At the moment, errors that are passed here are unrecoverable. When this is called, + * the camera has been released and is presumably showing a black preview. + * + * This is the right moment to show an error dialog to the user. + */ + @Override + public void onCameraError(CameraException error) {} + /** * Notifies that a picture previously captured with capturePicture() * or captureSnapshot() is ready to be shown or saved. @@ -605,6 +616,7 @@ all the code was changed. - *Better threading, start() in worker thread and callbacks in UI* - *Frame processor support* - *inject external loggers* +- *error handling* These are still things that need to be done, off the top of my head: @@ -612,7 +624,6 @@ These are still things that need to be done, off the top of my head: - [ ] add a `setPreferredAspectRatio` API to choose the capture size. Preview size will adapt, and then, if let free, the CameraView will adapt as well - [ ] animate grid lines similar to stock camera app - [ ] add onRequestPermissionResults for easy permission callback -- [ ] better error handling, maybe with a onError(e) method in the public listener, or have each public method return a boolean - [ ] decent code coverage # Contributing and licenses diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraCallbacksTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java similarity index 96% rename from cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraCallbacksTest.java rename to cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java index 26add023..894a6501 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraCallbacksTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java @@ -34,7 +34,7 @@ import static org.mockito.Mockito.verify; @RunWith(AndroidJUnit4.class) @MediumTest -public class CameraCallbacksTest extends BaseTest { +public class CameraViewCallbacksTest extends BaseTest { private CameraView camera; private CameraListener listener; @@ -226,6 +226,15 @@ public class CameraCallbacksTest extends BaseTest { // TODO: test onShutter, here or elsewhere + @Test + public void testCameraError() { + CameraException error = new CameraException(new RuntimeException("Error")); + completeTask().when(listener).onCameraError(error); + + camera.mCameraCallbacks.dispatchError(error); + assertNotNull(task.await(200)); + verify(listener, times(1)).onCameraError(error); + } @Test public void testProcessJpeg() { diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java index 31d94830..d3d24cd3 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java @@ -1,13 +1,12 @@ package com.otaliastudios.cameraview; -import android.content.Context; import android.graphics.Bitmap; import android.graphics.PointF; +import android.media.MediaRecorder; import android.support.test.filters.MediumTest; import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; -import android.view.ViewGroup; import org.junit.After; import org.junit.Before; @@ -42,6 +41,7 @@ public class IntegrationTest extends BaseTest { private CameraView camera; private Camera1 controller; private CameraListener listener; + private Task uiExceptionTask; @BeforeClass public static void grant() { @@ -51,6 +51,7 @@ public class IntegrationTest extends BaseTest { @Before public void setUp() { WorkerHandler.destroy(); + ui(new Runnable() { @Override public void run() { @@ -67,6 +68,17 @@ public class IntegrationTest extends BaseTest { rule.getActivity().inflate(camera); } }); + + // Ensure that controller exceptions are thrown on this thread (not on the UI thread). + uiExceptionTask = new Task<>(true); + WorkerHandler crashThread = WorkerHandler.get("CrashThread"); + crashThread.getThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + uiExceptionTask.end(e); + } + }); + controller.mCrashHandler = crashThread.get(); } @After @@ -76,7 +88,13 @@ public class IntegrationTest extends BaseTest { WorkerHandler.destroy(); } + private void waitForUiException() throws Throwable { + Throwable throwable = uiExceptionTask.await(2500); + if (throwable != null) throw throwable; + } + private CameraOptions waitForOpen(boolean expectSuccess) { + camera.start(); final Task open = new Task<>(true); doEndTask(open, 0).when(listener).onCameraOpened(any(CameraOptions.class)); CameraOptions result = open.await(4000); @@ -89,6 +107,7 @@ public class IntegrationTest extends BaseTest { } private void waitForClose(boolean expectSuccess) { + camera.stop(); final Task close = new Task<>(true); doEndTask(close, true).when(listener).onCameraClosed(); Boolean result = close.await(4000); @@ -99,10 +118,10 @@ public class IntegrationTest extends BaseTest { } } - private void waitForVideo(boolean expectSuccess) { + private void waitForVideoEnd(boolean expectSuccess) { final Task video = new Task<>(true); doEndTask(video, true).when(listener).onVideoTaken(any(File.class)); - Boolean result = video.await(2000); + Boolean result = video.await(8000); if (expectSuccess) { assertNotNull("Can take video", result); } else { @@ -122,6 +141,18 @@ public class IntegrationTest extends BaseTest { return result; } + private void waitForVideoStart() { + controller.mStartVideoTask.listen(); + camera.startCapturingVideo(null); + controller.mStartVideoTask.await(400); + } + + private void waitForVideoQuality(VideoQuality quality) { + controller.mVideoQualityTask.listen(); + camera.setVideoQuality(quality); + controller.mVideoQualityTask.await(400); + } + //region test open/close @Test @@ -129,26 +160,21 @@ public class IntegrationTest extends BaseTest { // Starting and stopping are hard to get since they happen on another thread. assertEquals(controller.getState(), CameraController.STATE_STOPPED); - camera.start(); waitForOpen(true); assertEquals(controller.getState(), CameraController.STATE_STARTED); - camera.stop(); waitForClose(true); assertEquals(controller.getState(), CameraController.STATE_STOPPED); } @Test public void testOpenTwice() { - camera.start(); waitForOpen(true); - camera.start(); waitForOpen(false); } @Test public void testCloseTwice() { - camera.stop(); waitForClose(false); } @@ -174,7 +200,6 @@ public class IntegrationTest extends BaseTest { public void testStartInitializesOptions() { assertNull(camera.getCameraOptions()); assertNull(camera.getExtraProperties()); - camera.start(); waitForOpen(true); assertNotNull(camera.getCameraOptions()); assertNotNull(camera.getExtraProperties()); @@ -187,7 +212,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetFacing() throws Exception { - camera.start(); CameraOptions o = waitForOpen(true); int size = o.getSupportedFacing().size(); if (size > 1) { @@ -206,7 +230,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetSessionType() throws Exception { camera.setSessionType(SessionType.PICTURE); - camera.start(); waitForOpen(true); // set session type should call stop and start again. @@ -228,7 +251,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetZoom() { - camera.start(); CameraOptions options = waitForOpen(true); controller.mZoomTask.listen(); @@ -246,7 +268,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetExposureCorrection() { - camera.start(); CameraOptions options = waitForOpen(true); controller.mExposureCorrectionTask.listen(); @@ -264,7 +285,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetFlash() { - camera.start(); CameraOptions options = waitForOpen(true); Flash[] values = Flash.values(); Flash oldValue = camera.getFlash(); @@ -283,7 +303,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetWhiteBalance() { - camera.start(); CameraOptions options = waitForOpen(true); WhiteBalance[] values = WhiteBalance.values(); WhiteBalance oldValue = camera.getWhiteBalance(); @@ -302,7 +321,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetHdr() { - camera.start(); CameraOptions options = waitForOpen(true); Hdr[] values = Hdr.values(); Hdr oldValue = camera.getHdr(); @@ -322,7 +340,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetAudio() { // TODO: when permissions are managed, check that Audio.ON triggers the audio permission - camera.start(); waitForOpen(true); Audio[] values = Audio.values(); for (Audio value : values) { @@ -333,7 +350,6 @@ public class IntegrationTest extends BaseTest { @Test public void testSetLocation() { - camera.start(); waitForOpen(true); controller.mLocationTask.listen(); camera.setLocation(10d, 2d); @@ -349,42 +365,33 @@ public class IntegrationTest extends BaseTest { //region testSetVideoQuality // This can be tricky because can trigger layout changes. - // TODO: the exception is swallowed. @Test(expected = IllegalStateException.class) - public void testSetVideoQuality_whileRecording() { + @Test(expected = RuntimeException.class) + public void testSetVideoQuality_whileRecording() throws Throwable { // Can't run on Travis, MediaRecorder not supported. // Error while starting MediaRecorder. java.lang.RuntimeException: start failed. camera.setSessionType(SessionType.VIDEO); - camera.setVideoQuality(VideoQuality.HIGHEST); - camera.start(); + waitForVideoQuality(VideoQuality.HIGHEST); waitForOpen(true); - camera.startCapturingVideo(null); - controller.mVideoQualityTask.listen(); - camera.setVideoQuality(VideoQuality.LOWEST); - controller.mVideoQualityTask.await(300); + waitForVideoStart(); + waitForVideoQuality(VideoQuality.LOWEST); + waitForUiException(); } @Test public void testSetVideoQuality_whileInPictureSessionType() { camera.setSessionType(SessionType.PICTURE); - camera.setVideoQuality(VideoQuality.HIGHEST); - camera.start(); + waitForVideoQuality(VideoQuality.HIGHEST); waitForOpen(true); - controller.mVideoQualityTask.listen(); - camera.setVideoQuality(VideoQuality.LOWEST); - controller.mVideoQualityTask.await(300); + waitForVideoQuality(VideoQuality.LOWEST); assertEquals(camera.getVideoQuality(), VideoQuality.LOWEST); } @Test public void testSetVideoQuality_whileNotStarted() { - controller.mVideoQualityTask.listen(); - camera.setVideoQuality(VideoQuality.HIGHEST); - controller.mVideoQualityTask.await(300); + waitForVideoQuality(VideoQuality.HIGHEST); assertEquals(camera.getVideoQuality(), VideoQuality.HIGHEST); - controller.mVideoQualityTask.listen(); - camera.setVideoQuality(VideoQuality.LOWEST); - controller.mVideoQualityTask.await(300); + waitForVideoQuality(VideoQuality.LOWEST); assertEquals(camera.getVideoQuality(), VideoQuality.LOWEST); } @@ -401,36 +408,34 @@ public class IntegrationTest extends BaseTest { //region test startVideo - // TODO: @Test(expected = IllegalStateException.class) - // Fails on Travis. Some emulators can't deal with MediaRecorder - // Error while starting MediaRecorder. java.lang.RuntimeException: start failed. - // as documented. This works locally though. - public void testStartVideo_whileInPictureMode() { + @Test(expected = RuntimeException.class) + public void testStartVideo_whileInPictureMode() throws Throwable { + // Fails on Travis. Some emulators can't deal with MediaRecorder + // Error while starting MediaRecorder. java.lang.RuntimeException: start failed. + // as documented. This works locally though. camera.setSessionType(SessionType.PICTURE); - camera.start(); waitForOpen(true); - camera.startCapturingVideo(null); + waitForVideoStart(); + waitForUiException(); } - // TODO: @Test - // Fails on Travis. Some emulators can't deal with MediaRecorder, - // Error while starting MediaRecorder. java.lang.RuntimeException: start failed. - // as documented. This works locally though. + @Test public void testStartEndVideo() { + // Fails on Travis. Some emulators can't deal with MediaRecorder, + // Error while starting MediaRecorder. java.lang.RuntimeException: start failed. + // as documented. This works locally though. camera.setSessionType(SessionType.VIDEO); - camera.start(); waitForOpen(true); - camera.startCapturingVideo(null, 1000); - waitForVideo(true); // waits 2000 + camera.startCapturingVideo(null, 4000); + waitForVideoEnd(true); } @Test public void testEndVideo_withoutStarting() { camera.setSessionType(SessionType.VIDEO); - camera.start(); waitForOpen(true); camera.stopCapturingVideo(); - waitForVideo(false); + waitForVideoEnd(false); } //endregion @@ -440,7 +445,6 @@ public class IntegrationTest extends BaseTest { @Test public void testStartAutoFocus() { - camera.start(); CameraOptions o = waitForOpen(true); final Task focus = new Task<>(true); @@ -469,7 +473,6 @@ public class IntegrationTest extends BaseTest { @Test public void testCapturePicture_concurrentCalls() throws Exception { // Second take should fail. - camera.start(); waitForOpen(true); CountDownLatch latch = new CountDownLatch(2); @@ -485,7 +488,6 @@ public class IntegrationTest extends BaseTest { @Test public void testCapturePicture_size() throws Exception { camera.setCropOutput(false); - camera.start(); waitForOpen(true); Size size = camera.getCaptureSize(); @@ -507,7 +509,6 @@ public class IntegrationTest extends BaseTest { @Test public void testCaptureSnapshot_concurrentCalls() throws Exception { // Second take should fail. - camera.start(); waitForOpen(true); CountDownLatch latch = new CountDownLatch(2); @@ -515,15 +516,14 @@ public class IntegrationTest extends BaseTest { camera.captureSnapshot(); camera.captureSnapshot(); - boolean did = latch.await(4, TimeUnit.SECONDS); + boolean did = latch.await(6, TimeUnit.SECONDS); assertFalse(did); - assertEquals(latch.getCount(), 1); + assertEquals(1, latch.getCount()); } @Test public void testCaptureSnapshot_size() throws Exception { camera.setCropOutput(false); - camera.start(); waitForOpen(true); Size size = camera.getPreviewSize(); @@ -552,7 +552,6 @@ public class IntegrationTest extends BaseTest { public void testFrameProcessing_simple() throws Exception { FrameProcessor processor = mock(FrameProcessor.class); camera.addFrameProcessor(processor); - camera.start(); waitForOpen(true); assert30Frames(processor); @@ -562,7 +561,6 @@ public class IntegrationTest extends BaseTest { public void testFrameProcessing_afterSnapshot() throws Exception { FrameProcessor processor = mock(FrameProcessor.class); camera.addFrameProcessor(processor); - camera.start(); waitForOpen(true); // In Camera1, snapshots will clear the preview callback @@ -577,11 +575,8 @@ public class IntegrationTest extends BaseTest { public void testFrameProcessing_afterRestart() throws Exception { FrameProcessor processor = mock(FrameProcessor.class); camera.addFrameProcessor(processor); - camera.start(); waitForOpen(true); - camera.stop(); waitForClose(true); - camera.start(); waitForOpen(true); assert30Frames(processor); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java index ccbc9eee..d777e45a 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java @@ -32,11 +32,11 @@ public class MockCameraController extends CameraController { } @Override - void onStart() throws Exception { + void onStart() { } @Override - void onStop() throws Exception { + void onStop() { } @Override diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java index 1d50fb96..235b4d29 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java @@ -16,13 +16,14 @@ import android.support.annotation.WorkerThread; import android.view.SurfaceHolder; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @SuppressWarnings("deprecation") -class Camera1 extends CameraController implements Camera.PreviewCallback { +class Camera1 extends CameraController implements Camera.PreviewCallback, Camera.ErrorCallback { private static final String TAG = Camera1.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); @@ -48,7 +49,6 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { } }; - private ScheduleRunnable mScheduleRunnable; private Mapper mMapper; private boolean mIsBound = false; private boolean mIsCapturingImage = false; @@ -56,31 +56,21 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { Camera1(CameraView.CameraCallbacks callback) { super(callback); - mScheduleRunnable = new ScheduleRunnable(); mMapper = new Mapper.Mapper1(); } - private class ScheduleRunnable implements Runnable { - private Task mTask; - private Runnable mAction; - private boolean mEnsureAvailable; - - @Override - public void run() { - if (mEnsureAvailable && !isCameraAvailable()) { - if (mTask != null) mTask.end(null); - } else { - mAction.run(); - if (mTask != null) mTask.end(null); + private void schedule(@Nullable final Task task, final boolean ensureAvailable, final Runnable action) { + mHandler.post(new Runnable() { + @Override + public void run() { + if (ensureAvailable && !isCameraAvailable()) { + if (task != null) task.end(null); + } else { + action.run(); + if (task != null) task.end(null); + } } - } - } - - private void schedule(@Nullable Task task, boolean ensureAvailable, Runnable action) { - mScheduleRunnable.mEnsureAvailable = ensureAvailable; - mScheduleRunnable.mAction = action; - mScheduleRunnable.mTask = task; - mHandler.post(mScheduleRunnable); + }); } // Preview surface is now available. If camera is open, set up. @@ -96,7 +86,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { bindToSurface(); } catch (Exception e) { LOG.e("onSurfaceAvailable:", "Exception while binding camera to preview.", e); - throw new RuntimeException(e); + throw new CameraException(e); } } } @@ -133,13 +123,17 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { // The act of binding an "open" camera to a "ready" preview. // These can happen at different times but we want to end up here. @WorkerThread - private void bindToSurface() throws Exception { + private void bindToSurface() { LOG.i("bindToSurface:", "Started"); Object output = mPreview.getOutput(); - if (mPreview.getOutputClass() == SurfaceHolder.class) { - mCamera.setPreviewDisplay((SurfaceHolder) output); - } else { - mCamera.setPreviewTexture((SurfaceTexture) output); + try { + if (mPreview.getOutputClass() == SurfaceHolder.class) { + mCamera.setPreviewDisplay((SurfaceHolder) output); + } else { + mCamera.setPreviewTexture((SurfaceTexture) output); + } + } catch (IOException e) { + throw new CameraException(e); } mCaptureSize = computeCaptureSize(); @@ -176,13 +170,14 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { @WorkerThread @Override - void onStart() throws Exception { + void onStart() { if (isCameraAvailable()) { LOG.w("onStart:", "Camera not available. Should not happen."); onStop(); // Should not happen. } if (collectCameraId()) { mCamera = Camera.open(mCameraId); + mCamera.setErrorCallback(this); // Set parameters that might have been set before the camera was opened. LOG.i("onStart:", "Applying default parameters."); @@ -206,15 +201,15 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { @WorkerThread @Override - void onStop() throws Exception { + void onStop() { Exception error = null; LOG.i("onStop:", "About to clean up."); mHandler.get().removeCallbacks(mPostFocusResetRunnable); mFrameManager.release(); if (mCamera != null) { - LOG.i("onStop:", "Clean up.", "Ending video?", mIsCapturingVideo); - if (mIsCapturingVideo) endVideo(); + LOG.i("onStop:", "Clean up.", "Ending video."); + endVideoImmediately(); try { LOG.i("onStop:", "Clean up.", "Stopping preview."); @@ -222,7 +217,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { mCamera.setPreviewCallbackWithBuffer(null); LOG.i("onStop:", "Clean up.", "Stopped preview."); } catch (Exception e) { - LOG.w("onStop:", "Clean up.", "Exception while stopping preview."); + LOG.w("onStop:", "Clean up.", "Exception while stopping preview.", e); error = e; } @@ -231,7 +226,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { mCamera.release(); LOG.i("onStop:", "Clean up.", "Released camera."); } catch (Exception e) { - LOG.w("onStop:", "Clean up.", "Exception while releasing camera."); + LOG.w("onStop:", "Clean up.", "Exception while releasing camera.", e); error = e; } } @@ -242,7 +237,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { mCaptureSize = null; mIsBound = false; - if (error != null) throw error; + if (error != null) throw new CameraException(error); } private boolean collectCameraId() { @@ -267,6 +262,20 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { } } + @Override + public void onError(int error, Camera camera) { + if (error == Camera.CAMERA_ERROR_SERVER_DIED) { + // Looks like this is recoverable. + LOG.w("Recoverable error inside the onError callback.", "CAMERA_ERROR_SERVER_DIED"); + stopImmediately(); + start(); + return; + } + + LOG.e("Error inside the onError callback.", error); + throw new CameraException(new RuntimeException(CameraLogger.lastMessage)); + } + @Override void setSessionType(SessionType sessionType) { if (sessionType != mSessionType) { @@ -512,9 +521,11 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { @Override void captureSnapshot() { + LOG.i("captureSnapshot: scheduling"); schedule(null, true, new Runnable() { @Override public void run() { + LOG.i("captureSnapshot: performing.", mIsCapturingImage); if (mIsCapturingImage) return; // This won't work while capturing a video. // Switch to capturePicture. @@ -523,9 +534,11 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { return; } mIsCapturingImage = true; + LOG.i("captureSnapshot: add preview callback."); mCamera.setOneShotPreviewCallback(new Camera.PreviewCallback() { @Override public void onPreviewFrame(final byte[] data, Camera camera) { + LOG.i("captureSnapshot: onShutter."); mCameraCallbacks.onShutter(true); // Got to rotate the preview frame, since byte[] data here does not include @@ -540,13 +553,17 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { final int postWidth = flip ? preHeight : preWidth; final int postHeight = flip ? preWidth : preHeight; final int format = mPreviewFormat; + LOG.i("captureSnapshot: to worker handler."); WorkerHandler.run(new Runnable() { @Override public void run() { + LOG.i("captureSnapshot: rotating."); final boolean consistentWithView = (sensorToDevice + sensorToDisplay + 180) % 180 == 0; byte[] rotatedData = RotationHelper.rotate(data, preWidth, preHeight, sensorToDevice); + LOG.i("captureSnapshot: rotated."); YuvImage yuv = new YuvImage(rotatedData, format, postWidth, postHeight, null); + LOG.i("captureSnapshot: dispatching to listeners."); mCameraCallbacks.processSnapshot(yuv, consistentWithView, exifFlip); mIsCapturingImage = false; } @@ -678,7 +695,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { @Override void startVideo(@NonNull final File videoFile) { - schedule(null, true, new Runnable() { + schedule(mStartVideoTask, true, new Runnable() { @Override public void run() { if (mIsCapturingVideo) return; @@ -693,7 +710,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { LOG.e("Error while starting MediaRecorder. Swallowing.", e); mVideoFile = null; mCamera.lock(); - endVideoNow(); + endVideoImmediately(); } } else { throw new IllegalStateException("Can't record video while session type is picture"); @@ -707,30 +724,28 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { schedule(null, false, new Runnable() { @Override public void run() { - endVideoNow(); + endVideoImmediately(); } }); } @WorkerThread - private void endVideoNow() { - if (mIsCapturingVideo) { - mIsCapturingVideo = false; - if (mMediaRecorder != null) { - try { - mMediaRecorder.stop(); - mMediaRecorder.release(); - } catch (Exception e) { - // This can happen if endVideo() is called right after startVideo(). - // We don't care. - LOG.w("Error while closing media recorder. Swallowing", e); - } - mMediaRecorder = null; - } - if (mVideoFile != null) { - mCameraCallbacks.dispatchOnVideoTaken(mVideoFile); - mVideoFile = null; + private void endVideoImmediately() { + LOG.i("endVideoImmediately:", "is capturing:", mIsCapturingVideo); + mIsCapturingVideo = false; + if (mMediaRecorder != null) { + try { + mMediaRecorder.stop(); + } catch (Exception e) { + // This can happen if endVideo() is called right after startVideo(). We don't care. + LOG.w("endVideoImmediately:", "Error while closing media recorder. Swallowing", e); } + mMediaRecorder.release(); + mMediaRecorder = null; + } + if (mVideoFile != null) { + mCameraCallbacks.dispatchOnVideoTaken(mVideoFile); + mVideoFile = null; } } @@ -741,18 +756,21 @@ class Camera1 extends CameraController implements Camera.PreviewCallback { mMediaRecorder.setCamera(mCamera); mMediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA); - + if (mAudio == Audio.ON) { + // Must be called before setOutputFormat. + mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.DEFAULT); + } CamcorderProfile profile = getCamcorderProfile(mCameraId, mVideoQuality); + mMediaRecorder.setOutputFormat(profile.fileFormat); + mMediaRecorder.setVideoFrameRate(profile.videoFrameRate); + mMediaRecorder.setVideoSize(profile.videoFrameWidth, profile.videoFrameHeight); + mMediaRecorder.setVideoEncoder(profile.videoCodec); + mMediaRecorder.setVideoEncodingBitRate(profile.videoBitRate); if (mAudio == Audio.ON) { - mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.CAMCORDER); - mMediaRecorder.setProfile(profile); - } else { - // Set all values contained in profile except audio settings - mMediaRecorder.setOutputFormat(profile.fileFormat); - mMediaRecorder.setVideoEncoder(profile.videoCodec); - mMediaRecorder.setVideoEncodingBitRate(profile.videoBitRate); - mMediaRecorder.setVideoFrameRate(profile.videoFrameRate); - mMediaRecorder.setVideoSize(profile.videoFrameWidth, profile.videoFrameHeight); + mMediaRecorder.setAudioChannels(profile.audioChannels); + mMediaRecorder.setAudioSamplingRate(profile.audioSampleRate); + mMediaRecorder.setAudioEncoder(profile.audioCodec); + mMediaRecorder.setAudioEncodingBitRate(profile.audioBitRate); } if (mLocation != null) { diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera2.java b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera2.java index 4d047ea7..67f4637b 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera2.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera2.java @@ -37,12 +37,12 @@ class Camera2 extends CameraController { } @Override - void onStart() throws Exception { + void onStart() { } @Override - void onStop() throws Exception { + void onStop() { } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java index 404420c0..4bc93579 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java @@ -2,13 +2,18 @@ package com.otaliastudios.cameraview; import android.graphics.PointF; import android.location.Location; +import android.os.Handler; +import android.os.Looper; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import java.io.File; -abstract class CameraController implements CameraPreview.SurfaceCallback, FrameManager.BufferCallback { +abstract class CameraController implements + CameraPreview.SurfaceCallback, + FrameManager.BufferCallback, + Thread.UncaughtExceptionHandler { private static final String TAG = CameraController.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); @@ -20,6 +25,8 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM protected final CameraView.CameraCallbacks mCameraCallbacks; protected CameraPreview mPreview; + protected WorkerHandler mHandler; + /* for tests */ Handler mCrashHandler; protected Facing mFacing; protected Flash mFlash; @@ -45,8 +52,6 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM protected int mDeviceOrientation; protected int mState = STATE_STOPPED; - protected WorkerHandler mHandler; - // Used for testing. Task mZoomTask = new Task<>(); Task mExposureCorrectionTask = new Task<>(); @@ -55,27 +60,13 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM Task mHdrTask = new Task<>(); Task mLocationTask = new Task<>(); Task mVideoQualityTask = new Task<>(); + Task mStartVideoTask = new Task<>(); CameraController(CameraView.CameraCallbacks callback) { mCameraCallbacks = callback; + mCrashHandler = new Handler(Looper.getMainLooper()); mHandler = WorkerHandler.get("CameraViewController"); - mHandler.getThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread thread, Throwable throwable) { - // Something went wrong. Thread is terminated (about to?). - // Move to other thread and stop resources. - LOG.w("Interrupting thread, due to exception.", throwable); - thread.interrupt(); - LOG.w("Interrupted thread. Posting a stopImmediately.", ss()); - mHandler = WorkerHandler.get("CameraViewController"); - mHandler.post(new Runnable() { - @Override - public void run() { - stopImmediately(); - } - }); - } - }); + mHandler.getThread().setUncaughtExceptionHandler(this); mFrameManager = new FrameManager(2, this); } @@ -84,6 +75,58 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM mPreview.setSurfaceCallback(this); } + //region Error handling + + @Override + public void uncaughtException(final Thread thread, final Throwable throwable) { + // Something went wrong. Thread is terminated (about to?). + // Move to other thread and release resources. + if (!(throwable instanceof CameraException)) { + // This is unexpected, either a bug or something the developer should know. + // Release and crash the UI thread so we get bug reports. + LOG.e("uncaughtException:", "Unexpected exception:", throwable); + destroy(); + mCrashHandler.post(new Runnable() { + @Override + public void run() { + RuntimeException exception; + if (throwable instanceof RuntimeException) { + exception = (RuntimeException) throwable; + } else { + exception = new RuntimeException(throwable); + } + throw exception; + } + }); + } else { + // At the moment all CameraExceptions are unrecoverable, there was something + // wrong when starting, stopping, or binding the camera to the preview. + final CameraException error = (CameraException) throwable; + LOG.e("uncaughtException:", "Interrupting thread with state:", ss(), "due to CameraException:", error); + thread.interrupt(); + mHandler = WorkerHandler.get("CameraViewController"); + mHandler.getThread().setUncaughtExceptionHandler(this); + LOG.i("uncaughtException:", "Calling stopImmediately and notifying."); + mHandler.post(new Runnable() { + @Override + public void run() { + stopImmediately(); + mCameraCallbacks.dispatchError(error); + } + }); + } + } + + final void destroy() { + LOG.i("destroy:", "state:", ss()); + // Prevent CameraController leaks. + mHandler.getThread().setUncaughtExceptionHandler(null); + // Stop if needed. + stopImmediately(); + } + + //endregion + //region Start&Stop private String ss() { @@ -102,20 +145,14 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM mHandler.post(new Runnable() { @Override public void run() { - try { - LOG.i("Start:", "executing. State:", ss()); - if (mState >= STATE_STARTING) return; - mState = STATE_STARTING; - LOG.i("Start:", "about to call onStart()", ss()); - onStart(); - LOG.i("Start:", "returned from onStart().", "Dispatching.", ss()); - mState = STATE_STARTED; - mCameraCallbacks.dispatchOnCameraOpened(mOptions); - - } catch (Exception e) { - LOG.e("Error while starting the camera engine.", e); - throw new RuntimeException(e); - } + LOG.i("Start:", "executing. State:", ss()); + if (mState >= STATE_STARTING) return; + mState = STATE_STARTING; + LOG.i("Start:", "about to call onStart()", ss()); + onStart(); + LOG.i("Start:", "returned from onStart().", "Dispatching.", ss()); + mState = STATE_STARTED; + mCameraCallbacks.dispatchOnCameraOpened(mOptions); } }); } @@ -126,38 +163,31 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM mHandler.post(new Runnable() { @Override public void run() { - try { - LOG.i("Stop:", "executing. State:", ss()); - if (mState <= STATE_STOPPED) return; - mState = STATE_STOPPING; - LOG.i("Stop:", "about to call onStop()"); - onStop(); - LOG.i("Stop:", "returned from onStop().", "Dispatching."); - mState = STATE_STOPPED; - mCameraCallbacks.dispatchOnCameraClosed(); - - } catch (Exception e) { - LOG.e("Error while stopping the camera engine.", e); - throw new RuntimeException(e); - } + LOG.i("Stop:", "executing. State:", ss()); + if (mState <= STATE_STOPPED) return; + mState = STATE_STOPPING; + LOG.i("Stop:", "about to call onStop()"); + onStop(); + LOG.i("Stop:", "returned from onStop().", "Dispatching."); + mState = STATE_STOPPED; + mCameraCallbacks.dispatchOnCameraClosed(); } }); } // Stops the preview synchronously, ensuring no exceptions are thrown. - void stopImmediately() { + final void stopImmediately() { try { // Don't check, try stop again. - LOG.i("Stop immediately. State was:", ss()); + LOG.i("stopImmediately:", "State was:", ss()); + if (mState == STATE_STOPPED) return; mState = STATE_STOPPING; - // Prevent leaking CameraController. - mHandler.getThread().setUncaughtExceptionHandler(null); onStop(); mState = STATE_STOPPED; - LOG.i("Stop immediately. Stopped. State is:", ss()); + LOG.i("stopImmediately:", "Stopped. State is:", ss()); } catch (Exception e) { // Do nothing. - LOG.i("Stop immediately. Exception while stopping.", e); + LOG.i("stopImmediately:", "Swallowing exception while stopping.", e); mState = STATE_STOPPED; } } @@ -168,29 +198,22 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM mHandler.post(new Runnable() { @Override public void run() { - try { - LOG.i("Restart:", "executing. Needs stopping:", mState > STATE_STOPPED, ss()); - // Don't stop if stopped. - if (mState > STATE_STOPPED) { - mState = STATE_STOPPING; - onStop(); - mState = STATE_STOPPED; - LOG.i("Restart:", "stopped. Dispatching.", ss()); - mCameraCallbacks.dispatchOnCameraClosed(); - } - - LOG.i("Restart: about to start. State:", ss()); - mState = STATE_STARTING; - onStart(); - mState = STATE_STARTED; - LOG.i("Restart: returned from start. Dispatching. State:", ss()); - mCameraCallbacks.dispatchOnCameraOpened(mOptions); - - } catch (Exception e) { - LOG.e("Error while restarting the camera engine.", e); - throw new RuntimeException(e); - + LOG.i("Restart:", "executing. Needs stopping:", mState > STATE_STOPPED, ss()); + // Don't stop if stopped. + if (mState > STATE_STOPPED) { + mState = STATE_STOPPING; + onStop(); + mState = STATE_STOPPED; + LOG.i("Restart:", "stopped. Dispatching.", ss()); + mCameraCallbacks.dispatchOnCameraClosed(); } + + LOG.i("Restart: about to start. State:", ss()); + mState = STATE_STARTING; + onStart(); + mState = STATE_STARTED; + LOG.i("Restart: returned from start. Dispatching. State:", ss()); + mCameraCallbacks.dispatchOnCameraOpened(mOptions); } }); } @@ -198,11 +221,11 @@ abstract class CameraController implements CameraPreview.SurfaceCallback, FrameM // Starts the preview. // At the end of this method camera must be available, e.g. for setting parameters. @WorkerThread - abstract void onStart() throws Exception; + abstract void onStart(); // Stops the preview. @WorkerThread - abstract void onStop() throws Exception; + abstract void onStop(); // Returns current state. final int getState() { diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java new file mode 100644 index 00000000..bdb11b0d --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraException.java @@ -0,0 +1,12 @@ +package com.otaliastudios.cameraview; + + +/** + * Holds an error with the camera configuration. + */ +public class CameraException extends RuntimeException { + + CameraException(Throwable cause) { + super(cause); + } +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraListener.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraListener.java index 7e5a6ddb..17e2e905 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraListener.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraListener.java @@ -1,6 +1,7 @@ package com.otaliastudios.cameraview; import android.graphics.PointF; +import android.support.annotation.NonNull; import android.support.annotation.UiThread; import java.io.File; @@ -29,6 +30,25 @@ public abstract class CameraListener { } + /** + * Notifies about an error during the camera setup or configuration. + * At the moment, errors that are passed here are unrecoverable. When this is called, + * the camera has been released and is presumably showing a black preview. + * + * This is the right moment to show an error dialog to the user. + * You can try calling start() again, but that is not guaranteed to work - if it doesn't, + * this callback will be invoked again. + * + * In the future, more information will be passed through the {@link CameraException} instance. + * + * @param exception the error + */ + @UiThread + public void onCameraError(@NonNull CameraException exception) { + + } + + /** * Notifies that a picture previously captured with {@link CameraView#capturePicture()} * or {@link CameraView#captureSnapshot()} is ready to be shown or saved. diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index f7e2ed95..71a95510 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -580,7 +580,7 @@ public class CameraView extends FrameLayout { public void destroy() { clearCameraListeners(); clearFrameProcessors(); - mCameraController.stopImmediately(); + mCameraController.destroy(); } //endregion @@ -1335,6 +1335,7 @@ public class CameraView extends FrameLayout { void dispatchOnZoomChanged(final float newValue, final PointF[] fingers); void dispatchOnExposureCorrectionChanged(float newValue, float[] bounds, PointF[] fingers); void dispatchFrame(Frame frame); + void dispatchError(CameraException exception); } private class Callbacks implements CameraCallbacks { @@ -1608,11 +1609,20 @@ public class CameraView extends FrameLayout { }); } } - } - - //endregion - //region Deprecated + @Override + public void dispatchError(final CameraException exception) { + mLogger.i("dispatchError", exception); + mUiHandler.post(new Runnable() { + @Override + public void run() { + for (CameraListener listener : mListeners) { + listener.onCameraError(exception); + } + } + }); + } + } //endregion } diff --git a/cameraview/src/test/java/com/otaliastudios/cameraview/CameraExceptionTest.java b/cameraview/src/test/java/com/otaliastudios/cameraview/CameraExceptionTest.java new file mode 100644 index 00000000..5474a4ed --- /dev/null +++ b/cameraview/src/test/java/com/otaliastudios/cameraview/CameraExceptionTest.java @@ -0,0 +1,16 @@ +package com.otaliastudios.cameraview; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class CameraExceptionTest { + + @Test + public void testConstructor() { + RuntimeException cause = new RuntimeException("Error"); + CameraException camera = new CameraException(cause); + assertEquals(cause, camera.getCause()); + } +}