From 96e2328fe6338f429da4dec18998dce42e837d8b Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Wed, 30 Dec 2020 20:35:55 +0100 Subject: [PATCH] [camera] Add iOS and Android implementations for managing auto exposure. (#3346) * Added platform interface methods for setting auto exposure. * Added platform interface methods for setting auto exposure. * Remove workspace files * Added auto exposure implementations for Android and iOS * iOS fix for setting the exposure point * Removed unnecessary check * Update platform interface dependency * Implement PR feedback * Restore test * Small improvements for exposure point resetting --- packages/camera/camera/CHANGELOG.md | 4 + .../io/flutter/plugins/camera/Camera.java | 175 +++++++- .../flutter/plugins/camera/CameraRegions.java | 59 +++ .../flutter/plugins/camera/DartMessenger.java | 17 +- .../plugins/camera/MethodCallHandlerImpl.java | 68 +++ .../plugins/camera/types/ExposureMode.java | 25 ++ .../plugins/camera/types/FlashMode.java | 26 +- .../plugins/camera/CameraRegionsTest.java | 105 +++++ .../plugins/camera/DartMessengerTest.java | 5 +- .../camera/types/ExposureModeTest.java | 33 ++ .../plugins/camera/types/FlashModeTest.java | 8 + packages/camera/camera/example/lib/main.dart | 316 +++++++++++--- .../camera/camera/ios/Classes/CameraPlugin.m | 121 +++++- packages/camera/camera/lib/camera.dart | 1 + .../camera/lib/src/camera_controller.dart | 176 +++++++- packages/camera/camera/pubspec.yaml | 6 +- packages/camera/camera/test/camera_test.dart | 399 +++++++++++++++++- .../camera/camera/test/camera_value_test.dart | 30 +- 18 files changed, 1453 insertions(+), 121 deletions(-) create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/ExposureMode.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionsTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index df543c74efcf..f576ee524ca1 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.4 + +* Adds auto exposure support for Android and iOS implementations. + ## 0.6.3+4 * Revert previous dependency update: Changed dependency on camera_platform_interface to >=1.04 <1.1.0. diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index 3c28d6655e48..d57a737c09f6 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -21,6 +21,7 @@ import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.CaptureResult; import android.hardware.camera2.TotalCaptureResult; +import android.hardware.camera2.params.MeteringRectangle; import android.hardware.camera2.params.OutputConfiguration; import android.hardware.camera2.params.SessionConfiguration; import android.media.CamcorderProfile; @@ -32,6 +33,8 @@ import android.os.Build.VERSION_CODES; import android.os.Handler; import android.os.Looper; +import android.util.Range; +import android.util.Rational; import android.util.Size; import android.view.OrientationEventListener; import android.view.Surface; @@ -40,6 +43,7 @@ import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugins.camera.PictureCaptureRequest.State; import io.flutter.plugins.camera.media.MediaRecorderBuilder; +import io.flutter.plugins.camera.types.ExposureMode; import io.flutter.plugins.camera.types.FlashMode; import io.flutter.plugins.camera.types.ResolutionPreset; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; @@ -80,7 +84,10 @@ public class Camera { private File videoRecordingFile; private int currentOrientation = ORIENTATION_UNKNOWN; private FlashMode flashMode; + private ExposureMode exposureMode; private PictureCaptureRequest pictureCaptureRequest; + private CameraRegions cameraRegions; + private int exposureOffset; public Camera( final Activity activity, @@ -100,6 +107,8 @@ public Camera( this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); this.applicationContext = activity.getApplicationContext(); this.flashMode = FlashMode.auto; + this.exposureMode = ExposureMode.auto; + this.exposureOffset = 0; orientationEventListener = new OrientationEventListener(activity.getApplicationContext()) { @Override @@ -158,15 +167,17 @@ public void open() throws CameraAccessException { public void onOpened(@NonNull CameraDevice device) { cameraDevice = device; try { + cameraRegions = new CameraRegions(getRegionBoundaries()); startPreview(); + dartMessenger.sendCameraInitializedEvent( + previewSize.getWidth(), + previewSize.getHeight(), + exposureMode, + isExposurePointSupported()); } catch (CameraAccessException e) { dartMessenger.sendCameraErrorEvent(e.getMessage()); close(); - return; } - - dartMessenger.sendCameraInitializedEvent( - previewSize.getWidth(), previewSize.getHeight()); } @Override @@ -605,16 +616,11 @@ public void resumeVideoRecording(@NonNull final Result result) { public void setFlashMode(@NonNull final Result result, FlashMode mode) throws CameraAccessException { // Get the flash availability - Boolean flashAvailable; - try { - flashAvailable = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.FLASH_INFO_AVAILABLE); - } catch (CameraAccessException e) { - result.error("setFlashModeFailed", e.getMessage(), null); - return; - } + Boolean flashAvailable = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.FLASH_INFO_AVAILABLE); + // Check if flash is available. if (flashAvailable == null || !flashAvailable) { result.error("setFlashModeFailed", "Device does not have flash capabilities", null); @@ -676,8 +682,133 @@ private void updateFlash(FlashMode mode) { } } + public void setExposureMode(@NonNull final Result result, ExposureMode mode) + throws CameraAccessException { + this.exposureMode = mode; + initPreviewCaptureBuilder(); + cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); + result.success(null); + } + + public void setExposurePoint(@NonNull final Result result, Double x, Double y) + throws CameraAccessException { + // Check if exposure point functionality is available. + if (!isExposurePointSupported()) { + result.error( + "setExposurePointFailed", "Device does not have exposure point capabilities", null); + return; + } + // Check if we are doing a reset or not + if (x == null || y == null) { + x = 0.5; + y = 0.5; + } + // Get the current region boundaries. + Size maxBoundaries = getRegionBoundaries(); + if (maxBoundaries == null) { + result.error("setExposurePointFailed", "Could not determine max region boundaries", null); + return; + } + // Set the metering rectangle + cameraRegions.setAutoExposureMeteringRectangleFromPoint(x, y); + // Apply it + initPreviewCaptureBuilder(); + this.cameraCaptureSession.setRepeatingRequest( + captureRequestBuilder.build(), pictureCaptureCallback, null); + result.success(null); + } + + @TargetApi(VERSION_CODES.P) + private boolean supportsDistortionCorrection() throws CameraAccessException { + int[] availableDistortionCorrectionModes = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.DISTORTION_CORRECTION_AVAILABLE_MODES); + if (availableDistortionCorrectionModes == null) availableDistortionCorrectionModes = new int[0]; + long nonOffModesSupported = + Arrays.stream(availableDistortionCorrectionModes) + .filter((value) -> value != CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) + .count(); + return nonOffModesSupported > 0; + } + + private Size getRegionBoundaries() throws CameraAccessException { + // No distortion correction support + if (android.os.Build.VERSION.SDK_INT < VERSION_CODES.P || !supportsDistortionCorrection()) { + return cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE); + } + // Get the current distortion correction mode + Integer distortionCorrectionMode = + captureRequestBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE); + // Return the correct boundaries depending on the mode + android.graphics.Rect rect; + if (distortionCorrectionMode == null + || distortionCorrectionMode == CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) { + rect = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.SENSOR_INFO_PRE_CORRECTION_ACTIVE_ARRAY_SIZE); + } else { + rect = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + } + return rect == null ? null : new Size(rect.width(), rect.height()); + } + + private boolean isExposurePointSupported() throws CameraAccessException { + Integer supportedRegions = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.CONTROL_MAX_REGIONS_AE); + return supportedRegions != null && supportedRegions > 0; + } + + public double getMinExposureOffset() throws CameraAccessException { + Range range = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE); + double minStepped = range == null ? 0 : range.getLower(); + double stepSize = getExposureOffsetStepSize(); + return minStepped * stepSize; + } + + public double getMaxExposureOffset() throws CameraAccessException { + Range range = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE); + double maxStepped = range == null ? 0 : range.getUpper(); + double stepSize = getExposureOffsetStepSize(); + return maxStepped * stepSize; + } + + public double getExposureOffsetStepSize() throws CameraAccessException { + Rational stepSize = + cameraManager + .getCameraCharacteristics(cameraDevice.getId()) + .get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP); + return stepSize == null ? 0.0 : stepSize.doubleValue(); + } + + public void setExposureOffset(@NonNull final Result result, double offset) + throws CameraAccessException { + // Set the exposure offset + double stepSize = getExposureOffsetStepSize(); + exposureOffset = (int) (offset / stepSize); + // Apply it + initPreviewCaptureBuilder(); + this.cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); + result.success(offset); + } + private void initPreviewCaptureBuilder() { captureRequestBuilder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO); + // Applying flash modes switch (flashMode) { case off: captureRequestBuilder.set( @@ -701,6 +832,22 @@ private void initPreviewCaptureBuilder() { captureRequestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH); break; } + // Applying auto exposure + MeteringRectangle aeRect = cameraRegions.getAEMeteringRectangle(); + captureRequestBuilder.set( + CaptureRequest.CONTROL_AE_REGIONS, + aeRect == null ? null : new MeteringRectangle[] {cameraRegions.getAEMeteringRectangle()}); + switch (exposureMode) { + case locked: + captureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, true); + break; + case auto: + default: + captureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, false); + break; + } + captureRequestBuilder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureOffset); + // Applying auto focus captureRequestBuilder.set( CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE); } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java new file mode 100644 index 000000000000..2285f67ad25c --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java @@ -0,0 +1,59 @@ +package io.flutter.plugins.camera; + +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; + +public final class CameraRegions { + private MeteringRectangle aeMeteringRectangle; + private Size maxBoundaries; + + public CameraRegions(Size maxBoundaries) { + assert (maxBoundaries == null || maxBoundaries.getWidth() > 0); + assert (maxBoundaries == null || maxBoundaries.getHeight() > 0); + this.maxBoundaries = maxBoundaries; + } + + public MeteringRectangle getAEMeteringRectangle() { + return aeMeteringRectangle; + } + + public Size getMaxBoundaries() { + return this.maxBoundaries; + } + + public void resetAutoExposureMeteringRectangle() { + this.aeMeteringRectangle = null; + } + + public void setAutoExposureMeteringRectangleFromPoint(double x, double y) { + this.aeMeteringRectangle = getMeteringRectangleForPoint(maxBoundaries, x, y); + } + + public MeteringRectangle getMeteringRectangleForPoint(Size maxBoundaries, double x, double y) { + assert (x >= 0 && x <= 1); + assert (y >= 0 && y <= 1); + if (maxBoundaries == null) + throw new IllegalStateException( + "Functionality for managing metering rectangles is unavailable as this CameraRegions instance was initialized with null boundaries."); + + // Interpolate the target coordinate + int targetX = (int) Math.round(x * ((double) (maxBoundaries.getWidth() - 1))); + int targetY = (int) Math.round(y * ((double) (maxBoundaries.getHeight() - 1))); + // Determine the dimensions of the metering triangle (10th of the viewport) + int targetWidth = (int) Math.round(((double) maxBoundaries.getWidth()) / 10d); + int targetHeight = (int) Math.round(((double) maxBoundaries.getHeight()) / 10d); + // Adjust target coordinate to represent top-left corner of metering rectangle + targetX -= targetWidth / 2; + targetY -= targetHeight / 2; + // Adjust target coordinate as to not fall out of bounds + if (targetX < 0) targetX = 0; + if (targetY < 0) targetY = 0; + int maxTargetX = maxBoundaries.getWidth() - 1 - targetWidth; + int maxTargetY = maxBoundaries.getHeight() - 1 - targetHeight; + if (targetX > maxTargetX) targetX = maxTargetX; + if (targetY > maxTargetY) targetY = maxTargetY; + + // Build the metering rectangle + return new MeteringRectangle(targetX, targetY, targetWidth, targetHeight, 1); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java index 49f9d9a76de0..2fee13816b51 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java @@ -4,6 +4,7 @@ import androidx.annotation.Nullable; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.camera.types.ExposureMode; import java.util.HashMap; import java.util.Map; @@ -20,13 +21,23 @@ enum EventType { channel = new MethodChannel(messenger, "flutter.io/cameraPlugin/camera" + cameraId); } - void sendCameraInitializedEvent(Integer previewWidth, Integer previewHeight) { + void sendCameraInitializedEvent( + Integer previewWidth, + Integer previewHeight, + ExposureMode exposureMode, + Boolean exposurePointSupported) { + assert (previewWidth != null); + assert (previewHeight != null); + assert (exposureMode != null); + assert (exposurePointSupported != null); this.send( EventType.INITIALIZED, new HashMap() { { - if (previewWidth != null) put("previewWidth", previewWidth.doubleValue()); - if (previewHeight != null) put("previewHeight", previewHeight.doubleValue()); + put("previewWidth", previewWidth.doubleValue()); + put("previewHeight", previewHeight.doubleValue()); + put("exposureMode", exposureMode.toString()); + put("exposurePointSupported", exposurePointSupported); } }); } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index 704504176518..78a10010f90b 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -10,6 +10,7 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugins.camera.CameraPermissions.PermissionsRegistry; +import io.flutter.plugins.camera.types.ExposureMode; import io.flutter.plugins.camera.types.FlashMode; import io.flutter.view.TextureRegistry; import java.util.HashMap; @@ -138,6 +139,73 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) } break; } + case "setExposureMode": + { + String modeStr = call.argument("mode"); + ExposureMode mode = ExposureMode.getValueForString(modeStr); + if (mode == null) { + result.error("setExposureModeFailed", "Unknown exposure mode " + modeStr, null); + return; + } + try { + camera.setExposureMode(result, mode); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "setExposurePoint": + { + Boolean reset = call.argument("reset"); + Double x = null; + Double y = null; + if (reset == null || !reset) { + x = call.argument("x"); + y = call.argument("y"); + } + try { + camera.setExposurePoint(result, x, y); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "getMinExposureOffset": + { + try { + result.success(camera.getMinExposureOffset()); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "getMaxExposureOffset": + { + try { + result.success(camera.getMaxExposureOffset()); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "getExposureOffsetStepSize": + { + try { + result.success(camera.getExposureOffsetStepSize()); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "setExposureOffset": + { + try { + camera.setExposureOffset(result, call.argument("offset")); + } catch (Exception e) { + handleException(e, result); + } + break; + } case "startImageStream": { try { diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/ExposureMode.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/ExposureMode.java new file mode 100644 index 000000000000..8066f59d2b14 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/ExposureMode.java @@ -0,0 +1,25 @@ +package io.flutter.plugins.camera.types; + +// Mirrors exposure_mode.dart +public enum ExposureMode { + auto("auto"), + locked("locked"); + + private final String strValue; + + ExposureMode(String strValue) { + this.strValue = strValue; + } + + public static ExposureMode getValueForString(String modeStr) { + for (ExposureMode value : values()) { + if (value.strValue.equals(modeStr)) return value; + } + return null; + } + + @Override + public String toString() { + return strValue; + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/FlashMode.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/FlashMode.java index 99d4915b3a6a..ee6fe489511f 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/FlashMode.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/FlashMode.java @@ -2,16 +2,26 @@ // Mirrors flash_mode.dart public enum FlashMode { - off, - auto, - always, - torch; + off("off"), + auto("auto"), + always("always"), + torch("torch"); + + private final String strValue; + + FlashMode(String strValue) { + this.strValue = strValue; + } public static FlashMode getValueForString(String modeStr) { - try { - return valueOf(modeStr); - } catch (IllegalArgumentException | NullPointerException e) { - return null; + for (FlashMode value : values()) { + if (value.strValue.equals(modeStr)) return value; } + return null; + } + + @Override + public String toString() { + return strValue; } } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionsTest.java new file mode 100644 index 000000000000..ca66918e2493 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionsTest.java @@ -0,0 +1,105 @@ +package io.flutter.plugins.camera; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class CameraRegionsTest { + + CameraRegions cameraRegions; + + @Before + public void setUp() { + this.cameraRegions = new CameraRegions(new Size(100, 50)); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_x_upper_bound() { + cameraRegions.getMeteringRectangleForPoint(new Size(10, 10), 1.5, 0); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_x_lower_bound() { + cameraRegions.getMeteringRectangleForPoint(new Size(10, 10), -0.5, 0); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_y_upper_bound() { + cameraRegions.getMeteringRectangleForPoint(new Size(10, 10), 0, 1.5); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_y_lower_bound() { + cameraRegions.getMeteringRectangleForPoint(new Size(10, 10), 0, -0.5); + } + + @Test(expected = IllegalStateException.class) + public void getMeteringRectangleForPoint_should_throw_for_null_boundaries() { + cameraRegions.getMeteringRectangleForPoint(null, 0, -0); + } + + @Test + public void getMeteringRectangleForPoint_should_return_valid_MeteringRectangle() { + MeteringRectangle r; + // Center + r = cameraRegions.getMeteringRectangleForPoint(cameraRegions.getMaxBoundaries(), 0.5, 0.5); + assertEquals(new MeteringRectangle(45, 23, 10, 5, 1), r); + + // Top left + r = cameraRegions.getMeteringRectangleForPoint(cameraRegions.getMaxBoundaries(), 0.0, 0.0); + assertEquals(new MeteringRectangle(0, 0, 10, 5, 1), r); + + // Bottom right + r = cameraRegions.getMeteringRectangleForPoint(cameraRegions.getMaxBoundaries(), 1.0, 1.0); + assertEquals(new MeteringRectangle(89, 44, 10, 5, 1), r); + + // Top left + r = cameraRegions.getMeteringRectangleForPoint(cameraRegions.getMaxBoundaries(), 0.0, 1.0); + assertEquals(new MeteringRectangle(0, 44, 10, 5, 1), r); + + // Top right + r = cameraRegions.getMeteringRectangleForPoint(cameraRegions.getMaxBoundaries(), 1.0, 0.0); + assertEquals(new MeteringRectangle(89, 0, 10, 5, 1), r); + } + + @Test(expected = AssertionError.class) + public void constructor_should_throw_for_0_width_boundary() { + new CameraRegions(new Size(0, 50)); + } + + @Test(expected = AssertionError.class) + public void constructor_should_throw_for_0_height_boundary() { + new CameraRegions(new Size(100, 0)); + } + + @Test + public void constructor_should_initialize() { + CameraRegions cr = new CameraRegions(new Size(100, 50)); + assertEquals(new Size(100, 50), cr.getMaxBoundaries()); + assertNull(cr.getAEMeteringRectangle()); + } + + @Test + public void setAutoExposureMeteringRectangleFromPoint_should_set_aeMeteringRectangle_for_point() { + CameraRegions cr = new CameraRegions(new Size(100, 50)); + cr.setAutoExposureMeteringRectangleFromPoint(0, 0); + assertEquals(new MeteringRectangle(0, 0, 10, 5, 1), cr.getAEMeteringRectangle()); + } + + @Test + public void resetAutoExposureMeteringRectangle_should_reset_aeMeteringRectangle() { + CameraRegions cr = new CameraRegions(new Size(100, 50)); + cr.setAutoExposureMeteringRectangleFromPoint(0, 0); + assertNotNull(cr.getAEMeteringRectangle()); + cr.resetAutoExposureMeteringRectangle(); + assertNull(cr.getAEMeteringRectangle()); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java index a689f2b6128f..f91bf82c7063 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java @@ -7,6 +7,7 @@ import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.StandardMethodCodec; +import io.flutter.plugins.camera.types.ExposureMode; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; @@ -58,7 +59,7 @@ public void sendCameraErrorEvent_includesErrorDescriptions() { @Test public void sendCameraInitializedEvent_includesPreviewSize() { - dartMessenger.sendCameraInitializedEvent(0, 0); + dartMessenger.sendCameraInitializedEvent(0, 0, ExposureMode.auto, true); List sentMessages = fakeBinaryMessenger.getMessages(); assertEquals(1, sentMessages.size()); @@ -66,6 +67,8 @@ public void sendCameraInitializedEvent_includesPreviewSize() { assertEquals("initialized", call.method); assertEquals(0, (double) call.argument("previewWidth"), 0); assertEquals(0, (double) call.argument("previewHeight"), 0); + assertEquals("ExposureMode auto", call.argument("exposureMode"), "auto"); + assertEquals("exposurePointSupported", call.argument("exposurePointSupported"), true); } @Test diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java new file mode 100644 index 000000000000..28d2343cedcd --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java @@ -0,0 +1,33 @@ +package io.flutter.plugins.camera.types; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class ExposureModeTest { + + @Test + public void getValueForString_returns_correct_values() { + assertEquals( + "Returns ExposureMode.auto for 'auto'", + ExposureMode.getValueForString("auto"), + ExposureMode.auto); + assertEquals( + "Returns ExposureMode.locked for 'locked'", + ExposureMode.getValueForString("locked"), + ExposureMode.locked); + } + + @Test + public void getValueForString_returns_null_for_nonexistant_value() { + assertEquals( + "Returns null for 'nonexistant'", ExposureMode.getValueForString("nonexistant"), null); + } + + @Test + public void toString_returns_correct_value() { + assertEquals("Returns 'auto' for ExposureMode.auto", ExposureMode.auto.toString(), "auto"); + assertEquals( + "Returns 'locked' for ExposureMode.locked", ExposureMode.locked.toString(), "locked"); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java index d2674e8c7e06..bba01836545a 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java @@ -27,4 +27,12 @@ public void getValueForString_returns_null_for_nonexistant_value() { assertEquals( "Returns null for 'nonexistant'", FlashMode.getValueForString("nonexistant"), null); } + + @Test + public void toString_returns_correct_value() { + assertEquals("Returns 'off' for FlashMode.off", FlashMode.off.toString(), "off"); + assertEquals("Returns 'auto' for FlashMode.auto", FlashMode.auto.toString(), "auto"); + assertEquals("Returns 'always' for FlashMode.always", FlashMode.always.toString(), "always"); + assertEquals("Returns 'torch' for FlashMode.torch", FlashMode.torch.toString(), "torch"); + } } diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index ee8e2c259b3d..c4fa1c5ed01e 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -35,13 +35,20 @@ void logError(String code, String message) => print('Error: $code\nError Message: $message'); class _CameraExampleHomeState extends State - with WidgetsBindingObserver { + with WidgetsBindingObserver, TickerProviderStateMixin { CameraController controller; XFile imageFile; XFile videoFile; VideoPlayerController videoController; VoidCallback videoPlayerListener; bool enableAudio = true; + double _minAvailableExposureOffset = 0.0; + double _maxAvailableExposureOffset = 0.0; + double _currentExposureOffset = 0.0; + AnimationController _flashModeControlRowAnimationController; + Animation _flashModeControlRowAnimation; + AnimationController _exposureModeControlRowAnimationController; + Animation _exposureModeControlRowAnimation; double _minAvailableZoom; double _maxAvailableZoom; double _currentScale = 1.0; @@ -54,11 +61,29 @@ class _CameraExampleHomeState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _flashModeControlRowAnimationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _flashModeControlRowAnimation = CurvedAnimation( + parent: _flashModeControlRowAnimationController, + curve: Curves.easeInCubic, + ); + _exposureModeControlRowAnimationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _exposureModeControlRowAnimation = CurvedAnimation( + parent: _exposureModeControlRowAnimationController, + curve: Curves.easeInCubic, + ); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _flashModeControlRowAnimationController.dispose(); + _exposureModeControlRowAnimationController.dispose(); super.dispose(); } @@ -108,8 +133,7 @@ class _CameraExampleHomeState extends State ), ), _captureControlRowWidget(), - _flashModeRowWidget(), - _toggleAudioWidget(), + _modeControlRowWidget(), Padding( padding: const EdgeInsets.all(5.0), child: Row( @@ -142,11 +166,15 @@ class _CameraExampleHomeState extends State child: Listener( onPointerDown: (_) => _pointers++, onPointerUp: (_) => _pointers--, - child: GestureDetector( - onScaleStart: _handleScaleStart, - onScaleUpdate: _handleScaleUpdate, - child: CameraPreview(controller), - ), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return GestureDetector( + onScaleStart: _handleScaleStart, + onScaleUpdate: _handleScaleUpdate, + onTapDown: (details) => onViewFinderTap(details, constraints), + child: CameraPreview(controller), + ); + }), ), ); } @@ -168,27 +196,6 @@ class _CameraExampleHomeState extends State await controller.setZoomLevel(_currentScale); } - /// Toggle recording audio - Widget _toggleAudioWidget() { - return Padding( - padding: const EdgeInsets.only(left: 25), - child: Row( - children: [ - const Text('Enable Audio:'), - Switch( - value: enableAudio, - onChanged: (bool value) { - enableAudio = value; - if (controller != null) { - onNewCameraSelected(controller.description); - } - }, - ), - ], - ), - ); - } - /// Display the thumbnail of the captured image or video. Widget _thumbnailWidget() { return Expanded( @@ -223,49 +230,156 @@ class _CameraExampleHomeState extends State ); } - /// Display a bar with buttons to change the flash mode - Widget _flashModeRowWidget() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.max, - children: [ - IconButton( - icon: const Icon(Icons.flash_off), - color: controller?.value?.flashMode == FlashMode.off - ? Colors.orange - : Colors.blue, - onPressed: controller != null - ? () => onFlashModeButtonPressed(FlashMode.off) - : null, - ), - IconButton( - icon: const Icon(Icons.flash_auto), - color: controller?.value?.flashMode == FlashMode.auto - ? Colors.orange - : Colors.blue, - onPressed: controller != null - ? () => onFlashModeButtonPressed(FlashMode.auto) - : null, + /// Display a bar with buttons to change the flash and exposure modes + Widget _modeControlRowWidget() { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + icon: Icon(Icons.flash_on), + color: Colors.blue, + onPressed: controller != null ? onFlashModeButtonPressed : null, + ), + IconButton( + icon: Icon(Icons.exposure), + color: Colors.blue, + onPressed: + controller != null ? onExposureModeButtonPressed : null, + ), + IconButton( + icon: Icon(enableAudio ? Icons.volume_up : Icons.volume_mute), + color: Colors.blue, + onPressed: controller != null ? onAudioModeButtonPressed : null, + ), + ], ), - IconButton( - icon: const Icon(Icons.flash_on), - color: controller?.value?.flashMode == FlashMode.always - ? Colors.orange - : Colors.blue, - onPressed: controller != null - ? () => onFlashModeButtonPressed(FlashMode.always) - : null, + _flashModeControlRowWidget(), + _exposureModeControlRowWidget(), + ], + ); + } + + Widget _flashModeControlRowWidget() { + return SizeTransition( + sizeFactor: _flashModeControlRowAnimation, + child: ClipRect( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + icon: Icon(Icons.flash_off), + color: controller?.value?.flashMode == FlashMode.off + ? Colors.orange + : Colors.blue, + onPressed: controller != null + ? () => onSetFlashModeButtonPressed(FlashMode.off) + : null, + ), + IconButton( + icon: Icon(Icons.flash_auto), + color: controller?.value?.flashMode == FlashMode.auto + ? Colors.orange + : Colors.blue, + onPressed: controller != null + ? () => onSetFlashModeButtonPressed(FlashMode.auto) + : null, + ), + IconButton( + icon: Icon(Icons.flash_on), + color: controller?.value?.flashMode == FlashMode.always + ? Colors.orange + : Colors.blue, + onPressed: controller != null + ? () => onSetFlashModeButtonPressed(FlashMode.always) + : null, + ), + IconButton( + icon: Icon(Icons.highlight), + color: controller?.value?.flashMode == FlashMode.torch + ? Colors.orange + : Colors.blue, + onPressed: controller != null + ? () => onSetFlashModeButtonPressed(FlashMode.torch) + : null, + ), + ], ), - IconButton( - icon: const Icon(Icons.highlight), - color: controller?.value?.flashMode == FlashMode.torch - ? Colors.orange - : Colors.blue, - onPressed: controller != null - ? () => onFlashModeButtonPressed(FlashMode.torch) - : null, + ), + ); + } + + Widget _exposureModeControlRowWidget() { + return SizeTransition( + sizeFactor: _exposureModeControlRowAnimation, + child: ClipRect( + child: Container( + color: Colors.grey.shade50, + child: Column( + children: [ + Center( + child: Text("Exposure Mode"), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + FlatButton( + child: Text('AUTO'), + textColor: + controller?.value?.exposureMode == ExposureMode.auto + ? Colors.orange + : Colors.blue, + onPressed: controller != null + ? () => + onSetExposureModeButtonPressed(ExposureMode.auto) + : null, + onLongPress: () { + if (controller != null) controller.setExposurePoint(null); + showInSnackBar('Resetting exposure point'); + }, + ), + FlatButton( + child: Text('LOCKED'), + textColor: + controller?.value?.exposureMode == ExposureMode.locked + ? Colors.orange + : Colors.blue, + onPressed: controller != null + ? () => + onSetExposureModeButtonPressed(ExposureMode.locked) + : null, + ), + ], + ), + Center( + child: Text("Exposure Offset"), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + Text(_minAvailableExposureOffset.toString()), + Slider( + value: _currentExposureOffset, + min: _minAvailableExposureOffset, + max: _maxAvailableExposureOffset, + label: _currentExposureOffset.toString(), + onChanged: _minAvailableExposureOffset == + _maxAvailableExposureOffset + ? null + : setExposureOffset, + ), + Text(_maxAvailableExposureOffset.toString()), + ], + ), + ], + ), ), - ], + ), ); } @@ -353,6 +467,13 @@ class _CameraExampleHomeState extends State _scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(message))); } + void onViewFinderTap(TapDownDetails details, BoxConstraints constraints) { + controller.setExposurePoint(Offset( + details.localPosition.dx / constraints.maxWidth, + details.localPosition.dy / constraints.maxHeight, + )); + } + void onNewCameraSelected(CameraDescription cameraDescription) async { if (controller != null) { await controller.dispose(); @@ -373,6 +494,8 @@ class _CameraExampleHomeState extends State try { await controller.initialize(); + _minAvailableExposureOffset = await controller.getMinExposureOffset(); + _maxAvailableExposureOffset = await controller.getMaxExposureOffset(); _maxAvailableZoom = await controller.getMaxZoomLevel(); _minAvailableZoom = await controller.getMinZoomLevel(); } on CameraException catch (e) { @@ -397,13 +520,45 @@ class _CameraExampleHomeState extends State }); } - void onFlashModeButtonPressed(FlashMode mode) { + void onFlashModeButtonPressed() { + if (_flashModeControlRowAnimationController.value == 1) { + _flashModeControlRowAnimationController.reverse(); + } else { + _flashModeControlRowAnimationController.forward(); + _exposureModeControlRowAnimationController.reverse(); + } + } + + void onExposureModeButtonPressed() { + if (_exposureModeControlRowAnimationController.value == 1) { + _exposureModeControlRowAnimationController.reverse(); + } else { + _exposureModeControlRowAnimationController.forward(); + _flashModeControlRowAnimationController.reverse(); + } + } + + void onAudioModeButtonPressed() { + enableAudio = !enableAudio; + if (controller != null) { + onNewCameraSelected(controller.description); + } + } + + void onSetFlashModeButtonPressed(FlashMode mode) { setFlashMode(mode).then((_) { if (mounted) setState(() {}); showInSnackBar('Flash mode set to ${mode.toString().split('.').last}'); }); } + void onSetExposureModeButtonPressed(ExposureMode mode) { + setExposureMode(mode).then((_) { + if (mounted) setState(() {}); + showInSnackBar('Exposure mode set to ${mode.toString().split('.').last}'); + }); + } + void onVideoRecordButtonPressed() { startVideoRecording().then((_) { if (mounted) setState(() {}); @@ -502,6 +657,27 @@ class _CameraExampleHomeState extends State } } + Future setExposureMode(ExposureMode mode) async { + try { + await controller.setExposureMode(mode); + } on CameraException catch (e) { + _showCameraException(e); + rethrow; + } + } + + Future setExposureOffset(double offset) async { + setState(() { + _currentExposureOffset = offset; + }); + try { + offset = await controller.setExposureOffset(offset); + } on CameraException catch (e) { + _showCameraException(e); + rethrow; + } + } + Future _startVideoPlayer() async { final VideoPlayerController vController = VideoPlayerController.file(File(videoFile.path)); diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index d54695233bdb..816792e2fc1d 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -176,6 +176,45 @@ static AVCaptureFlashMode getAVCaptureFlashModeForFlashMode(FlashMode mode) { } } +// Mirrors ExposureMode in camera.dart +typedef enum { + ExposureModeAuto, + ExposureModeLocked, + +} ExposureMode; + +static NSString *getStringForExposureMode(ExposureMode mode) { + switch (mode) { + case ExposureModeAuto: + return @"auto"; + case ExposureModeLocked: + return @"locked"; + } + NSError *error = [NSError errorWithDomain:NSCocoaErrorDomain + code:NSURLErrorUnknown + userInfo:@{ + NSLocalizedDescriptionKey : [NSString + stringWithFormat:@"Unknown string for exposure mode"] + }]; + @throw error; +} + +static ExposureMode getExposureModeForString(NSString *mode) { + if ([mode isEqualToString:@"auto"]) { + return ExposureModeAuto; + } else if ([mode isEqualToString:@"locked"]) { + return ExposureModeLocked; + } else { + NSError *error = [NSError errorWithDomain:NSCocoaErrorDomain + code:NSURLErrorUnknown + userInfo:@{ + NSLocalizedDescriptionKey : [NSString + stringWithFormat:@"Unknown exposure mode %@", mode] + }]; + @throw error; + } +} + // Mirrors ResolutionPreset in camera.dart typedef enum { veryLow, @@ -243,6 +282,7 @@ @interface FLTCam : NSObject *)messenger { if (!_isStreamingImages) { FlutterEventChannel *eventChannel = @@ -1063,7 +1159,10 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re [methodChannel invokeMethod:@"initialized" arguments:@{ @"previewWidth" : @(_camera.previewSize.width), - @"previewHeight" : @(_camera.previewSize.height) + @"previewHeight" : @(_camera.previewSize.height), + @"exposureMode" : getStringForExposureMode([_camera exposureMode]), + @"exposurePointSupported" : + @([_camera.captureDevice isExposurePointOfInterestSupported]), }]; [_camera start]; result(nil); @@ -1098,6 +1197,26 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re [_camera setZoomLevel:zoom Result:result]; } else if ([@"setFlashMode" isEqualToString:call.method]) { [_camera setFlashModeWithResult:result mode:call.arguments[@"mode"]]; + } else if ([@"setExposureMode" isEqualToString:call.method]) { + [_camera setExposureModeWithResult:result mode:call.arguments[@"mode"]]; + } else if ([@"setExposurePoint" isEqualToString:call.method]) { + BOOL reset = ((NSNumber *)call.arguments[@"reset"]).boolValue; + double x = 0.5; + double y = 0.5; + if (!reset) { + x = ((NSNumber *)call.arguments[@"x"]).doubleValue; + y = ((NSNumber *)call.arguments[@"y"]).doubleValue; + } + [_camera setExposurePointWithResult:result x:x y:y]; + } else if ([@"getMinExposureOffset" isEqualToString:call.method]) { + result(@(_camera.captureDevice.minExposureTargetBias)); + } else if ([@"getMaxExposureOffset" isEqualToString:call.method]) { + result(@(_camera.captureDevice.maxExposureTargetBias)); + } else if ([@"getExposureOffsetStepSize" isEqualToString:call.method]) { + result(@(0.0)); + } else if ([@"setExposureOffset" isEqualToString:call.method]) { + [_camera setExposureOffsetWithResult:result + offset:((NSNumber *)call.arguments[@"offset"]).doubleValue]; } else { result(FlutterMethodNotImplemented); } diff --git a/packages/camera/camera/lib/camera.dart b/packages/camera/camera/lib/camera.dart index 6c6214e96951..55e7aa9444aa 100644 --- a/packages/camera/camera/lib/camera.dart +++ b/packages/camera/camera/lib/camera.dart @@ -12,5 +12,6 @@ export 'package:camera_platform_interface/camera_platform_interface.dart' CameraException, CameraLensDirection, FlashMode, + ExposureMode, ResolutionPreset, XFile; diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index 1d7aed755f42..c1f44bc9630a 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -3,12 +3,14 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:math'; import 'package:camera/camera.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:pedantic/pedantic.dart'; final MethodChannel _channel = const MethodChannel('plugins.flutter.io/camera'); @@ -37,6 +39,8 @@ class CameraValue { this.isStreamingImages, bool isRecordingPaused, this.flashMode, + this.exposureMode, + this.exposurePointSupported, }) : _isRecordingPaused = isRecordingPaused; /// Creates a new camera controller state for an uninitialized controller. @@ -48,6 +52,7 @@ class CameraValue { isStreamingImages: false, isRecordingPaused: false, flashMode: FlashMode.auto, + exposurePointSupported: false, ); /// True after [CameraController.initialize] has completed successfully. @@ -91,6 +96,12 @@ class CameraValue { /// The flash mode the camera is currently set to. final FlashMode flashMode; + /// The exposure mode the camera is currently set to. + final ExposureMode exposureMode; + + /// Whether setting the exposure point is supported. + final bool exposurePointSupported; + /// Creates a modified copy of the object. /// /// Explicitly specified fields get the specified value, all other fields get @@ -104,6 +115,8 @@ class CameraValue { Size previewSize, bool isRecordingPaused, FlashMode flashMode, + ExposureMode exposureMode, + bool exposurePointSupported, }) { return CameraValue( isInitialized: isInitialized ?? this.isInitialized, @@ -114,6 +127,9 @@ class CameraValue { isStreamingImages: isStreamingImages ?? this.isStreamingImages, isRecordingPaused: isRecordingPaused ?? _isRecordingPaused, flashMode: flashMode ?? this.flashMode, + exposureMode: exposureMode ?? this.exposureMode, + exposurePointSupported: + exposurePointSupported ?? this.exposurePointSupported, ); } @@ -125,7 +141,9 @@ class CameraValue { 'errorDescription: $errorDescription, ' 'previewSize: $previewSize, ' 'isStreamingImages: $isStreamingImages, ' - 'flashMode: $flashMode)'; + 'flashMode: $flashMode, ' + 'exposureMode: $exposureMode, ' + 'exposurePointSupported: $exposurePointSupported)'; } } @@ -184,25 +202,34 @@ class CameraController extends ValueNotifier { ); } try { + Completer _initializeCompleter = Completer(); + _cameraId = await CameraPlatform.instance.createCamera( description, resolutionPreset, enableAudio: enableAudio, ); - final previewSize = - CameraPlatform.instance.onCameraInitialized(_cameraId).map((event) { - return Size( - event.previewWidth, - event.previewHeight, - ); - }).first; + unawaited(CameraPlatform.instance + .onCameraInitialized(_cameraId) + .first + .then((event) { + _initializeCompleter.complete(event); + })); await CameraPlatform.instance.initializeCamera(_cameraId); value = value.copyWith( isInitialized: true, - previewSize: await previewSize, + previewSize: await _initializeCompleter.future + .then((CameraInitializedEvent event) => Size( + event.previewWidth, + event.previewHeight, + )), + exposureMode: await _initializeCompleter.future + .then((event) => event.exposureMode), + exposurePointSupported: await _initializeCompleter.future + .then((event) => event.exposurePointSupported), ); } on PlatformException catch (e) { throw CameraException(e.code, e.message); @@ -532,6 +559,137 @@ class CameraController extends ValueNotifier { } } + /// Sets the exposure mode for taking pictures. + Future setExposureMode(ExposureMode mode) async { + try { + await CameraPlatform.instance.setExposureMode(_cameraId, mode); + value = value.copyWith(exposureMode: mode); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Sets the exposure point for automatically determining the exposure value. + Future setExposurePoint(Offset point) async { + if (point != null && + (point.dx < 0 || point.dx > 1 || point.dy < 0 || point.dy > 1)) { + throw ArgumentError( + 'The values of point should be anywhere between (0,0) and (1,1).'); + } + try { + await CameraPlatform.instance.setExposurePoint( + _cameraId, + point == null + ? null + : Point( + point.dx, + point.dy, + ), + ); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Gets the minimum supported exposure offset for the selected camera in EV units. + Future getMinExposureOffset() async { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'getMinExposureOffset was called on uninitialized CameraController', + ); + } + + try { + return CameraPlatform.instance.getMinExposureOffset(_cameraId); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Gets the maximum supported exposure offset for the selected camera in EV units. + Future getMaxExposureOffset() async { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'getMaxExposureOffset was called on uninitialized CameraController', + ); + } + + try { + return CameraPlatform.instance.getMaxExposureOffset(_cameraId); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Gets the supported step size for exposure offset for the selected camera in EV units. + /// + /// Returns 0 when the camera supports using a free value without stepping. + Future getExposureOffsetStepSize() async { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'getExposureOffsetStepSize was called on uninitialized CameraController', + ); + } + + try { + return CameraPlatform.instance.getExposureOffsetStepSize(_cameraId); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Sets the exposure offset for the selected camera. + /// + /// The supplied [offset] value should be in EV units. 1 EV unit represents a + /// doubling in brightness. It should be between the minimum and maximum offsets + /// obtained through `getMinExposureOffset` and `getMaxExposureOffset` respectively. + /// Throws a `CameraException` when an illegal offset is supplied. + /// + /// When the supplied [offset] value does not align with the step size obtained + /// through `getExposureStepSize`, it will automatically be rounded to the nearest step. + /// + /// Returns the (rounded) offset value that was set. + Future setExposureOffset(double offset) async { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'setExposureOffset was called on uninitialized CameraController', + ); + } + + // Check if offset is in range + List range = + await Future.wait([getMinExposureOffset(), getMaxExposureOffset()]); + if (offset < range[0] || offset > range[1]) { + throw CameraException( + "exposureOffsetOutOfBounds", + "The provided exposure offset was outside the supported range for this device.", + ); + } + + // Round to the closest step if needed + double stepSize = await getExposureOffsetStepSize(); + if (stepSize > 0) { + double inv = 1.0 / stepSize; + double roundedOffset = (offset * inv).roundToDouble() / inv; + if (roundedOffset > range[1]) { + roundedOffset = (offset * inv).floorToDouble() / inv; + } else if (roundedOffset < range[0]) { + roundedOffset = (offset * inv).ceilToDouble() / inv; + } + offset = roundedOffset; + } + + try { + return CameraPlatform.instance.setExposureOffset(_cameraId, offset); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + /// Releases the resources of this camera. @override Future dispose() async { diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index fa40ad03ae89..0811ac442852 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -2,13 +2,14 @@ name: camera description: A Flutter plugin for getting information about and controlling the camera on Android and iOS. Supports previewing the camera feed, capturing images, capturing video, and streaming image buffers to dart. -version: 0.6.3+4 +version: 0.6.4 homepage: https://github.com/flutter/plugins/tree/master/packages/camera/camera dependencies: flutter: sdk: flutter - camera_platform_interface: ">=1.0.4 <1.1.0" + camera_platform_interface: ^1.2.0 + pedantic: ^1.8.0 dev_dependencies: path_provider: ^0.5.0 @@ -17,7 +18,6 @@ dev_dependencies: sdk: flutter flutter_driver: sdk: flutter - pedantic: ^1.8.0 mockito: ^4.1.3 plugin_platform_interface: ^1.0.3 diff --git a/packages/camera/camera/test/camera_test.dart b/packages/camera/camera/test/camera_test.dart index 43dec7374901..5a4a7fc8771b 100644 --- a/packages/camera/camera/test/camera_test.dart +++ b/packages/camera/camera/test/camera_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:math'; import 'dart:ui'; import 'package:camera/camera.dart'; @@ -26,7 +27,8 @@ get mockAvailableCameras => [ get mockInitializeCamera => 13; -get mockOnCameraInitializedEvent => CameraInitializedEvent(13, 75, 75); +get mockOnCameraInitializedEvent => + CameraInitializedEvent(13, 75, 75, ExposureMode.auto, true); get mockOnCameraClosingEvent => null; @@ -603,6 +605,398 @@ void main() { 'This is a test error message', ))); }); + + test('setExposureMode() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + await cameraController.setExposureMode(ExposureMode.auto); + + verify(CameraPlatform.instance + .setExposureMode(cameraController.cameraId, ExposureMode.auto)) + .called(1); + }); + + test('setExposureMode() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + when(CameraPlatform.instance + .setExposureMode(cameraController.cameraId, ExposureMode.auto)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.setExposureMode(ExposureMode.auto), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test('setExposurePoint() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + await cameraController.setExposurePoint(Offset(0.5, 0.5)); + + verify(CameraPlatform.instance.setExposurePoint( + cameraController.cameraId, Point(0.5, 0.5))) + .called(1); + }); + + test('setExposurePoint() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + when(CameraPlatform.instance.setExposurePoint( + cameraController.cameraId, Point(0.5, 0.5))) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.setExposurePoint(Offset(0.5, 0.5)), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test('getMinExposureOffset() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + await cameraController.getMinExposureOffset(); + + verify(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .called(1); + }); + + test('getMinExposureOffset() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + when(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.getMinExposureOffset(), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test('getMaxExposureOffset() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + await cameraController.getMaxExposureOffset(); + + verify(CameraPlatform.instance + .getMaxExposureOffset(cameraController.cameraId)) + .called(1); + }); + + test('getMaxExposureOffset() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + when(CameraPlatform.instance + .getMaxExposureOffset(cameraController.cameraId)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.getMaxExposureOffset(), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test('getExposureOffsetStepSize() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + await cameraController.getExposureOffsetStepSize(); + + verify(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .called(1); + }); + + test( + 'getExposureOffsetStepSize() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + + when(CameraPlatform.instance + .getExposureOffsetStepSize(cameraController.cameraId)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.getExposureOffsetStepSize(), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test('setExposureOffset() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + when(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => -1.0); + when(CameraPlatform.instance + .getMaxExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => 2.0); + when(CameraPlatform.instance + .getExposureOffsetStepSize(cameraController.cameraId)) + .thenAnswer((_) async => 1.0); + + await cameraController.setExposureOffset(1.0); + + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 1.0)) + .called(1); + }); + + test('setExposureOffset() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + when(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => -1.0); + when(CameraPlatform.instance + .getMaxExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => 2.0); + when(CameraPlatform.instance + .getExposureOffsetStepSize(cameraController.cameraId)) + .thenAnswer((_) async => 1.0); + when(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 1.0)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.setExposureOffset(1.0), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test( + 'setExposureOffset() throws $CameraException when offset is out of bounds', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + when(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => -1.0); + when(CameraPlatform.instance + .getMaxExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => 2.0); + when(CameraPlatform.instance + .getExposureOffsetStepSize(cameraController.cameraId)) + .thenAnswer((_) async => 1.0); + + expect( + cameraController.setExposureOffset(3.0), + throwsA(isA().having( + (error) => error.description, + 'exposureOffsetOutOfBounds', + 'The provided exposure offset was outside the supported range for this device.', + ))); + expect( + cameraController.setExposureOffset(-2.0), + throwsA(isA().having( + (error) => error.description, + 'exposureOffsetOutOfBounds', + 'The provided exposure offset was outside the supported range for this device.', + ))); + + await cameraController.setExposureOffset(2.0); + await cameraController.setExposureOffset(-1.0); + await cameraController.setExposureOffset(-0.0); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 2.0)) + .called(1); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, -1.0)) + .called(1); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 0.0)) + .called(1); + }); + + test('setExposureOffset() rounds offset to nearest step', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + when(CameraPlatform.instance + .getMinExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => -1.0); + when(CameraPlatform.instance + .getMaxExposureOffset(cameraController.cameraId)) + .thenAnswer((_) async => 1.0); + when(CameraPlatform.instance + .getExposureOffsetStepSize(cameraController.cameraId)) + .thenAnswer((_) async => 0.4); + when(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 1.0)) + .thenAnswer((_) async => 1.0); + + await cameraController.setExposureOffset(1.0); + await cameraController.setExposureOffset(-1.0); + await cameraController.setExposureOffset(0.1); + await cameraController.setExposureOffset(0.2); + await cameraController.setExposureOffset(0.3); + await cameraController.setExposureOffset(0.4); + await cameraController.setExposureOffset(0.5); + await cameraController.setExposureOffset(0.6); + await cameraController.setExposureOffset(0.7); + await cameraController.setExposureOffset(-0.1); + await cameraController.setExposureOffset(-0.2); + await cameraController.setExposureOffset(-0.3); + await cameraController.setExposureOffset(-0.4); + await cameraController.setExposureOffset(-0.5); + await cameraController.setExposureOffset(-0.6); + await cameraController.setExposureOffset(-0.7); + + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 0.8)) + .called(3); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, -0.8)) + .called(3); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 0.0)) + .called(2); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, 0.4)) + .called(4); + verify(CameraPlatform.instance + .setExposureOffset(cameraController.cameraId, -0.4)) + .called(4); + }); }); } @@ -641,7 +1035,8 @@ class MockCameraPlatform extends Mock : Future.value(mockTakePicture); @override - Future startVideoRecording(int cameraId) => + Future startVideoRecording(int cameraId, + {Duration maxVideoDuration}) => Future.value(mockVideoRecordingXFile); } diff --git a/packages/camera/camera/test/camera_value_test.dart b/packages/camera/camera/test/camera_value_test.dart index 06b327cb1c29..d9193e212ea9 100644 --- a/packages/camera/camera/test/camera_value_test.dart +++ b/packages/camera/camera/test/camera_value_test.dart @@ -13,15 +13,16 @@ void main() { group('camera_value', () { test('Can be created', () { var cameraValue = const CameraValue( - isInitialized: false, - errorDescription: null, - previewSize: Size(10, 10), - isRecordingPaused: false, - isRecordingVideo: false, - isTakingPicture: false, - isStreamingImages: false, - flashMode: FlashMode.auto, - ); + isInitialized: false, + errorDescription: null, + previewSize: Size(10, 10), + isRecordingPaused: false, + isRecordingVideo: false, + isTakingPicture: false, + isStreamingImages: false, + flashMode: FlashMode.auto, + exposureMode: ExposureMode.auto, + exposurePointSupported: true); expect(cameraValue, isA()); expect(cameraValue.isInitialized, isFalse); @@ -31,6 +32,9 @@ void main() { expect(cameraValue.isRecordingVideo, isFalse); expect(cameraValue.isTakingPicture, isFalse); expect(cameraValue.isStreamingImages, isFalse); + expect(cameraValue.flashMode, FlashMode.auto); + expect(cameraValue.exposureMode, ExposureMode.auto); + expect(cameraValue.exposurePointSupported, true); }); test('Can be created as uninitialized', () { @@ -44,6 +48,9 @@ void main() { expect(cameraValue.isRecordingVideo, isFalse); expect(cameraValue.isTakingPicture, isFalse); expect(cameraValue.isStreamingImages, isFalse); + expect(cameraValue.flashMode, FlashMode.auto); + expect(cameraValue.exposureMode, null); + expect(cameraValue.exposurePointSupported, false); }); test('Can be copied with isInitialized', () { @@ -59,6 +66,8 @@ void main() { expect(cameraValue.isTakingPicture, isFalse); expect(cameraValue.isStreamingImages, isFalse); expect(cameraValue.flashMode, FlashMode.auto); + expect(cameraValue.exposureMode, null); + expect(cameraValue.exposurePointSupported, false); }); test('Has aspectRatio after setting size', () { @@ -97,10 +106,11 @@ void main() { isTakingPicture: false, isStreamingImages: false, flashMode: FlashMode.auto, + exposurePointSupported: true, ); expect(cameraValue.toString(), - 'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto)'); + 'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: null, exposurePointSupported: true)'); }); }); }