Merge "[CameraPipe] Fix ZoomControl linearZoom conversions and re-organize zoom conversion related code" into androidx-main
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt
index 9f5dbc4..25c172d 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt
@@ -117,12 +117,10 @@
     }
 
     override fun setZoomRatio(ratio: Float): ListenableFuture<Void> =
-        zoomControl.setZoomRatioAsync(ratio)
+        zoomControl.setZoomRatio(ratio)
 
-    override fun setLinearZoom(linearZoom: Float): ListenableFuture<Void> {
-        val ratio = zoomControl.toZoomRatio(linearZoom)
-        return setZoomRatio(ratio)
-    }
+    override fun setLinearZoom(linearZoom: Float): ListenableFuture<Void> =
+        zoomControl.setLinearZoom(linearZoom)
 
     override fun getFlashMode(): Int {
         return flashControl.flashMode
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZoomValue.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZoomValue.kt
index 5cc5969..8d19d82 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZoomValue.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZoomValue.kt
@@ -17,6 +17,8 @@
 package androidx.camera.camera2.pipe.integration.adapter
 
 import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.integration.internal.ZoomMath.getLinearZoomFromZoomRatio
+import androidx.camera.camera2.pipe.integration.internal.ZoomMath.getZoomRatioFromLinearZoom
 import androidx.camera.core.ZoomState
 
 /**
@@ -26,16 +28,39 @@
 data class ZoomValue(
     private val zoomRatio: Float,
     private val minZoomRatio: Float,
-    private val maxZoomRatio: Float
+    private val maxZoomRatio: Float,
 ) : ZoomState {
+    private var linearZoom: Float? = null
+
+    /**
+     * ZoomValue should be created with either zoomRatio or linearZoom and the other value should
+     * be calculated. If both are allowed to be set from outside, it becomes confusing regarding
+     * which value to use if the values don't align with conversion values.
+     * Secondary constructor with a LinearZoom value wrapper class is used for this purpose.
+     */
+    data class LinearZoom(val value: Float)
+    constructor(
+        linearZoom: LinearZoom,
+        minZoomRatio: Float,
+        maxZoomRatio: Float,
+    ) : this(
+        getZoomRatioFromLinearZoom(
+            linearZoom = linearZoom.value,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        ),
+        minZoomRatio,
+        maxZoomRatio
+    ) {
+        this.linearZoom = linearZoom.value
+    }
+
     override fun getZoomRatio(): Float = zoomRatio
     override fun getMaxZoomRatio(): Float = maxZoomRatio
     override fun getMinZoomRatio(): Float = minZoomRatio
-    override fun getLinearZoom(): Float {
-        val range = maxZoomRatio - minZoomRatio
-        if (range > 0) {
-            return (zoomRatio - minZoomRatio) / range
-        }
-        return 1.0f
-    }
-}
\ No newline at end of file
+    override fun getLinearZoom() = linearZoom ?: getLinearZoomFromZoomRatio(
+        zoomRatio = zoomRatio,
+        minZoomRatio = minZoomRatio,
+        maxZoomRatio = maxZoomRatio
+    )
+}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/ZoomCompat.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/ZoomCompat.kt
index 38d92ef..87ef64b 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/ZoomCompat.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/ZoomCompat.kt
@@ -30,8 +30,8 @@
 import dagger.Provides
 
 interface ZoomCompat {
-    val minZoom: Float
-    val maxZoom: Float
+    val minZoomRatio: Float
+    val maxZoomRatio: Float
 
     fun apply(
         zoomRatio: Float,
@@ -60,11 +60,11 @@
 }
 
 class CropRegionZoomCompat(private val cameraProperties: CameraProperties) : ZoomCompat {
-    override val minZoom: Float
+    override val minZoomRatio: Float
         get() = 1.0f
-    override val maxZoom: Float
+    override val maxZoomRatio: Float
         get() = cameraProperties.metadata.getOrDefault(
-            CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM, minZoom
+            CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM, minZoomRatio
         )
 
     override fun apply(
@@ -93,16 +93,16 @@
 
 @RequiresApi(Build.VERSION_CODES.R)
 class AndroidRZoomCompat(private val range: Range<Float>) : ZoomCompat {
-    override val minZoom: Float
+    override val minZoomRatio: Float
         get() = range.lower
-    override val maxZoom: Float
+    override val maxZoomRatio: Float
         get() = range.upper
 
     override fun apply(
         zoomRatio: Float,
         camera: UseCaseCamera
     ) {
-        require(zoomRatio in minZoom..maxZoom)
+        require(zoomRatio in minZoomRatio..maxZoomRatio)
         camera.setParameterAsync(CaptureRequest.CONTROL_ZOOM_RATIO, zoomRatio)
     }
-}
\ No newline at end of file
+}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/ZoomControl.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/ZoomControl.kt
index 698da1b..3c6df78 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/ZoomControl.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/ZoomControl.kt
@@ -21,6 +21,8 @@
 import androidx.camera.camera2.pipe.integration.adapter.asListenableFuture
 import androidx.camera.camera2.pipe.integration.compat.ZoomCompat
 import androidx.camera.camera2.pipe.integration.config.CameraScope
+import androidx.camera.camera2.pipe.integration.internal.ZoomMath.getLinearZoomFromZoomRatio
+import androidx.camera.camera2.pipe.integration.internal.ZoomMath.getZoomRatioFromLinearZoom
 import androidx.camera.core.CameraControl
 import androidx.camera.core.ZoomState
 import androidx.camera.core.impl.utils.futures.Futures
@@ -31,7 +33,6 @@
 import dagger.Module
 import dagger.multibindings.IntoSet
 import javax.inject.Inject
-import kotlin.math.abs
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
@@ -47,11 +48,11 @@
 ) : UseCaseCameraControl {
     // NOTE: minZoom may be lower than 1.0
     // NOTE: Default zoom ratio is 1.0 (DEFAULT_ZOOM_RATIO)
-    val minZoom: Float = zoomCompat.minZoom
-    val maxZoom: Float = zoomCompat.maxZoom
+    val minZoomRatio: Float = zoomCompat.minZoomRatio
+    val maxZoomRatio: Float = zoomCompat.maxZoomRatio
 
     val defaultZoomState by lazy {
-        ZoomValue(DEFAULT_ZOOM_RATIO, minZoom, maxZoom)
+        ZoomValue(DEFAULT_ZOOM_RATIO, minZoomRatio, maxZoomRatio)
     }
 
     private val _zoomState by lazy {
@@ -62,28 +63,18 @@
         get() = _zoomState
 
     /** Linear zoom is between 0.0f and 1.0f */
-    fun toLinearZoom(zoomRatio: Float): Float {
-        val range = zoomCompat.maxZoom - zoomCompat.minZoom
-        if (range > 0) {
-            return (zoomRatio - zoomCompat.minZoom) / range
-        }
-        return 0.0f
-    }
+    fun toLinearZoom(zoomRatio: Float) = getLinearZoomFromZoomRatio(
+        zoomRatio = zoomRatio,
+        minZoomRatio = minZoomRatio,
+        maxZoomRatio = maxZoomRatio
+    )
 
     /** Zoom ratio is commonly used as the "1x, 2x, 5x" zoom ratio. Zoom ratio may be less than 1 */
-    fun toZoomRatio(linearZoom: Float): Float {
-        val range = zoomCompat.maxZoom - zoomCompat.minZoom
-        if (range > 0) {
-            return linearZoom * range + zoomCompat.minZoom
-        }
-
-        // if minZoom = maxZoom = 2.0f, 2.0f should be returned instead of default 1.0f
-        if (nearZero(range)) {
-            return zoomCompat.minZoom
-        }
-
-        return DEFAULT_ZOOM_RATIO
-    }
+    private fun toZoomRatio(linearZoom: Float) = getZoomRatioFromLinearZoom(
+        linearZoom = linearZoom,
+        minZoomRatio = minZoomRatio,
+        maxZoomRatio = maxZoomRatio
+    )
 
     private var _useCaseCamera: UseCaseCamera? = null
     override var useCaseCamera: UseCaseCamera?
@@ -117,16 +108,29 @@
         }
     }
 
-    fun setZoomRatioAsync(ratio: Float): ListenableFuture<Void> {
+    fun setLinearZoom(linearZoom: Float): ListenableFuture<Void> {
+        val zoomValue = ZoomValue(
+            ZoomValue.LinearZoom(linearZoom),
+            minZoomRatio,
+            maxZoomRatio,
+        )
+        return setZoomValue(zoomValue)
+    }
+
+    fun setZoomRatio(zoomRatio: Float): ListenableFuture<Void> {
+        val zoomValue = ZoomValue(
+            zoomRatio,
+            minZoomRatio,
+            maxZoomRatio,
+        )
+        return setZoomValue(zoomValue)
+    }
+
+    fun setZoomValue(zoomValue: ZoomValue): ListenableFuture<Void> {
         // TODO: report IllegalArgumentException if ratio not in range
         return Futures.nonCancellationPropagating(
             useCaseCamera?.let {
                 threads.scope.launch(start = CoroutineStart.UNDISPATCHED) {
-                    val zoomValue = ZoomValue(
-                        ratio,
-                        minZoom,
-                        maxZoom
-                    )
                     setZoomState(zoomValue)
                     update()
                 }.asListenableFuture()
@@ -136,10 +140,6 @@
         )
     }
 
-    private fun nearZero(num: Float): Boolean {
-        return abs(num) < 2.0 * Math.ulp(abs(num))
-    }
-
     @Module
     abstract class Bindings {
         @Binds
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/internal/ZoomMath.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/internal/ZoomMath.kt
new file mode 100644
index 0000000..0114d24
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/internal/ZoomMath.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration.internal
+
+import androidx.core.math.MathUtils
+import kotlin.math.abs
+
+/**
+ * This class is used for containing the mathematical calculations for ZoomControl, mainly the
+ * conversions between zoomRatio and linearZoom.
+ *
+ * The linearZoom is the percentage of zoom amount i.e. how much cropWidth is being used,
+ * so linearZoom = 0.5 should represent the middle point of
+ * [minZoomCropWidth, maxZoomCropWidth] range. But that does not mean it should be the same as
+ * (minZoom + maxZoom) / 2. For example, consider the case where
+ * original cropWidth = 10000 for zoomRatio = 1.0f,
+ * minZoomRatio = 1.0f, maxZoomRatio = 10.0f,
+ * zoomRatio = 5.5f may not represent linearZoom = 0.5 i.e. the half zoom amount. Here,
+ * zoomRatio = 1.0f, cropWidth = 10000,
+ * zoomRatio = 5.5f, cropWidth = 1818.18
+ * zoomRatio = 10.0f, cropWidth = 1000
+ * As observed, zoomRatio = 5.5f does not yield cropWidth = 5500 which would be the actual
+ * zooming amount middle point.
+ */
+object ZoomMath {
+    fun getLinearZoomFromZoomRatio(
+        zoomRatio: Float,
+        minZoomRatio: Float,
+        maxZoomRatio: Float
+    ): Float {
+        // if zoom is not supported i.e. minZoomRatio = maxZoomRatio, return 0
+        if (areFloatsEqual(minZoomRatio, maxZoomRatio)) {
+            return 0f
+        }
+
+        if (areFloatsEqual(zoomRatio, maxZoomRatio)) {
+            return 1f
+        } else if (areFloatsEqual(zoomRatio, minZoomRatio)) {
+            return 0f
+        }
+
+        /**
+         * linearZoom should represent the percentage of zoom amount based on how much cropWidth
+         * is visible.
+         *
+         * The original sensor region width is considered as 1.0f here as we only need the
+         * linearZoom ratio, not the actual crop width.
+         */
+        val relativeCropWidth = 1.0f / zoomRatio
+        val relativeCropWidthInMaxZoom = 1.0f / maxZoomRatio
+        val relativeCropWidthInMinZoom = 1.0f / minZoomRatio
+
+        val linearZoom = (relativeCropWidthInMinZoom - relativeCropWidth) /
+            (relativeCropWidthInMinZoom - relativeCropWidthInMaxZoom)
+
+        return MathUtils.clamp(linearZoom, 0f, 1.0f)
+    }
+
+    fun getZoomRatioFromLinearZoom(
+        linearZoom: Float,
+        minZoomRatio: Float,
+        maxZoomRatio: Float
+    ): Float {
+        if (areFloatsEqual(linearZoom, 1.0f)) {
+            return maxZoomRatio
+        } else if (areFloatsEqual(linearZoom, 0f)) {
+            return minZoomRatio
+        }
+
+        /**
+         * This crop width is proportional to the real crop width.
+         * The real crop with = sensorWidth/ zoomRatio,  but we need the ratio only so we can
+         * assume sensorWidth as 1.0f.
+         */
+        val relativeCropWidthInMaxZoom = 1.0f / maxZoomRatio
+        val relativeCropWidthInMinZoom = 1.0f / minZoomRatio
+
+        val cropWidth = relativeCropWidthInMinZoom -
+            (relativeCropWidthInMinZoom - relativeCropWidthInMaxZoom) * linearZoom
+
+        val ratio = 1.0f / cropWidth
+
+        return MathUtils.clamp(ratio, minZoomRatio, maxZoomRatio)
+    }
+
+    private fun areFloatsEqual(num1: Float, num2: Float): Boolean {
+        return nearZero(num1 - num2)
+    }
+
+    private fun nearZero(num: Float): Boolean {
+        return abs(num) < 2.0 * Math.ulp(abs(num))
+    }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapterTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapterTest.kt
index fe14c55..685eb31 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapterTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapterTest.kt
@@ -98,12 +98,12 @@
         // if useCaseCamera is null, zoom setting operation will be cancelled
         zoomControl.useCaseCamera = FakeUseCaseCamera()
 
-        zoomControl.setZoomRatioAsync(3.0f)[3, TimeUnit.SECONDS]
+        val expectedZoomState = ZoomValue(3.0f, 1.0f, 10.0f)
+        zoomControl.setZoomValue(expectedZoomState)[3, TimeUnit.SECONDS]
 
-        // minZoom and maxZoom will be set as 0 due to FakeZoomCompat using those values
-        assertWithMessage("zoomState did not return default zoom ratio successfully")
+        assertWithMessage("zoomState did not return the correct zoom state successfully")
             .that(currentZoomState)
-            .isEqualTo(ZoomValue(3.0f, zoomControl.minZoom, zoomControl.maxZoom))
+            .isEqualTo(expectedZoomState)
     }
 
     @Test
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/ZoomControlTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/ZoomControlTest.kt
index 600f8ab..f1b057a 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/ZoomControlTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/ZoomControlTest.kt
@@ -66,9 +66,9 @@
 
     @Test
     fun canUpdateZoomRatioInCompat() {
-        zoomControl.setZoomRatioAsync(3.0f)[3, TimeUnit.SECONDS]
+        zoomControl.setZoomRatio(3.0f)[3, TimeUnit.SECONDS]
 
-        Truth.assertWithMessage("zoomState did not return default zoom state successfully")
+        Truth.assertWithMessage("zoomCompat not updated with correct zoom ratio")
             .that(zoomCompat.zoomRatio)
             .isEqualTo(3.0f)
     }
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/internal/ZoomMathTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/internal/ZoomMathTest.kt
new file mode 100644
index 0000000..f2fe667
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/internal/ZoomMathTest.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration.internal
+
+import androidx.camera.camera2.pipe.integration.internal.ZoomMath.getLinearZoomFromZoomRatio
+import androidx.camera.camera2.pipe.integration.internal.ZoomMath.getZoomRatioFromLinearZoom
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+private const val CROP_REGION_TOLERANCE = 5f
+class ZoomMathTest {
+    private val minZoomRatio = 0.6f
+    private val maxZoomRatio = 8f
+
+    @Test
+    fun getLinearZoomFromZoomRatio_zoomRatioIsMin_linearZoomIs0() {
+        val linearZoom = getLinearZoomFromZoomRatio(
+            zoomRatio = minZoomRatio,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+
+        assertThat(linearZoom).isEqualTo(0f)
+    }
+
+    @Test
+    fun getLinearZoomFromZoomRatio_zoomRatioIsMax_linearZoomIs1() {
+        val linearZoom = getLinearZoomFromZoomRatio(
+            zoomRatio = maxZoomRatio,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+
+        assertThat(linearZoom).isEqualTo(1f)
+    }
+
+    @Test
+    fun getLinearZoomFromZoomRatio_zoomUnsupported_linearZoomIs0() {
+        // zoom unsupported means minZoomRatio = maxZoomRatio
+        val linearZoom = getLinearZoomFromZoomRatio(
+            zoomRatio = 1.0f,
+            minZoomRatio = 1.0f,
+            maxZoomRatio = 1.0f
+        )
+
+        assertThat(linearZoom).isEqualTo(0f)
+    }
+
+    @Test
+    fun getZoomRatioFromLinearZoom_linearZoomIs0_zoomRatioIsMin() {
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = 0f,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+
+        assertThat(zoomRatio).isEqualTo(minZoomRatio)
+    }
+
+    @Test
+    fun getZoomRatioFromLinearZoom_linearZoomIs1_zoomRatioIsMax() {
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = 1.0f,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+
+        assertThat(zoomRatio).isEqualTo(maxZoomRatio)
+    }
+
+    @Test
+    fun getZoomRatioFromLinearZoom_zoomUnsupportedAndLinearZoom0_zoomRatioIsTheAllowedValue() {
+        // zoom unsupported means minZoomRatio = maxZoomRatio
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = 0f,
+            minZoomRatio = 1.0f,
+            maxZoomRatio = 1.0f
+        )
+
+        assertThat(zoomRatio).isEqualTo(1.0f)
+    }
+
+    @Test
+    fun getZoomRatioFromLinearZoom_zoomUnsupportedAndLinearZoom0_5f_zoomRatioIsTheAllowedValue() {
+        // zoom unsupported means minZoomRatio = maxZoomRatio
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = 0.5f,
+            minZoomRatio = 1.0f,
+            maxZoomRatio = 1.0f
+        )
+
+        assertThat(zoomRatio).isEqualTo(1.0f)
+    }
+
+    @Test
+    fun getZoomRatioFromLinearZoom_zoomUnsupportedAndLinearZoom1_zoomRatioIsTheAllowedValue() {
+        // zoom unsupported means minZoomRatio = maxZoomRatio
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = 1.0f,
+            minZoomRatio = 1.0f,
+            maxZoomRatio = 1.0f
+        )
+
+        assertThat(zoomRatio).isEqualTo(1.0f)
+    }
+
+    @Test
+    fun getLinearZoomFromZoomRatio_getZoomRatioFromLinearZoomReturnsSameRatio() {
+        val linearZoom = getLinearZoomFromZoomRatio(
+            zoomRatio = 2f,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = linearZoom,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+
+        assertThat(zoomRatio).isEqualTo(2f)
+    }
+
+    @Test
+    fun linearZoomIs0_5f_cropWidthIsHalf() {
+        val sensorRegionWidth = 10000f
+        val minZoomCropWidth = sensorRegionWidth / minZoomRatio
+        val maxZoomCropWidth = sensorRegionWidth / maxZoomRatio
+
+        val zoomRatio = getZoomRatioFromLinearZoom(
+            linearZoom = 0.5f,
+            minZoomRatio = minZoomRatio,
+            maxZoomRatio = maxZoomRatio
+        )
+        val cropWidth = sensorRegionWidth / zoomRatio
+
+        assertThat(cropWidth)
+            .isWithin(CROP_REGION_TOLERANCE)
+            .of((minZoomCropWidth + maxZoomCropWidth) / 2)
+    }
+
+    @Test
+    fun linearZoomIsIncreasedProgressively_cropWidthIsChangedLinearly() {
+        val sensorRegionWidth = 10000f
+        var previousCropWidth = sensorRegionWidth / minZoomRatio
+        var previousCropWidthDiff = Float.NaN
+
+        var linearZoom = 0.1f
+
+        while (linearZoom < 1f) {
+            val zoomRatio = getZoomRatioFromLinearZoom(
+                linearZoom = linearZoom,
+                minZoomRatio = minZoomRatio,
+                maxZoomRatio = maxZoomRatio
+            )
+
+            val cropWidth = sensorRegionWidth / zoomRatio
+            val cropWidthDiff = previousCropWidth - cropWidth
+
+            if (!previousCropWidthDiff.isNaN()) {
+                assertThat(cropWidthDiff)
+                    .isWithin(CROP_REGION_TOLERANCE)
+                    .of(previousCropWidthDiff)
+            }
+
+            previousCropWidthDiff = cropWidthDiff
+            previousCropWidth = cropWidth
+            linearZoom += 0.1f
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeZoomCompat.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeZoomCompat.kt
index cabac1a..6feabbd 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeZoomCompat.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeZoomCompat.kt
@@ -20,8 +20,8 @@
 import androidx.camera.camera2.pipe.integration.impl.UseCaseCamera
 
 class FakeZoomCompat constructor(
-    override val minZoom: Float = 0f,
-    override val maxZoom: Float = 0f,
+    override val minZoomRatio: Float = 0f,
+    override val maxZoomRatio: Float = 0f,
 ) : ZoomCompat {
     var zoomRatio = 0f
 
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ZoomControlDeviceTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ZoomControlDeviceTest.kt
index d41a8ce..0d7512c 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ZoomControlDeviceTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ZoomControlDeviceTest.kt
@@ -278,7 +278,7 @@
 
     @Test
     fun setZoomRatioBy2_0_cropRegionIsSetCorrectly() = runBlocking {
-        assumeTrue(getMaxDigitalZoom() != null && getMaxDigitalZoom()!! <= 2.0f + DELTA)
+        assumeTrue(getMaxDigitalZoom() != null && getMaxDigitalZoom()!! > 2.0f + DELTA)
 
         checkTestPreconditions(isAndroidRZoom = false)
 
@@ -342,11 +342,6 @@
 
     @Test
     fun setLinearZoomBy0_5_isHalfCropWidth() = runBlocking {
-        assumeFalse(
-            "b/267665704: CameraPipe linear zoom conversion to zoom ratio is not correct",
-            implName == "CameraPipeConfig"
-        )
-
         checkTestPreconditions(isAndroidRZoom = false)
 
         // crop region in percentage == 0 may be null, need to use sensor rect instead.
@@ -363,11 +358,6 @@
     @Test
     @SdkSuppress(minSdkVersion = 30)
     fun setLinearZoomBy0_5_androidRZoomRatioUpdatedCorrectly() = runBlocking {
-        assumeFalse(
-            "b/267665704: CameraPipe linear zoom conversion to zoom ratio is not correct",
-            implName == "CameraPipeConfig"
-        )
-
         checkTestPreconditions(isAndroidRZoom = true)
 
         val cropWidth = 10000f
@@ -388,11 +378,6 @@
 
     @Test
     fun setLinearZoom_cropWidthChangedLinearly() = runBlocking {
-        assumeFalse(
-            "b/267665704: CameraPipe linear zoom conversion to zoom ratio is not correct",
-            implName == "CameraPipeConfig"
-        )
-
         checkTestPreconditions(isAndroidRZoom = false)
 
         // crop region in percentage == 0 may be null, need to use sensor rect instead.
@@ -421,11 +406,6 @@
     @RequiresApi(Build.VERSION_CODES.R)
     @Test
     fun setLinearZoom_androidRZoomRatio_cropWidthChangedLinearly() = runBlocking {
-        assumeFalse(
-            "b/267665704: CameraPipe linear zoom conversion to zoom ratio is not correct",
-            implName == "CameraPipeConfig"
-        )
-
         checkTestPreconditions(isAndroidRZoom = true)
 
         val cropWidth = 10000f
@@ -527,7 +507,7 @@
 
     @Test
     fun getZoomRatioLiveData_observerIsCalledWhenZoomRatioIsSet() = runBlocking {
-        assumeTrue(getMaxDigitalZoom() != null && getMaxDigitalZoom()!! <= 2.0f + DELTA)
+        assumeTrue(getMaxDigitalZoom() != null && getMaxDigitalZoom()!! > 2.0f + DELTA)
 
         val latch1 = CountDownLatch(1)
         val latch2 = CountDownLatch(1)
@@ -579,11 +559,6 @@
 
     @Test
     fun getZoomPercentageLiveData_observerIsCalledWhenZoomPercentageIsSet() = runBlocking {
-        assumeFalse(
-            "b/267665704: CameraPipe linear zoom conversion to zoom ratio is not correct",
-            implName == "CameraPipeConfig"
-        )
-
         val latch1 = CountDownLatch(1)
         val latch2 = CountDownLatch(1)
         val latch3 = CountDownLatch(1)
@@ -611,7 +586,7 @@
 
     @Test
     fun getZoomPercentageLiveData_observerIsCalledWhenZoomRatioIsSet() = runBlocking {
-        assumeTrue(getMaxDigitalZoom() != null && getMaxDigitalZoom()!! <= 2.0f + DELTA)
+        assumeTrue(getMaxDigitalZoom() != null && getMaxDigitalZoom()!! > 2.0f + DELTA)
 
         val latch = CountDownLatch(3)
         withContext(Dispatchers.Main) {