From 4166031ce2b8bc58ce80b1fa7de0c05483f19cea Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Wed, 26 Jun 2019 17:58:58 -0500 Subject: [PATCH] Basic Camera2 open-close integration --- .../cameraview/engine/IntegrationTest.java | 3 +- .../cameraview/CameraOptions.java | 45 ++ .../otaliastudios/cameraview/CameraView.java | 6 + .../cameraview/engine/Camera1Engine.java | 29 +- .../cameraview/engine/Camera2Engine.java | 409 +++++++++++++++++- .../cameraview/engine/CameraEngine.java | 153 ++++--- .../cameraview/engine/Mapper.java | 19 +- .../internal/utils/WorkerHandler.java | 36 +- .../cameraview/preview/CameraPreview.java | 4 +- demo/src/main/res/layout/activity_camera.xml | 1 + 10 files changed, 619 insertions(+), 86 deletions(-) diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java index adfcb093..639df3e7 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/IntegrationTest.java @@ -109,7 +109,7 @@ public class IntegrationTest extends BaseTest { uiExceptionTask.end(e); } }); - controller.mCrashHandler = crashThread.get(); + controller.mCrashHandler = crashThread.getHandler(); } @After @@ -393,6 +393,7 @@ public class IntegrationTest extends BaseTest { controller.mPlaySoundsTask.await(300); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + Camera.CameraInfo info = new Camera.CameraInfo(); Camera.getCameraInfo(controller.mCameraId, info); if (info.canDisableShutterSound) { diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java index dc46ed9b..cb786671 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java @@ -1,7 +1,15 @@ package com.otaliastudios.cameraview; +import android.graphics.ImageFormat; import android.hardware.Camera; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.ImageReader; +import android.media.MediaRecorder; +import android.os.Build; import com.otaliastudios.cameraview.controls.Audio; import com.otaliastudios.cameraview.controls.Control; @@ -19,6 +27,7 @@ import com.otaliastudios.cameraview.size.AspectRatio; import com.otaliastudios.cameraview.size.Size; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import java.util.Arrays; import java.util.Collection; @@ -130,6 +139,42 @@ public class CameraOptions { } } } + // Camera2Engine constructor. + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @SuppressWarnings("deprecation") + public CameraOptions(@NonNull CameraManager manager, @NonNull CameraCharacteristics characteristics, boolean flipSizes) throws CameraAccessException { + Mapper mapper = Mapper.get(Engine.CAMERA2); + + // Facing + for (String cameraId : manager.getCameraIdList()) { + CameraCharacteristics cameraCharacteristics = manager.getCameraCharacteristics(cameraId); + Integer cameraFacing = cameraCharacteristics.get(CameraCharacteristics.LENS_FACING); + if (cameraFacing != null) { + Facing value = mapper.unmapFacing(cameraFacing); + if (value != null) supportedFacing.add(value); + } + } + + // Picture Sizes + StreamConfigurationMap streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (streamMap == null) throw new RuntimeException("StreamConfigurationMap is null. Should not happen."); + android.util.Size[] psizes = streamMap.getOutputSizes(ImageReader.class); + for (android.util.Size size : psizes) { + int width = flipSizes ? size.getHeight() : size.getWidth(); + int height = flipSizes ? size.getWidth() : size.getHeight(); + supportedPictureSizes.add(new Size(width, height)); + supportedPictureAspectRatio.add(AspectRatio.of(width, height)); + } + + // Video Sizes + android.util.Size[] vsizes = streamMap.getOutputSizes(MediaRecorder.class); + for (android.util.Size size : vsizes) { + int width = flipSizes ? size.getHeight() : size.getWidth(); + int height = flipSizes ? size.getWidth() : size.getHeight(); + supportedVideoSizes.add(new Size(width, height)); + supportedVideoAspectRatio.add(AspectRatio.of(width, height)); + } + } /** * Shorthand for getSupported*().contains(value). diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index ff6a09c9..fa2b0fc8 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -1718,6 +1718,12 @@ public class CameraView extends FrameLayout implements LifecycleObserver { private CameraLogger mLogger = CameraLogger.create(CameraCallbacks.class.getSimpleName()); + @NonNull + @Override + public Context getContext() { + return CameraView.this.getContext(); + } + @Override public void dispatchOnCameraOpened(final CameraOptions options) { mLogger.i("dispatchOnCameraOpened", options); 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 2a9602d3..8259a221 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java @@ -11,6 +11,7 @@ import android.location.Location; import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import android.view.SurfaceHolder; @@ -56,6 +57,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac private static final int AUTOFOCUS_END_DELAY_MILLIS = 2500; private Camera mCamera; + @VisibleForTesting int mCameraId; private boolean mIsBound = false; private Runnable mFocusEndRunnable; @@ -310,9 +312,9 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @Override protected void onStop() { LOG.i("onStop:", "About to clean up."); - mHandler.get().removeCallbacks(mFocusResetRunnable); + mHandler.remove(mFocusResetRunnable); if (mFocusEndRunnable != null) { - mHandler.get().removeCallbacks(mFocusEndRunnable); + mHandler.remove(mFocusEndRunnable); } if (mVideoRecorder != null) { mVideoRecorder.stop(); @@ -329,11 +331,6 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac mCaptureSize = null; mIsBound = false; LOG.w("onStop:", "Clean up.", "Returning."); - - // We were saving a reference to the exception here and throwing to the user. - // I don't think it's correct. We are closing and have already done our best - // to clean up resources. No need to throw. - // if (error != null) throw new CameraException(error); } private boolean collectCameraId() { @@ -364,7 +361,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac if (error == Camera.CAMERA_ERROR_SERVER_DIED) { // Looks like this is recoverable. LOG.w("Recoverable error inside the onError callback.", "CAMERA_ERROR_SERVER_DIED"); - stopImmediately(); + stopNow(); start(); return; } @@ -630,10 +627,6 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac stub.size = getUncroppedSnapshotSize(REF_OUTPUT); // Not the real size: it will be cropped to match the view ratio stub.rotation = offset(REF_SENSOR, REF_OUTPUT); // Actually it will be rotated and set to 0. AspectRatio outputRatio = flip(REF_OUTPUT, REF_VIEW) ? viewAspectRatio.flip() : viewAspectRatio; - // LOG.e("ROTBUG_pic", "aspectRatio (REF_VIEW):", viewAspectRatio); - // LOG.e("ROTBUG_pic", "aspectRatio (REF_OUTPUT):", outputRatio); - // LOG.e("ROTBUG_pic", "sizeUncropped (REF_OUTPUT):", result.size); - // LOG.e("ROTBUG_pic", "rotation:", result.rotation); LOG.v("Rotations", "SV", offset(REF_SENSOR, REF_VIEW), "VS", offset(REF_VIEW, REF_SENSOR)); LOG.v("Rotations", "SO", offset(REF_SENSOR, REF_OUTPUT), "OS", offset(REF_OUTPUT, REF_SENSOR)); @@ -656,7 +649,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac } private boolean isCameraAvailable() { - switch (mState) { + switch (getState()) { // If we are stopped, don't. case STATE_STOPPED: return false; @@ -920,7 +913,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac // 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.get().removeCallbacks(mFocusEndRunnable); + if (mFocusEndRunnable != null) mHandler.remove(mFocusEndRunnable); mFocusEndRunnable = new Runnable() { @Override public void run() { @@ -929,7 +922,7 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac } } }; - mHandler.get().postDelayed(mFocusEndRunnable, AUTOFOCUS_END_DELAY_MILLIS); + 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. @@ -938,13 +931,13 @@ public class Camera1Engine extends CameraEngine implements Camera.PreviewCallbac @Override public void onAutoFocus(boolean success, Camera camera) { if (mFocusEndRunnable != null) { - mHandler.get().removeCallbacks(mFocusEndRunnable); + mHandler.remove(mFocusEndRunnable); mFocusEndRunnable = null; } mCallback.dispatchOnFocusEnd(gesture, success, p); - mHandler.get().removeCallbacks(mFocusResetRunnable); + mHandler.remove(mFocusResetRunnable); if (shouldResetAutoFocus()) { - mHandler.get().postDelayed(mFocusResetRunnable, getAutoFocusResetDelay()); + mHandler.post(getAutoFocusResetDelay(), mFocusResetRunnable); } } }); 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 820d8c1b..7767c83d 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java @@ -1,8 +1,20 @@ package com.otaliastudios.cameraview.engine; +import android.annotation.SuppressLint; +import android.content.Context; import android.graphics.PointF; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.StreamConfigurationMap; import android.location.Location; import android.os.Build; +import android.view.Surface; import android.view.SurfaceHolder; import com.otaliastudios.cameraview.CameraException; @@ -18,13 +30,20 @@ import com.otaliastudios.cameraview.controls.Hdr; import com.otaliastudios.cameraview.controls.Mode; import com.otaliastudios.cameraview.controls.WhiteBalance; import com.otaliastudios.cameraview.gesture.Gesture; +import com.otaliastudios.cameraview.internal.utils.Task; import com.otaliastudios.cameraview.size.AspectRatio; +import com.otaliastudios.cameraview.size.Size; import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; @RequiresApi(Build.VERSION_CODES.LOLLIPOP) @@ -33,36 +52,391 @@ public class Camera2Engine extends CameraEngine { private static final String TAG = Camera2Engine.class.getSimpleName(); private static final CameraLogger LOG = CameraLogger.create(TAG); + private final CameraManager mManager; + private String mCameraId; + private CameraDevice mCamera; + private CameraCaptureSession mSession; // TODO must be released and nulled + private CaptureRequest.Builder mPreviewStreamRequestBuilder; // TODO must be nulled + private CaptureRequest mPreviewStreamRequest; // TODO must be nulled + private boolean mIsBound = false; + public Camera2Engine(Callback callback) { super(callback); mMapper = Mapper.get(Engine.CAMERA2); + mManager = (CameraManager) mCallback.getContext().getSystemService(Context.CAMERA_SERVICE); } - @Override - public void onSurfaceAvailable() { - + 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); + } + } + }); } - @Override - public void onSurfaceChanged() { + @NonNull + private T readCharacteristic(@NonNull CameraCharacteristics characteristics, + @NonNull CameraCharacteristics.Key key, + @NonNull T fallback) { + T value = characteristics.get(key); + return value == null ? fallback : value; + } + @NonNull + private Size computePreviewStreamSize() throws CameraAccessException { + CameraCharacteristics characteristics = mManager.getCameraCharacteristics(mCameraId); + StreamConfigurationMap streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (streamMap == null) throw new RuntimeException("StreamConfigurationMap is null. Should not happen."); + // This works because our previews return either a SurfaceTexture or a SurfaceHolder, which are + // accepted class types by the getOutputSizes method. + android.util.Size[] sizes = streamMap.getOutputSizes(mPreview.getOutputClass()); + List candidates = new ArrayList<>(sizes.length); + for (android.util.Size size : sizes) { + Size add = new Size(size.getWidth(), size.getHeight()); + if (!candidates.contains(add)) candidates.add(add); + } + return computePreviewStreamSize(candidates); } - @Override - public void onSurfaceDestroyed() { + private boolean collectCameraId() throws CameraAccessException { + int internalFacing = mMapper.map(mFacing); + int cameras = mManager.getCameraIdList().length; + LOG.i("collectCameraId", "Facing:", mFacing, "Internal:", internalFacing, "Cameras:", cameras); + for (String cameraId : mManager.getCameraIdList()) { + CameraCharacteristics characteristics = mManager.getCameraCharacteristics(cameraId); + if (internalFacing == readCharacteristic(characteristics, CameraCharacteristics.LENS_FACING, -99)) { + mCameraId = cameraId; + mSensorOffset = readCharacteristic(characteristics, CameraCharacteristics.SENSOR_ORIENTATION, 0); + return true; + } + } + return false; + } + private boolean isCameraAvailable() { + switch (getState()) { + // If we are stopped, don't. + case STATE_STOPPED: + return false; + // If we are going to be closed, don't act on camera. + // Even if mCamera != null, it might have been released. + case STATE_STOPPING: + return false; + // If we are started, mCamera should never be null. + case STATE_STARTED: + return true; + // If we are starting, theoretically we could act. + // Just check that camera is available. + case STATE_STARTING: + return mCamera != null; + } + return false; } @Override protected void onStart() { + if (isCameraAvailable()) { + LOG.w("onStart:", "Camera not available. Should not happen."); + onStop(); // Should not happen. + } + try { + if (collectCameraId()) { + createCamera(); + LOG.i("onStart:", "Ended"); + } else { + LOG.e("onStart:", "No camera available for facing", mFacing); + throw new CameraException(CameraException.REASON_NO_CAMERA); + } + } catch (CameraAccessException e) { + // TODO + } + } + + @SuppressLint("MissingPermission") + private void createCamera() throws CameraAccessException { + mManager.openCamera(mCameraId, new CameraDevice.StateCallback() { + @Override + public void onOpened(@NonNull CameraDevice camera) { + mCamera = camera; + + // TODO Set parameters that might have been set before the camera was opened. + try { + LOG.i("createCamera:", "Applying default parameters."); + CameraCharacteristics characteristics = mManager.getCameraCharacteristics(mCameraId); + mCameraOptions = new CameraOptions(mManager, characteristics, flip(REF_SENSOR, REF_VIEW)); + // applyDefaultFocus(params); + // applyFlash(params, Flash.OFF); + // applyLocation(params, null); + // applyWhiteBalance(params, WhiteBalance.AUTO); + // applyHdr(params, Hdr.OFF); + // applyPlaySounds(mPlaySounds); + // params.setRecordingHint(mMode == Mode.VIDEO); + // mCamera.setParameters(params); + } catch (CameraAccessException e) { + // TODO + throw new RuntimeException(e); + } + + // Set display orientation, not allowed during preview + // TODO not needed anymore? mCamera.setDisplayOrientation(offset(REF_SENSOR, REF_VIEW)); + + try { + if (shouldBindToSurface()) bindToSurface("onStart"); + } catch (CameraAccessException e) { + // TODO + throw new RuntimeException(e); + } + } + + @Override + public void onDisconnected(@NonNull CameraDevice camera) { + // TODO not sure what to do here. maybe stop(). Read docs. + + } + + @Override + public void onError(@NonNull CameraDevice camera, int error) { + // TODO + } + }, null); + } + private boolean shouldBindToSurface() { + return isCameraAvailable() && mPreview != null && mPreview.hasSurface() && !mIsBound; + } + + /** + * The act of binding an "open" camera to a "ready" preview. + * These can happen at different times but we want to end up here. + * At this point we are sure that mPreview is not null. + */ + @SuppressLint("Recycle") + @WorkerThread + private void bindToSurface(final @NonNull String trigger) throws CameraAccessException { + LOG.i("bindToSurface:", "Started"); + Object output = mPreview.getOutput(); + Surface previewSurface; + if (output instanceof SurfaceHolder) { + previewSurface = ((SurfaceHolder) output).getSurface(); + } else if (output instanceof SurfaceTexture) { + previewSurface = new Surface((SurfaceTexture) output); + } else { + throw new RuntimeException("Unknown CameraPreview output class."); + } + //noinspection ArraysAsListWithZeroOrOneArgument + List outputSurfaces = Arrays.asList(previewSurface); + + mPreviewStreamRequestBuilder = mCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); + mPreviewStreamRequestBuilder.addTarget(previewSurface); + + // null handler means using the current looper which is totally ok. + mCamera.createCaptureSession(outputSurfaces, new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(@NonNull CameraCaptureSession session) { + try { + mCaptureSize = computeCaptureSize(); + mPreviewStreamSize = computePreviewStreamSize(); + mSession = session; + mIsBound = true; + if (shouldStartPreview()) startPreview(trigger); + } catch (CameraAccessException e) { + // TODO + throw new RuntimeException(e); + } + } + + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession session) { + // TODO + } + + }, null); + } + + private boolean shouldStartPreview() { + return isCameraAvailable() && mIsBound; + } + + /** + * To be called when the preview size is setup or changed. + * @param trigger a log helper + */ + private void startPreview(@NonNull String trigger) { + LOG.i(trigger, "Dispatching onCameraPreviewStreamSizeChanged."); + mCallback.onCameraPreviewStreamSizeChanged(); + + Size previewSize = getPreviewStreamSize(REF_VIEW); + if (previewSize == null) { + throw new IllegalStateException("previewStreamSize should not be null at this point."); + } + mPreview.setStreamSize(previewSize.getWidth(), previewSize.getHeight()); + + // TODO mPreviewStreamFormat = params.getPreviewFormat(); + + // TODO: previewSize and captureSize + /* params.setPreviewSize(mPreviewStreamSize.getWidth(), mPreviewStreamSize.getHeight()); // <- not allowed during preview + if (mMode == Mode.PICTURE) { + params.setPictureSize(mCaptureSize.getWidth(), mCaptureSize.getHeight()); // <- allowed + } else { + // mCaptureSize in this case is a video size. The available video sizes are not necessarily + // a subset of the picture sizes, so we can't use the mCaptureSize value: it might crash. + // However, the setPictureSize() passed here is useless : we don't allow HQ pictures in video mode. + // While this might be lifted in the future, for now, just use a picture capture size. + Size pictureSize = computeCaptureSize(Mode.PICTURE); + params.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight()); + } */ + + // TODO mCamera.setPreviewCallbackWithBuffer(null); // Release anything left + // TODO mCamera.setPreviewCallbackWithBuffer(this); // Add ourselves + // TODO mFrameManager.allocateBuffers(ImageFormat.getBitsPerPixel(mPreviewStreamFormat), mPreviewStreamSize); + + LOG.i(trigger, "Starting preview with startPreview()."); + try { + mPreviewStreamRequest = mPreviewStreamRequestBuilder.build(); + mSession.setRepeatingRequest(mPreviewStreamRequest, null, null); + } catch (Exception e) { + LOG.e(trigger, "Failed to start preview.", e); + throw new CameraException(e, CameraException.REASON_FAILED_TO_START_PREVIEW); + } + LOG.i(trigger, "Started preview."); } @Override protected void onStop() { + LOG.i("onStop:", "About to clean up."); + if (mVideoRecorder != null) { + mVideoRecorder.stop(); + mVideoRecorder = null; + } + if (mCamera != null) { + stopPreview(); + if (mIsBound) unbindFromSurface(); + destroyCamera(); + } + mCameraOptions = null; + mCamera = null; + mPreviewStreamSize = null; + mCaptureSize = null; + mIsBound = false; + LOG.w("onStop:", "Clean up.", "Returning."); + } + private void stopPreview() { + mPreviewStreamFormat = 0; + mFrameManager.release(); + // TODO mCamera.setPreviewCallbackWithBuffer(null); // Release anything left + try { + mSession.stopRepeating(); + // TODO should wait for onReady? + } catch (CameraAccessException e) { + LOG.w("stopRepeating failed!", e); + } + mPreviewStreamRequest = null; } + private void unbindFromSurface() { + mIsBound = false; + mPreviewStreamRequestBuilder = null; + mPreviewStreamSize = null; + mCaptureSize = null; + mSession.close(); + mSession = null; + } + + private void destroyCamera() { + try { + LOG.i("destroyCamera:", "Clean up.", "Releasing camera."); + mCamera.close(); + LOG.i("destroyCamera:", "Clean up.", "Released camera."); + } catch (Exception e) { + LOG.w("destroyCamera:", "Clean up.", "Exception while releasing camera.", e); + } + mCamera = null; + mCameraOptions = null; + } + + + /** + * Preview surface is now available. If camera is open, set up. + * At this point we are sure that mPreview is not null. + */ + @Override + public void onSurfaceAvailable() { + LOG.i("onSurfaceAvailable:", "Size is", getPreviewSurfaceSize(REF_VIEW)); + schedule(null, false, new Runnable() { + @Override + public void run() { + LOG.i("onSurfaceAvailable:", "Inside handler. About to bind."); + try { + if (shouldBindToSurface()) bindToSurface("onSurfaceAvailable"); + } catch (CameraAccessException e) { + // TODO + throw new RuntimeException(e); + } + } + }); + } + + /** + * Preview surface did change its size. Compute a new preview size. + * This requires stopping and restarting the preview. + * At this point we are sure that mPreview is not null. + */ + @Override + public void onSurfaceChanged() { + LOG.i("onSurfaceChanged, size is", getPreviewSurfaceSize(REF_VIEW)); + schedule(null, true, new Runnable() { + @Override + public void run() { + if (!mIsBound) return; + + // Compute a new camera preview size and apply. + try { + Size newSize = computePreviewStreamSize(); + if (newSize.equals(mPreviewStreamSize)) return; + LOG.i("onSurfaceChanged:", "Computed a new preview size. Going on."); + mPreviewStreamSize = newSize; + } catch (CameraAccessException e) { + // TODO + throw new RuntimeException(e); + } + stopPreview(); + startPreview("onSurfaceChanged:"); + } + }); + } + + @Override + public void onSurfaceDestroyed() { + LOG.i("onSurfaceDestroyed"); + schedule(null, true, new Runnable() { + @Override + public void run() { + stopPreview(); + if (mIsBound) unbindFromSurface(); + } + }); + } + + + + + + + + + + + + + + @Override public void onBufferAvailable(@NonNull byte[] buffer) { @@ -75,7 +449,26 @@ public class Camera2Engine extends CameraEngine { @Override public void setFacing(@NonNull Facing facing) { - + final Facing old = mFacing; + if (facing != old) { + mFacing = facing; + schedule(null, true, new Runnable() { + @Override + public void run() { + boolean success; + try { + success = collectCameraId(); + } catch (CameraAccessException e) { + success = false; + } + if (success) { + restart(); + } else { + mFacing = old; + } + } + }); + } } @Override 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 b500ec68..f5258d4d 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java @@ -1,5 +1,6 @@ package com.otaliastudios.cameraview.engine; +import android.content.Context; import android.graphics.PointF; import android.location.Location; @@ -42,12 +43,47 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; + +/** + * PROCESS + * Setting up the Camera is usually a 4 steps process: + * 1. Setting up the Surface (done by {@link CameraPreview} + * 2. Opening the camera (done by us) + * 3. Binding the camera to the surface (done by us) + * 4. Starting the camera preview (done by us) + * + * The first two steps can actually happen at the same time, anyway + * the order is not guaranteed. + * 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. + * + * + * STATE + * In the {@link CameraEngine} notation, + * - START [Async] means doing step 2 (which will eventually trigger 3 and 4) + * - STOP [Async] means undoing 4 (if needed), undoing 3 (if needed), then undoing 2. + * - RESTART [Async] means completing a STOP then a START + * - DESTROY [Sync] means performing a silent and synchronous STOP, ignoring all exceptions. This can make the engine unusable. + * + * So the engine state 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}. + * This thread has a special {@link Thread.UncaughtExceptionHandler} that handles exceptions + * and dispatches error to the callback (instead of crashing the app). + * This lets subclasses run code safely and directly throw {@link CameraException}s when needed. + */ public abstract class CameraEngine implements CameraPreview.SurfaceCallback, - FrameManager.BufferCallback, - Thread.UncaughtExceptionHandler { + FrameManager.BufferCallback { public interface Callback { + @NonNull Context getContext(); void dispatchOnCameraOpened(CameraOptions options); void dispatchOnCameraClosed(); void onCameraPreviewStreamSizeChanged(); @@ -99,7 +135,6 @@ public abstract class CameraEngine implements @VisibleForTesting int mSnapshotMaxWidth = Integer.MAX_VALUE; // in REF_VIEW for consistency with SizeSelectors @VisibleForTesting int mSnapshotMaxHeight = Integer.MAX_VALUE; // in REF_VIEW for consistency with SizeSelectors - protected int mCameraId; protected CameraOptions mCameraOptions; protected Mapper mMapper; protected PictureRecorder mPictureRecorder; @@ -117,7 +152,8 @@ public abstract class CameraEngine implements private int mDisplayOffset; private int mDeviceOrientation; - protected int mState = STATE_STOPPED; + // Subclasses should not change this. Use getState() instead. + @VisibleForTesting int mState = STATE_STOPPED; // Used for testing. @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) Task mZoomTask = new Task<>(); @@ -132,73 +168,86 @@ public abstract class CameraEngine implements protected CameraEngine(Callback callback) { mCallback = callback; mCrashHandler = new Handler(Looper.getMainLooper()); - mHandler = WorkerHandler.get("CameraViewController"); - mHandler.getThread().setUncaughtExceptionHandler(this); + mHandler = WorkerHandler.get("CameraViewEngine"); + mHandler.getThread().setUncaughtExceptionHandler(new CrashExceptionHandler()); mFrameManager = new FrameManager(2, this); } public void setPreview(@NonNull CameraPreview cameraPreview) { + if (mPreview != null) mPreview.setSurfaceCallback(null); mPreview = cameraPreview; mPreview.setSurfaceCallback(this); } //region Error handling - private static class NoOpExceptionHandler implements Thread.UncaughtExceptionHandler { + /** + * The base exception handler, which inspects the exception and + * decides what to do. + */ + private class CrashExceptionHandler implements Thread.UncaughtExceptionHandler { @Override - public void uncaughtException(Thread t, Throwable e) { - // No-op. + public void uncaughtException(final Thread thread, final Throwable throwable) { + // Something went wrong. Thread is terminated (about to?). + // Move to other thread and release resources. + if (!(throwable instanceof CameraException)) { + // This is unexpected, either a bug or something the developer should know. + // Release and crash the UI thread so we get bug reports. + LOG.e("uncaughtException:", "Unexpected exception:", throwable); + destroy(); + mCrashHandler.post(new Runnable() { + @Override + public void run() { + RuntimeException exception; + if (throwable instanceof RuntimeException) { + exception = (RuntimeException) throwable; + } else { + exception = new RuntimeException(throwable); + } + throw exception; + } + }); + } else { + final CameraException error = (CameraException) throwable; + LOG.e("uncaughtException:", "Interrupting thread with state:", ss(), "due to CameraException:", error); + final boolean unrecoverable = error.isUnrecoverable(); + thread.interrupt(); + // Restart handler. + mHandler = WorkerHandler.get("CameraViewEngine"); + mHandler.getThread().setUncaughtExceptionHandler(new CrashExceptionHandler()); + mHandler.post(new Runnable() { + @Override + public void run() { + if (unrecoverable) stopNow(); + mCallback.dispatchError(error); + } + }); + } } } - @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(); - mCallback.dispatchError(error); - } - }); + /** + * A static exception handler used during destruction to avoid leaks, + * since the default handler is not static and the thread might survive the engine. + */ + private static class NoOpExceptionHandler implements Thread.UncaughtExceptionHandler { + @Override + public void uncaughtException(Thread t, Throwable e) { + // No-op. } } - // Public & not final so we can verify with mockito in CameraViewTest + /** + * Not final due to mockito requirements, but this is basically + * it, nothing more to do. + */ public void destroy() { LOG.i("destroy:", "state:", ss()); // Prevent CameraEngine leaks. Don't set to null, or exceptions // inside the standard stop() method might crash the main thread. mHandler.getThread().setUncaughtExceptionHandler(new NoOpExceptionHandler()); // Stop if needed. - stopImmediately(); + stopNow(); } //endregion @@ -254,18 +303,18 @@ public abstract class CameraEngine implements } // Stops the preview synchronously, ensuring no exceptions are thrown. - final void stopImmediately() { + final void stopNow() { try { // Don't check, try stop again. - LOG.i("stopImmediately:", "State was:", ss()); + LOG.i("stopNow:", "State was:", ss()); if (mState == STATE_STOPPED) return; mState = STATE_STOPPING; onStop(); mState = STATE_STOPPED; - LOG.i("stopImmediately:", "Stopped. State is:", ss()); + LOG.i("stopNow:", "Stopped. State is:", ss()); } catch (Exception e) { // Do nothing. - LOG.i("stopImmediately:", "Swallowing exception while stopping.", e); + LOG.i("stopNow:", "Swallowing exception while stopping.", e); mState = STATE_STOPPED; } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Mapper.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Mapper.java index 55461bad..d72c57ed 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Mapper.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Mapper.java @@ -1,6 +1,7 @@ package com.otaliastudios.cameraview.engine; import android.hardware.Camera; +import android.hardware.camera2.CameraCharacteristics; import android.os.Build; import com.otaliastudios.cameraview.controls.Engine; @@ -12,6 +13,7 @@ import com.otaliastudios.cameraview.controls.WhiteBalance; import java.util.HashMap; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; /** * A Mapper maps camera engine constants to CameraView constants. @@ -25,11 +27,11 @@ public abstract class Mapper { if (engine == Engine.CAMERA1) { if (CAMERA1 == null) CAMERA1 = new Camera1Mapper(); return CAMERA1; - } else if (engine == Engine.CAMERA2) { + } else if (engine == Engine.CAMERA2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (CAMERA2 == null) CAMERA2 = new Camera2Mapper(); return CAMERA2; } else { - throw new IllegalArgumentException("Unknown engine."); + throw new IllegalArgumentException("Unknown engine or unsupported API level."); } } @@ -130,8 +132,17 @@ public abstract class Mapper { } } + @SuppressWarnings("unchecked") + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) private static class Camera2Mapper extends Mapper { + private static final HashMap FACING = new HashMap<>(); + + static { + FACING.put(Facing.BACK, CameraCharacteristics.LENS_FACING_BACK); + FACING.put(Facing.FRONT, CameraCharacteristics.LENS_FACING_FRONT); + } + @Override public T map(Flash flash) { return null; @@ -139,7 +150,7 @@ public abstract class Mapper { @Override public T map(Facing facing) { - return null; + return (T) FACING.get(facing); } @Override @@ -159,7 +170,7 @@ public abstract class Mapper { @Override public Facing unmapFacing(T cameraConstant) { - return null; + return reverseLookup(FACING, cameraConstant); } @Override diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java index 17f58439..12a54d30 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/WorkerHandler.java @@ -10,6 +10,7 @@ import androidx.annotation.NonNull; import java.lang.ref.WeakReference; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; /** * Class holding a background handler. @@ -60,12 +61,19 @@ public class WorkerHandler { private HandlerThread mThread; private Handler mHandler; + private Executor mExecutor; private WorkerHandler(@NonNull String name) { mThread = new HandlerThread(name); mThread.setDaemon(true); mThread.start(); mHandler = new Handler(mThread.getLooper()); + mExecutor = new Executor() { + @Override + public void execute(Runnable command) { + post(command); + } + }; } /** @@ -76,11 +84,28 @@ public class WorkerHandler { mHandler.post(runnable); } + /** + * Post an action on this handler. + * @param delay the delay in millis + * @param runnable the action + */ + public void post(long delay, @NonNull Runnable runnable) { + mHandler.postDelayed(runnable, delay); + } + + /** + * Removes a previously added action from this handler. + * @param runnable the action + */ + public void remove(@NonNull Runnable runnable) { + mHandler.removeCallbacks(runnable); + } + /** * Returns the android backing {@link Handler}. * @return the handler */ - public Handler get() { + public Handler getHandler() { return mHandler; } @@ -102,6 +127,15 @@ public class WorkerHandler { return mThread.getLooper(); } + /** + * Returns an {@link Executor}. + * @return the executor + */ + @NonNull + public Executor getExecutor() { + return mExecutor; + } + /** * Destroys all handlers, interrupting their work and * removing them from our cache. 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 b89272d0..04c7f294 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java @@ -74,11 +74,11 @@ public abstract class CameraPreview { * Sets a callback to be notified of surface events (creation, change, destruction) * @param callback a callback */ - public final void setSurfaceCallback(@NonNull SurfaceCallback callback) { + public final void setSurfaceCallback(@Nullable SurfaceCallback callback) { mSurfaceCallback = callback; // If surface already available, dispatch. if (mOutputSurfaceWidth != 0 || mOutputSurfaceHeight != 0) { - mSurfaceCallback.onSurfaceAvailable(); + if (callback != null) callback.onSurfaceAvailable(); } } diff --git a/demo/src/main/res/layout/activity_camera.xml b/demo/src/main/res/layout/activity_camera.xml index 2c30c12d..8cc27af5 100644 --- a/demo/src/main/res/layout/activity_camera.xml +++ b/demo/src/main/res/layout/activity_camera.xml @@ -17,6 +17,7 @@ android:layout_marginBottom="88dp" android:keepScreenOn="true" app:cameraExperimental="true" + app:cameraEngine="camera2" app:cameraPlaySounds="true" app:cameraGrid="off" app:cameraFlash="off"