[camera] android-rework part 4: Android flash and zoom features (#3798)

* Base classes to support Android camera features

Co-authored-by: Andrew Coutts <andrewjohncoutts@gmail.com>

* Fixed formatting

* Applied feedback from PR

* Added Android Flash and Zoom features

Co-authored-by: Andrew Coutts <andrewjohncoutts@gmail.com>

* Use mockito-inline

* Fix formatting issue

* Processed feedback on pull request.

* Fixed formatting

* Fixed formatting

* Swap docs to match correct methods

Co-authored-by: Andrew Coutts <andrewjohncoutts@gmail.com>
diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle
index 7d7eb81..0907c1e 100644
--- a/packages/camera/camera/android/build.gradle
+++ b/packages/camera/camera/android/build.gradle
@@ -49,7 +49,7 @@
 dependencies {
     compileOnly 'androidx.annotation:annotation:1.1.0'
     testImplementation 'junit:junit:4.12'
-    testImplementation 'org.mockito:mockito-core:3.5.13'
+    testImplementation 'org.mockito:mockito-inline:3.5.13'
     testImplementation 'androidx.test:core:1.3.0'
     testImplementation 'org.robolectric:robolectric:4.3'
 }
diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashFeature.java
new file mode 100644
index 0000000..054c81f
--- /dev/null
+++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashFeature.java
@@ -0,0 +1,75 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.flash;
+
+import android.hardware.camera2.CaptureRequest;
+import io.flutter.plugins.camera.CameraProperties;
+import io.flutter.plugins.camera.features.CameraFeature;
+
+/** Controls the flash configuration on the {@link android.hardware.camera2} API. */
+public class FlashFeature extends CameraFeature<FlashMode> {
+  private FlashMode currentSetting = FlashMode.auto;
+
+  /**
+   * Creates a new instance of the {@link FlashFeature}.
+   *
+   * @param cameraProperties Collection of characteristics for the current camera device.
+   */
+  public FlashFeature(CameraProperties cameraProperties) {
+    super(cameraProperties);
+  }
+
+  @Override
+  public String getDebugName() {
+    return "FlashFeature";
+  }
+
+  @Override
+  public FlashMode getValue() {
+    return currentSetting;
+  }
+
+  @Override
+  public void setValue(FlashMode value) {
+    this.currentSetting = value;
+  }
+
+  @Override
+  public boolean checkIsSupported() {
+    Boolean available = cameraProperties.getFlashInfoAvailable();
+    return available != null && available;
+  }
+
+  @Override
+  public void updateBuilder(CaptureRequest.Builder requestBuilder) {
+    if (!checkIsSupported()) {
+      return;
+    }
+
+    switch (currentSetting) {
+      case off:
+        requestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON);
+        requestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF);
+        break;
+
+      case always:
+        requestBuilder.set(
+            CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH);
+        requestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF);
+        break;
+
+      case torch:
+        requestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON);
+        requestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH);
+        break;
+
+      case auto:
+        requestBuilder.set(
+            CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
+        requestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF);
+        break;
+    }
+  }
+}
diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java
new file mode 100644
index 0000000..d4a5ee0
--- /dev/null
+++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java
@@ -0,0 +1,31 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.flash;
+
+// Mirrors flash_mode.dart
+public enum FlashMode {
+  off("off"),
+  auto("auto"),
+  always("always"),
+  torch("torch");
+
+  private final String strValue;
+
+  FlashMode(String strValue) {
+    this.strValue = strValue;
+  }
+
+  public static FlashMode getValueForString(String modeStr) {
+    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/main/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeature.java
new file mode 100644
index 0000000..736fad4
--- /dev/null
+++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeature.java
@@ -0,0 +1,94 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.zoomlevel;
+
+import android.graphics.Rect;
+import android.hardware.camera2.CaptureRequest;
+import io.flutter.plugins.camera.CameraProperties;
+import io.flutter.plugins.camera.features.CameraFeature;
+
+/** Controls the zoom configuration on the {@link android.hardware.camera2} API. */
+public class ZoomLevelFeature extends CameraFeature<Float> {
+  private static final float MINIMUM_ZOOM_LEVEL = 1.0f;
+  private final boolean hasSupport;
+  private final Rect sensorArraySize;
+  private Float currentSetting = MINIMUM_ZOOM_LEVEL;
+  private Float maximumZoomLevel = MINIMUM_ZOOM_LEVEL;
+
+  /**
+   * Creates a new instance of the {@link ZoomLevelFeature}.
+   *
+   * @param cameraProperties Collection of characteristics for the current camera device.
+   */
+  public ZoomLevelFeature(CameraProperties cameraProperties) {
+    super(cameraProperties);
+
+    sensorArraySize = cameraProperties.getSensorInfoActiveArraySize();
+
+    if (sensorArraySize == null) {
+      maximumZoomLevel = MINIMUM_ZOOM_LEVEL;
+      hasSupport = false;
+      return;
+    }
+
+    Float maxDigitalZoom = cameraProperties.getScalerAvailableMaxDigitalZoom();
+    maximumZoomLevel =
+        ((maxDigitalZoom == null) || (maxDigitalZoom < MINIMUM_ZOOM_LEVEL))
+            ? MINIMUM_ZOOM_LEVEL
+            : maxDigitalZoom;
+
+    hasSupport = (Float.compare(maximumZoomLevel, MINIMUM_ZOOM_LEVEL) > 0);
+  }
+
+  @Override
+  public String getDebugName() {
+    return "ZoomLevelFeature";
+  }
+
+  @Override
+  public Float getValue() {
+    return currentSetting;
+  }
+
+  @Override
+  public void setValue(Float value) {
+    currentSetting = value;
+  }
+
+  @Override
+  public boolean checkIsSupported() {
+    return hasSupport;
+  }
+
+  @Override
+  public void updateBuilder(CaptureRequest.Builder requestBuilder) {
+    if (!checkIsSupported()) {
+      return;
+    }
+
+    final Rect computedZoom =
+        ZoomUtils.computeZoom(
+            currentSetting, sensorArraySize, MINIMUM_ZOOM_LEVEL, maximumZoomLevel);
+    requestBuilder.set(CaptureRequest.SCALER_CROP_REGION, computedZoom);
+  }
+
+  /**
+   * Gets the minimum supported zoom level.
+   *
+   * @return The minimum zoom level.
+   */
+  public float getMinimumZoomLevel() {
+    return MINIMUM_ZOOM_LEVEL;
+  }
+
+  /**
+   * Gets the maximum supported zoom level.
+   *
+   * @return The maximum zoom level.
+   */
+  public float getMaximumZoomLevel() {
+    return maximumZoomLevel;
+  }
+}
diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtils.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtils.java
new file mode 100644
index 0000000..a4890b9
--- /dev/null
+++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtils.java
@@ -0,0 +1,40 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.zoomlevel;
+
+import android.graphics.Rect;
+import androidx.annotation.NonNull;
+import androidx.core.math.MathUtils;
+
+/**
+ * Utility class containing methods that assist with zoom features in the {@link
+ * android.hardware.camera2} API.
+ */
+final class ZoomUtils {
+
+  /**
+   * Computes an image sensor area based on the supplied zoom settings.
+   *
+   * <p>The returned image sensor area can be applied to the {@link android.hardware.camera2} API in
+   * order to control zoom levels.
+   *
+   * @param zoom The desired zoom level.
+   * @param sensorArraySize The current area of the image sensor.
+   * @param minimumZoomLevel The minimum supported zoom level.
+   * @param maximumZoomLevel The maximim supported zoom level.
+   * @return An image sensor area based on the supplied zoom settings
+   */
+  static Rect computeZoom(
+      float zoom, @NonNull Rect sensorArraySize, float minimumZoomLevel, float maximumZoomLevel) {
+    final float newZoom = MathUtils.clamp(zoom, minimumZoomLevel, maximumZoomLevel);
+
+    final int centerX = sensorArraySize.width() / 2;
+    final int centerY = sensorArraySize.height() / 2;
+    final int deltaX = (int) ((0.5f * sensorArraySize.width()) / newZoom);
+    final int deltaY = (int) ((0.5f * sensorArraySize.height()) / newZoom);
+
+    return new Rect(centerX - deltaX, centerY - deltaY, centerX + deltaX, centerY + deltaY);
+  }
+}
diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java
new file mode 100644
index 0000000..eccfb07
--- /dev/null
+++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java
@@ -0,0 +1,156 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.flash;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.hardware.camera2.CaptureRequest;
+import io.flutter.plugins.camera.CameraProperties;
+import org.junit.Test;
+
+public class FlashFeatureTest {
+  @Test
+  public void getDebugName_should_return_the_name_of_the_feature() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    assertEquals("FlashFeature", flashFeature.getDebugName());
+  }
+
+  @Test
+  public void getValue_should_return_auto_if_not_set() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    assertEquals(FlashMode.auto, flashFeature.getValue());
+  }
+
+  @Test
+  public void getValue_should_echo_the_set_value() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+    FlashMode expectedValue = FlashMode.torch;
+
+    flashFeature.setValue(expectedValue);
+    FlashMode actualValue = flashFeature.getValue();
+
+    assertEquals(expectedValue, actualValue);
+  }
+
+  @Test
+  public void checkIsSupported_should_return_false_when_flash_info_available_is_null() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(null);
+
+    assertFalse(flashFeature.checkIsSupported());
+  }
+
+  @Test
+  public void checkIsSupported_should_return_false_when_flash_info_available_is_false() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(false);
+
+    assertFalse(flashFeature.checkIsSupported());
+  }
+
+  @Test
+  public void checkIsSupported_should_return_true_when_flash_info_available_is_true() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(true);
+
+    assertTrue(flashFeature.checkIsSupported());
+  }
+
+  @Test
+  public void updateBuilder_should_return_when_checkIsSupported_is_false() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(false);
+
+    flashFeature.updateBuilder(mockBuilder);
+
+    verify(mockBuilder, never()).set(any(), any());
+  }
+
+  @Test
+  public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_off() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(true);
+
+    flashFeature.setValue(FlashMode.off);
+    flashFeature.updateBuilder(mockBuilder);
+
+    verify(mockBuilder, times(1))
+        .set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON);
+    verify(mockBuilder, times(1)).set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF);
+  }
+
+  @Test
+  public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_always() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(true);
+
+    flashFeature.setValue(FlashMode.always);
+    flashFeature.updateBuilder(mockBuilder);
+
+    verify(mockBuilder, times(1))
+        .set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH);
+    verify(mockBuilder, times(1)).set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF);
+  }
+
+  @Test
+  public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_torch() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(true);
+
+    flashFeature.setValue(FlashMode.torch);
+    flashFeature.updateBuilder(mockBuilder);
+
+    verify(mockBuilder, times(1))
+        .set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON);
+    verify(mockBuilder, times(1)).set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH);
+  }
+
+  @Test
+  public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_auto() {
+    CameraProperties mockCameraProperties = mock(CameraProperties.class);
+    CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class);
+    FlashFeature flashFeature = new FlashFeature(mockCameraProperties);
+
+    when(mockCameraProperties.getFlashInfoAvailable()).thenReturn(true);
+
+    flashFeature.setValue(FlashMode.auto);
+    flashFeature.updateBuilder(mockBuilder);
+
+    verify(mockBuilder, times(1))
+        .set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
+    verify(mockBuilder, times(1)).set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF);
+  }
+}
diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java
new file mode 100644
index 0000000..c76708a
--- /dev/null
+++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java
@@ -0,0 +1,166 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.zoomlevel;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.Rect;
+import android.hardware.camera2.CaptureRequest;
+import io.flutter.plugins.camera.CameraProperties;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+public class ZoomLevelFeatureTest {
+  private MockedStatic<ZoomUtils> mockedStaticCameraZoom;
+  private CameraProperties mockCameraProperties;
+  private ZoomUtils mockCameraZoom;
+  private Rect mockZoomArea;
+  private Rect mockSensorArray;
+
+  @Before
+  public void before() {
+    mockedStaticCameraZoom = mockStatic(ZoomUtils.class);
+    mockCameraProperties = mock(CameraProperties.class);
+    mockCameraZoom = mock(ZoomUtils.class);
+    mockZoomArea = mock(Rect.class);
+    mockSensorArray = mock(Rect.class);
+
+    mockedStaticCameraZoom
+        .when(() -> ZoomUtils.computeZoom(anyFloat(), any(), anyFloat(), anyFloat()))
+        .thenReturn(mockZoomArea);
+  }
+
+  @After
+  public void after() {
+    mockedStaticCameraZoom.close();
+  }
+
+  @Test
+  public void ctor_when_parameters_are_valid() {
+    when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray);
+    when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f);
+
+    final ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    verify(mockCameraProperties, times(1)).getSensorInfoActiveArraySize();
+    verify(mockCameraProperties, times(1)).getScalerAvailableMaxDigitalZoom();
+    assertNotNull(zoomLevelFeature);
+    assertEquals(42f, zoomLevelFeature.getMaximumZoomLevel(), 0);
+  }
+
+  @Test
+  public void ctor_when_sensor_size_is_null() {
+    when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(null);
+    when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f);
+
+    final ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    verify(mockCameraProperties, times(1)).getSensorInfoActiveArraySize();
+    verify(mockCameraProperties, never()).getScalerAvailableMaxDigitalZoom();
+    assertNotNull(zoomLevelFeature);
+    assertFalse(zoomLevelFeature.checkIsSupported());
+    assertEquals(zoomLevelFeature.getMaximumZoomLevel(), 1.0f, 0);
+  }
+
+  @Test
+  public void ctor_when_max_zoom_is_null() {
+    when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray);
+    when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(null);
+
+    final ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    verify(mockCameraProperties, times(1)).getSensorInfoActiveArraySize();
+    verify(mockCameraProperties, times(1)).getScalerAvailableMaxDigitalZoom();
+    assertNotNull(zoomLevelFeature);
+    assertFalse(zoomLevelFeature.checkIsSupported());
+    assertEquals(zoomLevelFeature.getMaximumZoomLevel(), 1.0f, 0);
+  }
+
+  @Test
+  public void ctor_when_max_zoom_is_smaller_then_default_zoom_factor() {
+    when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray);
+    when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(0.5f);
+
+    final ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    verify(mockCameraProperties, times(1)).getSensorInfoActiveArraySize();
+    verify(mockCameraProperties, times(1)).getScalerAvailableMaxDigitalZoom();
+    assertNotNull(zoomLevelFeature);
+    assertFalse(zoomLevelFeature.checkIsSupported());
+    assertEquals(zoomLevelFeature.getMaximumZoomLevel(), 1.0f, 0);
+  }
+
+  @Test
+  public void getDebugName_should_return_the_name_of_the_feature() {
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    assertEquals("ZoomLevelFeature", zoomLevelFeature.getDebugName());
+  }
+
+  @Test
+  public void getValue_should_return_null_if_not_set() {
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    assertEquals(1.0, (float) zoomLevelFeature.getValue(), 0);
+  }
+
+  @Test
+  public void getValue_should_echo_setValue() {
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    zoomLevelFeature.setValue(2.3f);
+
+    assertEquals(2.3f, (float) zoomLevelFeature.getValue(), 0);
+  }
+
+  @Test
+  public void checkIsSupport_returns_false_by_default() {
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    assertFalse(zoomLevelFeature.checkIsSupported());
+  }
+
+  @Test
+  public void updateBuilder_should_set_scalar_crop_region_when_checkIsSupport_is_true() {
+    when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray);
+    when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f);
+
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+    CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class);
+
+    zoomLevelFeature.updateBuilder(mockBuilder);
+
+    verify(mockBuilder, times(1)).set(CaptureRequest.SCALER_CROP_REGION, mockZoomArea);
+  }
+
+  @Test
+  public void getMinimumZoomLevel() {
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    assertEquals(1.0f, zoomLevelFeature.getMinimumZoomLevel(), 0);
+  }
+
+  @Test
+  public void getMaximumZoomLevel() {
+    when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray);
+    when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f);
+
+    ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties);
+
+    assertEquals(42f, zoomLevelFeature.getMaximumZoomLevel(), 0);
+  }
+}
diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java
new file mode 100644
index 0000000..f83e5fb
--- /dev/null
+++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java
@@ -0,0 +1,64 @@
+// Copyright 2013 The Flutter 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 io.flutter.plugins.camera.features.zoomlevel;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import android.graphics.Rect;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class ZoomUtilsTest {
+  @Test
+  public void setZoom_when_sensor_size_equals_zero_should_return_crop_region_of_zero() {
+    final Rect sensorSize = new Rect(0, 0, 0, 0);
+    final Rect computedZoom = ZoomUtils.computeZoom(18f, sensorSize, 1f, 20f);
+
+    assertNotNull(computedZoom);
+    assertEquals(computedZoom.left, 0);
+    assertEquals(computedZoom.top, 0);
+    assertEquals(computedZoom.right, 0);
+    assertEquals(computedZoom.bottom, 0);
+  }
+
+  @Test
+  public void setZoom_when_sensor_size_is_valid_should_return_crop_region() {
+    final Rect sensorSize = new Rect(0, 0, 100, 100);
+    final Rect computedZoom = ZoomUtils.computeZoom(18f, sensorSize, 1f, 20f);
+
+    assertNotNull(computedZoom);
+    assertEquals(computedZoom.left, 48);
+    assertEquals(computedZoom.top, 48);
+    assertEquals(computedZoom.right, 52);
+    assertEquals(computedZoom.bottom, 52);
+  }
+
+  @Test
+  public void setZoom_when_zoom_is_greater_then_max_zoom_clamp_to_max_zoom() {
+    final Rect sensorSize = new Rect(0, 0, 100, 100);
+    final Rect computedZoom = ZoomUtils.computeZoom(25f, sensorSize, 1f, 10f);
+
+    assertNotNull(computedZoom);
+    assertEquals(computedZoom.left, 45);
+    assertEquals(computedZoom.top, 45);
+    assertEquals(computedZoom.right, 55);
+    assertEquals(computedZoom.bottom, 55);
+  }
+
+  @Test
+  public void setZoom_when_zoom_is_smaller_then_min_zoom_clamp_to_min_zoom() {
+    final Rect sensorSize = new Rect(0, 0, 100, 100);
+    final Rect computedZoom = ZoomUtils.computeZoom(0.5f, sensorSize, 1f, 10f);
+
+    assertNotNull(computedZoom);
+    assertEquals(computedZoom.left, 0);
+    assertEquals(computedZoom.top, 0);
+    assertEquals(computedZoom.right, 100);
+    assertEquals(computedZoom.bottom, 100);
+  }
+}