From 475f4ab96422166ab90612035c7df93c5bb9e5ba Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 23 Sep 2019 22:29:32 -0700 Subject: [PATCH 01/20] Replicated existing code and migrated references to new embedding, added new example Activity that uses new embedding - still needs lots of refactoring and tests. --- .../dev/flutter/plugins/camera/Camera.java | 557 ++++++++++++++++++ .../plugins/camera/CameraPermissions.java | 84 +++ .../flutter/plugins/camera/CameraPlugin.java | 207 +++++++ .../flutter/plugins/camera/CameraUtils.java | 122 ++++ .../camera/example/android/app/build.gradle | 4 + .../android/app/src/main/AndroidManifest.xml | 11 + .../plugins/cameraexample/MainActivity.java | 14 + .../camera/example/android/gradle.properties | 2 + 8 files changed, 1001 insertions(+) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java create mode 100644 packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java new file mode 100644 index 000000000000..8d205400ba7c --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -0,0 +1,557 @@ +package dev.flutter.plugins.camera; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.graphics.ImageFormat; +import android.graphics.SurfaceTexture; +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.CameraMetadata; +import android.hardware.camera2.CaptureFailure; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.CamcorderProfile; +import android.media.Image; +import android.media.ImageReader; +import android.media.MediaRecorder; +import android.os.Build; +import android.util.Size; +import android.view.OrientationEventListener; +import android.view.Surface; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.view.FlutterView; +import io.flutter.view.TextureRegistry; +import io.flutter.view.TextureRegistry.SurfaceTextureEntry; + +import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; +import static dev.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; + +public class Camera { + private final SurfaceTextureEntry flutterTexture; + private final CameraManager cameraManager; + private final OrientationEventListener orientationEventListener; + private final boolean isFrontFacing; + private final int sensorOrientation; + private final String cameraName; + private final Size captureSize; + private final Size previewSize; + private final boolean enableAudio; + + private CameraDevice cameraDevice; + private CameraCaptureSession cameraCaptureSession; + private ImageReader pictureImageReader; + private ImageReader imageStreamReader; + private EventChannel.EventSink eventSink; + private CaptureRequest.Builder captureRequestBuilder; + private MediaRecorder mediaRecorder; + private boolean recordingVideo; + private CamcorderProfile recordingProfile; + private int currentOrientation = ORIENTATION_UNKNOWN; + + // Mirrors camera.dart + public enum ResolutionPreset { + low, + medium, + high, + veryHigh, + ultraHigh, + max, + } + + public Camera( + final Activity activity, + final TextureRegistry textureRegistry, + final String cameraName, + final String resolutionPreset, + final boolean enableAudio) + throws CameraAccessException { + if (activity == null) { + throw new IllegalStateException("No activity available!"); + } + + this.cameraName = cameraName; + this.enableAudio = enableAudio; + this.flutterTexture = textureRegistry.createSurfaceTexture(); + this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + orientationEventListener = + new OrientationEventListener(activity.getApplicationContext()) { + @Override + public void onOrientationChanged(int i) { + if (i == ORIENTATION_UNKNOWN) { + return; + } + // Convert the raw deg angle to the nearest multiple of 90. + currentOrientation = (int) Math.round(i / 90.0) * 90; + } + }; + orientationEventListener.enable(); + + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); + StreamConfigurationMap streamConfigurationMap = + characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + //noinspection ConstantConditions + sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + //noinspection ConstantConditions + isFrontFacing = + characteristics.get(CameraCharacteristics.LENS_FACING) == CameraMetadata.LENS_FACING_FRONT; + ResolutionPreset preset = ResolutionPreset.valueOf(resolutionPreset); + recordingProfile = + CameraUtils.getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); + captureSize = new Size(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); + previewSize = computeBestPreviewSize(cameraName, preset); + } + + public void setupCameraEventChannel(EventChannel cameraEventChannel) { + cameraEventChannel.setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink sink) { + eventSink = sink; + } + + @Override + public void onCancel(Object arguments) { + eventSink = null; + } + }); + } + + private void prepareMediaRecorder(String outputFilePath) throws IOException { + if (mediaRecorder != null) { + mediaRecorder.release(); + } + mediaRecorder = new MediaRecorder(); + + // There's a specific order that mediaRecorder expects. Do not change the order + // of these function calls. + if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); + mediaRecorder.setOutputFormat(recordingProfile.fileFormat); + if (enableAudio) mediaRecorder.setAudioEncoder(recordingProfile.audioCodec); + mediaRecorder.setVideoEncoder(recordingProfile.videoCodec); + mediaRecorder.setVideoEncodingBitRate(recordingProfile.videoBitRate); + if (enableAudio) mediaRecorder.setAudioSamplingRate(recordingProfile.audioSampleRate); + mediaRecorder.setVideoFrameRate(recordingProfile.videoFrameRate); + mediaRecorder.setVideoSize(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); + mediaRecorder.setOutputFile(outputFilePath); + mediaRecorder.setOrientationHint(getMediaOrientation()); + + mediaRecorder.prepare(); + } + + @SuppressLint("MissingPermission") + public void open(@NonNull final Result result) throws CameraAccessException { + pictureImageReader = + ImageReader.newInstance( + captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); + + // Used to steam image byte data to dart side. + imageStreamReader = + ImageReader.newInstance( + previewSize.getWidth(), previewSize.getHeight(), ImageFormat.YUV_420_888, 2); + + cameraManager.openCamera( + cameraName, + new CameraDevice.StateCallback() { + @Override + public void onOpened(@NonNull CameraDevice device) { + cameraDevice = device; + try { + startPreview(); + } catch (CameraAccessException e) { + result.error("CameraAccess", e.getMessage(), null); + close(); + return; + } + Map reply = new HashMap<>(); + reply.put("textureId", flutterTexture.id()); + reply.put("previewWidth", previewSize.getWidth()); + reply.put("previewHeight", previewSize.getHeight()); + result.success(reply); + } + + @Override + public void onClosed(@NonNull CameraDevice camera) { + sendEvent(EventType.CAMERA_CLOSING); + super.onClosed(camera); + } + + @Override + public void onDisconnected(@NonNull CameraDevice cameraDevice) { + close(); + sendEvent(EventType.ERROR, "The camera was disconnected."); + } + + @Override + public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { + close(); + String errorDescription; + switch (errorCode) { + case ERROR_CAMERA_IN_USE: + errorDescription = "The camera device is in use already."; + break; + case ERROR_MAX_CAMERAS_IN_USE: + errorDescription = "Max cameras in use"; + break; + case ERROR_CAMERA_DISABLED: + errorDescription = "The camera device could not be opened due to a device policy."; + break; + case ERROR_CAMERA_DEVICE: + errorDescription = "The camera device has encountered a fatal error"; + break; + case ERROR_CAMERA_SERVICE: + errorDescription = "The camera service has encountered a fatal error."; + break; + default: + errorDescription = "Unknown camera error"; + } + sendEvent(EventType.ERROR, errorDescription); + } + }, + null); + } + + private void writeToFile(ByteBuffer buffer, File file) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(file)) { + while (0 < buffer.remaining()) { + outputStream.getChannel().write(buffer); + } + } + } + + SurfaceTextureEntry getFlutterTexture() { + return flutterTexture; + } + + public void takePicture(String filePath, @NonNull final Result result) { + final File file = new File(filePath); + + if (file.exists()) { + result.error( + "fileExists", "File at path '" + filePath + "' already exists. Cannot overwrite.", null); + return; + } + + pictureImageReader.setOnImageAvailableListener( + reader -> { + try (Image image = reader.acquireLatestImage()) { + ByteBuffer buffer = image.getPlanes()[0].getBuffer(); + writeToFile(buffer, file); + result.success(null); + } catch (IOException e) { + result.error("IOError", "Failed saving image", null); + } + }, + null); + + try { + final CaptureRequest.Builder captureBuilder = + cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); + captureBuilder.addTarget(pictureImageReader.getSurface()); + captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, getMediaOrientation()); + + cameraCaptureSession.capture( + captureBuilder.build(), + new CameraCaptureSession.CaptureCallback() { + @Override + public void onCaptureFailed( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull CaptureFailure failure) { + String reason; + switch (failure.getReason()) { + case CaptureFailure.REASON_ERROR: + reason = "An error happened in the framework"; + break; + case CaptureFailure.REASON_FLUSHED: + reason = "The capture has failed due to an abortCaptures() call"; + break; + default: + reason = "Unknown reason"; + } + result.error("captureFailure", reason, null); + } + }, + null); + } catch (CameraAccessException e) { + result.error("cameraAccess", e.getMessage(), null); + } + } + + private void createCaptureSession(int templateType, Surface... surfaces) + throws CameraAccessException { + createCaptureSession(templateType, null, surfaces); + } + + private void createCaptureSession( + int templateType, Runnable onSuccessCallback, Surface... surfaces) + throws CameraAccessException { + // Close any existing capture session. + closeCaptureSession(); + + // Create a new capture builder. + captureRequestBuilder = cameraDevice.createCaptureRequest(templateType); + + // Build Flutter surface to render to + SurfaceTexture surfaceTexture = flutterTexture.surfaceTexture(); + surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); + Surface flutterSurface = new Surface(surfaceTexture); + captureRequestBuilder.addTarget(flutterSurface); + + List remainingSurfaces = Arrays.asList(surfaces); + if (templateType != CameraDevice.TEMPLATE_PREVIEW) { + // If it is not preview mode, add all surfaces as targets. + for (Surface surface : remainingSurfaces) { + captureRequestBuilder.addTarget(surface); + } + } + + // Prepare the callback + CameraCaptureSession.StateCallback callback = + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(@NonNull CameraCaptureSession session) { + try { + if (cameraDevice == null) { + sendEvent(EventType.ERROR, "The camera was closed during configuration."); + return; + } + cameraCaptureSession = session; + captureRequestBuilder.set( + CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); + cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); + if (onSuccessCallback != null) { + onSuccessCallback.run(); + } + } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { + sendEvent(EventType.ERROR, e.getMessage()); + } + } + + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { + sendEvent(EventType.ERROR, "Failed to configure camera session."); + } + }; + + // Collect all surfaces we want to render to. + List surfaceList = new ArrayList<>(); + surfaceList.add(flutterSurface); + surfaceList.addAll(remainingSurfaces); + // Start the session + cameraDevice.createCaptureSession(surfaceList, callback, null); + } + + public void startVideoRecording(String filePath, Result result) { + if (new File(filePath).exists()) { + result.error("fileExists", "File at path '" + filePath + "' already exists.", null); + return; + } + try { + prepareMediaRecorder(filePath); + recordingVideo = true; + createCaptureSession( + CameraDevice.TEMPLATE_RECORD, () -> mediaRecorder.start(), mediaRecorder.getSurface()); + result.success(null); + } catch (CameraAccessException | IOException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + + public void stopVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + recordingVideo = false; + mediaRecorder.stop(); + mediaRecorder.reset(); + startPreview(); + result.success(null); + } catch (CameraAccessException | IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + + public void pauseVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mediaRecorder.pause(); + } else { + result.error("videoRecordingFailed", "pauseVideoRecording requires Android API +24.", null); + return; + } + } catch (IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + return; + } + + result.success(null); + } + + public void resumeVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mediaRecorder.resume(); + } else { + result.error( + "videoRecordingFailed", "resumeVideoRecording requires Android API +24.", null); + return; + } + } catch (IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + return; + } + + result.success(null); + } + + public void startPreview() throws CameraAccessException { + createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); + } + + public void startPreviewWithImageStream(EventChannel imageStreamChannel) + throws CameraAccessException { + createCaptureSession(CameraDevice.TEMPLATE_STILL_CAPTURE, imageStreamReader.getSurface()); + + imageStreamChannel.setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, EventChannel.EventSink imageStreamSink) { + setImageStreamImageAvailableListener(imageStreamSink); + } + + @Override + public void onCancel(Object o) { + imageStreamReader.setOnImageAvailableListener(null, null); + } + }); + } + + private void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) { + imageStreamReader.setOnImageAvailableListener( + reader -> { + Image img = reader.acquireLatestImage(); + if (img == null) return; + + List> planes = new ArrayList<>(); + for (Image.Plane plane : img.getPlanes()) { + ByteBuffer buffer = plane.getBuffer(); + + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes, 0, bytes.length); + + Map planeBuffer = new HashMap<>(); + planeBuffer.put("bytesPerRow", plane.getRowStride()); + planeBuffer.put("bytesPerPixel", plane.getPixelStride()); + planeBuffer.put("bytes", bytes); + + planes.add(planeBuffer); + } + + Map imageBuffer = new HashMap<>(); + imageBuffer.put("width", img.getWidth()); + imageBuffer.put("height", img.getHeight()); + imageBuffer.put("format", img.getFormat()); + imageBuffer.put("planes", planes); + + imageStreamSink.success(imageBuffer); + img.close(); + }, + null); + } + + private void sendEvent(EventType eventType) { + sendEvent(eventType, null); + } + + private void sendEvent(EventType eventType, String description) { + if (eventSink != null) { + Map event = new HashMap<>(); + event.put("eventType", eventType.toString().toLowerCase()); + // Only errors have description + if (eventType != EventType.ERROR) { + event.put("errorDescription", description); + } + eventSink.success(event); + } + } + + private void closeCaptureSession() { + if (cameraCaptureSession != null) { + cameraCaptureSession.close(); + cameraCaptureSession = null; + } + } + + public void close() { + closeCaptureSession(); + + if (cameraDevice != null) { + cameraDevice.close(); + cameraDevice = null; + } + if (pictureImageReader != null) { + pictureImageReader.close(); + pictureImageReader = null; + } + if (imageStreamReader != null) { + imageStreamReader.close(); + imageStreamReader = null; + } + if (mediaRecorder != null) { + mediaRecorder.reset(); + mediaRecorder.release(); + mediaRecorder = null; + } + } + + public void dispose() { + close(); + flutterTexture.release(); + orientationEventListener.disable(); + } + + private int getMediaOrientation() { + final int sensorOrientationOffset = + (currentOrientation == ORIENTATION_UNKNOWN) + ? 0 + : (isFrontFacing) ? -currentOrientation : currentOrientation; + return (sensorOrientationOffset + sensorOrientation + 360) % 360; + } + + private enum EventType { + ERROR, + CAMERA_CLOSING, + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java new file mode 100644 index 000000000000..2d70bd15b2a9 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java @@ -0,0 +1,84 @@ +package dev.flutter.plugins.camera; + +import android.Manifest.permission; +import android.app.Activity; +import android.content.pm.PackageManager; + +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.PluginRegistry; + +public class CameraPermissions { + private static final int CAMERA_REQUEST_ID = 9796; + private boolean ongoing = false; + + public void requestPermissions( + ActivityPluginBinding activityPluginBinding, + boolean enableAudio, + ResultCallback callback + ) { + if (ongoing) { + callback.onResult("cameraPermission", "Camera permission request ongoing"); + } + Activity activity = activityPluginBinding.getActivity(); + if (!hasCameraPermission(activity) || (enableAudio && !hasAudioPermission(activity))) { + activityPluginBinding.addRequestPermissionsResultListener( + new CameraRequestPermissionsListener( + (String errorCode, String errorDescription) -> { + ongoing = false; + callback.onResult(errorCode, errorDescription); + })); + ongoing = true; + ActivityCompat.requestPermissions( + activity, + enableAudio + ? new String[] {permission.CAMERA, permission.RECORD_AUDIO} + : new String[] {permission.CAMERA}, + CAMERA_REQUEST_ID); + } else { + // Permissions already exist. Call the callback with success. + callback.onResult(null, null); + } + } + + private boolean hasCameraPermission(Activity activity) { + return ContextCompat.checkSelfPermission(activity, permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + private boolean hasAudioPermission(Activity activity) { + return ContextCompat.checkSelfPermission(activity, permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + private static class CameraRequestPermissionsListener + implements PluginRegistry.RequestPermissionsResultListener { + final ResultCallback callback; + + private CameraRequestPermissionsListener(ResultCallback callback) { + this.callback = callback; + } + + @Override + public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { + if (id == CAMERA_REQUEST_ID) { + if (grantResults[0] != PackageManager.PERMISSION_GRANTED) { + callback.onResult("cameraPermission", "MediaRecorderCamera permission not granted"); + } else if (grantResults.length > 1 + && grantResults[1] != PackageManager.PERMISSION_GRANTED) { + callback.onResult("cameraPermission", "MediaRecorderAudio permission not granted"); + } else { + callback.onResult(null, null); + } + return true; + } + return false; + } + } + + interface ResultCallback { + void onResult(String errorCode, String errorDescription); + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java new file mode 100644 index 000000000000..c8dfb73d9c32 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -0,0 +1,207 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; +import android.os.Build; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +public class CameraPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { + + private final CameraPermissions cameraPermissions = new CameraPermissions(); + private EventChannel imageStreamChannel; + private Camera camera; + + private FlutterPluginBinding pluginBinding; + private ActivityPluginBinding activityBinding; + + @Override + public void onAttachedToEngine(FlutterPluginBinding flutterPluginBinding) { + this.pluginBinding = flutterPluginBinding; + this.imageStreamChannel = new EventChannel( + this.pluginBinding.getFlutterEngine().getDartExecutor(), + "plugins.flutter.io/camera/imageStream" + ); + } + + @Override + public void onDetachedFromEngine(FlutterPluginBinding flutterPluginBinding) { + this.pluginBinding = null; + } + + @Override + public void onAttachedToActivity(ActivityPluginBinding activityPluginBinding) { + // Setup + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + // When a background flutter view tries to register the plugin, the registrar has no activity. + // We stop the registration process as this plugin is foreground only. Also, if the sdk is + // less than 21 (min sdk for Camera2) we don't register the plugin. + return; + } + + this.activityBinding = activityPluginBinding; + + final MethodChannel channel = + new MethodChannel(pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera"); + + channel.setMethodCallHandler(this); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + // Ignore + } + + @Override + public void onReattachedToActivityForConfigChanges(ActivityPluginBinding activityPluginBinding) { + // Ignore + } + + @Override + public void onDetachedFromActivity() { + // Teardown + this.activityBinding = null; + } + + private void instantiateCamera(MethodCall call, Result result) throws CameraAccessException { + String cameraName = call.argument("cameraName"); + String resolutionPreset = call.argument("resolutionPreset"); + boolean enableAudio = call.argument("enableAudio"); + camera = new Camera( + activityBinding.getActivity(), + pluginBinding.getFlutterEngine().getRenderer(), + cameraName, + resolutionPreset, + enableAudio + ); + + EventChannel cameraEventChannel = + new EventChannel( + pluginBinding.getFlutterEngine().getDartExecutor(), + "flutter.io/cameraPlugin/cameraEvents" + camera.getFlutterTexture().id()); + camera.setupCameraEventChannel(cameraEventChannel); + + camera.open(result); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) { + switch (call.method) { + case "availableCameras": + try { + result.success(CameraUtils.getAvailableCameras(activityBinding.getActivity())); + } catch (Exception e) { + handleException(e, result); + } + break; + case "initialize": + { + if (camera != null) { + camera.close(); + } + cameraPermissions.requestPermissions( + activityBinding, + call.argument("enableAudio"), + (String errCode, String errDesc) -> { + if (errCode == null) { + try { + instantiateCamera(call, result); + } catch (Exception e) { + handleException(e, result); + } + } else { + result.error(errCode, errDesc, null); + } + }); + + break; + } + case "takePicture": + { + camera.takePicture(call.argument("path"), result); + break; + } + case "prepareForVideoRecording": + { + // This optimization is not required for Android. + result.success(null); + break; + } + case "startVideoRecording": + { + camera.startVideoRecording(call.argument("filePath"), result); + break; + } + case "stopVideoRecording": + { + camera.stopVideoRecording(result); + break; + } + case "pauseVideoRecording": + { + camera.pauseVideoRecording(result); + break; + } + case "resumeVideoRecording": + { + camera.resumeVideoRecording(result); + break; + } + case "startImageStream": + { + try { + camera.startPreviewWithImageStream(imageStreamChannel); + result.success(null); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "stopImageStream": + { + try { + camera.startPreview(); + result.success(null); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "dispose": + { + if (camera != null) { + camera.dispose(); + } + result.success(null); + break; + } + default: + result.notImplemented(); + break; + } + } + + // We move catching CameraAccessException out of onMethodCall because it causes a crash + // on plugin registration for sdks incompatible with Camera2 (< 21). We want this plugin to + // to be able to compile with <21 sdks for apps that want the camera and support earlier version. + @SuppressWarnings("ConstantConditions") + private void handleException(Exception exception, Result result) { + if (exception instanceof CameraAccessException) { + result.error("CameraAccess", exception.getMessage(), null); + } + + throw (RuntimeException) exception; + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java new file mode 100644 index 000000000000..65e4476c8e21 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java @@ -0,0 +1,122 @@ +package dev.flutter.plugins.camera; + +import android.app.Activity; +import android.content.Context; +import android.graphics.ImageFormat; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.CamcorderProfile; +import android.util.Size; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.flutter.plugins.camera.Camera.ResolutionPreset; + +/** Provides various utilities for camera. */ +public final class CameraUtils { + + private CameraUtils() {} + + static Size computeBestPreviewSize(String cameraName, ResolutionPreset preset) { + if (preset.ordinal() > ResolutionPreset.high.ordinal()) { + preset = ResolutionPreset.high; + } + + CamcorderProfile profile = + getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); + return new Size(profile.videoFrameWidth, profile.videoFrameHeight); + } + + static Size computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { + // For still image captures, we use the largest available size. + return Collections.max( + Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), + new CompareSizesByArea()); + } + + public static List> getAvailableCameras(Activity activity) + throws CameraAccessException { + CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + String[] cameraNames = cameraManager.getCameraIdList(); + List> cameras = new ArrayList<>(); + for (String cameraName : cameraNames) { + HashMap details = new HashMap<>(); + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); + details.put("name", cameraName); + int sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + details.put("sensorOrientation", sensorOrientation); + + int lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); + switch (lensFacing) { + case CameraMetadata.LENS_FACING_FRONT: + details.put("lensFacing", "front"); + break; + case CameraMetadata.LENS_FACING_BACK: + details.put("lensFacing", "back"); + break; + case CameraMetadata.LENS_FACING_EXTERNAL: + details.put("lensFacing", "external"); + break; + } + cameras.add(details); + } + return cameras; + } + + static CamcorderProfile getBestAvailableCamcorderProfileForResolutionPreset( + String cameraName, ResolutionPreset preset) { + int cameraId = Integer.parseInt(cameraName); + switch (preset) { + // All of these cases deliberately fall through to get the best available profile. + case max: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_HIGH)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_HIGH); + } + case ultraHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_2160P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_2160P); + } + case veryHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_1080P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_1080P); + } + case high: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_720P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_720P); + } + case medium: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_480P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_480P); + } + case low: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_QVGA)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_QVGA); + } + default: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_LOW)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_LOW); + } else { + throw new IllegalArgumentException( + "No capture session available for current capture session."); + } + } + } + + private static class CompareSizesByArea implements Comparator { + @Override + public int compare(Size lhs, Size rhs) { + // We cast here to ensure the multiplications won't overflow. + return Long.signum( + (long) lhs.getWidth() * lhs.getHeight() - (long) rhs.getWidth() * rhs.getHeight()); + } + } +} diff --git a/packages/camera/example/android/app/build.gradle b/packages/camera/example/android/app/build.gradle index 39003759e4a3..f4eb5498a77d 100644 --- a/packages/camera/example/android/app/build.gradle +++ b/packages/camera/example/android/app/build.gradle @@ -57,6 +57,10 @@ flutter { } dependencies { + // TODO: remove these dependencies when engine POM is working correctly. + implementation 'androidx.lifecycle:lifecycle-runtime:2.1.0' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0' + testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' diff --git a/packages/camera/example/android/app/src/main/AndroidManifest.xml b/packages/camera/example/android/app/src/main/AndroidManifest.xml index 15f6087e4ebe..51b898f1b4d6 100644 --- a/packages/camera/example/android/app/src/main/AndroidManifest.xml +++ b/packages/camera/example/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,17 @@ + + + + + + diff --git a/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java b/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java new file mode 100644 index 000000000000..cdc794a3d495 --- /dev/null +++ b/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java @@ -0,0 +1,14 @@ +package dev.flutter.plugins.cameraexample; + +import android.os.Bundle; + +import dev.flutter.plugins.camera.CameraPlugin; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +public class MainActivity extends FlutterActivity { + @Override + public void configureFlutterEngine(FlutterEngine flutterEngine) { + flutterEngine.getPlugins().add(new CameraPlugin()); + } +} diff --git a/packages/camera/example/android/gradle.properties b/packages/camera/example/android/gradle.properties index 8bd86f680510..4d3226abc21b 100644 --- a/packages/camera/example/android/gradle.properties +++ b/packages/camera/example/android/gradle.properties @@ -1 +1,3 @@ org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true \ No newline at end of file From 8cde03b0ddec35ff8eab955786c191282c8ea39b Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 23 Sep 2019 23:24:24 -0700 Subject: [PATCH 02/20] Introduced CameraPreviewDisplay and CameraImageStream to remove Flutter EventSink behavior from the Camera class to help simplify test dependencies. --- .../dev/flutter/plugins/camera/Camera.java | 56 ++++++------------- .../plugins/camera/CameraImageStream.java | 46 +++++++++++++++ .../flutter/plugins/camera/CameraPlugin.java | 17 ++++-- .../plugins/camera/CameraPreviewDisplay.java | 34 +++++++++++ 4 files changed, 109 insertions(+), 44 deletions(-) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 8d205400ba7c..fefb121a75ee 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -24,6 +24,7 @@ import android.view.Surface; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.io.File; import java.io.FileOutputStream; @@ -37,7 +38,6 @@ import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.view.FlutterView; import io.flutter.view.TextureRegistry; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; @@ -440,53 +440,31 @@ public void startPreview() throws CameraAccessException { createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); } - public void startPreviewWithImageStream(EventChannel imageStreamChannel) + public void startPreviewWithImageStream(@NonNull CameraPreviewDisplay previewDisplay) throws CameraAccessException { createCaptureSession(CameraDevice.TEMPLATE_STILL_CAPTURE, imageStreamReader.getSurface()); - imageStreamChannel.setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, EventChannel.EventSink imageStreamSink) { - setImageStreamImageAvailableListener(imageStreamSink); - } + previewDisplay.startStreaming(new CameraPreviewDisplay.ImageStreamConnection() { + @Override + public void onConnectionReady(@NonNull CameraImageStream stream) { + setImageStreamImageAvailableListener(stream); + } - @Override - public void onCancel(Object o) { - imageStreamReader.setOnImageAvailableListener(null, null); - } - }); + @Override + public void onConnectionClosed() { + imageStreamReader.setOnImageAvailableListener(null, null); + } + }); } - private void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) { + private void setImageStreamImageAvailableListener(@NonNull CameraImageStream cameraImageStream) { imageStreamReader.setOnImageAvailableListener( reader -> { - Image img = reader.acquireLatestImage(); - if (img == null) return; - - List> planes = new ArrayList<>(); - for (Image.Plane plane : img.getPlanes()) { - ByteBuffer buffer = plane.getBuffer(); - - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes, 0, bytes.length); - - Map planeBuffer = new HashMap<>(); - planeBuffer.put("bytesPerRow", plane.getRowStride()); - planeBuffer.put("bytesPerPixel", plane.getPixelStride()); - planeBuffer.put("bytes", bytes); - - planes.add(planeBuffer); - } - - Map imageBuffer = new HashMap<>(); - imageBuffer.put("width", img.getWidth()); - imageBuffer.put("height", img.getHeight()); - imageBuffer.put("format", img.getFormat()); - imageBuffer.put("planes", planes); + Image image = reader.acquireLatestImage(); + if (image == null) return; - imageStreamSink.success(imageBuffer); - img.close(); + cameraImageStream.sendImage(image); + image.close(); }, null); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java new file mode 100644 index 000000000000..67e2b94a9b0f --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java @@ -0,0 +1,46 @@ +package dev.flutter.plugins.camera; + +import android.media.Image; + +import androidx.annotation.NonNull; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.EventChannel; + +public class CameraImageStream { + private final EventChannel.EventSink imageStreamSink; + + CameraImageStream(@NonNull EventChannel.EventSink imageStreamSink) { + this.imageStreamSink = imageStreamSink; + } + + public void sendImage(@NonNull Image image) { + List> planes = new ArrayList<>(); + for (Image.Plane plane : image.getPlanes()) { + ByteBuffer buffer = plane.getBuffer(); + + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes, 0, bytes.length); + + Map planeBuffer = new HashMap<>(); + planeBuffer.put("bytesPerRow", plane.getRowStride()); + planeBuffer.put("bytesPerPixel", plane.getPixelStride()); + planeBuffer.put("bytes", bytes); + + planes.add(planeBuffer); + } + + Map imageBuffer = new HashMap<>(); + imageBuffer.put("width", image.getWidth()); + imageBuffer.put("height", image.getHeight()); + imageBuffer.put("format", image.getFormat()); + imageBuffer.put("planes", planes); + + imageStreamSink.success(imageBuffer); + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index c8dfb73d9c32..659e8d79dfc7 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -30,10 +30,6 @@ public class CameraPlugin implements FlutterPlugin, ActivityAware, MethodCallHan @Override public void onAttachedToEngine(FlutterPluginBinding flutterPluginBinding) { this.pluginBinding = flutterPluginBinding; - this.imageStreamChannel = new EventChannel( - this.pluginBinding.getFlutterEngine().getDartExecutor(), - "plugins.flutter.io/camera/imageStream" - ); } @Override @@ -53,12 +49,22 @@ public void onAttachedToActivity(ActivityPluginBinding activityPluginBinding) { this.activityBinding = activityPluginBinding; + this.imageStreamChannel = new EventChannel( + this.pluginBinding.getFlutterEngine().getDartExecutor(), + "plugins.flutter.io/camera/imageStream" + ); + final MethodChannel channel = new MethodChannel(pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera"); channel.setMethodCallHandler(this); } + // TODO: there are 2+ channels + // 1:EventChannel - plugins.flutter.io/camera/imageStream + // 1:MethodChannel - plugins.flutter.io/camera + // 0+:EventChannel - flutter.io/cameraPlugin/cameraEvents[textureId] + @Override public void onDetachedFromActivityForConfigChanges() { // Ignore @@ -162,7 +168,8 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) case "startImageStream": { try { - camera.startPreviewWithImageStream(imageStreamChannel); + CameraPreviewDisplay previewDisplay = new CameraPreviewDisplay(imageStreamChannel); + camera.startPreviewWithImageStream(previewDisplay); result.success(null); } catch (Exception e) { handleException(e, result); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java new file mode 100644 index 000000000000..23677380b2b6 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java @@ -0,0 +1,34 @@ +package dev.flutter.plugins.camera; + +import androidx.annotation.NonNull; + +import io.flutter.plugin.common.EventChannel; + +public class CameraPreviewDisplay { + private final EventChannel imageStreamChannel; + + /* package */ CameraPreviewDisplay(@NonNull EventChannel imageStreamChannel) { + this.imageStreamChannel = imageStreamChannel; + } + + void startStreaming(@NonNull final ImageStreamConnection connection) { + imageStreamChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, EventChannel.EventSink eventSink) { + CameraImageStream cameraImageStream = new CameraImageStream(eventSink); + connection.onConnectionReady(cameraImageStream); + } + + @Override + public void onCancel(Object o) { + connection.onConnectionClosed(); + } + }); + } + + interface ImageStreamConnection { + void onConnectionReady(@NonNull CameraImageStream stream); + + void onConnectionClosed(); + } +} From 2081f428bb104b6467530be292daa7d2c80f59f3 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 23 Sep 2019 23:54:39 -0700 Subject: [PATCH 03/20] Introduced a CameraEventListener to completely decouple Camera from EventSink. --- .../dev/flutter/plugins/camera/Camera.java | 89 +++++++++---------- .../flutter/plugins/camera/CameraPlugin.java | 42 +++++++-- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index fefb121a75ee..4d8004b77a1c 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -36,7 +36,6 @@ import java.util.List; import java.util.Map; -import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.view.TextureRegistry; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; @@ -59,7 +58,7 @@ public class Camera { private CameraCaptureSession cameraCaptureSession; private ImageReader pictureImageReader; private ImageReader imageStreamReader; - private EventChannel.EventSink eventSink; + private CameraEventHandler cameraEventHandler; private CaptureRequest.Builder captureRequestBuilder; private MediaRecorder mediaRecorder; private boolean recordingVideo; @@ -119,19 +118,20 @@ public void onOrientationChanged(int i) { previewSize = computeBestPreviewSize(cameraName, preset); } - public void setupCameraEventChannel(EventChannel cameraEventChannel) { - cameraEventChannel.setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object arguments, EventChannel.EventSink sink) { - eventSink = sink; - } + public void setCameraEventHandler(@Nullable CameraEventHandler handler) { + this.cameraEventHandler = handler; + } - @Override - public void onCancel(Object arguments) { - eventSink = null; - } - }); + private void onError(String description) { + if (cameraEventHandler != null) { + cameraEventHandler.onError(description); + } + } + + private void onCameraClosed() { + if (cameraEventHandler != null) { + cameraEventHandler.onCameraClosed(); + } } private void prepareMediaRecorder(String outputFilePath) throws IOException { @@ -159,14 +159,20 @@ private void prepareMediaRecorder(String outputFilePath) throws IOException { @SuppressLint("MissingPermission") public void open(@NonNull final Result result) throws CameraAccessException { - pictureImageReader = - ImageReader.newInstance( - captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); + pictureImageReader = ImageReader.newInstance( + captureSize.getWidth(), + captureSize.getHeight(), + ImageFormat.JPEG, + 2 + ); // Used to steam image byte data to dart side. - imageStreamReader = - ImageReader.newInstance( - previewSize.getWidth(), previewSize.getHeight(), ImageFormat.YUV_420_888, 2); + imageStreamReader = ImageReader.newInstance( + previewSize.getWidth(), + previewSize.getHeight(), + ImageFormat.YUV_420_888, + 2 + ); cameraManager.openCamera( cameraName, @@ -190,14 +196,14 @@ public void onOpened(@NonNull CameraDevice device) { @Override public void onClosed(@NonNull CameraDevice camera) { - sendEvent(EventType.CAMERA_CLOSING); + onCameraClosed(); super.onClosed(camera); } @Override public void onDisconnected(@NonNull CameraDevice cameraDevice) { close(); - sendEvent(EventType.ERROR, "The camera was disconnected."); + Camera.this.onError("The camera was disconnected."); } @Override @@ -223,7 +229,7 @@ public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { default: errorDescription = "Unknown camera error"; } - sendEvent(EventType.ERROR, errorDescription); + Camera.this.onError(errorDescription); } }, null); @@ -331,7 +337,7 @@ private void createCaptureSession( public void onConfigured(@NonNull CameraCaptureSession session) { try { if (cameraDevice == null) { - sendEvent(EventType.ERROR, "The camera was closed during configuration."); + onError("The camera was closed during configuration."); return; } cameraCaptureSession = session; @@ -342,13 +348,13 @@ public void onConfigured(@NonNull CameraCaptureSession session) { onSuccessCallback.run(); } } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { - sendEvent(EventType.ERROR, e.getMessage()); + onError(e.getMessage()); } } @Override public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { - sendEvent(EventType.ERROR, "Failed to configure camera session."); + onError("Failed to configure camera session."); } }; @@ -447,7 +453,7 @@ public void startPreviewWithImageStream(@NonNull CameraPreviewDisplay previewDis previewDisplay.startStreaming(new CameraPreviewDisplay.ImageStreamConnection() { @Override public void onConnectionReady(@NonNull CameraImageStream stream) { - setImageStreamImageAvailableListener(stream); + startSendingImagesToPreviewDisplay(stream); } @Override @@ -457,7 +463,7 @@ public void onConnectionClosed() { }); } - private void setImageStreamImageAvailableListener(@NonNull CameraImageStream cameraImageStream) { + private void startSendingImagesToPreviewDisplay(@NonNull CameraImageStream cameraImageStream) { imageStreamReader.setOnImageAvailableListener( reader -> { Image image = reader.acquireLatestImage(); @@ -469,22 +475,6 @@ private void setImageStreamImageAvailableListener(@NonNull CameraImageStream cam null); } - private void sendEvent(EventType eventType) { - sendEvent(eventType, null); - } - - private void sendEvent(EventType eventType, String description) { - if (eventSink != null) { - Map event = new HashMap<>(); - event.put("eventType", eventType.toString().toLowerCase()); - // Only errors have description - if (eventType != EventType.ERROR) { - event.put("errorDescription", description); - } - eventSink.success(event); - } - } - private void closeCaptureSession() { if (cameraCaptureSession != null) { cameraCaptureSession.close(); @@ -528,8 +518,13 @@ private int getMediaOrientation() { return (sensorOrientationOffset + sensorOrientation + 360) % 360; } - private enum EventType { - ERROR, - CAMERA_CLOSING, + /** + * Handler that, when registered with a {@link Camera}, is notified of errors + * and when the camera closes. + */ + /* package */ interface CameraEventHandler { + void onError(String description); + + void onCameraClosed(); } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 659e8d79dfc7..1dc21a597a82 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -9,6 +9,9 @@ import androidx.annotation.NonNull; +import java.util.HashMap; +import java.util.Map; + import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; @@ -93,13 +96,40 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce enableAudio ); - EventChannel cameraEventChannel = - new EventChannel( - pluginBinding.getFlutterEngine().getDartExecutor(), - "flutter.io/cameraPlugin/cameraEvents" + camera.getFlutterTexture().id()); - camera.setupCameraEventChannel(cameraEventChannel); - camera.open(result); + + EventChannel cameraEventChannel = new EventChannel( + pluginBinding.getFlutterEngine().getDartExecutor(), + "flutter.io/cameraPlugin/cameraEvents" + camera.getFlutterTexture().id() + ); + cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, final EventChannel.EventSink eventSink) { + final Camera.CameraEventHandler cameraEventHandler = new Camera.CameraEventHandler() { + @Override + public void onError(String description) { + Map event = new HashMap<>(); + event.put("eventType", "error"); + event.put("errorDescription", description); + eventSink.success(event); + } + + @Override + public void onCameraClosed() { + Map event = new HashMap<>(); + event.put("eventType", "camera_closing"); + eventSink.success(event); + } + }; + + camera.setCameraEventHandler(cameraEventHandler); + } + + @Override + public void onCancel(Object o) { + camera.setCameraEventHandler(null); + } + }); } @Override From b78bfcb95d94622b3c4215d8734ce940b4fa021e Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 00:43:21 -0700 Subject: [PATCH 04/20] Removed all references of Result from Camera by introducing a combination of callbacks and exceptions. All Result responses are now handled by the CameraPlugin, itself. --- .../dev/flutter/plugins/camera/Camera.java | 147 +++++++++--------- .../flutter/plugins/camera/CameraPlugin.java | 80 +++++++++- 2 files changed, 151 insertions(+), 76 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 4d8004b77a1c..a571cebdd029 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -32,11 +32,8 @@ import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.view.TextureRegistry; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; @@ -158,7 +155,7 @@ private void prepareMediaRecorder(String outputFilePath) throws IOException { } @SuppressLint("MissingPermission") - public void open(@NonNull final Result result) throws CameraAccessException { + public void open(@NonNull final OnCameraOpenedCallback callback) throws CameraAccessException { pictureImageReader = ImageReader.newInstance( captureSize.getWidth(), captureSize.getHeight(), @@ -166,7 +163,7 @@ public void open(@NonNull final Result result) throws CameraAccessException { 2 ); - // Used to steam image byte data to dart side. + // Used to stream image byte data to dart side. imageStreamReader = ImageReader.newInstance( previewSize.getWidth(), previewSize.getHeight(), @@ -183,15 +180,15 @@ public void onOpened(@NonNull CameraDevice device) { try { startPreview(); } catch (CameraAccessException e) { - result.error("CameraAccess", e.getMessage(), null); + callback.onCameraOpenFailed(e.getMessage()); close(); return; } - Map reply = new HashMap<>(); - reply.put("textureId", flutterTexture.id()); - reply.put("previewWidth", previewSize.getWidth()); - reply.put("previewHeight", previewSize.getHeight()); - result.success(reply); + callback.onCameraOpened( + flutterTexture.id(), + previewSize.getWidth(), + previewSize.getHeight() + ); } @Override @@ -247,12 +244,11 @@ SurfaceTextureEntry getFlutterTexture() { return flutterTexture; } - public void takePicture(String filePath, @NonNull final Result result) { + public void takePicture(String filePath, @NonNull final OnPictureTakenCallback callback) { final File file = new File(filePath); if (file.exists()) { - result.error( - "fileExists", "File at path '" + filePath + "' already exists. Cannot overwrite.", null); + callback.onFileAlreadyExists(); return; } @@ -261,9 +257,9 @@ public void takePicture(String filePath, @NonNull final Result result) { try (Image image = reader.acquireLatestImage()) { ByteBuffer buffer = image.getPlanes()[0].getBuffer(); writeToFile(buffer, file); - result.success(null); + callback.onPictureTaken(); } catch (IOException e) { - result.error("IOError", "Failed saving image", null); + callback.onFailedToSaveImage(); } }, null); @@ -293,12 +289,12 @@ public void onCaptureFailed( default: reason = "Unknown reason"; } - result.error("captureFailure", reason, null); + callback.onCaptureFailure(reason); } }, null); } catch (CameraAccessException e) { - result.error("cameraAccess", e.getMessage(), null); + callback.onCameraAccessFailure(e.getMessage()); } } @@ -366,80 +362,53 @@ public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession cameraDevice.createCaptureSession(surfaceList, callback, null); } - public void startVideoRecording(String filePath, Result result) { + public void startVideoRecording(String filePath) throws IOException, CameraAccessException, IllegalStateException { if (new File(filePath).exists()) { - result.error("fileExists", "File at path '" + filePath + "' already exists.", null); - return; - } - try { - prepareMediaRecorder(filePath); - recordingVideo = true; - createCaptureSession( - CameraDevice.TEMPLATE_RECORD, () -> mediaRecorder.start(), mediaRecorder.getSurface()); - result.success(null); - } catch (CameraAccessException | IOException e) { - result.error("videoRecordingFailed", e.getMessage(), null); + throw new IllegalStateException("File " + filePath + " already exists."); } + + prepareMediaRecorder(filePath); + recordingVideo = true; + createCaptureSession( + CameraDevice.TEMPLATE_RECORD, + () -> mediaRecorder.start(), + mediaRecorder.getSurface() + ); } - public void stopVideoRecording(@NonNull final Result result) { + public void stopVideoRecording() throws CameraAccessException { if (!recordingVideo) { - result.success(null); return; } - try { - recordingVideo = false; - mediaRecorder.stop(); - mediaRecorder.reset(); - startPreview(); - result.success(null); - } catch (CameraAccessException | IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } + recordingVideo = false; + mediaRecorder.stop(); + mediaRecorder.reset(); + startPreview(); } - public void pauseVideoRecording(@NonNull final Result result) { + public void pauseVideoRecording() throws IllegalStateException, UnsupportedOperationException { if (!recordingVideo) { - result.success(null); return; } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - mediaRecorder.pause(); - } else { - result.error("videoRecordingFailed", "pauseVideoRecording requires Android API +24.", null); - return; - } - } catch (IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mediaRecorder.pause(); + } else { + throw new UnsupportedOperationException("pauseVideoRecording requires Android API +24."); } - - result.success(null); } - public void resumeVideoRecording(@NonNull final Result result) { + public void resumeVideoRecording() throws IllegalStateException, UnsupportedOperationException { if (!recordingVideo) { - result.success(null); return; } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - mediaRecorder.resume(); - } else { - result.error( - "videoRecordingFailed", "resumeVideoRecording requires Android API +24.", null); - return; - } - } catch (IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mediaRecorder.resume(); + } else { + throw new UnsupportedOperationException("resumeVideoRecording requires Android API +24."); } - - result.success(null); } public void startPreview() throws CameraAccessException { @@ -518,6 +487,44 @@ private int getMediaOrientation() { return (sensorOrientationOffset + sensorOrientation + 360) % 360; } + /** + * Callback invoked when this {@link Camera} is opened. + * + *

Reports either success or failure. + */ + /* package */ interface OnCameraOpenedCallback { + /** + * The associated {@link Camera} was successfully opened and is tied to + * a {@link SurfaceTexture} with the given {@code textureId}, displayed + * at the given {@code previewWidth} and {@code previewHeight}. + */ + void onCameraOpened(long textureId, int previewWidth, int previewHeight); + + /** + * The associated {@link Camera} attempted to open, but failed. + * + *

The {@code Exception}'s {@code message} is provided. + */ + void onCameraOpenFailed(@NonNull String message); + } + + /** + * Callback invoked when this {@link Camera} takes a picture. + * + *

Reports either success or one of many causes of failure. + */ + /* package */ interface OnPictureTakenCallback { + void onPictureTaken(); + + void onFileAlreadyExists(); + + void onFailedToSaveImage(); + + void onCaptureFailure(@NonNull String reason); + + void onCameraAccessFailure(@NonNull String message); + } + /** * Handler that, when registered with a {@link Camera}, is notified of errors * and when the camera closes. diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 1dc21a597a82..528ef417e82c 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; +import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -96,7 +97,21 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce enableAudio ); - camera.open(result); + camera.open(new Camera.OnCameraOpenedCallback() { + @Override + public void onCameraOpened(long textureId, int previewWidth, int previewHeight) { + Map reply = new HashMap<>(); + reply.put("textureId", textureId); + reply.put("previewWidth", previewWidth); + reply.put("previewHeight", previewHeight); + result.success(reply); + } + + @Override + public void onCameraOpenFailed(@NonNull String message) { + result.error("CameraAccess", message, null); + } + }); EventChannel cameraEventChannel = new EventChannel( pluginBinding.getFlutterEngine().getDartExecutor(), @@ -166,7 +181,37 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) } case "takePicture": { - camera.takePicture(call.argument("path"), result); + final String filePath = call.argument("path"); + camera.takePicture(filePath, new Camera.OnPictureTakenCallback() { + @Override + public void onPictureTaken() { + result.success(null); + } + + @Override + public void onFileAlreadyExists() { + result.error( + "fileExists", + "File at path '" + filePath + "' already exists. Cannot overwrite.", + null + ); + } + + @Override + public void onFailedToSaveImage() { + result.error("IOError", "Failed saving image", null); + } + + @Override + public void onCaptureFailure(@NonNull String reason) { + result.error("captureFailure", reason, null); + } + + @Override + public void onCameraAccessFailure(@NonNull String message) { + result.error("cameraAccess", message, null); + } + }); break; } case "prepareForVideoRecording": @@ -177,22 +222,45 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) } case "startVideoRecording": { - camera.startVideoRecording(call.argument("filePath"), result); + String filePath = call.argument("filePath"); + try { + camera.startVideoRecording(filePath); + } catch (IllegalStateException e) { + result.error("fileExists", "File at path '" + filePath + "' already exists.", null); + } catch (CameraAccessException | IOException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } break; } case "stopVideoRecording": { - camera.stopVideoRecording(result); + try { + camera.stopVideoRecording(); + } catch (CameraAccessException | IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } break; } case "pauseVideoRecording": { - camera.pauseVideoRecording(result); + try { + camera.pauseVideoRecording(); + } catch (UnsupportedOperationException e) { + result.error("videoRecordingFailed", "pauseVideoRecording requires Android API +24.", null); + } catch (IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } break; } case "resumeVideoRecording": { - camera.resumeVideoRecording(result); + try { + camera.resumeVideoRecording(); + } catch (UnsupportedOperationException e) { + result.error("videoRecordingFailed","resumeVideoRecording requires Android API +24.",null); + } catch (IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } break; } case "startImageStream": From b45ae6828f61a86cedaff88fcf65263e2735c127 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 00:47:20 -0700 Subject: [PATCH 05/20] Refactored Camera such that getFlutterTexture() no longer needs to exist in Camera. --- .../src/main/java/dev/flutter/plugins/camera/Camera.java | 9 ++------- .../java/dev/flutter/plugins/camera/CameraPlugin.java | 9 +++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index a571cebdd029..6c6f84940323 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -34,7 +34,6 @@ import java.util.Arrays; import java.util.List; -import io.flutter.view.TextureRegistry; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; @@ -74,7 +73,7 @@ public enum ResolutionPreset { public Camera( final Activity activity, - final TextureRegistry textureRegistry, + final SurfaceTextureEntry flutterTexture, final String cameraName, final String resolutionPreset, final boolean enableAudio) @@ -85,7 +84,7 @@ public Camera( this.cameraName = cameraName; this.enableAudio = enableAudio; - this.flutterTexture = textureRegistry.createSurfaceTexture(); + this.flutterTexture = flutterTexture; this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); orientationEventListener = new OrientationEventListener(activity.getApplicationContext()) { @@ -240,10 +239,6 @@ private void writeToFile(ByteBuffer buffer, File file) throws IOException { } } - SurfaceTextureEntry getFlutterTexture() { - return flutterTexture; - } - public void takePicture(String filePath, @NonNull final OnPictureTakenCallback callback) { final File file = new File(filePath); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 528ef417e82c..e4bb1948e26e 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -21,6 +21,7 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.view.TextureRegistry; public class CameraPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { @@ -89,9 +90,13 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce String cameraName = call.argument("cameraName"); String resolutionPreset = call.argument("resolutionPreset"); boolean enableAudio = call.argument("enableAudio"); + TextureRegistry.SurfaceTextureEntry textureEntry = pluginBinding + .getFlutterEngine() + .getRenderer() + .createSurfaceTexture(); camera = new Camera( activityBinding.getActivity(), - pluginBinding.getFlutterEngine().getRenderer(), + textureEntry, cameraName, resolutionPreset, enableAudio @@ -115,7 +120,7 @@ public void onCameraOpenFailed(@NonNull String message) { EventChannel cameraEventChannel = new EventChannel( pluginBinding.getFlutterEngine().getDartExecutor(), - "flutter.io/cameraPlugin/cameraEvents" + camera.getFlutterTexture().id() + "flutter.io/cameraPlugin/cameraEvents" + textureEntry.id() ); cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { @Override From 6780abf20e11e7d2c3bfb7f004067b3218b30c89 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 00:50:47 -0700 Subject: [PATCH 06/20] Moved ResolutionPreset to standalone class and made Camera package private. --- .../java/dev/flutter/plugins/camera/Camera.java | 14 ++------------ .../dev/flutter/plugins/camera/CameraUtils.java | 2 -- .../flutter/plugins/camera/ResolutionPreset.java | 11 +++++++++++ 3 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 6c6f84940323..9872417a578a 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -39,7 +39,7 @@ import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; import static dev.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; -public class Camera { +/* package */ class Camera { private final SurfaceTextureEntry flutterTexture; private final CameraManager cameraManager; private final OrientationEventListener orientationEventListener; @@ -61,17 +61,7 @@ public class Camera { private CamcorderProfile recordingProfile; private int currentOrientation = ORIENTATION_UNKNOWN; - // Mirrors camera.dart - public enum ResolutionPreset { - low, - medium, - high, - veryHigh, - ultraHigh, - max, - } - - public Camera( + /* package */ Camera( final Activity activity, final SurfaceTextureEntry flutterTexture, final String cameraName, diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java index 65e4476c8e21..8201fed3f0fc 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java @@ -19,8 +19,6 @@ import java.util.List; import java.util.Map; -import dev.flutter.plugins.camera.Camera.ResolutionPreset; - /** Provides various utilities for camera. */ public final class CameraUtils { diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java new file mode 100644 index 000000000000..9c8922676d1f --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java @@ -0,0 +1,11 @@ +package dev.flutter.plugins.camera; + +// Mirrors camera.dart +/* package */ enum ResolutionPreset { + low, + medium, + high, + veryHigh, + ultraHigh, + max, +} From 8b93f6846b63144a3cdece015800df6034bbf81e Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 01:14:33 -0700 Subject: [PATCH 07/20] Re-organized Camera code order to clearly separate preview images, from taking pictures, from video recording. --- .../dev/flutter/plugins/camera/Camera.java | 336 ++++++++++-------- 1 file changed, 183 insertions(+), 153 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 9872417a578a..c67e80e0a33d 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -40,27 +40,46 @@ import static dev.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; /* package */ class Camera { - private final SurfaceTextureEntry flutterTexture; + + // Taking a picture + // NONE + + // Image preview + private ImageReader imageStreamReader; + + // Taking a picture & Image preview + private ImageReader pictureImageReader; + + // Video recording + private boolean recordingVideo; + private MediaRecorder mediaRecorder; + private CamcorderProfile recordingProfile; + private final boolean enableAudio; + + // Video recording & Image preview + private CaptureRequest.Builder captureRequestBuilder; + + // All 3 + @NonNull private final CameraManager cameraManager; - private final OrientationEventListener orientationEventListener; - private final boolean isFrontFacing; - private final int sensorOrientation; + @NonNull + private final SurfaceTextureEntry flutterTexture; + @NonNull private final String cameraName; + @NonNull private final Size captureSize; + @NonNull private final Size previewSize; - private final boolean enableAudio; - + private final boolean isFrontFacing; + private final int sensorOrientation; private CameraDevice cameraDevice; private CameraCaptureSession cameraCaptureSession; - private ImageReader pictureImageReader; - private ImageReader imageStreamReader; private CameraEventHandler cameraEventHandler; - private CaptureRequest.Builder captureRequestBuilder; - private MediaRecorder mediaRecorder; - private boolean recordingVideo; - private CamcorderProfile recordingProfile; + @NonNull + private final OrientationEventListener orientationEventListener; private int currentOrientation = ORIENTATION_UNKNOWN; + /* package */ Camera( final Activity activity, final SurfaceTextureEntry flutterTexture, @@ -76,18 +95,17 @@ this.enableAudio = enableAudio; this.flutterTexture = flutterTexture; this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); - orientationEventListener = - new OrientationEventListener(activity.getApplicationContext()) { - @Override - public void onOrientationChanged(int i) { - if (i == ORIENTATION_UNKNOWN) { - return; - } - // Convert the raw deg angle to the nearest multiple of 90. - currentOrientation = (int) Math.round(i / 90.0) * 90; - } - }; - orientationEventListener.enable(); + this.orientationEventListener = new OrientationEventListener(activity.getApplicationContext()) { + @Override + public void onOrientationChanged(int orientation) { + if (orientation == ORIENTATION_UNKNOWN) { + return; + } + // Convert the raw deg angle to the nearest multiple of 90. + currentOrientation = (int) Math.round(orientation / 90.0) * 90; + } + }; + this.orientationEventListener.enable(); CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); StreamConfigurationMap streamConfigurationMap = @@ -108,41 +126,7 @@ public void setCameraEventHandler(@Nullable CameraEventHandler handler) { this.cameraEventHandler = handler; } - private void onError(String description) { - if (cameraEventHandler != null) { - cameraEventHandler.onError(description); - } - } - - private void onCameraClosed() { - if (cameraEventHandler != null) { - cameraEventHandler.onCameraClosed(); - } - } - - private void prepareMediaRecorder(String outputFilePath) throws IOException { - if (mediaRecorder != null) { - mediaRecorder.release(); - } - mediaRecorder = new MediaRecorder(); - - // There's a specific order that mediaRecorder expects. Do not change the order - // of these function calls. - if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); - mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); - mediaRecorder.setOutputFormat(recordingProfile.fileFormat); - if (enableAudio) mediaRecorder.setAudioEncoder(recordingProfile.audioCodec); - mediaRecorder.setVideoEncoder(recordingProfile.videoCodec); - mediaRecorder.setVideoEncodingBitRate(recordingProfile.videoBitRate); - if (enableAudio) mediaRecorder.setAudioSamplingRate(recordingProfile.audioSampleRate); - mediaRecorder.setVideoFrameRate(recordingProfile.videoFrameRate); - mediaRecorder.setVideoSize(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); - mediaRecorder.setOutputFile(outputFilePath); - mediaRecorder.setOrientationHint(getMediaOrientation()); - - mediaRecorder.prepare(); - } - + //------ Start: Opening/Closing/Disposing of Camera ------- @SuppressLint("MissingPermission") public void open(@NonNull final OnCameraOpenedCallback callback) throws CameraAccessException { pictureImageReader = ImageReader.newInstance( @@ -221,14 +205,55 @@ public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { null); } - private void writeToFile(ByteBuffer buffer, File file) throws IOException { - try (FileOutputStream outputStream = new FileOutputStream(file)) { - while (0 < buffer.remaining()) { - outputStream.getChannel().write(buffer); - } + public void close() { + closeCaptureSession(); + + if (cameraDevice != null) { + cameraDevice.close(); + cameraDevice = null; + } + if (pictureImageReader != null) { + pictureImageReader.close(); + pictureImageReader = null; + } + if (imageStreamReader != null) { + imageStreamReader.close(); + imageStreamReader = null; + } + if (mediaRecorder != null) { + mediaRecorder.reset(); + mediaRecorder.release(); + mediaRecorder = null; + } + } + + private void closeCaptureSession() { + if (cameraCaptureSession != null) { + cameraCaptureSession.close(); + cameraCaptureSession = null; } } + public void dispose() { + close(); + flutterTexture.release(); + orientationEventListener.disable(); + } + + private void onCameraClosed() { + if (cameraEventHandler != null) { + cameraEventHandler.onCameraClosed(); + } + } + + private void onError(String description) { + if (cameraEventHandler != null) { + cameraEventHandler.onError(description); + } + } + //------ End: Opening/Closing/Disposing of Camera ------- + + //------ Start: Take picture with Camera ------- public void takePicture(String filePath, @NonNull final OnPictureTakenCallback callback) { final File file = new File(filePath); @@ -283,70 +308,16 @@ public void onCaptureFailed( } } - private void createCaptureSession(int templateType, Surface... surfaces) - throws CameraAccessException { - createCaptureSession(templateType, null, surfaces); - } - - private void createCaptureSession( - int templateType, Runnable onSuccessCallback, Surface... surfaces) - throws CameraAccessException { - // Close any existing capture session. - closeCaptureSession(); - - // Create a new capture builder. - captureRequestBuilder = cameraDevice.createCaptureRequest(templateType); - - // Build Flutter surface to render to - SurfaceTexture surfaceTexture = flutterTexture.surfaceTexture(); - surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); - Surface flutterSurface = new Surface(surfaceTexture); - captureRequestBuilder.addTarget(flutterSurface); - - List remainingSurfaces = Arrays.asList(surfaces); - if (templateType != CameraDevice.TEMPLATE_PREVIEW) { - // If it is not preview mode, add all surfaces as targets. - for (Surface surface : remainingSurfaces) { - captureRequestBuilder.addTarget(surface); + private void writeToFile(ByteBuffer buffer, File file) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(file)) { + while (0 < buffer.remaining()) { + outputStream.getChannel().write(buffer); } } - - // Prepare the callback - CameraCaptureSession.StateCallback callback = - new CameraCaptureSession.StateCallback() { - @Override - public void onConfigured(@NonNull CameraCaptureSession session) { - try { - if (cameraDevice == null) { - onError("The camera was closed during configuration."); - return; - } - cameraCaptureSession = session; - captureRequestBuilder.set( - CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); - cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - if (onSuccessCallback != null) { - onSuccessCallback.run(); - } - } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { - onError(e.getMessage()); - } - } - - @Override - public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { - onError("Failed to configure camera session."); - } - }; - - // Collect all surfaces we want to render to. - List surfaceList = new ArrayList<>(); - surfaceList.add(flutterSurface); - surfaceList.addAll(remainingSurfaces); - // Start the session - cameraDevice.createCaptureSession(surfaceList, callback, null); } + //------ End: Take picture with Camera ------- + //------ Start: Video recording with Camera ---- public void startVideoRecording(String filePath) throws IOException, CameraAccessException, IllegalStateException { if (new File(filePath).exists()) { throw new IllegalStateException("File " + filePath + " already exists."); @@ -361,6 +332,29 @@ public void startVideoRecording(String filePath) throws IOException, CameraAcces ); } + private void prepareMediaRecorder(String outputFilePath) throws IOException { + if (mediaRecorder != null) { + mediaRecorder.release(); + } + mediaRecorder = new MediaRecorder(); + + // There's a specific order that mediaRecorder expects. Do not change the order + // of these function calls. + if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); + mediaRecorder.setOutputFormat(recordingProfile.fileFormat); + if (enableAudio) mediaRecorder.setAudioEncoder(recordingProfile.audioCodec); + mediaRecorder.setVideoEncoder(recordingProfile.videoCodec); + mediaRecorder.setVideoEncodingBitRate(recordingProfile.videoBitRate); + if (enableAudio) mediaRecorder.setAudioSamplingRate(recordingProfile.audioSampleRate); + mediaRecorder.setVideoFrameRate(recordingProfile.videoFrameRate); + mediaRecorder.setVideoSize(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); + mediaRecorder.setOutputFile(outputFilePath); + mediaRecorder.setOrientationHint(getMediaOrientation()); + + mediaRecorder.prepare(); + } + public void stopVideoRecording() throws CameraAccessException { if (!recordingVideo) { return; @@ -395,7 +389,9 @@ public void resumeVideoRecording() throws IllegalStateException, UnsupportedOper throw new UnsupportedOperationException("resumeVideoRecording requires Android API +24."); } } + //------ End: Video recording with Camera ---- + //------ Start: Image preview with Camera ---- public void startPreview() throws CameraAccessException { createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); } @@ -417,6 +413,11 @@ public void onConnectionClosed() { }); } + private void createCaptureSession(int templateType, Surface... surfaces) + throws CameraAccessException { + createCaptureSession(templateType, null, surfaces); + } + private void startSendingImagesToPreviewDisplay(@NonNull CameraImageStream cameraImageStream) { imageStreamReader.setOnImageAvailableListener( reader -> { @@ -428,40 +429,68 @@ private void startSendingImagesToPreviewDisplay(@NonNull CameraImageStream camer }, null); } + //------ End: Image preview with Camera ---- - private void closeCaptureSession() { - if (cameraCaptureSession != null) { - cameraCaptureSession.close(); - cameraCaptureSession = null; - } - } - - public void close() { + //------ Start: Shared Camera behavior ----- + private void createCaptureSession( + int templateType, + Runnable onSuccessCallback, + Surface... surfaces + ) throws CameraAccessException { + // Close any existing capture session. closeCaptureSession(); - if (cameraDevice != null) { - cameraDevice.close(); - cameraDevice = null; - } - if (pictureImageReader != null) { - pictureImageReader.close(); - pictureImageReader = null; - } - if (imageStreamReader != null) { - imageStreamReader.close(); - imageStreamReader = null; - } - if (mediaRecorder != null) { - mediaRecorder.reset(); - mediaRecorder.release(); - mediaRecorder = null; + // Create a new capture builder. + captureRequestBuilder = cameraDevice.createCaptureRequest(templateType); + + // Build Flutter surface to render to + SurfaceTexture surfaceTexture = flutterTexture.surfaceTexture(); + surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); + Surface flutterSurface = new Surface(surfaceTexture); + captureRequestBuilder.addTarget(flutterSurface); + + List remainingSurfaces = Arrays.asList(surfaces); + if (templateType != CameraDevice.TEMPLATE_PREVIEW) { + // If it is not preview mode, add all surfaces as targets. + for (Surface surface : remainingSurfaces) { + captureRequestBuilder.addTarget(surface); + } } - } - public void dispose() { - close(); - flutterTexture.release(); - orientationEventListener.disable(); + // Prepare the callback + CameraCaptureSession.StateCallback callback = + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(@NonNull CameraCaptureSession session) { + try { + if (cameraDevice == null) { + onError("The camera was closed during configuration."); + return; + } + cameraCaptureSession = session; + captureRequestBuilder.set( + CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); + cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); + if (onSuccessCallback != null) { + onSuccessCallback.run(); + } + } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { + onError(e.getMessage()); + } + } + + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { + onError("Failed to configure camera session."); + } + }; + + // Collect all surfaces we want to render to. + List surfaceList = new ArrayList<>(); + surfaceList.add(flutterSurface); + surfaceList.addAll(remainingSurfaces); + // Start the session + cameraDevice.createCaptureSession(surfaceList, callback, null); } private int getMediaOrientation() { @@ -471,6 +500,7 @@ private int getMediaOrientation() { : (isFrontFacing) ? -currentOrientation : currentOrientation; return (sensorOrientationOffset + sensorOrientation + 360) % 360; } + //------ End: Shared Camera behavior ----- /** * Callback invoked when this {@link Camera} is opened. From c85fcc2e3563f44eb4fab463f93c831475fc1d30 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 01:17:13 -0700 Subject: [PATCH 08/20] Removed Activity reference from Camera. --- .../dev/flutter/plugins/camera/Camera.java | 22 ++++++++----------- .../flutter/plugins/camera/CameraPlugin.java | 4 ++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index c67e80e0a33d..6a57b0ea3246 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -1,7 +1,6 @@ package dev.flutter.plugins.camera; import android.annotation.SuppressLint; -import android.app.Activity; import android.content.Context; import android.graphics.ImageFormat; import android.graphics.SurfaceTexture; @@ -81,21 +80,18 @@ /* package */ Camera( - final Activity activity, - final SurfaceTextureEntry flutterTexture, - final String cameraName, - final String resolutionPreset, - final boolean enableAudio) - throws CameraAccessException { - if (activity == null) { - throw new IllegalStateException("No activity available!"); - } - + @NonNull Context context, + @NonNull CameraManager cameraManager, + @NonNull SurfaceTextureEntry flutterTexture, + @NonNull String cameraName, + @NonNull String resolutionPreset, + boolean enableAudio + ) throws CameraAccessException { this.cameraName = cameraName; this.enableAudio = enableAudio; this.flutterTexture = flutterTexture; - this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); - this.orientationEventListener = new OrientationEventListener(activity.getApplicationContext()) { + this.cameraManager = cameraManager; + this.orientationEventListener = new OrientationEventListener(context.getApplicationContext()) { @Override public void onOrientationChanged(int orientation) { if (orientation == ORIENTATION_UNKNOWN) { diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index e4bb1948e26e..f684a05e6bf0 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -4,7 +4,9 @@ package dev.flutter.plugins.camera; +import android.content.Context; import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; import android.os.Build; import androidx.annotation.NonNull; @@ -94,8 +96,10 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce .getFlutterEngine() .getRenderer() .createSurfaceTexture(); + camera = new Camera( activityBinding.getActivity(), + (CameraManager) activityBinding.getActivity().getSystemService(Context.CAMERA_SERVICE), textureEntry, cameraName, resolutionPreset, From e04653b6576c795980575993947fbe0d56afaf19 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 14:24:49 -0700 Subject: [PATCH 09/20] Refactored CameraPermissions to eliminate Activity, ActivityCompat, ContextCompat, permission, and ActivityPluginBinding dependencies. --- .../dev/flutter/plugins/camera/Camera.java | 59 +++++++++++++------ .../plugins/camera/CameraPermissions.java | 55 ++++++++--------- .../flutter/plugins/camera/CameraPlugin.java | 45 ++++++++++++-- 3 files changed, 105 insertions(+), 54 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 6a57b0ea3246..7044cff0c854 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -44,18 +44,23 @@ // NONE // Image preview + @Nullable private ImageReader imageStreamReader; // Taking a picture & Image preview + @Nullable private ImageReader pictureImageReader; // Video recording private boolean recordingVideo; + @Nullable private MediaRecorder mediaRecorder; + @Nullable private CamcorderProfile recordingProfile; private final boolean enableAudio; // Video recording & Image preview + @Nullable private CaptureRequest.Builder captureRequestBuilder; // All 3 @@ -71,8 +76,11 @@ private final Size previewSize; private final boolean isFrontFacing; private final int sensorOrientation; + @Nullable private CameraDevice cameraDevice; + @Nullable private CameraCaptureSession cameraCaptureSession; + @Nullable private CameraEventHandler cameraEventHandler; @NonNull private final OrientationEventListener orientationEventListener; @@ -250,7 +258,7 @@ private void onError(String description) { //------ End: Opening/Closing/Disposing of Camera ------- //------ Start: Take picture with Camera ------- - public void takePicture(String filePath, @NonNull final OnPictureTakenCallback callback) { + public void takePicture(@NonNull String filePath, @NonNull final OnPictureTakenCallback callback) { final File file = new File(filePath); if (file.exists()) { @@ -258,32 +266,19 @@ public void takePicture(String filePath, @NonNull final OnPictureTakenCallback c return; } - pictureImageReader.setOnImageAvailableListener( - reader -> { - try (Image image = reader.acquireLatestImage()) { - ByteBuffer buffer = image.getPlanes()[0].getBuffer(); - writeToFile(buffer, file); - callback.onPictureTaken(); - } catch (IOException e) { - callback.onFailedToSaveImage(); - } - }, - null); - try { - final CaptureRequest.Builder captureBuilder = - cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); - captureBuilder.addTarget(pictureImageReader.getSurface()); - captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, getMediaOrientation()); + prepareToSavePictureToFile(file, callback); + CaptureRequest request = createStillPictureCaptureRequest(pictureImageReader); cameraCaptureSession.capture( - captureBuilder.build(), + request, new CameraCaptureSession.CaptureCallback() { @Override public void onCaptureFailed( @NonNull CameraCaptureSession session, @NonNull CaptureRequest request, - @NonNull CaptureFailure failure) { + @NonNull CaptureFailure failure + ) { String reason; switch (failure.getReason()) { case CaptureFailure.REASON_ERROR: @@ -304,6 +299,32 @@ public void onCaptureFailed( } } + private void prepareToSavePictureToFile(@NonNull File file, @NonNull OnPictureTakenCallback callback) { + pictureImageReader.setOnImageAvailableListener( + reader -> { + // TODO(mattcarroll: The original implementation didn't remove the listener. I added that + // here. Should we be removing the listener, or no? + pictureImageReader.setOnImageAvailableListener(null, null); + + try (Image image = reader.acquireLatestImage()) { + ByteBuffer buffer = image.getPlanes()[0].getBuffer(); + writeToFile(buffer, file); + callback.onPictureTaken(); + } catch (IOException e) { + callback.onFailedToSaveImage(); + } + }, + null); + } + + private CaptureRequest createStillPictureCaptureRequest(@NonNull ImageReader imageReader) throws CameraAccessException { + final CaptureRequest.Builder captureBuilder = + cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); + captureBuilder.addTarget(imageReader.getSurface()); + captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, getMediaOrientation()); + return captureBuilder.build(); + } + private void writeToFile(ByteBuffer buffer, File file) throws IOException { try (FileOutputStream outputStream = new FileOutputStream(file)) { while (0 < buffer.remaining()) { diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java index 2d70bd15b2a9..3d99e0b7779b 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java @@ -1,58 +1,41 @@ package dev.flutter.plugins.camera; -import android.Manifest.permission; -import android.app.Activity; import android.content.pm.PackageManager; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.PluginRegistry; -public class CameraPermissions { +/* package */ class CameraPermissions { private static final int CAMERA_REQUEST_ID = 9796; + + private final CameraPermissionsDelegate delegate; private boolean ongoing = false; - public void requestPermissions( - ActivityPluginBinding activityPluginBinding, - boolean enableAudio, - ResultCallback callback - ) { + /* package */ CameraPermissions(@NonNull CameraPermissionsDelegate delegate) { + this.delegate = delegate; + } + + public void requestPermissions(boolean enableAudio, ResultCallback callback) { if (ongoing) { callback.onResult("cameraPermission", "Camera permission request ongoing"); } - Activity activity = activityPluginBinding.getActivity(); - if (!hasCameraPermission(activity) || (enableAudio && !hasAudioPermission(activity))) { - activityPluginBinding.addRequestPermissionsResultListener( + if (!delegate.hasCameraPermission() || (enableAudio && !delegate.hasAudioPermission())) { + delegate.addRequestPermissionsResultListener( new CameraRequestPermissionsListener( (String errorCode, String errorDescription) -> { ongoing = false; callback.onResult(errorCode, errorDescription); })); ongoing = true; - ActivityCompat.requestPermissions( - activity, - enableAudio - ? new String[] {permission.CAMERA, permission.RECORD_AUDIO} - : new String[] {permission.CAMERA}, - CAMERA_REQUEST_ID); + delegate.requestPermission(enableAudio, CAMERA_REQUEST_ID); } else { // Permissions already exist. Call the callback with success. callback.onResult(null, null); } } - private boolean hasCameraPermission(Activity activity) { - return ContextCompat.checkSelfPermission(activity, permission.CAMERA) - == PackageManager.PERMISSION_GRANTED; - } - - private boolean hasAudioPermission(Activity activity) { - return ContextCompat.checkSelfPermission(activity, permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED; - } - private static class CameraRequestPermissionsListener implements PluginRegistry.RequestPermissionsResultListener { final ResultCallback callback; @@ -64,6 +47,8 @@ private CameraRequestPermissionsListener(ResultCallback callback) { @Override public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { if (id == CAMERA_REQUEST_ID) { + // TODO(mattcarroll): fix bug where granting 1st permission and denying 2nd crashes + // due to submitting a reply twice. if (grantResults[0] != PackageManager.PERMISSION_GRANTED) { callback.onResult("cameraPermission", "MediaRecorderCamera permission not granted"); } else if (grantResults.length > 1 @@ -78,6 +63,16 @@ public boolean onRequestPermissionsResult(int id, String[] permissions, int[] gr } } + interface CameraPermissionsDelegate { + boolean hasCameraPermission(); + + boolean hasAudioPermission(); + + void requestPermission(boolean enableAudio, final @IntRange(from = 0) int requestCode); + + void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener); + } + interface ResultCallback { void onResult(String errorCode, String errorDescription); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index f684a05e6bf0..5b00967037df 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -4,12 +4,17 @@ package dev.flutter.plugins.camera; +import android.Manifest; import android.content.Context; +import android.content.pm.PackageManager; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraManager; +import android.icu.util.CurrencyAmount; import android.os.Build; import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; import java.io.IOException; import java.util.HashMap; @@ -23,29 +28,59 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugin.common.PluginRegistry; import io.flutter.view.TextureRegistry; public class CameraPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { - private final CameraPermissions cameraPermissions = new CameraPermissions(); private EventChannel imageStreamChannel; private Camera camera; private FlutterPluginBinding pluginBinding; private ActivityPluginBinding activityBinding; + private final CameraPermissions.CameraPermissionsDelegate permissionsDelegate = new CameraPermissions.CameraPermissionsDelegate() { + @Override + public boolean hasCameraPermission() { + return ContextCompat.checkSelfPermission(activityBinding.getActivity(), Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + @Override + public boolean hasAudioPermission() { + return ContextCompat.checkSelfPermission(activityBinding.getActivity(), Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + @Override + public void requestPermission(boolean enableAudio, int requestCode) { + ActivityCompat.requestPermissions( + activityBinding.getActivity(), + enableAudio + ? new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO} + : new String[] {Manifest.permission.CAMERA}, + requestCode); + } + + @Override + public void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener) { + activityBinding.addRequestPermissionsResultListener(listener); + } + }; + private final CameraPermissions cameraPermissions = new CameraPermissions(permissionsDelegate); + @Override - public void onAttachedToEngine(FlutterPluginBinding flutterPluginBinding) { + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { this.pluginBinding = flutterPluginBinding; } @Override - public void onDetachedFromEngine(FlutterPluginBinding flutterPluginBinding) { + public void onDetachedFromEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { this.pluginBinding = null; } @Override - public void onAttachedToActivity(ActivityPluginBinding activityPluginBinding) { + public void onAttachedToActivity(@NonNull ActivityPluginBinding activityPluginBinding) { // Setup if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { // When a background flutter view tries to register the plugin, the registrar has no activity. @@ -171,8 +206,8 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) if (camera != null) { camera.close(); } + cameraPermissions.requestPermissions( - activityBinding, call.argument("enableAudio"), (String errCode, String errDesc) -> { if (errCode == null) { From 60c1d20b339ac0b5b6a489aa6506012cccb14bf5 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 18:39:25 -0700 Subject: [PATCH 10/20] Refactored CameraPermissions into an Interface and AndroidCameraPermissions into the implementation to make it easier to fake a CameraPermissions in tests. --- .../camera/AndroidCameraPermissions.java | 89 +++++++++++++++++++ .../plugins/camera/CameraPermissions.java | 70 ++------------- .../flutter/plugins/camera/CameraPlugin.java | 62 ++++--------- 3 files changed, 114 insertions(+), 107 deletions(-) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java new file mode 100644 index 000000000000..7fa8dc43664b --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java @@ -0,0 +1,89 @@ +package dev.flutter.plugins.camera; + +import android.Manifest; +import android.content.pm.PackageManager; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.PluginRegistry; + +/* package */ class AndroidCameraPermissions implements CameraPermissions { + private static final int CAMERA_REQUEST_ID = 9796; + + @NonNull + private final ActivityPluginBinding activityBinding; + private boolean ongoing = false; + + /* package */ AndroidCameraPermissions(@NonNull ActivityPluginBinding activityBinding) { + this.activityBinding = activityBinding; + } + + @Override + public boolean hasCameraPermission() { + return ContextCompat.checkSelfPermission(activityBinding.getActivity(), Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + @Override + public boolean hasAudioPermission() { + return ContextCompat.checkSelfPermission(activityBinding.getActivity(), Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + public void requestPermissions(boolean enableAudio, CameraPermissions.ResultCallback callback) { + if (ongoing) { + callback.onResult("cameraPermission", "Camera permission request ongoing"); + } + if (!hasCameraPermission() || (enableAudio && !hasAudioPermission())) { + ongoing = true; + + activityBinding.addRequestPermissionsResultListener( + new CameraRequestPermissionsListener(callback) + ); + + ActivityCompat.requestPermissions( + activityBinding.getActivity(), + enableAudio + ? new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO} + : new String[] {Manifest.permission.CAMERA}, + CAMERA_REQUEST_ID); + } else { + // Permissions already exist. Call the callback with success. + callback.onResult(null, null); + } + } + + @Override + public void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener) { + activityBinding.addRequestPermissionsResultListener(listener); + } + + private static class CameraRequestPermissionsListener implements PluginRegistry.RequestPermissionsResultListener { + final CameraPermissions.ResultCallback callback; + + private CameraRequestPermissionsListener(CameraPermissions.ResultCallback callback) { + this.callback = callback; + } + + @Override + public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { + if (id == CAMERA_REQUEST_ID) { + // TODO(mattcarroll): fix bug where granting 1st permission and denying 2nd crashes + // due to submitting a reply twice. + if (grantResults[0] != PackageManager.PERMISSION_GRANTED) { + callback.onResult("cameraPermission", "MediaRecorderCamera permission not granted"); + } else if (grantResults.length > 1 + && grantResults[1] != PackageManager.PERMISSION_GRANTED) { + callback.onResult("cameraPermission", "MediaRecorderAudio permission not granted"); + } else { + callback.onSuccess(); + } + return true; + } + return false; + } + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java index 3d99e0b7779b..9b8e81acde4f 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java @@ -1,79 +1,21 @@ package dev.flutter.plugins.camera; -import android.content.pm.PackageManager; - -import androidx.annotation.IntRange; import androidx.annotation.NonNull; import io.flutter.plugin.common.PluginRegistry; -/* package */ class CameraPermissions { - private static final int CAMERA_REQUEST_ID = 9796; - - private final CameraPermissionsDelegate delegate; - private boolean ongoing = false; - - /* package */ CameraPermissions(@NonNull CameraPermissionsDelegate delegate) { - this.delegate = delegate; - } +/* package */ interface CameraPermissions { - public void requestPermissions(boolean enableAudio, ResultCallback callback) { - if (ongoing) { - callback.onResult("cameraPermission", "Camera permission request ongoing"); - } - if (!delegate.hasCameraPermission() || (enableAudio && !delegate.hasAudioPermission())) { - delegate.addRequestPermissionsResultListener( - new CameraRequestPermissionsListener( - (String errorCode, String errorDescription) -> { - ongoing = false; - callback.onResult(errorCode, errorDescription); - })); - ongoing = true; - delegate.requestPermission(enableAudio, CAMERA_REQUEST_ID); - } else { - // Permissions already exist. Call the callback with success. - callback.onResult(null, null); - } - } + boolean hasCameraPermission(); - private static class CameraRequestPermissionsListener - implements PluginRegistry.RequestPermissionsResultListener { - final ResultCallback callback; + boolean hasAudioPermission(); - private CameraRequestPermissionsListener(ResultCallback callback) { - this.callback = callback; - } + void requestPermissions(boolean enableAudio, ResultCallback callback); - @Override - public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { - if (id == CAMERA_REQUEST_ID) { - // TODO(mattcarroll): fix bug where granting 1st permission and denying 2nd crashes - // due to submitting a reply twice. - if (grantResults[0] != PackageManager.PERMISSION_GRANTED) { - callback.onResult("cameraPermission", "MediaRecorderCamera permission not granted"); - } else if (grantResults.length > 1 - && grantResults[1] != PackageManager.PERMISSION_GRANTED) { - callback.onResult("cameraPermission", "MediaRecorderAudio permission not granted"); - } else { - callback.onResult(null, null); - } - return true; - } - return false; - } - } - - interface CameraPermissionsDelegate { - boolean hasCameraPermission(); - - boolean hasAudioPermission(); - - void requestPermission(boolean enableAudio, final @IntRange(from = 0) int requestCode); - - void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener); - } + void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener); interface ResultCallback { + void onSuccess(); void onResult(String errorCode, String errorDescription); } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 5b00967037df..29bf59d168f5 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -4,17 +4,13 @@ package dev.flutter.plugins.camera; -import android.Manifest; import android.content.Context; -import android.content.pm.PackageManager; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraManager; -import android.icu.util.CurrencyAmount; import android.os.Build; import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; +import androidx.annotation.Nullable; import java.io.IOException; import java.util.HashMap; @@ -28,46 +24,20 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugin.common.PluginRegistry; import io.flutter.view.TextureRegistry; public class CameraPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { - private EventChannel imageStreamChannel; - private Camera camera; - + @Nullable private FlutterPluginBinding pluginBinding; + @Nullable private ActivityPluginBinding activityBinding; - - private final CameraPermissions.CameraPermissionsDelegate permissionsDelegate = new CameraPermissions.CameraPermissionsDelegate() { - @Override - public boolean hasCameraPermission() { - return ContextCompat.checkSelfPermission(activityBinding.getActivity(), Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED; - } - - @Override - public boolean hasAudioPermission() { - return ContextCompat.checkSelfPermission(activityBinding.getActivity(), Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED; - } - - @Override - public void requestPermission(boolean enableAudio, int requestCode) { - ActivityCompat.requestPermissions( - activityBinding.getActivity(), - enableAudio - ? new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO} - : new String[] {Manifest.permission.CAMERA}, - requestCode); - } - - @Override - public void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener) { - activityBinding.addRequestPermissionsResultListener(listener); - } - }; - private final CameraPermissions cameraPermissions = new CameraPermissions(permissionsDelegate); + @Nullable + private CameraPermissions cameraPermissions; + @Nullable + private EventChannel imageStreamChannel; + @Nullable + private Camera camera; @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { @@ -91,6 +61,8 @@ public void onAttachedToActivity(@NonNull ActivityPluginBinding activityPluginBi this.activityBinding = activityPluginBinding; + this.cameraPermissions = new AndroidCameraPermissions(activityBinding); + this.imageStreamChannel = new EventChannel( this.pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera/imageStream" @@ -209,15 +181,19 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) cameraPermissions.requestPermissions( call.argument("enableAudio"), - (String errCode, String errDesc) -> { - if (errCode == null) { + new CameraPermissions.ResultCallback() { + @Override + public void onSuccess() { try { instantiateCamera(call, result); } catch (Exception e) { handleException(e, result); } - } else { - result.error(errCode, errDesc, null); + } + + @Override + public void onResult(String errorCode, String errorDescription) { + result.error(errorCode, errorDescription, null); } }); From 13e4303fc8991915d77287f1e62ce72b4f1b26c7 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 20:26:05 -0700 Subject: [PATCH 11/20] Separated the CameraPlugin class from the concept of the comms CameraPluginProtocol, which resulted in the simplest possible implementation of CameraPlugin. Also added copyright to new files. --- .../camera/AndroidCameraPermissions.java | 6 +- .../plugins/camera/AndroidCameraSystem.java | 227 +++++++++++++ .../dev/flutter/plugins/camera/Camera.java | 4 +- .../plugins/camera/CameraImageStream.java | 4 + .../plugins/camera/CameraPermissions.java | 4 + .../flutter/plugins/camera/CameraPlugin.java | 312 +++--------------- .../plugins/camera/CameraPluginProtocol.java | 260 +++++++++++++++ .../plugins/camera/CameraPreviewDisplay.java | 4 + .../flutter/plugins/camera/CameraSystem.java | 104 ++++++ .../plugins/camera/ResolutionPreset.java | 4 + 10 files changed, 657 insertions(+), 272 deletions(-) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java index 7fa8dc43664b..a1abba9c922f 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java @@ -1,3 +1,7 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + package dev.flutter.plugins.camera; import android.Manifest; @@ -52,7 +56,7 @@ public void requestPermissions(boolean enableAudio, CameraPermissions.ResultCall CAMERA_REQUEST_ID); } else { // Permissions already exist. Call the callback with success. - callback.onResult(null, null); + callback.onSuccess(); } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java new file mode 100644 index 000000000000..d52e8239353a --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java @@ -0,0 +1,227 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.plugins.camera; + +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.EventChannel; +import io.flutter.view.TextureRegistry; + +/* package */ class AndroidCameraSystem implements CameraSystem { + @NonNull + private final FlutterPlugin.FlutterPluginBinding pluginBinding; + @NonNull + private final ActivityPluginBinding activityBinding; + @NonNull + private final CameraPermissions cameraPermissions; + @NonNull + private final EventChannel imageStreamChannel; + @Nullable + private Camera camera; + + /* package */ AndroidCameraSystem( + @NonNull FlutterPlugin.FlutterPluginBinding pluginBinding, + @NonNull ActivityPluginBinding activityBinding, + @NonNull CameraPermissions cameraPermissions, + @NonNull EventChannel imageStreamChannel + ) { + this.pluginBinding = pluginBinding; + this.activityBinding = activityBinding; + this.cameraPermissions = cameraPermissions; + this.imageStreamChannel = imageStreamChannel; + } + + @Override + public List> getAvailableCameras() throws CameraAccessException { + return CameraUtils.getAvailableCameras(activityBinding.getActivity()); + } + + @Override + public void initialize( + @NonNull CameraConfigurationRequest request, + @NonNull OnCameraInitializationCallback callback + ) { + if (camera != null) { + camera.close(); + } + + cameraPermissions.requestPermissions( + request.getEnableAudio(), + new CameraPermissions.ResultCallback() { + @Override + public void onSuccess() { + try { + instantiateCamera(request, callback); + } catch (Exception error) { + callback.onError("CameraAccess", error.getMessage()); + } + } + + @Override + public void onResult(String errorCode, String errorDescription) { + callback.onCameraPermissionError(errorCode, errorDescription); + } + }); + } + + private void instantiateCamera( + @NonNull CameraConfigurationRequest request, + @NonNull OnCameraInitializationCallback callback + ) throws CameraAccessException { + TextureRegistry.SurfaceTextureEntry textureEntry = pluginBinding + .getFlutterEngine() + .getRenderer() + .createSurfaceTexture(); + + camera = new Camera( + activityBinding.getActivity(), + (CameraManager) activityBinding.getActivity().getSystemService(Context.CAMERA_SERVICE), + textureEntry, + request.getCameraName(), + request.getResolutionPreset(), + request.getEnableAudio() + ); + + camera.open(new Camera.OnCameraOpenedCallback() { + @Override + public void onCameraOpened(long textureId, int previewWidth, int previewHeight) { + callback.onSuccess(textureId, previewWidth, previewHeight); + } + + @Override + public void onCameraOpenFailed(@NonNull String message) { + callback.onError("CameraAccess", message); + } + }); + + EventChannel cameraEventChannel = new EventChannel( + pluginBinding.getFlutterEngine().getDartExecutor(), + "flutter.io/cameraPlugin/cameraEvents" + textureEntry.id() + ); + cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, final EventChannel.EventSink eventSink) { + final Camera.CameraEventHandler cameraEventHandler = new Camera.CameraEventHandler() { + @Override + public void onError(String description) { + Map event = new HashMap<>(); + event.put("eventType", "error"); + event.put("errorDescription", description); + eventSink.success(event); + } + + @Override + public void onCameraClosed() { + Map event = new HashMap<>(); + event.put("eventType", "camera_closing"); + eventSink.success(event); + } + }; + + camera.setCameraEventHandler(cameraEventHandler); + } + + @Override + public void onCancel(Object o) { + camera.setCameraEventHandler(null); + } + }); + } + + @Override + public void takePicture(@NonNull String filePath, @NonNull Camera.OnPictureTakenCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + camera.takePicture(filePath, callback); + } + + @Override + public void startVideoRecording(@NonNull String filePath, @NonNull OnStartVideoRecordingCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.startVideoRecording(filePath); + } catch (IllegalStateException e) { + callback.onFileAlreadyExists(filePath); + } catch (CameraAccessException | IOException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } + + @Override + public void stopVideoRecording(@NonNull OnVideoRecordingCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.stopVideoRecording(); + callback.onSuccess(); + } catch (CameraAccessException | IllegalStateException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } + + @Override + public void pauseVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.pauseVideoRecording(); + } catch (UnsupportedOperationException e) { + callback.onUnsupportedOperation(); + } catch (IllegalStateException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } + + @Override + public void resumeVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.resumeVideoRecording(); + } catch (UnsupportedOperationException e) { + callback.onUnsupportedOperation(); + } catch (IllegalStateException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } + + @Override + public void startImageStream(@NonNull OnCameraAccessCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + CameraPreviewDisplay previewDisplay = new CameraPreviewDisplay(imageStreamChannel); + camera.startPreviewWithImageStream(previewDisplay); + callback.success(); + } catch (CameraAccessException e) { + callback.onCameraAccessFailure(e.getMessage()); + } + } + + @Override + public void stopImageStream(@NonNull OnCameraAccessCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.startPreview(); + callback.success(); + } catch (CameraAccessException e) { + callback.onCameraAccessFailure(e.getMessage()); + } + } + + @Override + public void dispose() { + if (camera != null) { + camera.dispose(); + } + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 7044cff0c854..52c60b8d3a1c 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -335,9 +335,9 @@ private void writeToFile(ByteBuffer buffer, File file) throws IOException { //------ End: Take picture with Camera ------- //------ Start: Video recording with Camera ---- - public void startVideoRecording(String filePath) throws IOException, CameraAccessException, IllegalStateException { + public void startVideoRecording(@NonNull String filePath) throws IOException, CameraAccessException, IllegalStateException { if (new File(filePath).exists()) { - throw new IllegalStateException("File " + filePath + " already exists."); + throw new IOException("File " + filePath + " already exists."); } prepareMediaRecorder(filePath); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java index 67e2b94a9b0f..f7f7c4497ca3 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java @@ -1,3 +1,7 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + package dev.flutter.plugins.camera; import android.media.Image; diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java index 9b8e81acde4f..064fc156f9bd 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java @@ -1,3 +1,7 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + package dev.flutter.plugins.camera; import androidx.annotation.NonNull; diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 29bf59d168f5..fa013c20213f 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -4,40 +4,25 @@ package dev.flutter.plugins.camera; -import android.content.Context; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraManager; import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.EventChannel; -import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.view.TextureRegistry; -public class CameraPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { +public class CameraPlugin implements FlutterPlugin, ActivityAware { @Nullable private FlutterPluginBinding pluginBinding; @Nullable private ActivityPluginBinding activityBinding; @Nullable - private CameraPermissions cameraPermissions; - @Nullable - private EventChannel imageStreamChannel; - @Nullable - private Camera camera; + private CameraPluginProtocol cameraPluginProtocol; @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { @@ -51,282 +36,71 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding flutterPluginBind @Override public void onAttachedToActivity(@NonNull ActivityPluginBinding activityPluginBinding) { - // Setup - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - // When a background flutter view tries to register the plugin, the registrar has no activity. - // We stop the registration process as this plugin is foreground only. Also, if the sdk is - // less than 21 (min sdk for Camera2) we don't register the plugin. - return; - } - this.activityBinding = activityPluginBinding; - - this.cameraPermissions = new AndroidCameraPermissions(activityBinding); - - this.imageStreamChannel = new EventChannel( - this.pluginBinding.getFlutterEngine().getDartExecutor(), - "plugins.flutter.io/camera/imageStream" - ); - - final MethodChannel channel = - new MethodChannel(pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera"); - - channel.setMethodCallHandler(this); + setup(); } - // TODO: there are 2+ channels - // 1:EventChannel - plugins.flutter.io/camera/imageStream - // 1:MethodChannel - plugins.flutter.io/camera - // 0+:EventChannel - flutter.io/cameraPlugin/cameraEvents[textureId] - @Override public void onDetachedFromActivityForConfigChanges() { - // Ignore + teardown(); + this.activityBinding = null; } @Override - public void onReattachedToActivityForConfigChanges(ActivityPluginBinding activityPluginBinding) { - // Ignore + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding activityPluginBinding) { + this.activityBinding = activityPluginBinding; + setup(); } @Override public void onDetachedFromActivity() { - // Teardown + teardown(); this.activityBinding = null; } - private void instantiateCamera(MethodCall call, Result result) throws CameraAccessException { - String cameraName = call.argument("cameraName"); - String resolutionPreset = call.argument("resolutionPreset"); - boolean enableAudio = call.argument("enableAudio"); - TextureRegistry.SurfaceTextureEntry textureEntry = pluginBinding - .getFlutterEngine() - .getRenderer() - .createSurfaceTexture(); + private void setup() { + // Setup + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + // When a background flutter view tries to register the plugin, the registrar has no activity. + // We stop the registration process as this plugin is foreground only. Also, if the sdk is + // less than 21 (min sdk for Camera2) we don't register the plugin. + return; + } - camera = new Camera( - activityBinding.getActivity(), - (CameraManager) activityBinding.getActivity().getSystemService(Context.CAMERA_SERVICE), - textureEntry, - cameraName, - resolutionPreset, - enableAudio + CameraPermissions cameraPermissions = new AndroidCameraPermissions(activityBinding); + EventChannel imageStreamChannel = new EventChannel( + this.pluginBinding.getFlutterEngine().getDartExecutor(), + "plugins.flutter.io/camera/imageStream" ); + CameraSystem cameraSystem = new AndroidCameraSystem( + pluginBinding, + activityBinding, + cameraPermissions, + imageStreamChannel + ); + this.cameraPluginProtocol = new CameraPluginProtocol(cameraSystem); - camera.open(new Camera.OnCameraOpenedCallback() { - @Override - public void onCameraOpened(long textureId, int previewWidth, int previewHeight) { - Map reply = new HashMap<>(); - reply.put("textureId", textureId); - reply.put("previewWidth", previewWidth); - reply.put("previewHeight", previewHeight); - result.success(reply); - } - - @Override - public void onCameraOpenFailed(@NonNull String message) { - result.error("CameraAccess", message, null); - } - }); - - EventChannel cameraEventChannel = new EventChannel( + final MethodChannel channel = new MethodChannel( pluginBinding.getFlutterEngine().getDartExecutor(), - "flutter.io/cameraPlugin/cameraEvents" + textureEntry.id() + "plugins.flutter.io/camera" ); - cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, final EventChannel.EventSink eventSink) { - final Camera.CameraEventHandler cameraEventHandler = new Camera.CameraEventHandler() { - @Override - public void onError(String description) { - Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", description); - eventSink.success(event); - } - - @Override - public void onCameraClosed() { - Map event = new HashMap<>(); - event.put("eventType", "camera_closing"); - eventSink.success(event); - } - }; - - camera.setCameraEventHandler(cameraEventHandler); - } - - @Override - public void onCancel(Object o) { - camera.setCameraEventHandler(null); - } - }); + channel.setMethodCallHandler(cameraPluginProtocol.getCameraSystemChannelHandler()); } - @Override - public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) { - switch (call.method) { - case "availableCameras": - try { - result.success(CameraUtils.getAvailableCameras(activityBinding.getActivity())); - } catch (Exception e) { - handleException(e, result); - } - break; - case "initialize": - { - if (camera != null) { - camera.close(); - } - - cameraPermissions.requestPermissions( - call.argument("enableAudio"), - new CameraPermissions.ResultCallback() { - @Override - public void onSuccess() { - try { - instantiateCamera(call, result); - } catch (Exception e) { - handleException(e, result); - } - } - - @Override - public void onResult(String errorCode, String errorDescription) { - result.error(errorCode, errorDescription, null); - } - }); - - break; - } - case "takePicture": - { - final String filePath = call.argument("path"); - camera.takePicture(filePath, new Camera.OnPictureTakenCallback() { - @Override - public void onPictureTaken() { - result.success(null); - } - - @Override - public void onFileAlreadyExists() { - result.error( - "fileExists", - "File at path '" + filePath + "' already exists. Cannot overwrite.", - null - ); - } - - @Override - public void onFailedToSaveImage() { - result.error("IOError", "Failed saving image", null); - } - - @Override - public void onCaptureFailure(@NonNull String reason) { - result.error("captureFailure", reason, null); - } - - @Override - public void onCameraAccessFailure(@NonNull String message) { - result.error("cameraAccess", message, null); - } - }); - break; - } - case "prepareForVideoRecording": - { - // This optimization is not required for Android. - result.success(null); - break; - } - case "startVideoRecording": - { - String filePath = call.argument("filePath"); - try { - camera.startVideoRecording(filePath); - } catch (IllegalStateException e) { - result.error("fileExists", "File at path '" + filePath + "' already exists.", null); - } catch (CameraAccessException | IOException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } - break; - } - case "stopVideoRecording": - { - try { - camera.stopVideoRecording(); - } catch (CameraAccessException | IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } - break; - } - case "pauseVideoRecording": - { - try { - camera.pauseVideoRecording(); - } catch (UnsupportedOperationException e) { - result.error("videoRecordingFailed", "pauseVideoRecording requires Android API +24.", null); - } catch (IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } - break; - } - case "resumeVideoRecording": - { - try { - camera.resumeVideoRecording(); - } catch (UnsupportedOperationException e) { - result.error("videoRecordingFailed","resumeVideoRecording requires Android API +24.",null); - } catch (IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } - break; - } - case "startImageStream": - { - try { - CameraPreviewDisplay previewDisplay = new CameraPreviewDisplay(imageStreamChannel); - camera.startPreviewWithImageStream(previewDisplay); - result.success(null); - } catch (Exception e) { - handleException(e, result); - } - break; - } - case "stopImageStream": - { - try { - camera.startPreview(); - result.success(null); - } catch (Exception e) { - handleException(e, result); - } - break; - } - case "dispose": - { - if (camera != null) { - camera.dispose(); - } - result.success(null); - break; - } - default: - result.notImplemented(); - break; - } - } + // TODO: there are 2+ channels + // 1:EventChannel - plugins.flutter.io/camera/imageStream + // 1:MethodChannel - plugins.flutter.io/camera + // 0+:EventChannel - flutter.io/cameraPlugin/cameraEvents[textureId] - // We move catching CameraAccessException out of onMethodCall because it causes a crash - // on plugin registration for sdks incompatible with Camera2 (< 21). We want this plugin to - // to be able to compile with <21 sdks for apps that want the camera and support earlier version. - @SuppressWarnings("ConstantConditions") - private void handleException(Exception exception, Result result) { - if (exception instanceof CameraAccessException) { - result.error("CameraAccess", exception.getMessage(), null); + private void teardown() { + // Teardown + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + // When a background flutter view tries to register the plugin, the registrar has no activity. + // We stop the registration process as this plugin is foreground only. Also, if the sdk is + // less than 21 (min sdk for Camera2) we don't register the plugin. + return; } - throw (RuntimeException) exception; + cameraPluginProtocol.release(); } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java new file mode 100644 index 000000000000..c6b87c743d38 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -0,0 +1,260 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +/* package */ class CameraPluginProtocol { + + @NonNull + private CameraSystem cameraSystem; + @NonNull + private final CameraSystemChannelHandler channelHandler; + + /* package */ CameraPluginProtocol(@NonNull CameraSystem cameraSystem) { + this.cameraSystem = cameraSystem; + this.channelHandler = new CameraSystemChannelHandler(cameraSystem); + } + + public void release() { + cameraSystem.dispose(); + } + + @NonNull + public MethodChannel.MethodCallHandler getCameraSystemChannelHandler() { + return channelHandler; + } + + private static class CameraSystemChannelHandler implements MethodChannel.MethodCallHandler { + @NonNull + private final CameraSystem cameraSystem; + + CameraSystemChannelHandler(@NonNull CameraSystem cameraSystem) { + this.cameraSystem = cameraSystem; + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull final MethodChannel.Result result) { + switch (call.method) { + case "availableCameras": + try { + List> serializedCameras = cameraSystem.getAvailableCameras(); + result.success(serializedCameras); + } catch (Exception e) { + handleException(e, result); + } + break; + case "initialize": + { + CameraSystem.CameraConfigurationRequest request = new CameraSystem.CameraConfigurationRequest( + call.argument("cameraName"), + call.argument("resolutionPreset"), + call.argument("enableAudio") + ); + + cameraSystem.initialize(request, new CameraSystem.OnCameraInitializationCallback() { + @Override + public void onCameraPermissionError(@NonNull String errorCode, @NonNull String description) { + result.error(errorCode, description, null); + } + + @Override + public void onSuccess(long textureId, int previewWidth, int previewHeight) { + Map reply = new HashMap<>(); + reply.put("textureId", textureId); + reply.put("previewWidth", previewWidth); + reply.put("previewHeight", previewHeight); + result.success(reply); + } + + @Override + public void onError(@NonNull String errorCode, @NonNull String description) { + result.error(errorCode, description, null); + } + }); + break; + } + case "takePicture": + { + final String filePath = call.argument("path"); + cameraSystem.takePicture(filePath, new Camera.OnPictureTakenCallback() { + @Override + public void onPictureTaken() { + result.success(null); + } + + @Override + public void onFileAlreadyExists() { + result.error( + "fileExists", + "File at path '" + filePath + "' already exists. Cannot overwrite.", + null + ); + } + + @Override + public void onFailedToSaveImage() { + result.error("IOError", "Failed saving image", null); + } + + @Override + public void onCaptureFailure(@NonNull String reason) { + result.error("captureFailure", reason, null); + } + + @Override + public void onCameraAccessFailure(@NonNull String message) { + result.error("cameraAccess", message, null); + } + }); + break; + } + case "prepareForVideoRecording": + { + // This optimization is not required for Android. + result.success(null); + break; + } + case "startVideoRecording": + { + String filePath = call.argument("filePath"); + cameraSystem.startVideoRecording(filePath, new CameraSystem.OnStartVideoRecordingCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onFileAlreadyExists(@NonNull String filePath) { + result.error("fileExists", "File at path '" + filePath + "' already exists.", null); + } + + @Override + public void onVideoRecordingFailed(@NonNull String message) { + result.error("videoRecordingFailed", message, null); + } + }); + break; + } + case "stopVideoRecording": + { + cameraSystem.stopVideoRecording(new CameraSystem.OnVideoRecordingCommandCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onVideoRecordingFailed(@NonNull String message) { + result.error("videoRecordingFailed", message, null); + } + }); + break; + } + case "pauseVideoRecording": + { + cameraSystem.pauseVideoRecording(new CameraSystem.OnApiDependentVideoRecordingCommandCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onUnsupportedOperation() { + result.error("videoRecordingFailed", "pauseVideoRecording requires Android API +24.", null); + } + + @Override + public void onVideoRecordingFailed(@NonNull String message) { + result.error("videoRecordingFailed", message, null); + } + }); + break; + } + case "resumeVideoRecording": + { + cameraSystem.resumeVideoRecording(new CameraSystem.OnApiDependentVideoRecordingCommandCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onUnsupportedOperation() { + result.error("videoRecordingFailed","resumeVideoRecording requires Android API +24.",null); + } + + @Override + public void onVideoRecordingFailed(@NonNull String message) { + result.error("videoRecordingFailed", message, null); + } + }); + break; + } + case "startImageStream": + { + cameraSystem.startImageStream(new CameraSystem.OnCameraAccessCommandCallback() { + @Override + public void success() { + result.success(null); + } + + @Override + public void onCameraAccessFailure(@NonNull String message) { + result.error("CameraAccess", message, null); + } + }); + break; + } + case "stopImageStream": + { + cameraSystem.stopImageStream(new CameraSystem.OnCameraAccessCommandCallback() { + @Override + public void success() { + result.success(null); + } + + @Override + public void onCameraAccessFailure(@NonNull String message) { + result.error("CameraAccess", message, null); + } + }); + break; + } + case "dispose": + { + cameraSystem.dispose(); + result.success(null); + break; + } + default: + result.notImplemented(); + break; + } + } + + // We move catching CameraAccessException out of onMethodCall because it causes a crash + // on plugin registration for sdks incompatible with Camera2 (< 21). We want this plugin to + // to be able to compile with <21 sdks for apps that want the camera and support earlier version. + @SuppressWarnings("ConstantConditions") + private void handleException(Exception exception, MethodChannel.Result result) { + if (exception instanceof CameraAccessException) { + result.error("CameraAccess", exception.getMessage(), null); + } + + throw (RuntimeException) exception; + } + } + +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java index 23677380b2b6..7017f1dbb092 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java @@ -1,3 +1,7 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + package dev.flutter.plugins.camera; import androidx.annotation.NonNull; diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java new file mode 100644 index 000000000000..cb142718feb1 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -0,0 +1,104 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.Map; + +/* package */ interface CameraSystem { + List> getAvailableCameras() throws CameraAccessException; + + void initialize( + @NonNull CameraConfigurationRequest request, + @NonNull OnCameraInitializationCallback callback + ); + + void takePicture( + @NonNull String filePath, + @NonNull Camera.OnPictureTakenCallback callback + ); + + void startVideoRecording( + @NonNull String filePath, + @NonNull OnStartVideoRecordingCallback callback + ); + + void stopVideoRecording(@NonNull OnVideoRecordingCommandCallback callback); + + void pauseVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback); + + void resumeVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback); + + void startImageStream(@NonNull OnCameraAccessCommandCallback callback); + + void stopImageStream(@NonNull OnCameraAccessCommandCallback callback); + + void dispose(); + + /* package */ interface OnCameraInitializationCallback { + void onCameraPermissionError(@NonNull String errorCode, @NonNull String description); + + void onSuccess(long textureId, int previewWidth, int previewHeight); + + void onError(@NonNull String errorCode, @NonNull String description); + } + + /* package */ interface OnVideoRecordingCommandCallback { + void onSuccess(); + + void onVideoRecordingFailed(@NonNull String message); + } + + /* package */ interface OnStartVideoRecordingCallback extends OnVideoRecordingCommandCallback { + void onFileAlreadyExists(@NonNull String filePath); + } + + /* package */ interface OnApiDependentVideoRecordingCommandCallback extends OnVideoRecordingCommandCallback { + void onUnsupportedOperation(); + } + + /* package */ interface OnCameraAccessCommandCallback { + void success(); + + void onCameraAccessFailure(@NonNull String message); + } + + /* package */ class CameraConfigurationRequest { + @NonNull + private final String cameraName; + @NonNull + private final String resolutionPreset; + @NonNull + private final boolean enableAudio; + + /* package */ CameraConfigurationRequest( + @NonNull String cameraName, + @NonNull String resolutionPreset, + @NonNull boolean enableAudio + ) { + this.cameraName = cameraName; + this.resolutionPreset = resolutionPreset; + this.enableAudio = enableAudio; + } + + @NonNull + public String getCameraName() { + return cameraName; + } + + @NonNull + public String getResolutionPreset() { + return resolutionPreset; + } + + public boolean getEnableAudio() { + return enableAudio; + } + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java index 9c8922676d1f..8a8f315b5ac0 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java @@ -1,3 +1,7 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + package dev.flutter.plugins.camera; // Mirrors camera.dart From d5da9ce01e1118fd9811a4c8d60ae1a922f9181b Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 24 Sep 2019 22:39:46 -0700 Subject: [PATCH 12/20] Introduced CameraDetails data structure & wrote unit tests for CameraPluginProtocol, hitting 30% code coverage of the class at this point. --- packages/camera/android/build.gradle | 4 + .../plugins/camera/AndroidCameraSystem.java | 14 +- .../flutter/plugins/camera/CameraDetails.java | 59 ++++ .../flutter/plugins/camera/CameraPlugin.java | 2 +- .../plugins/camera/CameraPluginProtocol.java | 15 +- .../flutter/plugins/camera/CameraSystem.java | 23 +- .../camera/example/android/app/build.gradle | 4 +- .../camera/CameraPluginProtocolTest.java | 256 ++++++++++++++++++ 8 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraDetails.java create mode 100644 packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java diff --git a/packages/camera/android/build.gradle b/packages/camera/android/build.gradle index dd544c084ba7..3be686864490 100644 --- a/packages/camera/android/build.gradle +++ b/packages/camera/android/build.gradle @@ -51,5 +51,9 @@ android { dependencies { implementation 'androidx.annotation:annotation:1.0.0' implementation 'androidx.core:core:1.0.0' + + testImplementation 'junit:junit:4.12' + testImplementation 'androidx.test:core:1.2.0' // for robolectric + testImplementation 'org.mockito:mockito-core:1.10.19' } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java index d52e8239353a..f83e825668ad 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java @@ -12,6 +12,7 @@ import androidx.annotation.Nullable; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,8 +47,17 @@ } @Override - public List> getAvailableCameras() throws CameraAccessException { - return CameraUtils.getAvailableCameras(activityBinding.getActivity()); + public List getAvailableCameras() throws CameraAccessException { + List> allCameraDetailsSerialized = CameraUtils.getAvailableCameras(activityBinding.getActivity()); + List allCameraDetails = new ArrayList<>(); + for (Map serializedDetails : allCameraDetailsSerialized) { + allCameraDetails.add(new CameraDetails( + (String) serializedDetails.get("name"), + (Integer) serializedDetails.get("screenOrientation"), + (String) serializedDetails.get("lensDirection") + )); + } + return allCameraDetails; } @Override diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraDetails.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraDetails.java new file mode 100644 index 000000000000..2ec5389aca11 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraDetails.java @@ -0,0 +1,59 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.plugins.camera; + +import androidx.annotation.NonNull; + +class CameraDetails { + @NonNull + private String name; + private int sensorOrientation; + @NonNull + private String lensDirection; + + CameraDetails( + @NonNull String name, + int sensorOrientation, + @NonNull String lensDirection + ) { + this.name = name; + this.sensorOrientation = sensorOrientation; + this.lensDirection = lensDirection; + } + + @NonNull + public String getName() { + return name; + } + + public int getSensorOrientation() { + return sensorOrientation; + } + + @NonNull + public String getLensDirection() { + return lensDirection; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CameraDetails that = (CameraDetails) o; + + if (sensorOrientation != that.sensorOrientation) return false; + if (!name.equals(that.name)) return false; + return lensDirection.equals(that.lensDirection); + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + sensorOrientation; + result = 31 * result + lensDirection.hashCode(); + return result; + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index fa013c20213f..2066078c5ebc 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -69,7 +69,7 @@ private void setup() { CameraPermissions cameraPermissions = new AndroidCameraPermissions(activityBinding); EventChannel imageStreamChannel = new EventChannel( - this.pluginBinding.getFlutterEngine().getDartExecutor(), + pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera/imageStream" ); CameraSystem cameraSystem = new AndroidCameraSystem( diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java index c6b87c743d38..13b8e7dbe7bd 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -8,6 +8,7 @@ import androidx.annotation.NonNull; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,8 +50,18 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final MethodChannel. switch (call.method) { case "availableCameras": try { - List> serializedCameras = cameraSystem.getAvailableCameras(); - result.success(serializedCameras); + List allCameraDetails = cameraSystem.getAvailableCameras(); + + List> allCameraDetailsSerialized = new ArrayList<>(); + for (CameraDetails cameraDetails : allCameraDetails) { + Map serializedDetails = new HashMap<>(); + serializedDetails.put("name", cameraDetails.getName()); + serializedDetails.put("sensorOrientation", cameraDetails.getSensorOrientation()); + serializedDetails.put("lensDirection", cameraDetails.getLensDirection()); + allCameraDetailsSerialized.add(serializedDetails); + } + + result.success(allCameraDetailsSerialized); } catch (Exception e) { handleException(e, result); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java index cb142718feb1..5c2457877d03 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -9,10 +9,9 @@ import androidx.annotation.NonNull; import java.util.List; -import java.util.Map; /* package */ interface CameraSystem { - List> getAvailableCameras() throws CameraAccessException; + List getAvailableCameras() throws CameraAccessException; void initialize( @NonNull CameraConfigurationRequest request, @@ -100,5 +99,25 @@ public String getResolutionPreset() { public boolean getEnableAudio() { return enableAudio; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CameraConfigurationRequest that = (CameraConfigurationRequest) o; + + if (enableAudio != that.enableAudio) return false; + if (!cameraName.equals(that.cameraName)) return false; + return resolutionPreset.equals(that.resolutionPreset); + } + + @Override + public int hashCode() { + int result = cameraName.hashCode(); + result = 31 * result + resolutionPreset.hashCode(); + result = 31 * result + (enableAudio ? 1 : 0); + return result; + } } } diff --git a/packages/camera/example/android/app/build.gradle b/packages/camera/example/android/app/build.gradle index f4eb5498a77d..ed2031f22775 100644 --- a/packages/camera/example/android/app/build.gradle +++ b/packages/camera/example/android/app/build.gradle @@ -62,6 +62,6 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0' testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + testImplementation 'androidx.test:core:1.2.0' // for robolectric + testImplementation 'org.mockito:mockito-core:1.10.19' } diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java new file mode 100644 index 000000000000..8b7cec54ca82 --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java @@ -0,0 +1,256 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +import static junit.framework.TestCase.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +public class CameraPluginProtocolTest { + + private CameraSystem fakeCameraSystem; + private CameraPluginProtocol protocol; // This is the object under test. + private MethodChannel.MethodCallHandler channelHandler; + + @Before + public void setup() { + fakeCameraSystem = mock(CameraSystem.class); + protocol = new CameraPluginProtocol(fakeCameraSystem); + channelHandler = protocol.getCameraSystemChannelHandler(); + } + + @Test + public void itHandlesRequestForAvailableCameras() throws CameraAccessException { + // Setup test. + // Hard code the value that we expect to be sent from Android to Flutter. + final List> expectedResponse = Arrays.asList( + createFakeSerializedCameraConfig( + "fake_camera_1", + 1, + "front" + ), + createFakeSerializedCameraConfig( + "fake_camera_2", + 1, + "back" + ) + ); + + // Fake the CameraSystem to return CameraDetails that should yield our + // expected output. + List fakeCameraList = Arrays.asList( + new CameraDetails( + "fake_camera_1", + 1, + "front" + ), + new CameraDetails( + "fake_camera_2", + 1, + "back" + ) + ); + when(fakeCameraSystem.getAvailableCameras()).thenReturn(fakeCameraList); + + final MethodCall availableCamerasRequest = new MethodCall("availableCameras", null); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(availableCamerasRequest, fakeResult); + + // Verify results. + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(List.class); + verify(fakeResult, times(1)).success(responseCaptor.capture()); + + // Verify that we received the expected 2 cameras. + final List actualResponse = responseCaptor.getValue(); + assertEquals(expectedResponse, actualResponse); + } + + @NonNull + private Map createFakeSerializedCameraConfig( + @NonNull String cameraName, + @NonNull int sensorOrientation, + @NonNull String lensDirection + ) { + final Map serializedCamera = new HashMap<>(); + serializedCamera.put("name", cameraName); + serializedCamera.put("sensorOrientation", sensorOrientation); + serializedCamera.put("lensDirection", lensDirection); + return serializedCamera; + } + + @Test + public void itHandlesCameraInitializationHappyPath() { + // Setup test. + final CameraSystem.CameraConfigurationRequest expectedCameraRequest = createFakeCameraConfigurationRequest(); + final Map expectedSuccessResponse = createFakeInitializationResponse( + 1l, // Do not forget to make a "long" + 1920, + 1080 + ); + + // Wire up fakes. + final MethodCall initializeCameraRequest = createFakeInitializationMethodCall( + "fake_camera_1", + "fake_resolution_preset", + true + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(initializeCameraRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraInitializationCallback.class); + verify(fakeCameraSystem, times(1)).initialize(eq(expectedCameraRequest), callbackCaptor.capture()); + callbackCaptor.getValue().onSuccess(1, 1920, 1080); + + // Verify expected success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(expectedSuccessResponse)); + } + + @Test + public void itHandlesCameraInitializationWithPermissionError() { + // Setup test. + // Wire up fakes. + final MethodCall initializeCameraRequest = createFakeInitializationMethodCall( + "fake_camera_1", + "fake_resolution_preset", + true + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(initializeCameraRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraInitializationCallback.class); + verify(fakeCameraSystem, times(1)).initialize( + any(CameraSystem.CameraConfigurationRequest.class), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onCameraPermissionError("fake_error", "fake_description"); + + // Verify expected error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("fake_error"), + eq("fake_description"), + eq(null) + ); + } + + @Test + public void itHandlesCameraInitializationWithGenericError() { + // Setup test. + final MethodCall initializeCameraRequest = createFakeInitializationMethodCall( + "fake_camera_1", + "fake_resolution_preset", + true + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(initializeCameraRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraInitializationCallback.class); + verify(fakeCameraSystem, times(1)).initialize( + any(CameraSystem.CameraConfigurationRequest.class), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onError("fake_error", "fake_description"); + + // Verify expected error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("fake_error"), + eq("fake_description"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeInitializationMethodCall( + @NonNull String cameraName, + @NonNull String resolutionPreset, + boolean enableAudio + ) { + Map requestArguments = new HashMap<>(); + requestArguments.put("cameraName", cameraName); + requestArguments.put("resolutionPreset", resolutionPreset); + requestArguments.put("enableAudio", enableAudio); + return new MethodCall("initialize", requestArguments); + } + + @NonNull + private CameraSystem.CameraConfigurationRequest createFakeCameraConfigurationRequest() { + return new CameraSystem.CameraConfigurationRequest( + "fake_camera_1", + "fake_resolution_preset", + true + ); + } + + @NonNull + private Map createFakeInitializationResponse( + long textureId, + int previewWidth, + int previewHeight + ) { + Map initializationResponse = new HashMap<>(); + initializationResponse.put("textureId", textureId); // Do not forget to make a "long" + initializationResponse.put("previewWidth", previewWidth); + initializationResponse.put("previewHeight", previewHeight); + return initializationResponse; + } + + @Test + public void itHandlesPrepareForVideoRecordingWithAutomaticSuccess() { + // Setup test. + final MethodCall prepareForVideoRecording = new MethodCall("prepareForVideoRecording", null); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(prepareForVideoRecording, fakeResult); + + // Verify results. + verifyZeroInteractions(fakeCameraSystem); + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesDisposeRequest() { + // Setup test. + final MethodCall disposeRequest = new MethodCall("dispose", null); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(disposeRequest, fakeResult); + + // Verify results. + verify(fakeCameraSystem, times(1)).dispose(); + verify(fakeResult, times(1)).success(eq(null)); + } +} From 436d9ca587c6d51d7f8dcb7ba3d09b1d41fa20aa Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 25 Sep 2019 15:33:28 -0700 Subject: [PATCH 13/20] Added unit tests for CameraPluginProtocol and reached 100% coverage for that class. --- .../plugins/camera/AndroidCameraSystem.java | 6 +- .../plugins/camera/CameraPluginProtocol.java | 10 +- .../flutter/plugins/camera/CameraSystem.java | 2 +- .../plugins/cameraexample/MainActivity.java | 3 +- .../camera/CameraPluginProtocolTest.java | 603 +++++++++++++++++- 5 files changed, 611 insertions(+), 13 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java index f83e825668ad..430c40beaef6 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java @@ -53,8 +53,8 @@ public List getAvailableCameras() throws CameraAccessException { for (Map serializedDetails : allCameraDetailsSerialized) { allCameraDetails.add(new CameraDetails( (String) serializedDetails.get("name"), - (Integer) serializedDetails.get("screenOrientation"), - (String) serializedDetails.get("lensDirection") + (Integer) serializedDetails.get("sensorOrientation"), + (String) serializedDetails.get("lensFacing") )); } return allCameraDetails; @@ -175,7 +175,7 @@ public void stopVideoRecording(@NonNull OnVideoRecordingCommandCallback callback // TODO(mattcarroll): determine desired behavior when no camera is active try { camera.stopVideoRecording(); - callback.onSuccess(); + callback.success(); } catch (CameraAccessException | IllegalStateException e) { callback.onVideoRecordingFailed(e.getMessage()); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java index 13b8e7dbe7bd..4a0b6fe7e94f 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -57,7 +57,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final MethodChannel. Map serializedDetails = new HashMap<>(); serializedDetails.put("name", cameraDetails.getName()); serializedDetails.put("sensorOrientation", cameraDetails.getSensorOrientation()); - serializedDetails.put("lensDirection", cameraDetails.getLensDirection()); + serializedDetails.put("lensFacing", cameraDetails.getLensDirection()); allCameraDetailsSerialized.add(serializedDetails); } @@ -142,7 +142,7 @@ public void onCameraAccessFailure(@NonNull String message) { String filePath = call.argument("filePath"); cameraSystem.startVideoRecording(filePath, new CameraSystem.OnStartVideoRecordingCallback() { @Override - public void onSuccess() { + public void success() { result.success(null); } @@ -162,7 +162,7 @@ public void onVideoRecordingFailed(@NonNull String message) { { cameraSystem.stopVideoRecording(new CameraSystem.OnVideoRecordingCommandCallback() { @Override - public void onSuccess() { + public void success() { result.success(null); } @@ -177,7 +177,7 @@ public void onVideoRecordingFailed(@NonNull String message) { { cameraSystem.pauseVideoRecording(new CameraSystem.OnApiDependentVideoRecordingCommandCallback() { @Override - public void onSuccess() { + public void success() { result.success(null); } @@ -197,7 +197,7 @@ public void onVideoRecordingFailed(@NonNull String message) { { cameraSystem.resumeVideoRecording(new CameraSystem.OnApiDependentVideoRecordingCommandCallback() { @Override - public void onSuccess() { + public void success() { result.success(null); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java index 5c2457877d03..c28a02d59a12 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -49,7 +49,7 @@ void startVideoRecording( } /* package */ interface OnVideoRecordingCommandCallback { - void onSuccess(); + void success(); void onVideoRecordingFailed(@NonNull String message); } diff --git a/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java b/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java index cdc794a3d495..2f891161eba4 100644 --- a/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java +++ b/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java @@ -1,7 +1,5 @@ package dev.flutter.plugins.cameraexample; -import android.os.Bundle; - import dev.flutter.plugins.camera.CameraPlugin; import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.engine.FlutterEngine; @@ -9,6 +7,7 @@ public class MainActivity extends FlutterActivity { @Override public void configureFlutterEngine(FlutterEngine flutterEngine) { + // TODO(mattcarroll): need to migrate path provider plugin to support picture and video recording flutterEngine.getPlugins().add(new CameraPlugin()); } } diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java index 8b7cec54ca82..cd359f419273 100644 --- a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java @@ -17,6 +17,7 @@ import io.flutter.plugin.common.MethodChannel; import static junit.framework.TestCase.assertEquals; +import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; @@ -39,7 +40,7 @@ public void setup() { } @Test - public void itHandlesRequestForAvailableCameras() throws CameraAccessException { + public void itHandlesRequestForAvailableCamerasHappyPath() throws CameraAccessException { // Setup test. // Hard code the value that we expect to be sent from Android to Flutter. final List> expectedResponse = Arrays.asList( @@ -86,6 +87,37 @@ public void itHandlesRequestForAvailableCameras() throws CameraAccessException { assertEquals(expectedResponse, actualResponse); } + @Test + public void itHandlesRequestForAvailableCamerasWhenCameraAccessFailed() throws CameraAccessException { + // Setup test. + // We mock the exception that we throw because instantiating one does not + // seem to correctly assemble its "message". This might happen because this + // is an Android exception, but I'm not sure. + CameraAccessException fakeException = mock(CameraAccessException.class); + when(fakeException.getMessage()).thenReturn("CameraAccessException intentionally thrown in test."); + + when(fakeCameraSystem.getAvailableCameras()).thenThrow(fakeException); + + final MethodCall availableCamerasRequest = new MethodCall("availableCameras", null); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + boolean exceptionWasThrown = false; + + // Execute behavior under test. + try { + channelHandler.onMethodCall(availableCamerasRequest, fakeResult); + } catch (Exception e) { + exceptionWasThrown = true; + } + + // Verify results. + verify(fakeResult, times(1)).error( + eq("CameraAccess"), + eq("CameraAccessException intentionally thrown in test."), + eq(null) + ); + assertTrue(exceptionWasThrown); + } + @NonNull private Map createFakeSerializedCameraConfig( @NonNull String cameraName, @@ -95,7 +127,7 @@ private Map createFakeSerializedCameraConfig( final Map serializedCamera = new HashMap<>(); serializedCamera.put("name", cameraName); serializedCamera.put("sensorOrientation", sensorOrientation); - serializedCamera.put("lensDirection", lensDirection); + serializedCamera.put("lensFacing", lensDirection); return serializedCamera; } @@ -226,6 +258,550 @@ private Map createFakeInitializationResponse( return initializationResponse; } + @Test + public void itHandlesTakePictureRequestHappyPath() { + // Setup test. + final MethodCall takePictureRequest = createFakeTakePictureMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(takePictureRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Camera.OnPictureTakenCallback.class); + verify(fakeCameraSystem, times(1)).takePicture( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onPictureTaken(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesTakePictureRequestWhenFileAlreadyExists() { + // Setup test. + final MethodCall takePictureRequest = createFakeTakePictureMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(takePictureRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Camera.OnPictureTakenCallback.class); + verify(fakeCameraSystem, times(1)).takePicture( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onFileAlreadyExists(); + + // Verify expected error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("fileExists"), + eq("File at path '/some/image/path' already exists. Cannot overwrite."), + eq(null) + ); + } + + @Test + public void itHandlesTakePictureRequestWhenFailedToSaveImage() { + // Setup test. + final MethodCall takePictureRequest = createFakeTakePictureMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(takePictureRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Camera.OnPictureTakenCallback.class); + verify(fakeCameraSystem, times(1)).takePicture( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onFailedToSaveImage(); + + // Verify expected error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("IOError"), + eq("Failed saving image"), + eq(null) + ); + } + + @Test + public void itHandlesTakePictureRequestWhenCaptureFailed() { + // Setup test. + final MethodCall takePictureRequest = createFakeTakePictureMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(takePictureRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Camera.OnPictureTakenCallback.class); + verify(fakeCameraSystem, times(1)).takePicture( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onCaptureFailure("Because this is a test"); + + // Verify expected error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("captureFailure"), + eq("Because this is a test"), + eq(null) + ); + } + + @Test + public void itHandlesTakePictureRequestWhenCameraAccessFailed() { + // Setup test. + final MethodCall takePictureRequest = createFakeTakePictureMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(takePictureRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Camera.OnPictureTakenCallback.class); + verify(fakeCameraSystem, times(1)).takePicture( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onCameraAccessFailure("Because this is a test"); + + // Verify expected error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("cameraAccess"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeTakePictureMethodCall( + @NonNull String filePath + ) { + Map requestArguments = new HashMap<>(); + requestArguments.put("path", filePath); + return new MethodCall("takePicture", requestArguments); + } + + @Test + public void itHandlesStartVideoRecordingHappyPath() { + // Setup test. + final MethodCall startVideoRecordingRequest = createFakeStartVideoRecordingMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(startVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnStartVideoRecordingCallback.class); + verify(fakeCameraSystem, times(1)).startVideoRecording( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().success(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesStartVideoRecordingWhenFileAlreadyExists() { + // Setup test. + final MethodCall startVideoRecordingRequest = createFakeStartVideoRecordingMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(startVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnStartVideoRecordingCallback.class); + verify(fakeCameraSystem, times(1)).startVideoRecording( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onFileAlreadyExists("/some/image/path"); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("fileExists"), + eq("File at path '/some/image/path' already exists."), + eq(null) + ); + } + + @Test + public void itHandlesStartVideoRecordingWhenVideoRecordingFailed() { + // Setup test. + final MethodCall startVideoRecordingRequest = createFakeStartVideoRecordingMethodCall( + "/some/image/path" + ); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(startVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnStartVideoRecordingCallback.class); + verify(fakeCameraSystem, times(1)).startVideoRecording( + eq("/some/image/path"), + callbackCaptor.capture() + ); + callbackCaptor.getValue().onVideoRecordingFailed("Because this is a test"); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("videoRecordingFailed"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeStartVideoRecordingMethodCall( + @NonNull String filePath + ) { + Map requestArguments = new HashMap<>(); + requestArguments.put("filePath", filePath); + return new MethodCall("startVideoRecording", requestArguments); + } + + @Test + public void itHandlesStopVideoRecordingHappyPath() { + // Setup test. + final MethodCall stopVideoRecordingRequest = createFakeStopVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(stopVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).stopVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().success(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesStopVideoRecordingWhenVideoRecordingFailed() { + // Setup test. + final MethodCall startVideoRecordingRequest = createFakeStopVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(startVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).stopVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onVideoRecordingFailed("Because this is a test"); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("videoRecordingFailed"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeStopVideoRecordingMethodCall() { + Map requestArguments = new HashMap<>(); + return new MethodCall("stopVideoRecording", requestArguments); + } + + @Test + public void itHandlesPauseVideoRecordingHappyPath() { + // Setup test. + final MethodCall pauseVideoRecordingRequest = createFakePauseVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(pauseVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).pauseVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().success(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesPauseVideoRecordingWhenOperationNotSupported() { + // Setup test. + final MethodCall pauseVideoRecordingRequest = createFakePauseVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(pauseVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).pauseVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onUnsupportedOperation(); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("videoRecordingFailed"), + eq("pauseVideoRecording requires Android API +24."), + eq(null) + ); + } + + @Test + public void itHandlesPauseVideoRecordingWhenVideoRecordingFailed() { + // Setup test. + final MethodCall pauseVideoRecordingRequest = createFakePauseVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(pauseVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).pauseVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onVideoRecordingFailed("Because this is a test"); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("videoRecordingFailed"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakePauseVideoRecordingMethodCall() { + Map requestArguments = new HashMap<>(); + return new MethodCall("pauseVideoRecording", requestArguments); + } + + @Test + public void itHandlesResumeVideoRecordingHappyPath() { + // Setup test. + final MethodCall resumeVideoRecordingRequest = createFakeResumeVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(resumeVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).resumeVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().success(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesResumeVideoRecordingWhenOperationNotSupported() { + // Setup test. + final MethodCall resumeVideoRecordingRequest = createFakeResumeVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(resumeVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).resumeVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onUnsupportedOperation(); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("videoRecordingFailed"), + eq("resumeVideoRecording requires Android API +24."), + eq(null) + ); + } + + @Test + public void itHandlesResumeVideoRecordingWhenVideoRecordingFailed() { + // Setup test. + final MethodCall resumeVideoRecordingRequest = createFakeResumeVideoRecordingMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(resumeVideoRecordingRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + verify(fakeCameraSystem, times(1)).resumeVideoRecording( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onVideoRecordingFailed("Because this is a test"); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("videoRecordingFailed"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeResumeVideoRecordingMethodCall() { + Map requestArguments = new HashMap<>(); + return new MethodCall("resumeVideoRecording", requestArguments); + } + + @Test + public void itHandlesStartImageStreamRequestHappyPath() { + // Setup test. + final MethodCall startImageStreamRequest = createFakeStartImageStreamMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(startImageStreamRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraAccessCommandCallback.class); + verify(fakeCameraSystem, times(1)).startImageStream( + callbackCaptor.capture() + ); + callbackCaptor.getValue().success(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesStartImageStreamRequestWhenCameraAccessFailed() { + // Setup test. + final MethodCall startImageStreamRequest = createFakeStartImageStreamMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(startImageStreamRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraAccessCommandCallback.class); + verify(fakeCameraSystem, times(1)).startImageStream( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onCameraAccessFailure("Because this is a test"); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("CameraAccess"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeStartImageStreamMethodCall() { + Map requestArguments = new HashMap<>(); + return new MethodCall("startImageStream", requestArguments); + } + + @Test + public void itHandlesStopImageStreamRequestHappyPath() { + // Setup test. + final MethodCall stopImageStreamRequest = createFakeStopImageStreamMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(stopImageStreamRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraAccessCommandCallback.class); + verify(fakeCameraSystem, times(1)).stopImageStream( + callbackCaptor.capture() + ); + callbackCaptor.getValue().success(); + + // Verify success response was sent from Android to Flutter. + verify(fakeResult, times(1)).success(eq(null)); + } + + @Test + public void itHandlesStopImageStreamRequestWhenCameraAccessFailed() { + // Setup test. + final MethodCall stopImageStreamRequest = createFakeStopImageStreamMethodCall(); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(stopImageStreamRequest, fakeResult); + + // Verify that the CameraSystem was invoked with the expected request. + // Then invoke the callback so that the channel sends a response. + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(CameraSystem.OnCameraAccessCommandCallback.class); + verify(fakeCameraSystem, times(1)).stopImageStream( + callbackCaptor.capture() + ); + callbackCaptor.getValue().onCameraAccessFailure("Because this is a test"); + + // Verify error response was sent from Android to Flutter. + verify(fakeResult, times(1)).error( + eq("CameraAccess"), + eq("Because this is a test"), + eq(null) + ); + } + + @NonNull + private MethodCall createFakeStopImageStreamMethodCall() { + Map requestArguments = new HashMap<>(); + return new MethodCall("stopImageStream", requestArguments); + } + @Test public void itHandlesPrepareForVideoRecordingWithAutomaticSuccess() { // Setup test. @@ -253,4 +829,27 @@ public void itHandlesDisposeRequest() { verify(fakeCameraSystem, times(1)).dispose(); verify(fakeResult, times(1)).success(eq(null)); } + + @Test + public void itHandlesNonExistantRequestTypes() { + // Setup test. + final MethodCall nonExistentRequest = new MethodCall("doesNotExist", null); + final MethodChannel.Result fakeResult = mock(MethodChannel.Result.class); + + // Execute behavior under test. + channelHandler.onMethodCall(nonExistentRequest, fakeResult); + + // Verify results. + verifyZeroInteractions(fakeCameraSystem); + verify(fakeResult, times(1)).notImplemented(); + } + + @Test + public void itDisposesCameraSystemWhenReleased() { + // Execute behavior under test. + protocol.release(); + + // Verify results. + verify(fakeCameraSystem, times(1)).dispose(); + } } From 3dcc0ab3fd9de15b56b3a137eb9689c1d0f593a9 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 25 Sep 2019 17:15:13 -0700 Subject: [PATCH 14/20] Moved CameraPreviewDisplay and CameraImageStream implementations into CameraPluginProtocol because they involve channel comms, then added unit tests for ChannelCameraPreviewDisplay and ChannelCameraImageStream to keep us at 100% coverage for the protocol. --- .../plugins/camera/AndroidCameraSystem.java | 9 +- .../plugins/camera/CameraImageStream.java | 41 +--- .../flutter/plugins/camera/CameraPlugin.java | 3 +- .../plugins/camera/CameraPluginProtocol.java | 84 +++++++ .../plugins/camera/CameraPreviewDisplay.java | 25 +-- .../camera/example/android/app/build.gradle | 4 +- .../camera/ChannelCameraImageStreamTest.java | 207 ++++++++++++++++++ .../ChannelCameraPreviewDisplayTest.java | 52 +++++ .../org.mockito.plugins.MockMaker | 1 + 9 files changed, 357 insertions(+), 69 deletions(-) create mode 100644 packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraImageStreamTest.java create mode 100644 packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraPreviewDisplayTest.java create mode 100644 packages/camera/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java index 430c40beaef6..6673e7bf4df2 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java @@ -30,7 +30,7 @@ @NonNull private final CameraPermissions cameraPermissions; @NonNull - private final EventChannel imageStreamChannel; + private final CameraPreviewDisplay cameraPreviewDisplay; @Nullable private Camera camera; @@ -38,12 +38,12 @@ @NonNull FlutterPlugin.FlutterPluginBinding pluginBinding, @NonNull ActivityPluginBinding activityBinding, @NonNull CameraPermissions cameraPermissions, - @NonNull EventChannel imageStreamChannel + @NonNull CameraPreviewDisplay cameraPreviewDisplay ) { this.pluginBinding = pluginBinding; this.activityBinding = activityBinding; this.cameraPermissions = cameraPermissions; - this.imageStreamChannel = imageStreamChannel; + this.cameraPreviewDisplay = cameraPreviewDisplay; } @Override @@ -209,8 +209,7 @@ public void resumeVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCal public void startImageStream(@NonNull OnCameraAccessCommandCallback callback) { // TODO(mattcarroll): determine desired behavior when no camera is active try { - CameraPreviewDisplay previewDisplay = new CameraPreviewDisplay(imageStreamChannel); - camera.startPreviewWithImageStream(previewDisplay); + camera.startPreviewWithImageStream(cameraPreviewDisplay); callback.success(); } catch (CameraAccessException e) { callback.onCameraAccessFailure(e.getMessage()); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java index f7f7c4497ca3..e4bfd2fe100a 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java @@ -8,43 +8,6 @@ import androidx.annotation.NonNull; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import io.flutter.plugin.common.EventChannel; - -public class CameraImageStream { - private final EventChannel.EventSink imageStreamSink; - - CameraImageStream(@NonNull EventChannel.EventSink imageStreamSink) { - this.imageStreamSink = imageStreamSink; - } - - public void sendImage(@NonNull Image image) { - List> planes = new ArrayList<>(); - for (Image.Plane plane : image.getPlanes()) { - ByteBuffer buffer = plane.getBuffer(); - - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes, 0, bytes.length); - - Map planeBuffer = new HashMap<>(); - planeBuffer.put("bytesPerRow", plane.getRowStride()); - planeBuffer.put("bytesPerPixel", plane.getPixelStride()); - planeBuffer.put("bytes", bytes); - - planes.add(planeBuffer); - } - - Map imageBuffer = new HashMap<>(); - imageBuffer.put("width", image.getWidth()); - imageBuffer.put("height", image.getHeight()); - imageBuffer.put("format", image.getFormat()); - imageBuffer.put("planes", planes); - - imageStreamSink.success(imageBuffer); - } +public interface CameraImageStream { + void sendImage(@NonNull Image image); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 2066078c5ebc..591d9d6791a2 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -72,11 +72,12 @@ private void setup() { pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera/imageStream" ); + CameraPreviewDisplay cameraImageStream = new CameraPluginProtocol.ChannelCameraPreviewDisplay(imageStreamChannel); CameraSystem cameraSystem = new AndroidCameraSystem( pluginBinding, activityBinding, cameraPermissions, - imageStreamChannel + cameraImageStream ); this.cameraPluginProtocol = new CameraPluginProtocol(cameraSystem); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java index 4a0b6fe7e94f..2fdd0b64bd71 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -5,17 +5,35 @@ package dev.flutter.plugins.camera; import android.hardware.camera2.CameraAccessException; +import android.media.Image; import androidx.annotation.NonNull; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; +/** + * Handles all channel communications for the Camera plugin. + * + *

This class has the following responsibilities: + *

    + *
  1. Parse incoming channel requests
  2. + *
  3. Invoke a corresponding high-level behavior
  4. + *
  5. Serialize and send results
  6. + *
+ * + *

This class should not include any plugin implementation responsibilities. The purpose + * of this class is to codify the channel communication protocol, and that's all. By limiting + * the scope of this class to the channel protocol, we can easily achieve 100% unit test coverage + * of all incoming request processing, and result responses. + */ /* package */ class CameraPluginProtocol { @NonNull @@ -268,4 +286,70 @@ private void handleException(Exception exception, MethodChannel.Result result) { } } + /** + * Implementation of a {@link CameraPreviewDisplay} that uses an {@link EventChannel} + * to send camera preview images to Flutter. + * + *

See {@link ChannelCameraImageStream} for serialization details. + */ + /* package */ static class ChannelCameraPreviewDisplay implements CameraPreviewDisplay { + private final EventChannel streamChannel; + + /* package */ ChannelCameraPreviewDisplay(@NonNull EventChannel streamChannel) { + this.streamChannel = streamChannel; + } + + @Override + public void startStreaming(@NonNull final CameraPreviewDisplay.ImageStreamConnection connection) { + streamChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, EventChannel.EventSink eventSink) { + CameraImageStream cameraImageStream = new ChannelCameraImageStream(eventSink); + connection.onConnectionReady(cameraImageStream); + } + + @Override + public void onCancel(Object o) { + connection.onConnectionClosed(); + } + }); + } + } + + /** + * Implementation of {@link CameraImageStream} that uses an {@link EventChannel.EventSink} to + * serialize and send camera preview images to Flutter. + */ + /* package */ static class ChannelCameraImageStream implements CameraImageStream { + private EventChannel.EventSink imageStreamSink; + + /* package */ ChannelCameraImageStream(@NonNull EventChannel.EventSink imageStreamSink) { + this.imageStreamSink = imageStreamSink; + } + + public void sendImage(@NonNull Image image) { + List> planes = new ArrayList<>(); + for (Image.Plane plane : image.getPlanes()) { + ByteBuffer buffer = plane.getBuffer(); + + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes, 0, bytes.length); + + Map planeBuffer = new HashMap<>(); + planeBuffer.put("bytesPerRow", plane.getRowStride()); + planeBuffer.put("bytesPerPixel", plane.getPixelStride()); + planeBuffer.put("bytes", bytes); + + planes.add(planeBuffer); + } + + Map imageBuffer = new HashMap<>(); + imageBuffer.put("width", image.getWidth()); + imageBuffer.put("height", image.getHeight()); + imageBuffer.put("format", image.getFormat()); + imageBuffer.put("planes", planes); + + imageStreamSink.success(imageBuffer); + } + } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java index 7017f1dbb092..d51a064d3732 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java @@ -6,29 +6,8 @@ import androidx.annotation.NonNull; -import io.flutter.plugin.common.EventChannel; - -public class CameraPreviewDisplay { - private final EventChannel imageStreamChannel; - - /* package */ CameraPreviewDisplay(@NonNull EventChannel imageStreamChannel) { - this.imageStreamChannel = imageStreamChannel; - } - - void startStreaming(@NonNull final ImageStreamConnection connection) { - imageStreamChannel.setStreamHandler(new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, EventChannel.EventSink eventSink) { - CameraImageStream cameraImageStream = new CameraImageStream(eventSink); - connection.onConnectionReady(cameraImageStream); - } - - @Override - public void onCancel(Object o) { - connection.onConnectionClosed(); - } - }); - } +public interface CameraPreviewDisplay { + void startStreaming(@NonNull final ImageStreamConnection connection); interface ImageStreamConnection { void onConnectionReady(@NonNull CameraImageStream stream); diff --git a/packages/camera/example/android/app/build.gradle b/packages/camera/example/android/app/build.gradle index ed2031f22775..ef432a5581fa 100644 --- a/packages/camera/example/android/app/build.gradle +++ b/packages/camera/example/android/app/build.gradle @@ -63,5 +63,7 @@ dependencies { testImplementation 'junit:junit:4.12' testImplementation 'androidx.test:core:1.2.0' // for robolectric - testImplementation 'org.mockito:mockito-core:1.10.19' + testImplementation 'org.mockito:mockito-core:2.28.2' + testImplementation 'org.mockito:mockito-inline:2.28.2' // for mocking final classes + } diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraImageStreamTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraImageStreamTest.java new file mode 100644 index 000000000000..f5cfa9e7c274 --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraImageStreamTest.java @@ -0,0 +1,207 @@ +package dev.flutter.plugins.camera; + +import android.media.Image; + +import androidx.annotation.NonNull; + +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.EventChannel; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ChannelCameraImageStreamTest { + + @Test + public void itSerializesImageAndSendsThroughChannel() { + // Setup test. + // One row of pixels, with 1 byte per pixel. + final byte[] fakeImageBytes = new byte[] { + 0b00000001, + 0b00000010, + 0b00000011, + 0b00000100, + 0b00000101, + 0b00000110, + 0b00000111, + 0b00001000, + 0b00001001, + 0b00001010 + }; + + // Create a fake Image. Most numbers don't matter, but + // each plane's bytesPerRow and bytesPerPixel must be + // congruent with the corresponding fake bytes. + final Image fakeImage = new FakeImageBuilder() + .width(1920) + .height(1080) + .format(1) + .buildPlane() + .bytesPerRow(10) + .bytesPerPixel(1) + .bytes(fakeImageBytes) + .build() + .build(); + + final EventChannel.EventSink fakeEventSink = mock(EventChannel.EventSink.class); + final CameraPluginProtocol.ChannelCameraImageStream imageStream = new CameraPluginProtocol.ChannelCameraImageStream(fakeEventSink); + + // Execute the behavior under test. + imageStream.sendImage(fakeImage); + + // Verify results. + // Verify that the channel was invoked, and capture the response. + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Map.class); + verify(fakeEventSink, times(1)).success(responseCaptor.capture()); + Map response = (Map) responseCaptor.getValue(); + + // Verify the reported width, height, and format of the Image. + assertEquals(1920, response.get("width")); + assertEquals(1080, response.get("height")); + assertEquals(1, response.get("format")); + + // Verify the reported Plane's bytes per row and bytes per pixel. + List> serializedPlanes = (List>) response.get("planes"); + assertEquals(1, serializedPlanes.size()); + Map serializedPlane = serializedPlanes.get(0); + assertEquals(10, serializedPlane.get("bytesPerRow")); + assertEquals(1, serializedPlane.get("bytesPerPixel")); + + // Verify the reported Plane's bytes value. + byte[] serializedBytes = (byte[]) serializedPlane.get("bytes"); + assertArrayEquals(fakeImageBytes, serializedBytes); + } + + /** + * Builds a fake {@link Image} with desired properties. + */ + private static class FakeImageBuilder { + private int width; + private int height; + private int format; + private final List fakePlanes = new ArrayList<>(); + + @NonNull + public FakeImageBuilder width(int width) { + this.width = width; + return this; + } + + @NonNull + public FakeImageBuilder height(int height) { + this.height = height; + return this; + } + + @NonNull + public FakeImageBuilder format(int format) { + this.format = format; + return this; + } + + @NonNull + public FakeImagePlaneBuilder buildPlane() { + return new FakeImagePlaneBuilder(this); + } + + private void addPlane(@NonNull Image.Plane fakePlane) { + fakePlanes.add(fakePlane); + } + + @NonNull + public Image build() { + Image fakeImage = mock(Image.class); + when(fakeImage.getWidth()).thenReturn(width); + when(fakeImage.getHeight()).thenReturn(height); + when(fakeImage.getFormat()).thenReturn(format); + when(fakeImage.getPlanes()).thenReturn(fakePlanes.toArray(new Image.Plane[] {})); + return fakeImage; + } + } + + /** + * Builds a fake {@link Image.Plane} to go within a fake {@link Image} + * as produced by a given {@link FakeImagePlaneBuilder}. + */ + private static class FakeImagePlaneBuilder { + private final FakeImageBuilder imageBuilder; + private int bytesPerRow; + private int bytesPerPixel; + private byte[] bytes; + + private FakeImagePlaneBuilder(@NonNull FakeImageBuilder imageBuilder) { + this.imageBuilder = imageBuilder; + } + + @NonNull + public FakeImagePlaneBuilder bytesPerRow(int count) { + this.bytesPerRow = count; + return this; + } + + @NonNull + public FakeImagePlaneBuilder bytesPerPixel(int count) { + this.bytesPerPixel = count; + return this; + } + + @NonNull + public FakeImagePlaneBuilder bytes(@NonNull byte[] bytes) { + this.bytes = bytes; + return this; + } + + @NonNull + public FakeImageBuilder build() { + Image.Plane fakePlane = mock(Image.Plane.class); + when(fakePlane.getRowStride()).thenReturn(bytesPerRow); + when(fakePlane.getPixelStride()).thenReturn(bytesPerPixel); + + final int byteCount = bytesPerRow * bytesPerPixel; + assertEquals( + "Provided bytes must match expected byte count: row * pixel", + byteCount, + bytes.length + ); + + ByteBuffer buffer = mock(ByteBuffer.class); + when(buffer.remaining()).thenReturn(byteCount); + when(buffer.get(any(byte[].class), eq(0), eq(byteCount))).thenAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + byte[] outputBytes = invocation.getArgument(0); + assertEquals( + "The array that is receiving bytes must be the same size as the fake array of bytes.", + bytes.length, + outputBytes.length + ); + for (int i = 0; i < bytes.length; ++i) { + outputBytes[i] = bytes[i]; + } + return null; + } + }); + when(fakePlane.getBuffer()).thenReturn(buffer); + + imageBuilder.addPlane(fakePlane); + + return imageBuilder; + } + } + +} diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraPreviewDisplayTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraPreviewDisplayTest.java new file mode 100644 index 000000000000..20ebe95ef1af --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraPreviewDisplayTest.java @@ -0,0 +1,52 @@ +package dev.flutter.plugins.camera; + +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import dev.flutter.plugins.camera.CameraPluginProtocol.ChannelCameraPreviewDisplay; +import io.flutter.plugin.common.EventChannel; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class ChannelCameraPreviewDisplayTest { + + @Test + public void itNotifiesImageStreamConnectionWhenItsReadyToStreamImages() { + final EventChannel fakeEventChannel = mock(EventChannel.class); + final EventChannel.EventSink fakeEventSink = mock(EventChannel.EventSink.class); + final ChannelCameraPreviewDisplay display = new ChannelCameraPreviewDisplay(fakeEventChannel); + final CameraPreviewDisplay.ImageStreamConnection fakeImageStreamConnection = mock(CameraPreviewDisplay.ImageStreamConnection.class); + + display.startStreaming(fakeImageStreamConnection); + + ArgumentCaptor streamCaptor = ArgumentCaptor.forClass(EventChannel.StreamHandler.class); + verify(fakeEventChannel, times(1)).setStreamHandler(streamCaptor.capture()); + EventChannel.StreamHandler streamHandler = streamCaptor.getValue(); + + streamHandler.onListen(null, fakeEventSink); + verify(fakeImageStreamConnection, times(1)).onConnectionReady(any(CameraPluginProtocol.ChannelCameraImageStream.class)); + } + + @Test + public void itClosesImageStreamConnectionWhenChannelCloses() { + final EventChannel fakeEventChannel = mock(EventChannel.class); + final EventChannel.EventSink fakeEventSink = mock(EventChannel.EventSink.class); + final ChannelCameraPreviewDisplay display = new ChannelCameraPreviewDisplay(fakeEventChannel); + final CameraPreviewDisplay.ImageStreamConnection fakeImageStreamConnection = mock(CameraPreviewDisplay.ImageStreamConnection.class); + + display.startStreaming(fakeImageStreamConnection); + + ArgumentCaptor streamCaptor = ArgumentCaptor.forClass(EventChannel.StreamHandler.class); + verify(fakeEventChannel, times(1)).setStreamHandler(streamCaptor.capture()); + EventChannel.StreamHandler streamHandler = streamCaptor.getValue(); + + streamHandler.onListen(null, fakeEventSink); + streamHandler.onCancel(null); + + verify(fakeImageStreamConnection, times(1)).onConnectionClosed(); + } + +} diff --git a/packages/camera/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/packages/camera/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000000..ca6ee9cea8ec --- /dev/null +++ b/packages/camera/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file From 02ab397780d9852e65fa8bfe76aec7ef4704ed6c Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 25 Sep 2019 21:12:16 -0700 Subject: [PATCH 15/20] Extracted per-camera channel construction out into CameraPlugin for testing purposes. --- .../plugins/camera/AndroidCameraSystem.java | 41 ++++++---------- .../plugins/camera/CameraImageStream.java | 3 ++ .../flutter/plugins/camera/CameraPlugin.java | 22 +++++---- .../plugins/camera/CameraPluginProtocol.java | 48 +++++++++++++++++++ .../plugins/camera/CameraPreviewDisplay.java | 4 ++ .../flutter/plugins/camera/CameraSystem.java | 3 ++ 6 files changed, 87 insertions(+), 34 deletions(-) diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java index 6673e7bf4df2..4e724998d29a 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java @@ -13,7 +13,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -22,6 +21,9 @@ import io.flutter.plugin.common.EventChannel; import io.flutter.view.TextureRegistry; +/** + * Android implementation of a {@link CameraSystem}. + */ /* package */ class AndroidCameraSystem implements CameraSystem { @NonNull private final FlutterPlugin.FlutterPluginBinding pluginBinding; @@ -31,6 +33,8 @@ private final CameraPermissions cameraPermissions; @NonNull private final CameraPreviewDisplay cameraPreviewDisplay; + @NonNull + private final CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory; @Nullable private Camera camera; @@ -38,12 +42,14 @@ @NonNull FlutterPlugin.FlutterPluginBinding pluginBinding, @NonNull ActivityPluginBinding activityBinding, @NonNull CameraPermissions cameraPermissions, - @NonNull CameraPreviewDisplay cameraPreviewDisplay - ) { + @NonNull CameraPreviewDisplay cameraPreviewDisplay, + @NonNull CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory + ) { this.pluginBinding = pluginBinding; this.activityBinding = activityBinding; this.cameraPermissions = cameraPermissions; this.cameraPreviewDisplay = cameraPreviewDisplay; + this.cameraEventChannelFactory = cameraEventChannelFactory; } @Override @@ -118,36 +124,19 @@ public void onCameraOpenFailed(@NonNull String message) { } }); - EventChannel cameraEventChannel = new EventChannel( - pluginBinding.getFlutterEngine().getDartExecutor(), - "flutter.io/cameraPlugin/cameraEvents" + textureEntry.id() - ); + final CameraPluginProtocol.ChannelCameraEventHandler eventHandler = new CameraPluginProtocol.ChannelCameraEventHandler(); + camera.setCameraEventHandler(eventHandler); + + EventChannel cameraEventChannel = cameraEventChannelFactory.createCameraEventChannel(textureEntry.id()); cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { @Override public void onListen(Object o, final EventChannel.EventSink eventSink) { - final Camera.CameraEventHandler cameraEventHandler = new Camera.CameraEventHandler() { - @Override - public void onError(String description) { - Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", description); - eventSink.success(event); - } - - @Override - public void onCameraClosed() { - Map event = new HashMap<>(); - event.put("eventType", "camera_closing"); - eventSink.success(event); - } - }; - - camera.setCameraEventHandler(cameraEventHandler); + eventHandler.setEventSink(eventSink); } @Override public void onCancel(Object o) { - camera.setCameraEventHandler(null); + eventHandler.setEventSink(null); } }); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java index e4bfd2fe100a..d8cc5a3215ef 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java @@ -8,6 +8,9 @@ import androidx.annotation.NonNull; +/** + * Serializes and sends an {@link Image} to a destination through a stream. + */ public interface CameraImageStream { void sendImage(@NonNull Image image); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index 591d9d6791a2..d132fe70d9a0 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -73,26 +73,32 @@ private void setup() { "plugins.flutter.io/camera/imageStream" ); CameraPreviewDisplay cameraImageStream = new CameraPluginProtocol.ChannelCameraPreviewDisplay(imageStreamChannel); + CameraPluginProtocol.CameraEventChannelFactory cameraChannelFactory = new CameraPluginProtocol.CameraEventChannelFactory() { + @NonNull + @Override + public EventChannel createCameraEventChannel(long textureId) { + return new EventChannel( + pluginBinding.getFlutterEngine().getDartExecutor(), + "flutter.io/cameraPlugin/cameraEvents" + textureId + ); + } + }; CameraSystem cameraSystem = new AndroidCameraSystem( pluginBinding, activityBinding, cameraPermissions, - cameraImageStream + cameraImageStream, + cameraChannelFactory ); this.cameraPluginProtocol = new CameraPluginProtocol(cameraSystem); - final MethodChannel channel = new MethodChannel( + final MethodChannel primaryPluginChannel = new MethodChannel( pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera" ); - channel.setMethodCallHandler(cameraPluginProtocol.getCameraSystemChannelHandler()); + primaryPluginChannel.setMethodCallHandler(cameraPluginProtocol.getCameraSystemChannelHandler()); } - // TODO: there are 2+ channels - // 1:EventChannel - plugins.flutter.io/camera/imageStream - // 1:MethodChannel - plugins.flutter.io/camera - // 0+:EventChannel - flutter.io/cameraPlugin/cameraEvents[textureId] - private void teardown() { // Teardown if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java index 2fdd0b64bd71..19940831f0a4 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -8,12 +8,14 @@ import android.media.Image; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodCall; @@ -286,6 +288,11 @@ private void handleException(Exception exception, MethodChannel.Result result) { } } + interface CameraEventChannelFactory { + @NonNull + EventChannel createCameraEventChannel(long textureId); + } + /** * Implementation of a {@link CameraPreviewDisplay} that uses an {@link EventChannel} * to send camera preview images to Flutter. @@ -352,4 +359,45 @@ public void sendImage(@NonNull Image image) { imageStreamSink.success(imageBuffer); } } + + /* package */ static class ChannelCameraEventHandler implements Camera.CameraEventHandler { + private final List> queuedEvents = new CopyOnWriteArrayList<>(); + private EventChannel.EventSink eventSink; + + /* package */ ChannelCameraEventHandler() {} + + public void setEventSink(@Nullable EventChannel.EventSink eventSink) { + this.eventSink = eventSink; + + // Send all queue'd events. + if (!queuedEvents.isEmpty()) { + while (!queuedEvents.isEmpty()) { + eventSink.success(queuedEvents.remove(0)); + } + } + } + + @Override + public void onError(String description) { + Map event = new HashMap<>(); + event.put("eventType", "error"); + event.put("errorDescription", description); + sendEvent(event); + } + + @Override + public void onCameraClosed() { + Map event = new HashMap<>(); + event.put("eventType", "camera_closing"); + sendEvent(event); + } + + private void sendEvent(@NonNull Map event) { + if (eventSink != null) { + eventSink.success(event); + } else { + queuedEvents.add(event); + } + } + } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java index d51a064d3732..cadc909136ea 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java @@ -6,6 +6,10 @@ import androidx.annotation.NonNull; +/** + * Displays a camera preview by streaming {@link Image}s from an Android + * camera by way of a given {@link ImageStreamConnection}. + */ public interface CameraPreviewDisplay { void startStreaming(@NonNull final ImageStreamConnection connection); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java index c28a02d59a12..c5e0d26e98a6 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -10,6 +10,9 @@ import java.util.List; +/** + * Top-level facade for all Camera plugin behavior. + */ /* package */ interface CameraSystem { List getAvailableCameras() throws CameraAccessException; From e5408d91e3a92bd02bb10ee2a8042b40406b44f0 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 25 Sep 2019 21:54:48 -0700 Subject: [PATCH 16/20] Refactored so that CameraSystem and AndroidCameraSystem could be reduced to just a CameraSystem that doesn't know about Android stuff. --- .../plugins/camera/AndroidCameraFactory.java | 48 ++++ .../plugins/camera/AndroidCameraHardware.java | 34 +++ .../plugins/camera/AndroidCameraSystem.java | 225 ------------------ .../dev/flutter/plugins/camera/Camera.java | 4 + .../flutter/plugins/camera/CameraFactory.java | 14 ++ .../plugins/camera/CameraHardware.java | 12 + .../flutter/plugins/camera/CameraPlugin.java | 41 +++- .../plugins/camera/CameraPluginProtocol.java | 18 +- .../flutter/plugins/camera/CameraSystem.java | 186 +++++++++++++-- .../flutter/plugins/camera/CameraUtils.java | 5 +- 10 files changed, 330 insertions(+), 257 deletions(-) create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraFactory.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java delete mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java create mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraFactory.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraFactory.java new file mode 100644 index 000000000000..8dec6e874c8c --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraFactory.java @@ -0,0 +1,48 @@ +package dev.flutter.plugins.camera; + +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.view.TextureRegistry; + +/* package */ class AndroidCameraFactory implements CameraFactory { + @NonNull + private final FlutterPlugin.FlutterPluginBinding pluginBinding; + @NonNull + private final ActivityPluginBinding activityBinding; + + /* package */ AndroidCameraFactory( + @NonNull FlutterPlugin.FlutterPluginBinding pluginBinding, + @NonNull ActivityPluginBinding activityBinding + ) { + this.pluginBinding = pluginBinding; + this.activityBinding = activityBinding; + } + + @NonNull + @Override + public Camera createCamera( + @NonNull String cameraName, + @NonNull String resolutionPreset, + boolean enableAudio + ) throws CameraAccessException { + TextureRegistry.SurfaceTextureEntry textureEntry = pluginBinding + .getFlutterEngine() + .getRenderer() + .createSurfaceTexture(); + + return new Camera( + activityBinding.getActivity(), + (CameraManager) activityBinding.getActivity().getSystemService(Context.CAMERA_SERVICE), + textureEntry, + cameraName, + resolutionPreset, + enableAudio + ); + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java new file mode 100644 index 000000000000..8df25a3875fb --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java @@ -0,0 +1,34 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/* package */ class AndroidCameraHardware implements CameraHardware { + @NonNull + private final CameraManager cameraManager; + + /* package */ AndroidCameraHardware(@NonNull CameraManager cameraManager) { + this.cameraManager = cameraManager; + } + + @NonNull + @Override + public List getAvailableCameras() throws CameraAccessException { + List> allCameraDetailsSerialized = CameraUtils.getAvailableCameras(cameraManager); + List allCameraDetails = new ArrayList<>(); + for (Map serializedDetails : allCameraDetailsSerialized) { + allCameraDetails.add(new CameraDetails( + (String) serializedDetails.get("name"), + (Integer) serializedDetails.get("sensorOrientation"), + (String) serializedDetails.get("lensFacing") + )); + } + return allCameraDetails; + } +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java deleted file mode 100644 index 4e724998d29a..000000000000 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraSystem.java +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2019 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package dev.flutter.plugins.camera; - -import android.content.Context; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import io.flutter.embedding.engine.plugins.FlutterPlugin; -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; -import io.flutter.plugin.common.EventChannel; -import io.flutter.view.TextureRegistry; - -/** - * Android implementation of a {@link CameraSystem}. - */ -/* package */ class AndroidCameraSystem implements CameraSystem { - @NonNull - private final FlutterPlugin.FlutterPluginBinding pluginBinding; - @NonNull - private final ActivityPluginBinding activityBinding; - @NonNull - private final CameraPermissions cameraPermissions; - @NonNull - private final CameraPreviewDisplay cameraPreviewDisplay; - @NonNull - private final CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory; - @Nullable - private Camera camera; - - /* package */ AndroidCameraSystem( - @NonNull FlutterPlugin.FlutterPluginBinding pluginBinding, - @NonNull ActivityPluginBinding activityBinding, - @NonNull CameraPermissions cameraPermissions, - @NonNull CameraPreviewDisplay cameraPreviewDisplay, - @NonNull CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory - ) { - this.pluginBinding = pluginBinding; - this.activityBinding = activityBinding; - this.cameraPermissions = cameraPermissions; - this.cameraPreviewDisplay = cameraPreviewDisplay; - this.cameraEventChannelFactory = cameraEventChannelFactory; - } - - @Override - public List getAvailableCameras() throws CameraAccessException { - List> allCameraDetailsSerialized = CameraUtils.getAvailableCameras(activityBinding.getActivity()); - List allCameraDetails = new ArrayList<>(); - for (Map serializedDetails : allCameraDetailsSerialized) { - allCameraDetails.add(new CameraDetails( - (String) serializedDetails.get("name"), - (Integer) serializedDetails.get("sensorOrientation"), - (String) serializedDetails.get("lensFacing") - )); - } - return allCameraDetails; - } - - @Override - public void initialize( - @NonNull CameraConfigurationRequest request, - @NonNull OnCameraInitializationCallback callback - ) { - if (camera != null) { - camera.close(); - } - - cameraPermissions.requestPermissions( - request.getEnableAudio(), - new CameraPermissions.ResultCallback() { - @Override - public void onSuccess() { - try { - instantiateCamera(request, callback); - } catch (Exception error) { - callback.onError("CameraAccess", error.getMessage()); - } - } - - @Override - public void onResult(String errorCode, String errorDescription) { - callback.onCameraPermissionError(errorCode, errorDescription); - } - }); - } - - private void instantiateCamera( - @NonNull CameraConfigurationRequest request, - @NonNull OnCameraInitializationCallback callback - ) throws CameraAccessException { - TextureRegistry.SurfaceTextureEntry textureEntry = pluginBinding - .getFlutterEngine() - .getRenderer() - .createSurfaceTexture(); - - camera = new Camera( - activityBinding.getActivity(), - (CameraManager) activityBinding.getActivity().getSystemService(Context.CAMERA_SERVICE), - textureEntry, - request.getCameraName(), - request.getResolutionPreset(), - request.getEnableAudio() - ); - - camera.open(new Camera.OnCameraOpenedCallback() { - @Override - public void onCameraOpened(long textureId, int previewWidth, int previewHeight) { - callback.onSuccess(textureId, previewWidth, previewHeight); - } - - @Override - public void onCameraOpenFailed(@NonNull String message) { - callback.onError("CameraAccess", message); - } - }); - - final CameraPluginProtocol.ChannelCameraEventHandler eventHandler = new CameraPluginProtocol.ChannelCameraEventHandler(); - camera.setCameraEventHandler(eventHandler); - - EventChannel cameraEventChannel = cameraEventChannelFactory.createCameraEventChannel(textureEntry.id()); - cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, final EventChannel.EventSink eventSink) { - eventHandler.setEventSink(eventSink); - } - - @Override - public void onCancel(Object o) { - eventHandler.setEventSink(null); - } - }); - } - - @Override - public void takePicture(@NonNull String filePath, @NonNull Camera.OnPictureTakenCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - camera.takePicture(filePath, callback); - } - - @Override - public void startVideoRecording(@NonNull String filePath, @NonNull OnStartVideoRecordingCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - try { - camera.startVideoRecording(filePath); - } catch (IllegalStateException e) { - callback.onFileAlreadyExists(filePath); - } catch (CameraAccessException | IOException e) { - callback.onVideoRecordingFailed(e.getMessage()); - } - } - - @Override - public void stopVideoRecording(@NonNull OnVideoRecordingCommandCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - try { - camera.stopVideoRecording(); - callback.success(); - } catch (CameraAccessException | IllegalStateException e) { - callback.onVideoRecordingFailed(e.getMessage()); - } - } - - @Override - public void pauseVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - try { - camera.pauseVideoRecording(); - } catch (UnsupportedOperationException e) { - callback.onUnsupportedOperation(); - } catch (IllegalStateException e) { - callback.onVideoRecordingFailed(e.getMessage()); - } - } - - @Override - public void resumeVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - try { - camera.resumeVideoRecording(); - } catch (UnsupportedOperationException e) { - callback.onUnsupportedOperation(); - } catch (IllegalStateException e) { - callback.onVideoRecordingFailed(e.getMessage()); - } - } - - @Override - public void startImageStream(@NonNull OnCameraAccessCommandCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - try { - camera.startPreviewWithImageStream(cameraPreviewDisplay); - callback.success(); - } catch (CameraAccessException e) { - callback.onCameraAccessFailure(e.getMessage()); - } - } - - @Override - public void stopImageStream(@NonNull OnCameraAccessCommandCallback callback) { - // TODO(mattcarroll): determine desired behavior when no camera is active - try { - camera.startPreview(); - callback.success(); - } catch (CameraAccessException e) { - callback.onCameraAccessFailure(e.getMessage()); - } - } - - @Override - public void dispose() { - if (camera != null) { - camera.dispose(); - } - } -} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 52c60b8d3a1c..8895364c7e70 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -126,6 +126,10 @@ public void onOrientationChanged(int orientation) { previewSize = computeBestPreviewSize(cameraName, preset); } + public long getTextureId() { + return flutterTexture.id(); + } + public void setCameraEventHandler(@Nullable CameraEventHandler handler) { this.cameraEventHandler = handler; } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java new file mode 100644 index 000000000000..fb46a5841e9b --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java @@ -0,0 +1,14 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +public interface CameraFactory { + @NonNull + Camera createCamera( + @NonNull String cameraName, + @NonNull String resolutionPreset, + boolean enableAudio + ) throws CameraAccessException; +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java new file mode 100644 index 000000000000..9c29068664fa --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java @@ -0,0 +1,12 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +import java.util.List; + +public interface CameraHardware { + @NonNull + List getAvailableCameras() throws CameraAccessException; +} diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java index d132fe70d9a0..87c2023cbf39 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -4,6 +4,8 @@ package dev.flutter.plugins.camera; +import android.content.Context; +import android.hardware.camera2.CameraManager; import android.os.Build; import androidx.annotation.NonNull; @@ -67,12 +69,32 @@ private void setup() { return; } + CameraSystem cameraSystem = createCameraSystem(); + this.cameraPluginProtocol = new CameraPluginProtocol(cameraSystem); + this.cameraPluginProtocol.connect(new MethodChannel( + pluginBinding.getFlutterEngine().getDartExecutor(), + "plugins.flutter.io/camera" + )); + } + + /** + * Selects all concrete dependencies to construct a functional {@link CameraSystem} and + * then returns the assembled {@link CameraSystem}. + */ + @NonNull + private CameraSystem createCameraSystem() { CameraPermissions cameraPermissions = new AndroidCameraPermissions(activityBinding); + + CameraHardware cameraHardware = new AndroidCameraHardware( + (CameraManager) pluginBinding.getApplicationContext().getSystemService(Context.CAMERA_SERVICE) + ); + EventChannel imageStreamChannel = new EventChannel( pluginBinding.getFlutterEngine().getDartExecutor(), "plugins.flutter.io/camera/imageStream" ); CameraPreviewDisplay cameraImageStream = new CameraPluginProtocol.ChannelCameraPreviewDisplay(imageStreamChannel); + CameraPluginProtocol.CameraEventChannelFactory cameraChannelFactory = new CameraPluginProtocol.CameraEventChannelFactory() { @NonNull @Override @@ -83,20 +105,19 @@ public EventChannel createCameraEventChannel(long textureId) { ); } }; - CameraSystem cameraSystem = new AndroidCameraSystem( + + CameraFactory cameraFactory = new AndroidCameraFactory( pluginBinding, - activityBinding, - cameraPermissions, - cameraImageStream, - cameraChannelFactory + activityBinding ); - this.cameraPluginProtocol = new CameraPluginProtocol(cameraSystem); - final MethodChannel primaryPluginChannel = new MethodChannel( - pluginBinding.getFlutterEngine().getDartExecutor(), - "plugins.flutter.io/camera" + return new CameraSystem( + cameraPermissions, + cameraHardware, + cameraImageStream, + cameraChannelFactory, + cameraFactory ); - primaryPluginChannel.setMethodCallHandler(cameraPluginProtocol.getCameraSystemChannelHandler()); } private void teardown() { diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java index 19940831f0a4..c2b1c4e235c1 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -40,6 +41,8 @@ @NonNull private CameraSystem cameraSystem; + @Nullable + private MethodChannel primaryChannel; @NonNull private final CameraSystemChannelHandler channelHandler; @@ -48,12 +51,25 @@ this.channelHandler = new CameraSystemChannelHandler(cameraSystem); } + public void connect(@NonNull MethodChannel channel) { + this.primaryChannel = channel; + this.primaryChannel.setMethodCallHandler(getCameraSystemChannelHandler()); + } + + public void disconnect() { + if (primaryChannel != null) { + primaryChannel.setMethodCallHandler(null); + } + } + public void release() { + disconnect(); cameraSystem.dispose(); } + @VisibleForTesting @NonNull - public MethodChannel.MethodCallHandler getCameraSystemChannelHandler() { + /* package */ MethodChannel.MethodCallHandler getCameraSystemChannelHandler() { return channelHandler; } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java index c5e0d26e98a6..fb747024d406 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -7,41 +7,189 @@ import android.hardware.camera2.CameraAccessException; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.IOException; import java.util.List; +import io.flutter.plugin.common.EventChannel; + /** * Top-level facade for all Camera plugin behavior. */ -/* package */ interface CameraSystem { - List getAvailableCameras() throws CameraAccessException; +/* package */ class CameraSystem { + @NonNull + private final CameraPermissions cameraPermissions; + @NonNull + private final CameraHardware cameraHardware; + @NonNull + private final CameraPreviewDisplay cameraPreviewDisplay; + @NonNull + private final CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory; + @NonNull + private final CameraFactory cameraFactory; + @Nullable + private Camera camera; + + /* package */ CameraSystem( + @NonNull CameraPermissions cameraPermissions, + @NonNull CameraHardware cameraHardware, + @NonNull CameraPreviewDisplay cameraPreviewDisplay, + @NonNull CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory, + @NonNull CameraFactory cameraFactory + ) { + this.cameraPermissions = cameraPermissions; + this.cameraHardware = cameraHardware; + this.cameraPreviewDisplay = cameraPreviewDisplay; + this.cameraEventChannelFactory = cameraEventChannelFactory; + this.cameraFactory = cameraFactory; + } + + public List getAvailableCameras() throws CameraAccessException { + return cameraHardware.getAvailableCameras(); + } - void initialize( + public void initialize( @NonNull CameraConfigurationRequest request, @NonNull OnCameraInitializationCallback callback - ); + ) { + if (camera != null) { + camera.close(); + } - void takePicture( - @NonNull String filePath, - @NonNull Camera.OnPictureTakenCallback callback - ); + if (!cameraPermissions.hasCameraPermission() || (request.getEnableAudio() )) + + cameraPermissions.requestPermissions( + request.getEnableAudio(), + new CameraPermissions.ResultCallback() { + @Override + public void onSuccess() { + try { + openCamera(request, callback); + } catch (Exception error) { + callback.onError("CameraAccess", error.getMessage()); + } + } + + @Override + public void onResult(String errorCode, String errorDescription) { + callback.onCameraPermissionError(errorCode, errorDescription); + } + }); + } - void startVideoRecording( - @NonNull String filePath, - @NonNull OnStartVideoRecordingCallback callback - ); + private void openCamera( + @NonNull CameraConfigurationRequest request, + @NonNull OnCameraInitializationCallback callback + ) throws CameraAccessException { + camera = cameraFactory.createCamera( + request.getCameraName(), + request.getResolutionPreset(), + request.getEnableAudio() + ); + + camera.open(new Camera.OnCameraOpenedCallback() { + @Override + public void onCameraOpened(long textureId, int previewWidth, int previewHeight) { + callback.onSuccess(textureId, previewWidth, previewHeight); + } + + @Override + public void onCameraOpenFailed(@NonNull String message) { + callback.onError("CameraAccess", message); + } + }); + + final CameraPluginProtocol.ChannelCameraEventHandler eventHandler = new CameraPluginProtocol.ChannelCameraEventHandler(); + camera.setCameraEventHandler(eventHandler); + + EventChannel cameraEventChannel = cameraEventChannelFactory.createCameraEventChannel(camera.getTextureId()); + cameraEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, final EventChannel.EventSink eventSink) { + eventHandler.setEventSink(eventSink); + } + + @Override + public void onCancel(Object o) { + eventHandler.setEventSink(null); + } + }); + } + + public void takePicture(@NonNull String filePath, @NonNull Camera.OnPictureTakenCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + camera.takePicture(filePath, callback); + } + + public void startVideoRecording(@NonNull String filePath, @NonNull OnStartVideoRecordingCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.startVideoRecording(filePath); + } catch (IllegalStateException e) { + callback.onFileAlreadyExists(filePath); + } catch (CameraAccessException | IOException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } - void stopVideoRecording(@NonNull OnVideoRecordingCommandCallback callback); + public void stopVideoRecording(@NonNull OnVideoRecordingCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.stopVideoRecording(); + callback.success(); + } catch (CameraAccessException | IllegalStateException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } - void pauseVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback); + public void pauseVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.pauseVideoRecording(); + } catch (UnsupportedOperationException e) { + callback.onUnsupportedOperation(); + } catch (IllegalStateException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } - void resumeVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback); + public void resumeVideoRecording(@NonNull OnApiDependentVideoRecordingCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.resumeVideoRecording(); + } catch (UnsupportedOperationException e) { + callback.onUnsupportedOperation(); + } catch (IllegalStateException e) { + callback.onVideoRecordingFailed(e.getMessage()); + } + } - void startImageStream(@NonNull OnCameraAccessCommandCallback callback); + public void startImageStream(@NonNull OnCameraAccessCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.startPreviewWithImageStream(cameraPreviewDisplay); + callback.success(); + } catch (CameraAccessException e) { + callback.onCameraAccessFailure(e.getMessage()); + } + } - void stopImageStream(@NonNull OnCameraAccessCommandCallback callback); + public void stopImageStream(@NonNull OnCameraAccessCommandCallback callback) { + // TODO(mattcarroll): determine desired behavior when no camera is active + try { + camera.startPreview(); + callback.success(); + } catch (CameraAccessException e) { + callback.onCameraAccessFailure(e.getMessage()); + } + } - void dispose(); + public void dispose() { + if (camera != null) { + camera.dispose(); + } + } /* package */ interface OnCameraInitializationCallback { void onCameraPermissionError(@NonNull String errorCode, @NonNull String description); @@ -71,7 +219,7 @@ void startVideoRecording( void onCameraAccessFailure(@NonNull String message); } - /* package */ class CameraConfigurationRequest { + /* package */ static class CameraConfigurationRequest { @NonNull private final String cameraName; @NonNull diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java index 8201fed3f0fc..c659516b5f7d 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java @@ -11,6 +11,8 @@ import android.media.CamcorderProfile; import android.util.Size; +import androidx.annotation.NonNull; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -41,9 +43,8 @@ static Size computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap new CompareSizesByArea()); } - public static List> getAvailableCameras(Activity activity) + public static List> getAvailableCameras(@NonNull CameraManager cameraManager) throws CameraAccessException { - CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); String[] cameraNames = cameraManager.getCameraIdList(); List> cameras = new ArrayList<>(); for (String cameraName : cameraNames) { From 6ea1b4fc1b39e76493498ead0c6461e971661765 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 25 Sep 2019 22:13:41 -0700 Subject: [PATCH 17/20] Deleted CameraUtils, and continued to cleanup relationships. --- .../plugins/camera/AndroidCameraHardware.java | 38 ++++-- .../dev/flutter/plugins/camera/Camera.java | 52 +++++++- .../flutter/plugins/camera/CameraFactory.java | 6 + .../plugins/camera/CameraHardware.java | 7 + .../flutter/plugins/camera/CameraSystem.java | 8 ++ .../flutter/plugins/camera/CameraUtils.java | 121 ------------------ 6 files changed, 100 insertions(+), 132 deletions(-) delete mode 100644 packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java index 8df25a3875fb..81d3b85aed0f 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java @@ -1,13 +1,14 @@ package dev.flutter.plugins.camera; import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.List; -import java.util.Map; /* package */ class AndroidCameraHardware implements CameraHardware { @NonNull @@ -20,15 +21,34 @@ @NonNull @Override public List getAvailableCameras() throws CameraAccessException { - List> allCameraDetailsSerialized = CameraUtils.getAvailableCameras(cameraManager); - List allCameraDetails = new ArrayList<>(); - for (Map serializedDetails : allCameraDetailsSerialized) { - allCameraDetails.add(new CameraDetails( - (String) serializedDetails.get("name"), - (Integer) serializedDetails.get("sensorOrientation"), - (String) serializedDetails.get("lensFacing") + String[] cameraNames = cameraManager.getCameraIdList(); + List cameras = new ArrayList<>(); + + for (String cameraName : cameraNames) { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); + + int sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + + int lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); + String lensFacingName = null; + switch (lensFacing) { + case CameraMetadata.LENS_FACING_FRONT: + lensFacingName = "front"; + break; + case CameraMetadata.LENS_FACING_BACK: + lensFacingName = "back"; + break; + case CameraMetadata.LENS_FACING_EXTERNAL: + lensFacingName = "external"; + break; + } + + cameras.add(new CameraDetails( + cameraName, + sensorOrientation, + lensFacingName )); } - return allCameraDetails; + return cameras; } } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java index 8895364c7e70..8c91ffacdaea 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -36,7 +36,6 @@ import io.flutter.view.TextureRegistry.SurfaceTextureEntry; import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; -import static dev.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; /* package */ class Camera { @@ -121,11 +120,60 @@ public void onOrientationChanged(int orientation) { characteristics.get(CameraCharacteristics.LENS_FACING) == CameraMetadata.LENS_FACING_FRONT; ResolutionPreset preset = ResolutionPreset.valueOf(resolutionPreset); recordingProfile = - CameraUtils.getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); + getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); captureSize = new Size(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); previewSize = computeBestPreviewSize(cameraName, preset); } + private Size computeBestPreviewSize(String cameraName, ResolutionPreset preset) { + if (preset.ordinal() > ResolutionPreset.high.ordinal()) { + preset = ResolutionPreset.high; + } + + CamcorderProfile profile = + getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); + return new Size(profile.videoFrameWidth, profile.videoFrameHeight); + } + + private CamcorderProfile getBestAvailableCamcorderProfileForResolutionPreset( + String cameraName, ResolutionPreset preset) { + int cameraId = Integer.parseInt(cameraName); + switch (preset) { + // All of these cases deliberately fall through to get the best available profile. + case max: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_HIGH)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_HIGH); + } + case ultraHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_2160P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_2160P); + } + case veryHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_1080P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_1080P); + } + case high: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_720P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_720P); + } + case medium: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_480P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_480P); + } + case low: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_QVGA)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_QVGA); + } + default: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_LOW)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_LOW); + } else { + throw new IllegalArgumentException( + "No capture session available for current capture session."); + } + } + } + public long getTextureId() { return flutterTexture.id(); } diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java index fb46a5841e9b..e05ffcabd0bc 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java @@ -4,6 +4,12 @@ import androidx.annotation.NonNull; +/** + * Produces {@link Camera} instances when requested. + * + *

This factory exists to separate the dependencies required to create a {@link Camera} + * from the need to be able to create {@link Camera}s, which is useful from a testing standpoint. + */ public interface CameraFactory { @NonNull Camera createCamera( diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java index 9c29068664fa..58ba2049bbcb 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java @@ -6,6 +6,13 @@ import java.util.List; +/** + * Represents a device with cameras and provides relevant queries. + * + *

This concept is separated from its implementation so that other objects can express a + * dependency on these queries, regardless of how these queries are implemented, which is useful + * for testing. + */ public interface CameraHardware { @NonNull List getAvailableCameras() throws CameraAccessException; diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java index fb747024d406..6bc9b2353be3 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -16,6 +16,14 @@ /** * Top-level facade for all Camera plugin behavior. + * + *

The public interface of this class is purposefully established as close to a 1:1 relationship + * with the plugin's channel communication as possible, representing a channel-agnostic implementation + * of the plugin. + * + *

This class avoids Android implementation details, by design. It allows tests to verify + * top-level behavior expectations with JVM tests. All of {@code CameraSystem}'s conceptual + * dependencies are modeled with interfaces, or classes that can be easily mocked. */ /* package */ class CameraSystem { @NonNull diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java deleted file mode 100644 index c659516b5f7d..000000000000 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraUtils.java +++ /dev/null @@ -1,121 +0,0 @@ -package dev.flutter.plugins.camera; - -import android.app.Activity; -import android.content.Context; -import android.graphics.ImageFormat; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraCharacteristics; -import android.hardware.camera2.CameraManager; -import android.hardware.camera2.CameraMetadata; -import android.hardware.camera2.params.StreamConfigurationMap; -import android.media.CamcorderProfile; -import android.util.Size; - -import androidx.annotation.NonNull; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** Provides various utilities for camera. */ -public final class CameraUtils { - - private CameraUtils() {} - - static Size computeBestPreviewSize(String cameraName, ResolutionPreset preset) { - if (preset.ordinal() > ResolutionPreset.high.ordinal()) { - preset = ResolutionPreset.high; - } - - CamcorderProfile profile = - getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); - return new Size(profile.videoFrameWidth, profile.videoFrameHeight); - } - - static Size computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { - // For still image captures, we use the largest available size. - return Collections.max( - Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), - new CompareSizesByArea()); - } - - public static List> getAvailableCameras(@NonNull CameraManager cameraManager) - throws CameraAccessException { - String[] cameraNames = cameraManager.getCameraIdList(); - List> cameras = new ArrayList<>(); - for (String cameraName : cameraNames) { - HashMap details = new HashMap<>(); - CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); - details.put("name", cameraName); - int sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); - details.put("sensorOrientation", sensorOrientation); - - int lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); - switch (lensFacing) { - case CameraMetadata.LENS_FACING_FRONT: - details.put("lensFacing", "front"); - break; - case CameraMetadata.LENS_FACING_BACK: - details.put("lensFacing", "back"); - break; - case CameraMetadata.LENS_FACING_EXTERNAL: - details.put("lensFacing", "external"); - break; - } - cameras.add(details); - } - return cameras; - } - - static CamcorderProfile getBestAvailableCamcorderProfileForResolutionPreset( - String cameraName, ResolutionPreset preset) { - int cameraId = Integer.parseInt(cameraName); - switch (preset) { - // All of these cases deliberately fall through to get the best available profile. - case max: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_HIGH)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_HIGH); - } - case ultraHigh: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_2160P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_2160P); - } - case veryHigh: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_1080P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_1080P); - } - case high: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_720P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_720P); - } - case medium: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_480P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_480P); - } - case low: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_QVGA)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_QVGA); - } - default: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_LOW)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_LOW); - } else { - throw new IllegalArgumentException( - "No capture session available for current capture session."); - } - } - } - - private static class CompareSizesByArea implements Comparator { - @Override - public int compare(Size lhs, Size rhs) { - // We cast here to ensure the multiplications won't overflow. - return Long.signum( - (long) lhs.getWidth() * lhs.getHeight() - (long) rhs.getWidth() * rhs.getHeight()); - } - } -} From 4669cb4eaf0d3c398865b14d5e8dedc0478694c5 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 26 Sep 2019 01:52:08 -0700 Subject: [PATCH 18/20] Added tests for CameraSystem - at 93% line coverage. --- .../plugins/camera/CameraPermissions.java | 2 +- .../flutter/plugins/camera/CameraSystem.java | 34 +- .../plugins/camera/CameraSystemTest.java | 756 ++++++++++++++++++ 3 files changed, 774 insertions(+), 18 deletions(-) create mode 100644 packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraSystemTest.java diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java index 064fc156f9bd..29e661ebe2cf 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java @@ -14,7 +14,7 @@ boolean hasAudioPermission(); - void requestPermissions(boolean enableAudio, ResultCallback callback); + void requestPermissions(boolean enableAudio, @NonNull ResultCallback callback); void addRequestPermissionsResultListener(@NonNull PluginRegistry.RequestPermissionsResultListener listener); diff --git a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java index 6bc9b2353be3..41e882e0569c 100644 --- a/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -65,25 +65,23 @@ public void initialize( camera.close(); } - if (!cameraPermissions.hasCameraPermission() || (request.getEnableAudio() )) - - cameraPermissions.requestPermissions( - request.getEnableAudio(), - new CameraPermissions.ResultCallback() { - @Override - public void onSuccess() { - try { - openCamera(request, callback); - } catch (Exception error) { - callback.onError("CameraAccess", error.getMessage()); - } + cameraPermissions.requestPermissions( + request.getEnableAudio(), + new CameraPermissions.ResultCallback() { + @Override + public void onSuccess() { + try { + openCamera(request, callback); + } catch (Exception error) { + callback.onError("CameraAccess", error.getMessage()); } + } - @Override - public void onResult(String errorCode, String errorDescription) { - callback.onCameraPermissionError(errorCode, errorDescription); - } - }); + @Override + public void onResult(String errorCode, String errorDescription) { + callback.onCameraPermissionError(errorCode, errorDescription); + } + }); } private void openCamera( @@ -108,6 +106,7 @@ public void onCameraOpenFailed(@NonNull String message) { } }); + // TODO(mattcarroll): remove the ChannelCameraEventHandler reference from CameraSystem, it's a protocol detail. final CameraPluginProtocol.ChannelCameraEventHandler eventHandler = new CameraPluginProtocol.ChannelCameraEventHandler(); camera.setCameraEventHandler(eventHandler); @@ -186,6 +185,7 @@ public void startImageStream(@NonNull OnCameraAccessCommandCallback callback) { public void stopImageStream(@NonNull OnCameraAccessCommandCallback callback) { // TODO(mattcarroll): determine desired behavior when no camera is active try { + // TODO(mattcarroll): verify that startPreview() is really what should run here camera.startPreview(); callback.success(); } catch (CameraAccessException e) { diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraSystemTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraSystemTest.java new file mode 100644 index 000000000000..49526829ef1e --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraSystemTest.java @@ -0,0 +1,756 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import dev.flutter.plugins.camera.CameraPermissions.ResultCallback; +import io.flutter.plugin.common.EventChannel; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Most of the tests in this suite are white box tests that unabashedly verify implementation + * details. This is not ideal. There are probably a number of end-to-end tests that would render + * most of these verifications superfluous. If such tests are added, consider removed some or + * all of the tests in this suite. + */ +public class CameraSystemTest { + + private CameraPermissions cameraPermissions; + private CameraHardware cameraHardware; + private CameraPreviewDisplay cameraPreviewDisplay; + private CameraPluginProtocol.CameraEventChannelFactory cameraEventChannelFactory; + private CameraFactory cameraFactory; + + private CameraSystem cameraSystem; // object under test. + + @Before + public void setup() { + cameraPermissions = mock(CameraPermissions.class); + cameraHardware = mock(CameraHardware.class); + cameraPreviewDisplay = mock(CameraPreviewDisplay.class); + cameraEventChannelFactory = mock(CameraPluginProtocol.CameraEventChannelFactory.class); + cameraFactory = mock(CameraFactory.class); + + cameraSystem = new CameraSystem( + cameraPermissions, + cameraHardware, + cameraPreviewDisplay, + cameraEventChannelFactory, + cameraFactory + ); + } + + @Test + public void itLooksUpAvailableCameras() throws CameraAccessException { + // Setup test. + final CameraDetails cameraDetails = new CameraDetails( + "fake_camera_1", + 1, + "front" + ); + when(cameraHardware.getAvailableCameras()).thenReturn(Collections.singletonList(cameraDetails)); + + // Execute the behavior under test. + final List actualCameraDetails = cameraSystem.getAvailableCameras(); + + // Verify results. + assertEquals(1, actualCameraDetails.size()); + assertEquals(cameraDetails, actualCameraDetails.get(0)); + } + + @Test + public void itInitializesSingleCameraHappyPath() throws CameraAccessException { + // Setup test. + // Grant all permissions. + grantFakePermissions(); + + // Setup CameraFactory to return a fake Camera. + final Camera fakeCamera = mock(Camera.class); + when(cameraFactory.createCamera(anyString(), anyString(), anyBoolean())).thenReturn(fakeCamera); + + // Configure CameraEventChannelFactory to return a fake EventChannel + final EventChannel fakeEventChannel = mock(EventChannel.class); + when(cameraEventChannelFactory.createCameraEventChannel(anyLong())).thenReturn(fakeEventChannel); + final EventChannel.EventSink fakeEventSink = mock(EventChannel.EventSink.class); + + final CameraSystem.CameraConfigurationRequest request = new CameraSystem.CameraConfigurationRequest( + "fake_camera_1", + "fake_preset", + true + ); + + CameraSystem.OnCameraInitializationCallback callback = mock(CameraSystem.OnCameraInitializationCallback.class); + + // Execute behavior under test. + cameraSystem.initialize(request, callback); + + // Capture the cameraEventChannel's stream handler and invoke it. + ArgumentCaptor streamHandlerCaptor = ArgumentCaptor.forClass(EventChannel.StreamHandler.class); + verify(fakeEventChannel, times(1)).setStreamHandler(streamHandlerCaptor.capture()); + + // Simulate a successful opening of the camera's event stream. + streamHandlerCaptor.getValue().onListen(null, fakeEventSink); + + // Capture the OnCameraOpenedCallback. + ArgumentCaptor openedCallbackCaptor = ArgumentCaptor.forClass(Camera.OnCameraOpenedCallback.class); + verify(fakeCamera, times(1)).open(openedCallbackCaptor.capture()); + + // Simulate a successful camera open. + openedCallbackCaptor.getValue().onCameraOpened( + 12345l, + 1920, + 1080 + ); + + // Verify results. + verify(callback, times(1)).onSuccess( + 12345l, + 1920, + 1080 + ); + // TODO(mattcarroll): verify that an event handler was setup + } + + @Test + public void itReportsPermissionErrorWhenInitializingSingleCamera() throws CameraAccessException { + // Setup test. + // Automatically decline permissions. + declineFakePermissions("FakeError", "Permissions denied in test."); + + // Setup CameraFactory to return a fake Camera. + final Camera fakeCamera = mock(Camera.class); + when(cameraFactory.createCamera(anyString(), anyString(), anyBoolean())).thenReturn(fakeCamera); + + // Configure CameraEventChannelFactory to return a fake EventChannel + final EventChannel fakeEventChannel = mock(EventChannel.class); + when(cameraEventChannelFactory.createCameraEventChannel(anyLong())).thenReturn(fakeEventChannel); + + final CameraSystem.CameraConfigurationRequest request = new CameraSystem.CameraConfigurationRequest( + "fake_camera_1", + "fake_preset", + true + ); + + CameraSystem.OnCameraInitializationCallback callback = mock(CameraSystem.OnCameraInitializationCallback.class); + + // Execute behavior under test. + cameraSystem.initialize(request, callback); + + // Verify results. + verify(callback, times(1)).onCameraPermissionError("FakeError", "Permissions denied in test."); + } + + @Test + public void itReportsCameraCreationErrorWhenInitializingSingleCamera() throws CameraAccessException { + // Setup test. + // Grant all permissions. + grantFakePermissions(); + + // Setup CameraFactory to throw an exception. + CameraAccessException exception = mock(CameraAccessException.class); + when(exception.getMessage()).thenReturn("fake message"); + when(cameraFactory.createCamera(anyString(), anyString(), anyBoolean())).thenThrow(exception); + + final CameraSystem.CameraConfigurationRequest request = new CameraSystem.CameraConfigurationRequest( + "fake_camera_1", + "fake_preset", + true + ); + + CameraSystem.OnCameraInitializationCallback callback = mock(CameraSystem.OnCameraInitializationCallback.class); + + // Execute behavior under test. + cameraSystem.initialize(request, callback); + + // Verify results. + verify(callback, times(1)).onError("CameraAccess", "fake message"); + } + + @Test + public void itReportsCameraOpenErrorWhenInitializingSingleCamera() throws CameraAccessException { + // Setup test. + // Grant all permissions. + grantFakePermissions(); + + // Setup CameraFactory to return a fake Camera. + final Camera fakeCamera = mock(Camera.class); + when(cameraFactory.createCamera(anyString(), anyString(), anyBoolean())).thenReturn(fakeCamera); + + // Configure CameraEventChannelFactory to return a fake EventChannel + final EventChannel fakeEventChannel = mock(EventChannel.class); + when(cameraEventChannelFactory.createCameraEventChannel(anyLong())).thenReturn(fakeEventChannel); + final EventChannel.EventSink fakeEventSink = mock(EventChannel.EventSink.class); + + final CameraSystem.CameraConfigurationRequest request = new CameraSystem.CameraConfigurationRequest( + "fake_camera_1", + "fake_preset", + true + ); + + CameraSystem.OnCameraInitializationCallback callback = mock(CameraSystem.OnCameraInitializationCallback.class); + + // Execute behavior under test. + cameraSystem.initialize(request, callback); + + // Capture the cameraEventChannel's stream handler and invoke it. + ArgumentCaptor streamHandlerCaptor = ArgumentCaptor.forClass(EventChannel.StreamHandler.class); + verify(fakeEventChannel, times(1)).setStreamHandler(streamHandlerCaptor.capture()); + + // Simulate a successful opening of the camera's event stream. + streamHandlerCaptor.getValue().onListen(null, fakeEventSink); + + // Capture the OnCameraOpenedCallback. + ArgumentCaptor openedCallbackCaptor = ArgumentCaptor.forClass(Camera.OnCameraOpenedCallback.class); + verify(fakeCamera, times(1)).open(openedCallbackCaptor.capture()); + + // Simulate a successful camera open. + openedCallbackCaptor.getValue().onCameraOpenFailed("Testing a failure."); + + // Verify results. + verify(callback, times(1)).onError("CameraAccess", "Testing a failure."); + } + + @Test + public void itTakesPicture() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final Camera.OnPictureTakenCallback callback = mock(Camera.OnPictureTakenCallback.class); + + // Execute behavior under test. + cameraSystem.takePicture("/some/file/path", callback); + + // Verify results. + verify(fakeCamera, times(1)).takePicture(eq("/some/file/path"), eq(callback)); + } + + @Test + public void itStartsVideoRecordingHappyPath() throws CameraAccessException, IOException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final CameraSystem.OnStartVideoRecordingCallback callback = mock(CameraSystem.OnStartVideoRecordingCallback.class); + + // Execute behavior under test. + cameraSystem.startVideoRecording("/some/file/path", callback); + + // Verify results. + verify(fakeCamera, times(1)).startVideoRecording(eq("/some/file/path")); + verifyNoMoreInteractions(callback); + } + + @Test + public void itReportsFileAlreadyExistsWhenStartingVideoRecording() throws CameraAccessException, IOException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + doThrow(new IllegalStateException("file already exists")) + .when(fakeCamera) + .startVideoRecording(anyString()); + + final CameraSystem.OnStartVideoRecordingCallback callback = mock(CameraSystem.OnStartVideoRecordingCallback.class); + + // Execute behavior under test. + cameraSystem.startVideoRecording("/some/file/path", callback); + + // Verify results. + verify(callback, times(1)).onFileAlreadyExists(eq("/some/file/path")); + } + + @Test + public void itReportsVideoRecordingFailedWhenStartingVideoRecording() throws CameraAccessException, IOException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + CameraAccessException exception = mock(CameraAccessException.class); + when(exception.getMessage()).thenReturn("fake message"); + + doThrow(exception) + .when(fakeCamera) + .startVideoRecording(anyString()); + + final CameraSystem.OnStartVideoRecordingCallback callback = mock(CameraSystem.OnStartVideoRecordingCallback.class); + + // Execute behavior under test. + cameraSystem.startVideoRecording("/some/file/path", callback); + + // Verify results. + verify(callback, times(1)).onVideoRecordingFailed("fake message"); + } + + @Test + public void itStopsVideoRecordingHappyPath() throws CameraAccessException, IOException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final CameraSystem.OnVideoRecordingCommandCallback callback = mock(CameraSystem.OnVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.stopVideoRecording(callback); + + // Verify results. + verify(fakeCamera, times(1)).stopVideoRecording(); + verify(callback, times(1)).success(); + } + + @Test + public void itReportsVideoRecordingFailedWhenStoppingVideoRecording() throws CameraAccessException, IOException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + CameraAccessException exception = mock(CameraAccessException.class); + when(exception.getMessage()).thenReturn("fake message"); + + doThrow(exception) + .when(fakeCamera) + .stopVideoRecording(); + + final CameraSystem.OnVideoRecordingCommandCallback callback = mock(CameraSystem.OnVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.stopVideoRecording(callback); + + // Verify results. + verify(callback, times(1)).onVideoRecordingFailed("fake message"); + } + + @Test + public void itPausesVideoRecordingHappyPath() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final CameraSystem.OnApiDependentVideoRecordingCommandCallback callback = mock(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.pauseVideoRecording(callback); + + // Verify results. + verify(fakeCamera, times(1)).pauseVideoRecording(); + verifyNoMoreInteractions(callback); + } + + @Test + public void itReportsUnsupportedOperationWhenPausingVideoRecording() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + doThrow(new UnsupportedOperationException()) + .when(fakeCamera) + .pauseVideoRecording(); + + final CameraSystem.OnApiDependentVideoRecordingCommandCallback callback = mock(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.pauseVideoRecording(callback); + + // Verify results. + verify(callback, times(1)).onUnsupportedOperation(); + } + + @Test + public void itReportsVideoRecordingFailedWhenPausingVideoRecording() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + doThrow(new IllegalStateException("fake failure")) + .when(fakeCamera) + .pauseVideoRecording(); + + final CameraSystem.OnApiDependentVideoRecordingCommandCallback callback = mock(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.pauseVideoRecording(callback); + + // Verify results. + verify(callback, times(1)).onVideoRecordingFailed(eq("fake failure")); + } + + @Test + public void itResumesVideoRecordingHappyPath() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final CameraSystem.OnApiDependentVideoRecordingCommandCallback callback = mock(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.resumeVideoRecording(callback); + + // Verify results. + verify(fakeCamera, times(1)).resumeVideoRecording(); + verifyNoMoreInteractions(callback); + } + + @Test + public void itReportsUnsupportedOperationWhenResumingVideoRecording() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + doThrow(new UnsupportedOperationException()) + .when(fakeCamera) + .resumeVideoRecording(); + + final CameraSystem.OnApiDependentVideoRecordingCommandCallback callback = mock(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.resumeVideoRecording(callback); + + // Verify results. + verify(callback, times(1)).onUnsupportedOperation(); + } + + @Test + public void itReportsVideoRecordingFailedWhenResumingVideoRecording() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + doThrow(new IllegalStateException("fake failure")) + .when(fakeCamera) + .resumeVideoRecording(); + + final CameraSystem.OnApiDependentVideoRecordingCommandCallback callback = mock(CameraSystem.OnApiDependentVideoRecordingCommandCallback.class); + + // Execute behavior under test. + cameraSystem.resumeVideoRecording(callback); + + // Verify results. + verify(callback, times(1)).onVideoRecordingFailed(eq("fake failure")); + } + + @Test + public void itStartsImageStreamHappyPath() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final CameraSystem.OnCameraAccessCommandCallback callback = mock(CameraSystem.OnCameraAccessCommandCallback.class); + + // Execute behavior under test. + cameraSystem.startImageStream(callback); + + // Verify results. + verify(fakeCamera, times(1)).startPreviewWithImageStream(any(CameraPreviewDisplay.class)); + verify(callback, times(1)).success(); + } + + @Test + public void itReportsCameraAccessFailureWhenStartingImageStream() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + CameraAccessException exception = mock(CameraAccessException.class); + when(exception.getMessage()).thenReturn("fake failure"); + + doThrow(exception) + .when(fakeCamera) + .startPreviewWithImageStream(any(CameraPreviewDisplay.class)); + + final CameraSystem.OnCameraAccessCommandCallback callback = mock(CameraSystem.OnCameraAccessCommandCallback.class); + + // Execute behavior under test. + cameraSystem.startImageStream(callback); + + // Verify results. + verify(callback, times(1)).onCameraAccessFailure(eq("fake failure")); + } + + @Test + public void itStopsImageStreamHappyPath() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + final CameraSystem.OnCameraAccessCommandCallback callback = mock(CameraSystem.OnCameraAccessCommandCallback.class); + + // Execute behavior under test. + cameraSystem.stopImageStream(callback); + + // Verify results. + verify(fakeCamera, times(1)).startPreview(); + verify(callback, times(1)).success(); + } + + @Test + public void itReportsCameraAccessFailureWhenStoppingImageStream() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + CameraAccessException exception = mock(CameraAccessException.class); + when(exception.getMessage()).thenReturn("fake failure"); + + doThrow(exception) + .when(fakeCamera) + .startPreview(); + + final CameraSystem.OnCameraAccessCommandCallback callback = mock(CameraSystem.OnCameraAccessCommandCallback.class); + + // Execute behavior under test. + cameraSystem.stopImageStream(callback); + + // Verify results. + verify(callback, times(1)).onCameraAccessFailure(eq("fake failure")); + } + + @Test + public void itDisposesActiveCamera() throws CameraAccessException { + // Setup test. + final Camera fakeCamera = initializeFakeCamera( + cameraSystem, + "fake_camera", + "fake_preset", + true, + 12345l, + 1920, + 1080 + ); + + // Execute behavior under test. + cameraSystem.dispose(); + + // Verify results. + verify(fakeCamera, times(1)).dispose(); + } + + /** + * Most CameraSystem behaviors depend on first having a Camera created and opened. This method + * sets up fakes and runs CameraSystem through its initialization process, preparing it for + * camera-specific behaviors. + */ + private Camera initializeFakeCamera( + @NonNull CameraSystem cameraSystem, + @NonNull String cameraName, + @NonNull String resolutionPreset, + boolean enableAudio, + long textureId, + int previewWidth, + int previewHeight + ) throws CameraAccessException { + // Grant all permissions. + grantFakePermissions(); + + // Setup CameraFactory to return a fake Camera. + final Camera fakeCamera = mock(Camera.class); + when(cameraFactory.createCamera(anyString(), anyString(), anyBoolean())).thenReturn(fakeCamera); + + // Configure CameraEventChannelFactory to return a fake EventChannel + final EventChannel fakeEventChannel = mock(EventChannel.class); + when(cameraEventChannelFactory.createCameraEventChannel(anyLong())).thenReturn(fakeEventChannel); + final EventChannel.EventSink fakeEventSink = mock(EventChannel.EventSink.class); + + final CameraSystem.CameraConfigurationRequest request = new CameraSystem.CameraConfigurationRequest( + cameraName, + resolutionPreset, + enableAudio + ); + + CameraSystem.OnCameraInitializationCallback callback = mock(CameraSystem.OnCameraInitializationCallback.class); + + // Execute behavior under test. + cameraSystem.initialize(request, callback); + + // Capture the cameraEventChannel's stream handler and invoke it. + ArgumentCaptor streamHandlerCaptor = ArgumentCaptor.forClass(EventChannel.StreamHandler.class); + verify(fakeEventChannel, times(1)).setStreamHandler(streamHandlerCaptor.capture()); + + // Simulate a successful opening of the camera's event stream. + streamHandlerCaptor.getValue().onListen(null, fakeEventSink); + + // Capture the OnCameraOpenedCallback. + ArgumentCaptor openedCallbackCaptor = ArgumentCaptor.forClass(Camera.OnCameraOpenedCallback.class); + verify(fakeCamera, times(1)).open(openedCallbackCaptor.capture()); + + // Simulate a successful camera open. + openedCallbackCaptor.getValue().onCameraOpened( + 12345l, + 1920, + 1080 + ); + + return fakeCamera; + } + + private void grantFakePermissions() { + // Permission queries return true. + when(cameraPermissions.hasCameraPermission()).thenReturn(true); + when(cameraPermissions.hasAudioPermission()).thenReturn(true); + + // Permission requests automatically report success. + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) { + // immediately invoke success to pretend permissions are granted. + CameraPermissions.ResultCallback callback = invocation.getArgument(1); + callback.onSuccess(); + return null; + } + }).when(cameraPermissions).requestPermissions(anyBoolean(), any(ResultCallback.class)); + } + + private void declineFakePermissions( + @NonNull String errorCode, + @NonNull String errorDescription + ) { + // Permission queries return false. + when(cameraPermissions.hasCameraPermission()).thenReturn(false); + when(cameraPermissions.hasAudioPermission()).thenReturn(false); + + // Permission requests automatically report failure. + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) { + // immediately invoke success to pretend permissions are granted. + CameraPermissions.ResultCallback callback = invocation.getArgument(1); + callback.onResult(errorCode, errorDescription); + return null; + } + }).when(cameraPermissions).requestPermissions(anyBoolean(), any(ResultCallback.class)); + } +} From 410d0ac5c46a62e57ee2320c435008b756662616 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 26 Sep 2019 01:55:50 -0700 Subject: [PATCH 19/20] Added tests to ensure that CameraPlugin does nothing without an Activity. --- .../flutter/plugins/camera/CameraPluginTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginTest.java diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginTest.java new file mode 100644 index 000000000000..d83c8539b72c --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginTest.java @@ -0,0 +1,16 @@ +package dev.flutter.plugins.camera; + +import org.junit.Test; + +public class CameraPluginTest { + @Test + public void itDoesNothingWhenAttachedToFlutterEngineWithNoActivity() { + final CameraPlugin cameraPlugin = new CameraPlugin(); + cameraPlugin.onAttachedToEngine(null); + cameraPlugin.onDetachedFromEngine(null); + + // The fact that we get here without crashing means that nothing + // significant is happening, because camera API access would crash + // a JVM test. + } +} From 0bd65227831885694f209b048fad054aff76026a Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 26 Sep 2019 02:07:35 -0700 Subject: [PATCH 20/20] Added tests for ChannelCameraEventHandler - now at CameraSystem (93%), CameraPluginProtocol (97%), CameraDetails (50%), CameraPlugin (12%), the rest 0%. --- .../camera/ChannelCameraEventHandlerTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraEventHandlerTest.java diff --git a/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraEventHandlerTest.java b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraEventHandlerTest.java new file mode 100644 index 000000000000..ae91fd932286 --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/ChannelCameraEventHandlerTest.java @@ -0,0 +1,75 @@ +package dev.flutter.plugins.camera; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import io.flutter.plugin.common.EventChannel; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class ChannelCameraEventHandlerTest { + + @Test + public void itReportsError() { + // Setup test. + final EventChannel.EventSink fakeSink = mock(EventChannel.EventSink.class); + final CameraPluginProtocol.ChannelCameraEventHandler handler = new CameraPluginProtocol.ChannelCameraEventHandler(); + handler.setEventSink(fakeSink); + + final Map expectedOutput = new HashMap<>(); + expectedOutput.put("eventType", "error"); + expectedOutput.put("errorDescription", "Fake error description"); + + // Execute behavior under test. + handler.onError("Fake error description"); + + // Verify results. + verify(fakeSink, times(1)).success(eq(expectedOutput)); + } + + @Test + public void itReportsCameraClosed() { + // Setup test. + final EventChannel.EventSink fakeSink = mock(EventChannel.EventSink.class); + final CameraPluginProtocol.ChannelCameraEventHandler handler = new CameraPluginProtocol.ChannelCameraEventHandler(); + handler.setEventSink(fakeSink); + + final Map expectedOutput = new HashMap<>(); + expectedOutput.put("eventType", "camera_closing"); + + // Execute behavior under test. + handler.onCameraClosed(); + + // Verify results. + verify(fakeSink, times(1)).success(eq(expectedOutput)); + } + + @Test + public void itQueuesEventsUntilSinkIsAvailable() { + // Setup test. + final EventChannel.EventSink fakeSink = mock(EventChannel.EventSink.class); + final CameraPluginProtocol.ChannelCameraEventHandler handler = new CameraPluginProtocol.ChannelCameraEventHandler(); + + final Map expectedErrorOutput = new HashMap<>(); + expectedErrorOutput.put("eventType", "error"); + expectedErrorOutput.put("errorDescription", "Fake error description"); + + final Map expectedClosedOutput = new HashMap<>(); + expectedClosedOutput.put("eventType", "camera_closing"); + + // Execute behavior under test. + handler.onError("Fake error description"); + handler.onCameraClosed(); + handler.setEventSink(fakeSink); + + // Verify results. + verify(fakeSink, times(1)).success(eq(expectedErrorOutput)); + verify(fakeSink, times(1)).success(eq(expectedClosedOutput)); + } + +}