Merge "Add Camera2Extensions auto validation tests" into androidx-main
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/camera2extensions/Camera2ExtensionsActivityTest.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/camera2extensions/Camera2ExtensionsActivityTest.kt
new file mode 100644
index 0000000..ad26829
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/camera2extensions/Camera2ExtensionsActivityTest.kt
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2022 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.integration.extensions.camera2extensions
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import androidx.camera.camera2.Camera2Config
+import androidx.camera.integration.extensions.Camera2ExtensionsActivity
+import androidx.camera.integration.extensions.INTENT_EXTRA_CAMERA_ID
+import androidx.camera.integration.extensions.INTENT_EXTRA_EXTENSION_MODE
+import androidx.camera.integration.extensions.util.Camera2ExtensionsTestUtil
+import androidx.camera.integration.extensions.util.Camera2ExtensionsTestUtil.isCamera2ExtensionModeSupported
+import androidx.camera.integration.extensions.util.EXTENSIONS_TEST_APP_PACKAGE
+import androidx.camera.integration.extensions.util.waitForCaptureSessionConfiguredIdle
+import androidx.camera.integration.extensions.util.waitForImageSavedIdle
+import androidx.camera.integration.extensions.util.waitForPreviewIdle
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.CoreAppTestUtil
+import androidx.camera.testing.LabTestRule
+import androidx.camera.testing.StressTestRule
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import org.junit.After
+import org.junit.Assume
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.ClassRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+/**
+ * Auto validation tests for Camera2 Extensions with [Camera2ExtensionsActivity]
+ */
+@LargeTest
+@RunWith(Parameterized::class)
+@SdkSuppress(minSdkVersion = 31)
+class Camera2ExtensionsActivityTest(
+    private val cameraId: String,
+    private val extensionMode: Int
+) {
+    private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @get:Rule
+    val useCamera = CameraUtil.grantCameraPermissionAndPreTest(
+        CameraUtil.PreTestCameraIdList(Camera2Config.defaultConfig())
+    )
+
+    @get:Rule
+    val permissionRule: GrantPermissionRule =
+        GrantPermissionRule.grant(
+            Manifest.permission.WRITE_EXTERNAL_STORAGE
+        )
+
+    @get:Rule
+    val labTest: LabTestRule = LabTestRule()
+
+    @Before
+    fun setup() {
+        Assume.assumeTrue(CameraUtil.deviceHasCamera())
+        CoreAppTestUtil.assumeCompatibleDevice()
+        // Clears the device UI and check if there is no dialog or lock screen on the top of the
+        // window before start the test.
+        CoreAppTestUtil.prepareDeviceUI(InstrumentationRegistry.getInstrumentation())
+        // Uses the natural orientation throughout these tests to ensure the activity isn't
+        // recreated unexpectedly. This will also freeze the sensors until
+        // mDevice.unfreezeRotation() in the tearDown() method. Any simulated rotations will be
+        // explicitly initiated from within the test.
+        device.setOrientationNatural()
+    }
+
+    @After
+    fun tearDown() {
+        // Unfreezes rotation so the device can choose the orientation via its own policy. Be nice
+        // to other tests :)
+        device.unfreezeRotation()
+        device.pressHome()
+    }
+
+    companion object {
+        @ClassRule
+        @JvmField val stressTest = StressTestRule()
+
+        @Parameterized.Parameters(name = "cameraId = {0}, extensionMode = {1}")
+        @JvmStatic
+        fun parameters() = Camera2ExtensionsTestUtil.getAllCameraIdExtensionModeCombinations()
+    }
+
+    @LabTestRule.LabTestOnly
+    @Test
+    fun checkPreviewUpdated() {
+        val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+            cameraId,
+            extensionMode
+        )
+        with(activityScenario) { // Launches activity
+            use { // Ensures that ActivityScenario is cleaned up properly
+                // Waits for preview to receive enough frames for its IdlingResource to idle.
+                waitForPreviewIdle()
+            }
+        }
+    }
+
+    @LabTestRule.LabTestOnly
+    @Test
+    fun canCaptureSingleImage() {
+        val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+            cameraId,
+            extensionMode
+        )
+        with(activityScenario) { // Launches activity
+            use { // Ensures that ActivityScenario is cleaned up properly
+                // Triggers the capture function and waits for the image being saved
+                waitForImageSavedIdle()
+            }
+        }
+    }
+
+    @LabTestRule.LabTestOnly
+    @Test
+    fun checkPreviewUpdated_afterPauseResume() {
+        val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+            cameraId,
+            extensionMode
+        )
+        with(activityScenario) { // Launches activity
+            use { // Ensures that ActivityScenario is cleaned up properly
+                // Waits for preview to receive enough frames for its IdlingResource to idle.
+                waitForPreviewIdle()
+
+                // Pauses and resumes the activity
+                moveToState(Lifecycle.State.CREATED)
+                moveToState(Lifecycle.State.RESUMED)
+
+                // Waits for preview to receive enough frames again
+                waitForPreviewIdle()
+            }
+        }
+    }
+
+    @LabTestRule.LabTestOnly
+    @Test
+    fun canCaptureImage_afterPauseResume() {
+        val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+            cameraId,
+            extensionMode
+        )
+        with(activityScenario) { // Launches activity
+            use { // Ensures that ActivityScenario is cleaned up properly
+                // Triggers the capture function and waits for the image being saved
+                waitForImageSavedIdle()
+
+                // Pauses and resumes the activity
+                moveToState(Lifecycle.State.CREATED)
+                moveToState(Lifecycle.State.RESUMED)
+
+                // Waits for the capture session configured again after resuming the activity
+                waitForCaptureSessionConfiguredIdle()
+
+                // Triggers the capture function and waits for the image being saved again
+                waitForImageSavedIdle()
+            }
+        }
+    }
+
+    @LabTestRule.LabTestOnly
+    @Test
+    fun canCaptureMultipleImages() {
+        val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+            cameraId,
+            extensionMode
+        )
+        with(activityScenario) { // Launches activity
+            use { // Ensures that ActivityScenario is cleaned up properly
+                repeat(5) {
+                    // Triggers the capture function and waits for the image being saved
+                    waitForImageSavedIdle()
+                }
+            }
+        }
+    }
+
+    private fun launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+        cameraId: String,
+        extensionMode: Int
+    ): ActivityScenario<Camera2ExtensionsActivity> {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        assumeTrue(isCamera2ExtensionModeSupported(context, cameraId, extensionMode))
+        val intent = context.packageManager
+            .getLaunchIntentForPackage(EXTENSIONS_TEST_APP_PACKAGE)!!.apply {
+                putExtra(INTENT_EXTRA_CAMERA_ID, cameraId)
+                putExtra(INTENT_EXTRA_EXTENSION_MODE, extensionMode)
+                setClassName(context, Camera2ExtensionsActivity::class.java.name)
+                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+            }
+
+        val activityScenario: ActivityScenario<Camera2ExtensionsActivity> =
+            ActivityScenario.launch(intent)
+
+        // Waits for the capture session being configured
+        activityScenario.waitForCaptureSessionConfiguredIdle()
+
+        return activityScenario
+    }
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsActivityTestExtensions.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsActivityTestExtensions.kt
new file mode 100644
index 0000000..f7a24cd
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsActivityTestExtensions.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2022 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.integration.extensions.util
+
+import androidx.annotation.RequiresApi
+import androidx.camera.integration.extensions.Camera2ExtensionsActivity
+import androidx.camera.integration.extensions.R
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.IdlingRegistry
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.assertion.ViewAssertions
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.testutils.withActivity
+
+const val EXTENSIONS_TEST_APP_PACKAGE = "androidx.camera.integration.extensions"
+
+/**
+ * Waits until the capture session has been configured and its idling resource has become idle.
+ */
+@RequiresApi(31)
+internal fun ActivityScenario<Camera2ExtensionsActivity>.waitForCaptureSessionConfiguredIdle() {
+    val idlingResource = withActivity {
+        getCaptureSessionConfiguredIdlingResource()
+    }
+    try {
+        IdlingRegistry.getInstance().register(idlingResource)
+        // Waits for the CaptureSessionConfiguredIdlingResource becoming idle
+        Espresso.onIdle()
+    } finally { // Always releases the idling resource, in case of timeout exceptions.
+        IdlingRegistry.getInstance().unregister(idlingResource)
+    }
+}
+
+/**
+ * Waits until the preview has received frames and its idling resource has become idle.
+ */
+@RequiresApi(31)
+internal fun ActivityScenario<Camera2ExtensionsActivity>.waitForPreviewIdle() {
+    val idlingResource = withActivity {
+        resetPreviewIdlingResource()
+        getPreviewIdlingResource()
+    }
+    try {
+        IdlingRegistry.getInstance().register(idlingResource)
+        // Waits for the PreviewIdlingResource becoming idle
+        Espresso.onView(ViewMatchers.withId(R.id.viewFinder))
+            .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+    } finally { // Always releases the idling resource, in case of timeout exceptions.
+        IdlingRegistry.getInstance().unregister(idlingResource)
+    }
+}
+
+/**
+ * Waits until captured image has been saved and its idling resource has become idle.
+ */
+@RequiresApi(31)
+internal fun ActivityScenario<Camera2ExtensionsActivity>.waitForImageSavedIdle() {
+    val idlingResource = withActivity {
+        getImageSavedIdlingResource()
+    }
+    try {
+        IdlingRegistry.getInstance().register(idlingResource)
+        // Performs click action and waits for the ImageSavedIdlingResource becoming idle
+        Espresso.onView(ViewMatchers.withId(R.id.Picture)).perform(ViewActions.click())
+    } finally { // Always releases the idling resource, in case of timeout exceptions.
+        IdlingRegistry.getInstance().unregister(idlingResource)
+        withActivity { deleteSessionImages() }
+    }
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsTestUtil.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsTestUtil.kt
new file mode 100644
index 0000000..d2195a8
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsTestUtil.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 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.integration.extensions.util
+
+import android.content.Context
+import android.hardware.camera2.CameraExtensionCharacteristics
+import android.hardware.camera2.CameraManager
+import androidx.annotation.RequiresApi
+import androidx.camera.testing.CameraUtil
+
+@RequiresApi(31)
+object Camera2ExtensionsTestUtil {
+
+    /**
+     * Gets a list of all camera id and extension mode combinations.
+     */
+    @JvmStatic
+    fun getAllCameraIdExtensionModeCombinations(): List<Array<Any>> =
+        arrayListOf<Array<Any>>().apply {
+            CameraUtil.getBackwardCompatibleCameraIdListOrThrow().forEach { cameraId ->
+                AVAILABLE_EXTENSION_MODES.forEach { mode ->
+                    add(arrayOf(cameraId, mode))
+                }
+            }
+        }
+
+    @JvmStatic
+    fun isCamera2ExtensionModeSupported(
+        context: Context,
+        cameraId: String,
+        extensionMode: Int
+    ): Boolean {
+        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
+        val extensionCharacteristics = cameraManager.getCameraExtensionCharacteristics(cameraId)
+        return extensionCharacteristics.supportedExtensions.contains(extensionMode)
+    }
+
+    /**
+     * Camera2 extension modes
+     */
+    @Suppress("DEPRECATION") // EXTENSION_BEAUTY
+    @JvmStatic
+    val AVAILABLE_EXTENSION_MODES = arrayOf(
+        CameraExtensionCharacteristics.EXTENSION_AUTOMATIC,
+        CameraExtensionCharacteristics.EXTENSION_BEAUTY,
+        CameraExtensionCharacteristics.EXTENSION_BOKEH,
+        CameraExtensionCharacteristics.EXTENSION_HDR,
+        CameraExtensionCharacteristics.EXTENSION_NIGHT,
+    )
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt
index d682e82..c284eb7 100644
--- a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt
@@ -17,6 +17,7 @@
 package androidx.camera.integration.extensions
 
 import android.annotation.SuppressLint
+import android.content.ContentResolver
 import android.content.Context
 import android.content.Intent
 import android.graphics.SurfaceTexture
@@ -30,40 +31,46 @@
 import android.hardware.camera2.params.ExtensionSessionConfiguration
 import android.hardware.camera2.params.OutputConfiguration
 import android.media.ImageReader
+import android.net.Uri
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
 import android.util.Log
+import android.util.Size
 import android.view.Menu
 import android.view.MenuItem
 import android.view.Surface
 import android.view.TextureView
+import android.view.View
 import android.view.ViewStub
 import android.widget.Button
+import android.widget.FrameLayout
 import android.widget.Toast
 import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
 import androidx.appcompat.app.AppCompatActivity
 import androidx.camera.core.impl.utils.futures.Futures
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.calculateRelativeImageRotationDegrees
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.createExtensionCaptureCallback
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.getDisplayRotationDegrees
 import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.getExtensionModeStringFromId
 import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.getLensFacingCameraId
 import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.pickPreviewResolution
 import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.pickStillImageResolution
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.transformPreview
 import androidx.camera.integration.extensions.utils.FileUtil
+import androidx.camera.integration.extensions.utils.TransformUtil.calculateRelativeImageRotationDegrees
+import androidx.camera.integration.extensions.utils.TransformUtil.surfaceRotationToRotationDegrees
+import androidx.camera.integration.extensions.utils.TransformUtil.transformTextureView
 import androidx.camera.integration.extensions.validation.CameraValidationResultActivity
 import androidx.concurrent.futures.CallbackToFutureAdapter
 import androidx.concurrent.futures.CallbackToFutureAdapter.Completer
 import androidx.core.util.Preconditions
 import androidx.lifecycle.lifecycleScope
+import androidx.test.espresso.idling.CountingIdlingResource
 import com.google.common.util.concurrent.ListenableFuture
 import java.text.Format
 import java.text.SimpleDateFormat
 import java.util.Calendar
 import java.util.Locale
 import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicLong
 import kotlin.coroutines.resume
 import kotlin.coroutines.resumeWithException
 import kotlinx.coroutines.Deferred
@@ -77,6 +84,15 @@
 
 private const val TAG = "Camera2ExtensionsAct~"
 private const val EXTENSION_MODE_INVALID = -1
+private const val FRAMES_UNTIL_VIEW_IS_READY = 10
+
+// Launch the activity with the specified camera id.
+@VisibleForTesting
+const val INTENT_EXTRA_CAMERA_ID = "camera_id"
+
+// Launch the activity with the specified extension mode.
+@VisibleForTesting
+const val INTENT_EXTRA_EXTENSION_MODE = "extension_mode"
 
 @RequiresApi(31)
 class Camera2ExtensionsActivity : AppCompatActivity() {
@@ -127,6 +143,8 @@
     private var currentExtensionIdx = -1
     private val supportedExtensionModes = mutableListOf<Int>()
 
+    private lateinit var containerView: View
+
     private lateinit var textureView: TextureView
 
     private lateinit var previewSurface: Surface
@@ -139,7 +157,7 @@
             height: Int
         ) {
             previewSurface = Surface(surfaceTexture)
-            openCameraWithExtensionMode(currentCameraId)
+            setupAndStartPreview(currentCameraId, currentExtensionMode)
         }
 
         override fun onSurfaceTextureSizeChanged(
@@ -154,10 +172,31 @@
         }
 
         override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
+            if (captureProcessStartedIdlingResource.isIdleNow &&
+                receivedPreviewFrameCount.getAndIncrement() >= FRAMES_UNTIL_VIEW_IS_READY &&
+                !previewIdlingResource.isIdleNow
+            ) {
+                previewIdlingResource.decrement()
+            }
         }
     }
 
-    private val captureCallbacks = createExtensionCaptureCallback()
+    private val captureCallbacks = object : CameraExtensionSession.ExtensionCaptureCallback() {
+        override fun onCaptureProcessStarted(
+            session: CameraExtensionSession,
+            request: CaptureRequest
+        ) {
+            if (receivedCaptureProcessStartedCount.getAndIncrement() >=
+                FRAMES_UNTIL_VIEW_IS_READY && !captureProcessStartedIdlingResource.isIdleNow
+            ) {
+                captureProcessStartedIdlingResource.decrement()
+            }
+        }
+
+        override fun onCaptureFailed(session: CameraExtensionSession, request: CaptureRequest) {
+            Log.e(TAG, "onCaptureFailed!!")
+        }
+    }
 
     private var restartOnStart = false
 
@@ -167,6 +206,37 @@
 
     private var imageSaveTerminationFuture: ListenableFuture<Any?> = Futures.immediateFuture(null)
 
+    /**
+     * Used to wait for the capture session is configured.
+     */
+    private val captureSessionConfiguredIdlingResource =
+        CountingIdlingResource("captureSessionConfigured").apply { increment() }
+    /**
+     * Used to wait for the ExtensionCaptureCallback#onCaptureProcessStarted is called which means
+     * an image is captured and extension processing is triggered.
+     */
+    private val captureProcessStartedIdlingResource =
+        CountingIdlingResource("captureProcessStarted").apply { increment() }
+
+    /**
+     * Used to wait for the preview is ready. This will become idle after
+     * captureProcessStartedIdlingResource becomes idle and
+     * [SurfaceTextureListener#onSurfaceTextureUpdated()] is also called. It means that there has
+     * been images captured to trigger the extension processing and the preview's SurfaceTexture is
+     * also updated by [SurfaceTexture#updateTexImage()] calls.
+     */
+    private val previewIdlingResource = CountingIdlingResource("preview").apply { increment() }
+
+    /**
+     * Used to trigger a picture taking action and waits for the image being saved.
+     */
+    private val imageSavedIdlingResource = CountingIdlingResource("imageSaved")
+
+    private val receivedCaptureProcessStartedCount: AtomicLong = AtomicLong(0)
+    private val receivedPreviewFrameCount: AtomicLong = AtomicLong(0)
+
+    private lateinit var sessionImageUriSet: SessionMediaUriSet
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         Log.d(TAG, "onCreate()")
@@ -191,8 +261,15 @@
             return
         }
 
-        updateExtensionInfo()
+        sessionImageUriSet = SessionMediaUriSet(contentResolver)
 
+        // Gets params from extra bundle
+        intent.extras?.let { bundle ->
+            currentCameraId = bundle.getString(INTENT_EXTRA_CAMERA_ID, currentCameraId)
+            currentExtensionMode = bundle.getInt(INTENT_EXTRA_EXTENSION_MODE, currentExtensionMode)
+        }
+
+        updateExtensionInfo()
         setupTextureView()
         enableUiControl(false)
         setupUiControl()
@@ -240,7 +317,8 @@
     private fun setupTextureView() {
         val viewFinderStub = findViewById<ViewStub>(R.id.viewFinderStub)
         viewFinderStub.layoutResource = R.layout.full_textureview
-        textureView = viewFinderStub.inflate() as TextureView
+        containerView = viewFinderStub.inflate()
+        textureView = containerView.findViewById(R.id.textureView)
         textureView.surfaceTextureListener = surfaceTextureListener
     }
 
@@ -260,7 +338,7 @@
             restartPreview = true
             extensionModeToggleButton.text = getExtensionModeStringFromId(currentExtensionMode)
 
-            closeCaptureSession()
+            closeCaptureSessionAsync()
         }
 
         val cameraSwitchButton = findViewById<Button>(R.id.Switch)
@@ -280,12 +358,13 @@
             currentCameraId = newCameraId
             restartCamera = true
 
-            closeCamera()
+            closeCameraAsync()
         }
 
         val captureButton = findViewById<Button>(R.id.Picture)
         captureButton.setOnClickListener {
             enableUiControl(false)
+            resetImageSavedIdlingResource()
             takePicture()
         }
     }
@@ -296,7 +375,7 @@
         activityStopped = false
         if (restartOnStart) {
             restartOnStart = false
-            openCameraWithExtensionMode(currentCameraId)
+            setupAndStartPreview(currentCameraId, currentExtensionMode)
         }
     }
 
@@ -306,8 +385,8 @@
         // Needs to close the camera first. Otherwise, the next activity might be failed to open
         // the camera and configure the capture session.
         runBlocking {
-            closeCaptureSession().await()
-            closeCamera().await()
+            closeCaptureSessionAsync().await()
+            closeCameraAsync().await()
         }
         restartOnStart = true
         activityStopped = true
@@ -323,34 +402,101 @@
         Log.d(TAG, "onDestroy()--")
     }
 
-    private fun closeCamera(): Deferred<Unit> = lifecycleScope.async(cameraTaskDispatcher) {
+    private fun closeCameraAsync(): Deferred<Unit> = lifecycleScope.async(cameraTaskDispatcher) {
         Log.d(TAG, "closeCamera()++")
         cameraDevice?.close()
         cameraDevice = null
         Log.d(TAG, "closeCamera()--")
     }
 
-    private fun closeCaptureSession(): Deferred<Unit> = lifecycleScope.async(cameraTaskDispatcher) {
-        Log.d(TAG, "closeCaptureSession()++")
-        try {
-            cameraExtensionSession?.close()
-            cameraExtensionSession = null
-        } catch (e: Exception) {
-            Log.e(TAG, e.toString())
+    private fun closeCaptureSessionAsync(): Deferred<Unit> =
+        lifecycleScope.async(cameraTaskDispatcher) {
+            Log.d(TAG, "closeCaptureSession()++")
+            resetCaptureSessionConfiguredIdlingResource()
+            try {
+                cameraExtensionSession?.close()
+                cameraExtensionSession = null
+            } catch (e: Exception) {
+                Log.e(TAG, e.toString())
+            }
+            Log.d(TAG, "closeCaptureSession()--")
         }
-        Log.d(TAG, "closeCaptureSession()--")
+
+    /**
+     * Sets up the UI layout settings for the specified camera and extension mode. And then,
+     * triggers to open the camera and capture session to start the preview with the extension mode
+     * enabled.
+     */
+    @Suppress("DEPRECATION") /* defaultDisplay */
+    private fun setupAndStartPreview(cameraId: String, extensionMode: Int) {
+        if (!textureView.isAvailable) {
+            Toast.makeText(
+                this, "TextureView is invalid!!",
+                Toast.LENGTH_SHORT
+            ).show()
+            finish()
+            return
+        }
+
+        val previewResolution = pickPreviewResolution(
+            cameraManager,
+            cameraId,
+            resources.displayMetrics,
+            extensionMode
+        )
+
+        if (previewResolution == null) {
+            Toast.makeText(
+                this,
+                "Invalid preview extension sizes!.",
+                Toast.LENGTH_SHORT
+            ).show()
+            finish()
+            return
+        }
+
+        Log.d(TAG, "Set default buffer size to previewResolution: $previewResolution")
+
+        textureView.surfaceTexture?.setDefaultBufferSize(
+            previewResolution.width,
+            previewResolution.height
+        )
+
+        textureView.layoutParams =
+            FrameLayout.LayoutParams(previewResolution.width, previewResolution.height)
+
+        val containerViewSize = Size(containerView.width, containerView.height)
+
+        val lensFacing =
+            cameraManager.getCameraCharacteristics(cameraId)[CameraCharacteristics.LENS_FACING]
+
+        transformTextureView(
+            textureView,
+            containerViewSize,
+            previewResolution,
+            windowManager.defaultDisplay.rotation,
+            cameraSensorRotationDegrees,
+            lensFacing == CameraCharacteristics.LENS_FACING_BACK
+        )
+
+        startPreview(cameraId, extensionMode)
     }
 
-    private fun openCameraWithExtensionMode(cameraId: String) =
+    /**
+     * Opens the camera and capture session to start the preview with the extension mode enabled.
+     */
+    private fun startPreview(cameraId: String, extensionMode: Int) =
         lifecycleScope.launch(cameraTaskDispatcher) {
             Log.d(TAG, "openCameraWithExtensionMode()++ cameraId: $cameraId")
-            cameraDevice = openCamera(cameraManager, cameraId)
-            cameraExtensionSession = openCaptureSession()
+            if (cameraDevice == null || cameraDevice!!.id != cameraId) {
+                cameraDevice = openCamera(cameraManager, cameraId)
+            }
+            cameraExtensionSession = openCaptureSession(extensionMode)
 
             lifecycleScope.launch(Dispatchers.Main) {
                 if (activityStopped) {
-                    closeCaptureSession()
-                    closeCamera()
+                    closeCaptureSessionAsync()
+                    closeCameraAsync()
                 }
             }
             Log.d(TAG, "openCameraWithExtensionMode()--")
@@ -382,7 +528,7 @@
                         if (restartCamera) {
                             restartCamera = false
                             updateExtensionInfo()
-                            openCameraWithExtensionMode(currentCameraId)
+                            setupAndStartPreview(currentCameraId, currentExtensionMode)
                         }
                     }
                 }
@@ -407,10 +553,9 @@
     /**
      * Opens and returns the extensions session (as the result of the suspend coroutine)
      */
-    private suspend fun openCaptureSession(): CameraExtensionSession =
+    private suspend fun openCaptureSession(extensionMode: Int): CameraExtensionSession =
         suspendCancellableCoroutine { cont ->
             Log.d(TAG, "openCaptureSession")
-            setupPreview()
 
             if (stillImageReader != null) {
                 val imageReaderToClose = stillImageReader!!
@@ -426,7 +571,7 @@
             outputConfig.add(OutputConfiguration(stillImageReader!!.surface))
             outputConfig.add(OutputConfiguration(previewSurface))
             val extensionConfiguration = ExtensionSessionConfiguration(
-                currentExtensionMode, outputConfig,
+                extensionMode, outputConfig,
                 cameraTaskDispatcher.asExecutor(), object : CameraExtensionSession.StateCallback() {
                     override fun onClosed(session: CameraExtensionSession) {
                         Log.d(TAG, "CaptureSession - onClosed: $session")
@@ -435,8 +580,11 @@
                             if (restartPreview) {
                                 restartPreview = false
 
+                                val newExtensionMode = currentExtensionMode
+
                                 lifecycleScope.launch(cameraTaskDispatcher) {
-                                    cameraExtensionSession = openCaptureSession()
+                                    cameraExtensionSession =
+                                        openCaptureSession(newExtensionMode)
                                 }
                             }
                         }
@@ -453,7 +601,12 @@
                                 cameraTaskDispatcher.asExecutor(), captureCallbacks
                             )
                             cont.resume(session)
-                            runOnUiThread { enableUiControl(true) }
+                            runOnUiThread {
+                                enableUiControl(true)
+                                if (!captureSessionConfiguredIdlingResource.isIdleNow) {
+                                    captureSessionConfiguredIdlingResource.decrement()
+                                }
+                            }
                         } catch (e: CameraAccessException) {
                             Log.e(TAG, e.toString())
                             cont.resumeWithException(
@@ -478,47 +631,14 @@
             }
         }
 
-    @Suppress("DEPRECATION") /* defaultDisplay */
-    private fun setupPreview() {
-        if (!textureView.isAvailable) {
-            Toast.makeText(
-                this, "TextureView is invalid!!",
-                Toast.LENGTH_SHORT
-            ).show()
-            finish()
-            return
-        }
-
-        val previewResolution = pickPreviewResolution(
-            cameraManager,
-            currentCameraId,
-            resources.displayMetrics,
-            currentExtensionMode
-        )
-
-        if (previewResolution == null) {
-            Toast.makeText(
-                this,
-                "Invalid preview extension sizes!.",
-                Toast.LENGTH_SHORT
-            ).show()
-            finish()
-            return
-        }
-
-        textureView.surfaceTexture?.setDefaultBufferSize(
-            previewResolution.width,
-            previewResolution.height
-        )
-        transformPreview(textureView, previewResolution, windowManager.defaultDisplay.rotation)
-    }
-
     private fun setupImageReader(): ImageReader {
         val (size, format) = pickStillImageResolution(
             extensionCharacteristics,
             currentExtensionMode
         )
 
+        Log.d(TAG, "Setup image reader - size: $size, format: $format")
+
         return ImageReader.newInstance(size.width, size.height, format, 1)
     }
 
@@ -542,9 +662,15 @@
         stillImageReader!!.setOnImageAvailableListener(
             { reader: ImageReader ->
                 lifecycleScope.launch(cameraTaskDispatcher) {
-                    acquireImageAndSave(reader)
+                    acquireImageAndSave(reader)?.let { sessionImageUriSet.add(it) }
+
                     stillImageReader!!.setOnImageAvailableListener(null, null)
                     takePictureCompleter?.set(null)
+
+                    if (!imageSavedIdlingResource.isIdleNow) {
+                        imageSavedIdlingResource.decrement()
+                    }
+
                     lifecycleScope.launch(Dispatchers.Main) {
                         enableUiControl(true)
                     }
@@ -582,7 +708,8 @@
     /**
      * Acquires the latest image from the image reader and save it to the Pictures folder
      */
-    private fun acquireImageAndSave(imageReader: ImageReader) {
+    private fun acquireImageAndSave(imageReader: ImageReader): Uri? {
+        var uri: Uri? = null
         try {
             val formatter: Format =
                 SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
@@ -592,13 +719,13 @@
                 }"
 
             val rotationDegrees = calculateRelativeImageRotationDegrees(
-                (getDisplayRotationDegrees(display!!.rotation)),
+                (surfaceRotationToRotationDegrees(display!!.rotation)),
                 cameraSensorRotationDegrees,
                 currentCameraId == backCameraId
             )
 
             imageReader.acquireLatestImage().let { image ->
-                val uri = FileUtil.saveImage(
+                uri = FileUtil.saveImage(
                     image,
                     fileName,
                     ".jpg",
@@ -622,6 +749,8 @@
         } catch (e: Exception) {
             Log.e(TAG, e.toString())
         }
+
+        return uri
     }
 
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -649,8 +778,8 @@
         // Needs to close the camera first. Otherwise, the next activity might be failed to open
         // the camera and configure the capture session.
         runBlocking {
-            closeCaptureSession().await()
-            closeCamera().await()
+            closeCaptureSessionAsync().await()
+            closeCameraAsync().await()
         }
 
         val intent = Intent()
@@ -658,4 +787,66 @@
         intent.setClassName(this, className)
         startActivity(intent)
     }
+
+    @VisibleForTesting
+    fun getCaptureSessionConfiguredIdlingResource(): CountingIdlingResource =
+        captureSessionConfiguredIdlingResource
+
+    @VisibleForTesting
+    fun getPreviewIdlingResource(): CountingIdlingResource = previewIdlingResource
+
+    @VisibleForTesting
+    fun getImageSavedIdlingResource(): CountingIdlingResource = imageSavedIdlingResource
+
+    private fun resetCaptureSessionConfiguredIdlingResource() {
+        if (captureSessionConfiguredIdlingResource.isIdleNow) {
+            captureSessionConfiguredIdlingResource.increment()
+        }
+    }
+
+    @VisibleForTesting
+    fun resetPreviewIdlingResource() {
+        receivedCaptureProcessStartedCount.set(0)
+        receivedPreviewFrameCount.set(0)
+
+        if (captureProcessStartedIdlingResource.isIdleNow) {
+            captureProcessStartedIdlingResource.increment()
+        }
+
+        if (previewIdlingResource.isIdleNow) {
+            previewIdlingResource.increment()
+        }
+    }
+
+    private fun resetImageSavedIdlingResource() {
+        if (imageSavedIdlingResource.isIdleNow) {
+            imageSavedIdlingResource.increment()
+        }
+    }
+
+    @VisibleForTesting
+    fun deleteSessionImages() {
+        sessionImageUriSet.deleteAllUris()
+    }
+
+    private class SessionMediaUriSet constructor(val contentResolver: ContentResolver) {
+        private val mSessionMediaUris: MutableSet<Uri> = mutableSetOf()
+
+        fun add(uri: Uri) {
+            synchronized(mSessionMediaUris) {
+                mSessionMediaUris.add(uri)
+            }
+        }
+
+        fun deleteAllUris() {
+            synchronized(mSessionMediaUris) {
+                val it =
+                    mSessionMediaUris.iterator()
+                while (it.hasNext()) {
+                    contentResolver.delete(it.next(), null, null)
+                    it.remove()
+                }
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
index 8bbd835..7d9f688 100644
--- a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
@@ -18,24 +18,16 @@
 
 import android.annotation.SuppressLint
 import android.graphics.ImageFormat
-import android.graphics.Matrix
 import android.graphics.Point
 import android.graphics.SurfaceTexture
 import android.hardware.camera2.CameraCharacteristics
 import android.hardware.camera2.CameraExtensionCharacteristics
-import android.hardware.camera2.CameraExtensionSession
-import android.hardware.camera2.CameraExtensionSession.ExtensionCaptureCallback
 import android.hardware.camera2.CameraManager
-import android.hardware.camera2.CaptureRequest
 import android.os.Build
 import android.util.DisplayMetrics
 import android.util.Log
 import android.util.Size
-import android.view.Surface
-import android.view.TextureView
 import androidx.annotation.RequiresApi
-import java.math.BigDecimal
-import java.math.RoundingMode
 import java.util.stream.Collectors
 
 private const val TAG = "Camera2ExtensionsUtil"
@@ -83,49 +75,6 @@
     }
 
     /**
-     * Creates a default extension capture callback implementation.
-     */
-    @RequiresApi(Build.VERSION_CODES.S)
-    @JvmStatic
-    fun createExtensionCaptureCallback(): ExtensionCaptureCallback {
-        return object : ExtensionCaptureCallback() {
-            override fun onCaptureStarted(
-                session: CameraExtensionSession,
-                request: CaptureRequest,
-                timestamp: Long
-            ) {
-            }
-
-            override fun onCaptureProcessStarted(
-                session: CameraExtensionSession,
-                request: CaptureRequest
-            ) {
-            }
-
-            override fun onCaptureFailed(
-                session: CameraExtensionSession,
-                request: CaptureRequest
-            ) {
-                Log.v(TAG, "onCaptureProcessFailed")
-            }
-
-            override fun onCaptureSequenceCompleted(
-                session: CameraExtensionSession,
-                sequenceId: Int
-            ) {
-                Log.v(TAG, "onCaptureProcessSequenceCompleted: $sequenceId")
-            }
-
-            override fun onCaptureSequenceAborted(
-                session: CameraExtensionSession,
-                sequenceId: Int
-            ) {
-                Log.v(TAG, "onCaptureProcessSequenceAborted: $sequenceId")
-            }
-        }
-    }
-
-    /**
      * Picks a preview resolution that is both close/same as the display size and supported by camera
      * and extensions.
      */
@@ -213,135 +162,4 @@
 
         return Pair(stillCaptureSize, stillFormat)
     }
-
-    /**
-     * Transforms the texture view to display the content of resolution in correct direction and
-     * aspect ratio.
-     */
-    @JvmStatic
-    fun transformPreview(textureView: TextureView, resolution: Size, displayRotation: Int) {
-        if (resolution.width == 0 || resolution.height == 0) {
-            return
-        }
-        if (textureView.width == 0 || textureView.height == 0) {
-            return
-        }
-        val matrix = Matrix()
-        val left: Int = textureView.left
-        val right: Int = textureView.right
-        val top: Int = textureView.top
-        val bottom: Int = textureView.bottom
-
-        // Compute the preview ui size based on the available width, height, and ui orientation.
-        val viewWidth = right - left
-        val viewHeight = bottom - top
-        val displayRotationDegrees: Int = getDisplayRotationDegrees(displayRotation)
-        val scaled: Size = calculatePreviewViewDimens(
-            resolution, viewWidth, viewHeight, displayRotation
-        )
-
-        // Compute the center of the view.
-        val centerX = (viewWidth / 2).toFloat()
-        val centerY = (viewHeight / 2).toFloat()
-
-        // Do corresponding rotation to correct the preview direction
-        matrix.postRotate((-displayRotationDegrees).toFloat(), centerX, centerY)
-
-        // Compute the scale value for center crop mode
-        var xScale = scaled.width / viewWidth.toFloat()
-        var yScale = scaled.height / viewHeight.toFloat()
-        if (displayRotationDegrees % 180 == 90) {
-            xScale = scaled.width / viewHeight.toFloat()
-            yScale = scaled.height / viewWidth.toFloat()
-        }
-
-        // Only two digits after the decimal point are valid for postScale. Need to get ceiling of
-        // two digits floating value to do the scale operation. Otherwise, the result may be scaled
-        // not large enough and will have some blank lines on the screen.
-        xScale = BigDecimal(xScale.toDouble()).setScale(2, RoundingMode.CEILING).toFloat()
-        yScale = BigDecimal(yScale.toDouble()).setScale(2, RoundingMode.CEILING).toFloat()
-
-        // Do corresponding scale to resolve the deformation problem
-        matrix.postScale(xScale, yScale, centerX, centerY)
-        textureView.setTransform(matrix)
-    }
-
-    /**
-     * Converts the display rotation to degrees value.
-     *
-     * @return One of 0, 90, 180, 270.
-     */
-    @JvmStatic
-    fun getDisplayRotationDegrees(displayRotation: Int): Int = when (displayRotation) {
-        Surface.ROTATION_0 -> 0
-        Surface.ROTATION_90 -> 90
-        Surface.ROTATION_180 -> 180
-        Surface.ROTATION_270 -> 270
-        else -> throw UnsupportedOperationException(
-            "Unsupported display rotation: $displayRotation"
-        )
-    }
-
-    /**
-     * Calculates the delta between a source rotation and destination rotation.
-     *
-     * <p>A typical use of this method would be calculating the angular difference between the
-     * display orientation (destRotationDegrees) and camera sensor orientation
-     * (sourceRotationDegrees).
-     *
-     * @param destRotationDegrees   The destination rotation relative to the device's natural
-     *                              rotation.
-     * @param sourceRotationDegrees The source rotation relative to the device's natural rotation.
-     * @param isOppositeFacing      Whether the source and destination planes are facing opposite
-     *                              directions.
-     */
-    @JvmStatic
-    fun calculateRelativeImageRotationDegrees(
-        destRotationDegrees: Int,
-        sourceRotationDegrees: Int,
-        isOppositeFacing: Boolean
-    ): Int {
-        val result: Int = if (isOppositeFacing) {
-            (sourceRotationDegrees - destRotationDegrees + 360) % 360
-        } else {
-            (sourceRotationDegrees + destRotationDegrees) % 360
-        }
-
-        return result
-    }
-
-    /**
-     * Calculates the preview size which can display the source image in correct aspect ratio.
-     */
-    @JvmStatic
-    private fun calculatePreviewViewDimens(
-        srcSize: Size,
-        parentWidth: Int,
-        parentHeight: Int,
-        displayRotation: Int
-    ): Size {
-        var inWidth = srcSize.width
-        var inHeight = srcSize.height
-        if (displayRotation == 0 || displayRotation == 180) {
-            // Need to reverse the width and height since we're in landscape orientation.
-            inWidth = srcSize.height
-            inHeight = srcSize.width
-        }
-        var outWidth = parentWidth
-        var outHeight = parentHeight
-        if (inWidth != 0 && inHeight != 0) {
-            val vfRatio = inWidth / inHeight.toFloat()
-            val parentRatio = parentWidth / parentHeight.toFloat()
-
-            // Match shortest sides together.
-            if (vfRatio < parentRatio) {
-                outWidth = parentWidth
-                outHeight = Math.round(parentWidth / vfRatio)
-            } else {
-                outWidth = Math.round(parentHeight * vfRatio)
-                outHeight = parentHeight
-            }
-        }
-        return Size(outWidth, outHeight)
-    }
 }
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/TransformUtil.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/TransformUtil.kt
new file mode 100644
index 0000000..fb3c5ef
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/TransformUtil.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2022 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.integration.extensions.utils
+
+import android.graphics.Matrix
+import android.graphics.Rect
+import android.graphics.RectF
+import android.util.Size
+import android.view.Surface
+import android.view.TextureView
+import androidx.core.graphics.toRect
+
+// Normalized space (-1, -1) - (1, 1).
+private val NORMALIZED_RECT = RectF(-1f, -1f, 1f, 1f)
+
+object TransformUtil {
+
+    /**
+     * Converts [Surface] rotation to rotation degrees: 90, 180, 270 or 0.
+     */
+    @JvmStatic
+    fun surfaceRotationToRotationDegrees(rotationValue: Int): Int = when (rotationValue) {
+        Surface.ROTATION_0 -> 0
+        Surface.ROTATION_90 -> 90
+        Surface.ROTATION_180 -> 180
+        Surface.ROTATION_270 -> 270
+        else -> throw UnsupportedOperationException(
+            "Unsupported display rotation: $rotationValue"
+        )
+    }
+
+    /**
+     * Calculates the delta between a source rotation and destination rotation.
+     *
+     * <p>A typical use of this method would be calculating the angular difference between the
+     * display orientation (destRotationDegrees) and camera sensor orientation
+     * (sourceRotationDegrees).
+     *
+     * @param destRotationDegrees   The destination rotation relative to the device's natural
+     *                              rotation.
+     * @param sourceRotationDegrees The source rotation relative to the device's natural rotation.
+     * @param isOppositeFacing      Whether the source and destination planes are facing opposite
+     *                              directions.
+     */
+    @JvmStatic
+    fun calculateRelativeImageRotationDegrees(
+        destRotationDegrees: Int,
+        sourceRotationDegrees: Int,
+        isOppositeFacing: Boolean
+    ): Int = if (isOppositeFacing) {
+        (sourceRotationDegrees - destRotationDegrees + 360) % 360
+    } else {
+        (sourceRotationDegrees + destRotationDegrees) % 360
+    }
+
+    /**
+     * Calculates the transformation and applies it to the inner view of [TextureView] preview.
+     *
+     * [TextureView] needs a preliminary correction since it doesn't handle the
+     * display rotation.
+     */
+    @JvmStatic
+    fun transformTextureView(
+        preview: TextureView,
+        containerViewSize: Size,
+        resolution: Size,
+        targetRotation: Int,
+        sensorRotationDegrees: Int,
+        isOppositeFacing: Boolean
+    ) {
+        // For TextureView, correct the orientation to match the target rotation.
+        preview.setTransform(getTextureViewCorrectionMatrix(resolution, targetRotation))
+
+        val surfaceRectInPreview = getTransformedSurfaceRect(
+            containerViewSize,
+            resolution,
+            calculateRelativeImageRotationDegrees(
+                surfaceRotationToRotationDegrees(targetRotation),
+                sensorRotationDegrees,
+                isOppositeFacing
+            )
+        )
+
+        preview.pivotX = 0f
+        preview.pivotY = 0f
+        preview.scaleX = surfaceRectInPreview.width() / resolution.width
+        preview.scaleY = surfaceRectInPreview.height() / resolution.height
+        preview.translationX = surfaceRectInPreview.left - preview.left
+        preview.translationY = surfaceRectInPreview.top - preview.top
+    }
+
+    /**
+     * Creates a matrix that makes [TextureView]'s rotation matches the target rotation.
+     *
+     * The value should be applied by calling [TextureView.setTransform]. Usually the target
+     * rotation is the display rotation. In that case, this matrix will just make a [TextureView]
+     * works like a SurfaceView. If not, then it will further correct it to the desired rotation.
+     *
+     */
+    @JvmStatic
+    private fun getTextureViewCorrectionMatrix(resolution: Size, targetRotation: Int): Matrix {
+        val surfaceRect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
+        val rotationDegrees = -surfaceRotationToRotationDegrees(targetRotation)
+        return getRectToRect(surfaceRect, surfaceRect, rotationDegrees)
+    }
+
+    /**
+     * Gets the transform from one {@link Rect} to another with rotation degrees.
+     *
+     * <p> Following is how the source is mapped to the target with a 90° rotation. The rect
+     * <a, b, c, d> is mapped to <a', b', c', d'>.
+     *
+     * <pre>
+     *  a----------b               d'-----------a'
+     *  |  source  |    -90°->     |            |
+     *  d----------c               |   target   |
+     *                             |            |
+     *                             c'-----------b'
+     * </pre>
+     */
+    @JvmStatic
+    private fun getRectToRect(
+        source: RectF,
+        target: RectF,
+        rotationDegrees: Int
+    ): Matrix = Matrix().apply {
+        // Map source to normalized space.
+        setRectToRect(source, NORMALIZED_RECT, Matrix.ScaleToFit.FILL)
+        // Add rotation.
+        postRotate(rotationDegrees.toFloat())
+        // Restore the normalized space to target's coordinates.
+        postConcat(getNormalizedToBuffer(target))
+    }
+
+    /**
+     * Gets the transform from a normalized space (-1, -1) - (1, 1) to the given rect.
+     */
+    @JvmStatic
+    private fun getNormalizedToBuffer(viewPortRect: RectF): Matrix = Matrix().apply {
+        setRectToRect(
+            NORMALIZED_RECT,
+            viewPortRect,
+            Matrix.ScaleToFit.FILL
+        )
+    }
+
+    /**
+     * Gets the transformed [Surface] rect in the preview coordinates.
+     *
+     * Returns desired rect of the inner view that once applied, the only part visible to
+     * end users is the crop rect.
+     */
+    @JvmStatic
+    private fun getTransformedSurfaceRect(
+        containerViewSize: Size,
+        resolution: Size,
+        rotationDegrees: Int
+    ): RectF {
+        val surfaceToPreviewMatrix = getSurfaceToPreviewMatrix(
+            containerViewSize,
+            resolution,
+            rotationDegrees
+        )
+        val rect =
+            RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
+        surfaceToPreviewMatrix.mapRect(rect)
+        return rect
+    }
+
+    /**
+     * Calculates the transformation from [Surface] coordinates to the preview coordinates.
+     *
+     *  The calculation is based on making the crop rect to center fill the preview.
+     */
+    @JvmStatic
+    private fun getSurfaceToPreviewMatrix(
+        containerViewSize: Size,
+        resolution: Size,
+        rotationDegrees: Int
+    ): Matrix {
+        val surfaceRect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
+
+        // Get the target of the mapping, the coordinates of the crop rect in the preview.
+        val previewCropRect = getPreviewCropRect(
+            containerViewSize, surfaceRect.toRect(), rotationDegrees
+        )
+
+        return getRectToRect(surfaceRect, previewCropRect, rotationDegrees)
+    }
+
+    /**
+     * Gets the crop rect in the preview coordinates.
+     */
+    @JvmStatic
+    private fun getPreviewCropRect(
+        containerViewSize: Size,
+        surfaceCropRect: Rect,
+        rotationDegrees: Int
+    ): RectF {
+        val containerViewRect = RectF(
+            0f, 0f, containerViewSize.width.toFloat(),
+            containerViewSize.height.toFloat()
+        )
+        val rotatedCropRectSize = getRotatedCropRectSize(surfaceCropRect, rotationDegrees)
+        val rotatedCropRect =
+            RectF(0f, 0f, rotatedCropRectSize.width.toFloat(), rotatedCropRectSize.height.toFloat())
+
+        Matrix().apply {
+            // android.graphics.Matrix doesn't support fill scale types. The workaround is
+            // mapping inversely from destination to source, then invert the matrix.
+            setRectToRect(containerViewRect, rotatedCropRect, Matrix.ScaleToFit.CENTER)
+            invert(this)
+            mapRect(rotatedCropRect)
+        }
+
+        return rotatedCropRect
+    }
+
+    /**
+     * Returns crop rect size with target rotation applied.
+     */
+    @JvmStatic
+    private fun getRotatedCropRectSize(surfaceCropRect: Rect, rotationDegrees: Int): Size =
+        if (is90or270(rotationDegrees)) {
+            Size(surfaceCropRect.height(), surfaceCropRect.width())
+        } else Size(surfaceCropRect.width(), surfaceCropRect.height())
+
+    /**
+     * Returns true if the rotation degrees is 90 or 270.
+     */
+    @JvmStatic
+    private fun is90or270(rotationDegrees: Int): Boolean {
+        if (rotationDegrees == 90 || rotationDegrees == 270) {
+            return true
+        }
+        if (rotationDegrees == 0 || rotationDegrees == 180) {
+            return false
+        }
+        throw IllegalArgumentException("Invalid rotation degrees: $rotationDegrees")
+    }
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml b/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml
index 1a8adfa..586c25a 100644
--- a/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml
+++ b/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml
@@ -26,6 +26,7 @@
 
     <ViewStub
         android:id="@+id/viewFinderStub"
+        android:inflatedId="@id/viewFinder"
         android:layout_width="0dp"
         android:layout_height="0dp"
         app:layout_constraintBottom_toBottomOf="parent"
diff --git a/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml b/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml
index 18ebf88..5d76592 100644
--- a/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml
+++ b/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml
@@ -14,6 +14,13 @@
   limitations under the License.
   -->
 
-<TextureView xmlns:android="http://schemas.android.com/apk/res/android"
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
-    android:layout_height="match_parent" />
+    android:layout_height="match_parent">
+
+    <TextureView
+        android:id="@+id/textureView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+</FrameLayout>
diff --git a/camera/integration-tests/extensionstestapp/src/main/res/values/ids.xml b/camera/integration-tests/extensionstestapp/src/main/res/values/ids.xml
new file mode 100644
index 0000000..18de602
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/main/res/values/ids.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2022 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.
+  -->
+
+<resources>
+    <item type="id" name="viewFinder" />
+</resources>