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/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..81d3b85aed0f --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraHardware.java @@ -0,0 +1,54 @@ +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; + +/* 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 { + 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 cameras; + } +} 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..a1abba9c922f --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/AndroidCameraPermissions.java @@ -0,0 +1,93 @@ +// 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; +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.onSuccess(); + } + } + + @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/Camera.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java new file mode 100644 index 000000000000..8c91ffacdaea --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/Camera.java @@ -0,0 +1,621 @@ +package dev.flutter.plugins.camera; + +import android.annotation.SuppressLint; +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 androidx.annotation.Nullable; + +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.List; + +import io.flutter.view.TextureRegistry.SurfaceTextureEntry; + +import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; + +/* package */ class Camera { + + // Taking a picture + // 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 + @NonNull + private final CameraManager cameraManager; + @NonNull + private final SurfaceTextureEntry flutterTexture; + @NonNull + private final String cameraName; + @NonNull + private final Size captureSize; + @NonNull + 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; + private int currentOrientation = ORIENTATION_UNKNOWN; + + + /* package */ Camera( + @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; + this.orientationEventListener = new OrientationEventListener(context.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 = + 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 = + 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(); + } + + public void setCameraEventHandler(@Nullable CameraEventHandler handler) { + this.cameraEventHandler = handler; + } + + //------ Start: Opening/Closing/Disposing of Camera ------- + @SuppressLint("MissingPermission") + public void open(@NonNull final OnCameraOpenedCallback callback) throws CameraAccessException { + pictureImageReader = ImageReader.newInstance( + captureSize.getWidth(), + captureSize.getHeight(), + ImageFormat.JPEG, + 2 + ); + + // Used to stream 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) { + callback.onCameraOpenFailed(e.getMessage()); + close(); + return; + } + callback.onCameraOpened( + flutterTexture.id(), + previewSize.getWidth(), + previewSize.getHeight() + ); + } + + @Override + public void onClosed(@NonNull CameraDevice camera) { + onCameraClosed(); + super.onClosed(camera); + } + + @Override + public void onDisconnected(@NonNull CameraDevice cameraDevice) { + close(); + Camera.this.onError("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"; + } + Camera.this.onError(errorDescription); + } + }, + 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; + } + } + + 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(@NonNull String filePath, @NonNull final OnPictureTakenCallback callback) { + final File file = new File(filePath); + + if (file.exists()) { + callback.onFileAlreadyExists(); + return; + } + + try { + prepareToSavePictureToFile(file, callback); + + CaptureRequest request = createStillPictureCaptureRequest(pictureImageReader); + cameraCaptureSession.capture( + request, + 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"; + } + callback.onCaptureFailure(reason); + } + }, + null); + } catch (CameraAccessException e) { + callback.onCameraAccessFailure(e.getMessage()); + } + } + + 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()) { + outputStream.getChannel().write(buffer); + } + } + } + //------ End: Take picture with Camera ------- + + //------ Start: Video recording with Camera ---- + public void startVideoRecording(@NonNull String filePath) throws IOException, CameraAccessException, IllegalStateException { + if (new File(filePath).exists()) { + throw new IOException("File " + filePath + " already exists."); + } + + prepareMediaRecorder(filePath); + recordingVideo = true; + createCaptureSession( + CameraDevice.TEMPLATE_RECORD, + () -> mediaRecorder.start(), + mediaRecorder.getSurface() + ); + } + + 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; + } + + recordingVideo = false; + mediaRecorder.stop(); + mediaRecorder.reset(); + startPreview(); + } + + public void pauseVideoRecording() throws IllegalStateException, UnsupportedOperationException { + if (!recordingVideo) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mediaRecorder.pause(); + } else { + throw new UnsupportedOperationException("pauseVideoRecording requires Android API +24."); + } + } + + public void resumeVideoRecording() throws IllegalStateException, UnsupportedOperationException { + if (!recordingVideo) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mediaRecorder.resume(); + } else { + 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()); + } + + public void startPreviewWithImageStream(@NonNull CameraPreviewDisplay previewDisplay) + throws CameraAccessException { + createCaptureSession(CameraDevice.TEMPLATE_STILL_CAPTURE, imageStreamReader.getSurface()); + + previewDisplay.startStreaming(new CameraPreviewDisplay.ImageStreamConnection() { + @Override + public void onConnectionReady(@NonNull CameraImageStream stream) { + startSendingImagesToPreviewDisplay(stream); + } + + @Override + public void onConnectionClosed() { + imageStreamReader.setOnImageAvailableListener(null, null); + } + }); + } + + private void createCaptureSession(int templateType, Surface... surfaces) + throws CameraAccessException { + createCaptureSession(templateType, null, surfaces); + } + + private void startSendingImagesToPreviewDisplay(@NonNull CameraImageStream cameraImageStream) { + imageStreamReader.setOnImageAvailableListener( + reader -> { + Image image = reader.acquireLatestImage(); + if (image == null) return; + + cameraImageStream.sendImage(image); + image.close(); + }, + null); + } + //------ End: Image preview with Camera ---- + + //------ Start: Shared Camera behavior ----- + 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) { + 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() { + final int sensorOrientationOffset = + (currentOrientation == ORIENTATION_UNKNOWN) + ? 0 + : (isFrontFacing) ? -currentOrientation : currentOrientation; + return (sensorOrientationOffset + sensorOrientation + 360) % 360; + } + //------ End: Shared Camera behavior ----- + + /** + * 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. + */ + /* package */ interface CameraEventHandler { + void onError(String description); + + void onCameraClosed(); + } +} 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/CameraFactory.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java new file mode 100644 index 000000000000..e05ffcabd0bc --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraFactory.java @@ -0,0 +1,20 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +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( + @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..58ba2049bbcb --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraHardware.java @@ -0,0 +1,19 @@ +package dev.flutter.plugins.camera; + +import android.hardware.camera2.CameraAccessException; + +import androidx.annotation.NonNull; + +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/CameraImageStream.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java new file mode 100644 index 000000000000..d8cc5a3215ef --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraImageStream.java @@ -0,0 +1,16 @@ +// 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; + +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/CameraPermissions.java b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java new file mode 100644 index 000000000000..29e661ebe2cf --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPermissions.java @@ -0,0 +1,25 @@ +// 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; + +import io.flutter.plugin.common.PluginRegistry; + +/* package */ interface CameraPermissions { + + boolean hasCameraPermission(); + + boolean hasAudioPermission(); + + void requestPermissions(boolean enableAudio, @NonNull ResultCallback callback); + + 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 new file mode 100644 index 000000000000..87c2023cbf39 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPlugin.java @@ -0,0 +1,134 @@ +// 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.CameraManager; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +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.MethodChannel; + +public class CameraPlugin implements FlutterPlugin, ActivityAware { + + @Nullable + private FlutterPluginBinding pluginBinding; + @Nullable + private ActivityPluginBinding activityBinding; + @Nullable + private CameraPluginProtocol cameraPluginProtocol; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + this.pluginBinding = flutterPluginBinding; + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + this.pluginBinding = null; + } + + @Override + public void onAttachedToActivity(@NonNull ActivityPluginBinding activityPluginBinding) { + this.activityBinding = activityPluginBinding; + setup(); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + teardown(); + this.activityBinding = null; + } + + @Override + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding activityPluginBinding) { + this.activityBinding = activityPluginBinding; + setup(); + } + + @Override + public void onDetachedFromActivity() { + teardown(); + this.activityBinding = null; + } + + 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; + } + + 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 + public EventChannel createCameraEventChannel(long textureId) { + return new EventChannel( + pluginBinding.getFlutterEngine().getDartExecutor(), + "flutter.io/cameraPlugin/cameraEvents" + textureId + ); + } + }; + + CameraFactory cameraFactory = new AndroidCameraFactory( + pluginBinding, + activityBinding + ); + + return new CameraSystem( + cameraPermissions, + cameraHardware, + cameraImageStream, + cameraChannelFactory, + cameraFactory + ); + } + + 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; + } + + 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..c2b1c4e235c1 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPluginProtocol.java @@ -0,0 +1,419 @@ +// 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.media.Image; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +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; +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 + private CameraSystem cameraSystem; + @Nullable + private MethodChannel primaryChannel; + @NonNull + private final CameraSystemChannelHandler channelHandler; + + /* package */ CameraPluginProtocol(@NonNull CameraSystem cameraSystem) { + this.cameraSystem = cameraSystem; + 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 + /* package */ 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 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("lensFacing", cameraDetails.getLensDirection()); + allCameraDetailsSerialized.add(serializedDetails); + } + + result.success(allCameraDetailsSerialized); + } 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 success() { + 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 success() { + 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 success() { + 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 success() { + 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; + } + } + + interface CameraEventChannelFactory { + @NonNull + EventChannel createCameraEventChannel(long textureId); + } + + /** + * 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); + } + } + + /* 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 new file mode 100644 index 000000000000..cadc909136ea --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraPreviewDisplay.java @@ -0,0 +1,21 @@ +// 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; + +/** + * 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); + + interface ImageStreamConnection { + void onConnectionReady(@NonNull CameraImageStream stream); + + void onConnectionClosed(); + } +} 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..41e882e0569c --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/CameraSystem.java @@ -0,0 +1,282 @@ +// 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 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. + * + *

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 + 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(); + } + + 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 { + openCamera(request, callback); + } catch (Exception error) { + callback.onError("CameraAccess", error.getMessage()); + } + } + + @Override + public void onResult(String errorCode, String errorDescription) { + callback.onCameraPermissionError(errorCode, errorDescription); + } + }); + } + + 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); + } + }); + + // TODO(mattcarroll): remove the ChannelCameraEventHandler reference from CameraSystem, it's a protocol detail. + 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()); + } + } + + 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()); + } + } + + 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()); + } + } + + 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()); + } + } + + 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()); + } + } + + 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) { + callback.onCameraAccessFailure(e.getMessage()); + } + } + + public void dispose() { + if (camera != null) { + camera.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 success(); + + 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 */ static 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; + } + + @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/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..8a8f315b5ac0 --- /dev/null +++ b/packages/camera/android/src/main/java/dev/flutter/plugins/camera/ResolutionPreset.java @@ -0,0 +1,15 @@ +// 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 +/* package */ enum ResolutionPreset { + low, + medium, + high, + veryHigh, + ultraHigh, + max, +} diff --git a/packages/camera/example/android/app/build.gradle b/packages/camera/example/android/app/build.gradle index 39003759e4a3..ef432a5581fa 100644 --- a/packages/camera/example/android/app/build.gradle +++ b/packages/camera/example/android/app/build.gradle @@ -57,7 +57,13 @@ 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' + testImplementation 'androidx.test:core:1.2.0' // for robolectric + 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/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..2f891161eba4 --- /dev/null +++ b/packages/camera/example/android/app/src/main/java/dev/flutter/plugins/cameraexample/MainActivity.java @@ -0,0 +1,13 @@ +package dev.flutter.plugins.cameraexample; + +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) { + // 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 new file mode 100644 index 000000000000..cd359f419273 --- /dev/null +++ b/packages/camera/example/android/app/src/test/java/dev/flutter/plugins/camera/CameraPluginProtocolTest.java @@ -0,0 +1,855 @@ +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.junit.Assert.assertTrue; +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 itHandlesRequestForAvailableCamerasHappyPath() 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); + } + + @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, + @NonNull int sensorOrientation, + @NonNull String lensDirection + ) { + final Map serializedCamera = new HashMap<>(); + serializedCamera.put("name", cameraName); + serializedCamera.put("sensorOrientation", sensorOrientation); + serializedCamera.put("lensFacing", 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 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. + 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)); + } + + @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(); + } +} 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. + } +} 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)); + } +} 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)); + } + +} 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 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