[external/jetpack-camera-app] Upstream merge am: 4919941812 am: 310a14dd9e

Original change: https://android-review.googlesource.com/c/platform/external/jetpack-camera-app/+/3263806

Change-Id: I5a11c0c61dc54c3d7c026892d6fb144e1a8602e2
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.gitignore b/.gitignore
index 4e44987..7b74e6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,5 @@
 local.properties
 .idea/deploymentTargetDropDown.xml
 .idea/gradle.xml
-.idea/deploymentTargetSelector.xml
\ No newline at end of file
+.idea/deploymentTargetSelector.xml
+.idea/androidTestResultsUserPreferences.xml
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
index 3d86388..7edae9a 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
@@ -16,29 +16,35 @@
 package com.google.jetpackcamera
 
 import android.app.Activity
-import android.content.ComponentName
-import android.content.Intent
 import android.net.Uri
 import android.os.Environment
 import android.provider.MediaStore
 import androidx.compose.ui.test.isDisplayed
 import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.longClick
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
 import androidx.test.uiautomator.UiDevice
+import com.google.common.truth.Truth
 import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG
 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
 import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
 import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
 import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp
+import com.google.jetpackcamera.utils.doesImageFileExist
+import com.google.jetpackcamera.utils.getIntent
+import com.google.jetpackcamera.utils.getTestUri
 import com.google.jetpackcamera.utils.runScenarioTest
 import com.google.jetpackcamera.utils.runScenarioTestForResult
 import java.io.File
-import java.net.URLConnection
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -71,16 +77,18 @@
         composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
             composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed()
         }
-        assert(File(DIR_PATH).lastModified() > timeStamp)
-        deleteFilesInDirAfterTimestamp(timeStamp)
+        Truth.assertThat(File(DIR_PATH).lastModified() > timeStamp).isTrue()
+        deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
     }
 
     @Test
     fun image_capture_external() {
         val timeStamp = System.currentTimeMillis()
-        val uri = getTestUri(timeStamp)
+        val uri = getTestUri(DIR_PATH, timeStamp, "jpg")
         val result =
-            runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+            runScenarioTestForResult<MainActivity>(
+                getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
+            ) {
                 // Wait for the capture button to be displayed
                 composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
                     composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
@@ -90,16 +98,18 @@
                     .assertExists()
                     .performClick()
             }
-        assert(result?.resultCode == Activity.RESULT_OK)
-        assert(doesImageFileExist(uri))
-        deleteFilesInDirAfterTimestamp(timeStamp)
+        Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_OK)
+        Truth.assertThat(doesImageFileExist(uri, "image")).isTrue()
+        deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
     }
 
     @Test
     fun image_capture_external_illegal_uri() {
         val uri = Uri.parse("asdfasdf")
         val result =
-            runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+            runScenarioTestForResult<MainActivity>(
+                getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
+            ) {
                 // Wait for the capture button to be displayed
                 composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
                     composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
@@ -113,55 +123,34 @@
                 }
                 uiDevice.pressBack()
             }
-        assert(result?.resultCode == Activity.RESULT_CANCELED)
-        assert(!doesImageFileExist(uri))
+        Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+        Truth.assertThat(doesImageFileExist(uri, "image")).isFalse()
     }
 
-    private fun doesImageFileExist(uri: Uri): Boolean {
-        val file = File(uri.path)
-        if (file.exists()) {
-            val mimeType = URLConnection.guessContentTypeFromName(uri.path)
-            return mimeType != null && mimeType.startsWith("image")
-        }
-        return false
-    }
-
-    private fun deleteFilesInDirAfterTimestamp(timeStamp: Long): Boolean {
-        var hasDeletedFile = false
-        for (file in File(DIR_PATH).listFiles()) {
-            if (file.lastModified() >= timeStamp) {
-                file.delete()
-                if (file.exists()) {
-                    file.getCanonicalFile().delete()
-                    if (file.exists()) {
-                        instrumentation.targetContext.applicationContext.deleteFile(file.getName())
-                    }
+    @Test
+    fun video_capture_during_image_capture_external() {
+        val timeStamp = System.currentTimeMillis()
+        val uri = getTestUri(DIR_PATH, timeStamp, "mp4")
+        val result =
+            runScenarioTestForResult<MainActivity>(
+                getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
+            ) {
+                // Wait for the capture button to be displayed
+                composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
                 }
-                hasDeletedFile = true
+
+                composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+                    .assertExists()
+                    .performTouchInput { longClick() }
+                composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG)
+                        .isDisplayed()
+                }
+                uiDevice.pressBack()
             }
-        }
-        return hasDeletedFile
-    }
-
-    private fun getTestUri(timeStamp: Long): Uri {
-        return Uri.fromFile(
-            File(
-                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
-                "$timeStamp.jpg"
-            )
-        )
-    }
-
-    private fun getIntent(uri: Uri): Intent {
-        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
-        intent.setComponent(
-            ComponentName(
-                "com.google.jetpackcamera",
-                "com.google.jetpackcamera.MainActivity"
-            )
-        )
-        intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
-        return intent
+        Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+        Truth.assertThat(doesImageFileExist(uri, "video")).isFalse()
     }
 
     companion object {
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
index ee81021..545b406 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
@@ -15,27 +15,36 @@
  */
 package com.google.jetpackcamera
 
-import android.app.Instrumentation
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
+import android.app.Activity
 import android.net.Uri
 import android.os.Environment
-import androidx.activity.result.ActivityResultRegistry
-import androidx.activity.result.contract.ActivityResultContract
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.core.app.ActivityOptionsCompat
-import androidx.test.core.app.ActivityScenario
-import androidx.test.core.app.ApplicationProvider
+import android.provider.MediaStore
+import androidx.compose.ui.test.ComposeTimeoutException
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
-import androidx.test.uiautomator.By
 import androidx.test.uiautomator.UiDevice
-import androidx.test.uiautomator.Until
+import com.google.common.truth.Truth
 import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
-import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_FAILURE_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG
 import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.VIDEO_DURATION_MILLIS
+import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp
+import com.google.jetpackcamera.utils.doesImageFileExist
+import com.google.jetpackcamera.utils.getIntent
+import com.google.jetpackcamera.utils.getTestUri
+import com.google.jetpackcamera.utils.runScenarioTest
+import com.google.jetpackcamera.utils.runScenarioTestForResult
 import java.io.File
 import org.junit.Rule
 import org.junit.Test
@@ -47,73 +56,120 @@
     val permissionsRule: GrantPermissionRule =
         GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
 
+    @get:Rule
+    val composeTestRule = createEmptyComposeRule()
+
     private val instrumentation = InstrumentationRegistry.getInstrumentation()
-    private var activityScenario: ActivityScenario<MainActivity>? = null
     private val uiDevice = UiDevice.getInstance(instrumentation)
 
     @Test
-    fun video_capture_external_with_image_capture_intent() = run {
+    fun video_capture() = runScenarioTest<MainActivity> {
         val timeStamp = System.currentTimeMillis()
-        val uri = getTestUri(timeStamp)
-        getTestRegistry {
-            activityScenario = ActivityScenario.launchActivityForResult(it)
-            uiDevice.wait(
-                Until.findObject(By.res(CAPTURE_BUTTON)),
-                5000
-            )
-            uiDevice.findObject(By.res(CAPTURE_BUTTON)).longClick()
-            uiDevice.wait(
-                Until.findObject(By.res(VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG)),
-                5000
-            )
-            uiDevice.pressBack()
-            activityScenario!!.result
-        }.register("key", TEST_CONTRACT) { result ->
-            assert(!result)
-        }.launch(uri)
-    }
-
-    private fun getTestRegistry(
-        launch: (Intent) -> Instrumentation.ActivityResult
-    ): ActivityResultRegistry {
-        val testRegistry = object : ActivityResultRegistry() {
-            override fun <I, O> onLaunch(
-                requestCode: Int,
-                contract: ActivityResultContract<I, O>,
-                input: I,
-                options: ActivityOptionsCompat?
-            ) {
-                // contract.create
-                val launchIntent = contract.createIntent(
-                    ApplicationProvider.getApplicationContext(),
-                    input
-                )
-                val result: Instrumentation.ActivityResult = launch(launchIntent)
-                dispatchResult(requestCode, result.resultCode, result.resultData)
-            }
+        // Wait for the capture button to be displayed
+        composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+            composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
         }
-        return testRegistry
+        longClickForVideoRecording()
+        composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) {
+            composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed()
+        }
+        Truth.assertThat(File(DIR_PATH).lastModified() > timeStamp).isTrue()
+        deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
     }
 
-    private fun getTestUri(timeStamp: Long): Uri {
-        return Uri.fromFile(
-            File(
-                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
-                "$timeStamp.mp4"
-            )
-        )
+    @Test
+    fun video_capture_external_intent() {
+        val timeStamp = System.currentTimeMillis()
+        val uri = getTestUri(DIR_PATH, timeStamp, "mp4")
+        val result =
+            runScenarioTestForResult<MainActivity>(
+                getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
+            ) {
+                // Wait for the capture button to be displayed
+                composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+                }
+                longClickForVideoRecording()
+            }
+        Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_OK)
+        Truth.assertThat(doesImageFileExist(uri, "video")).isTrue()
+        deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
+    }
+
+    @Test
+    fun video_capture_external_illegal_uri() {
+        val uri = Uri.parse("asdfasdf")
+        val result =
+            runScenarioTestForResult<MainActivity>(
+                getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
+            ) {
+                // Wait for the capture button to be displayed
+                composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+                }
+                longClickForVideoRecording()
+                composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(VIDEO_CAPTURE_FAILURE_TAG).isDisplayed()
+                }
+                uiDevice.pressBack()
+            }
+        Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+        Truth.assertThat(doesImageFileExist(uri, "video")).isFalse()
+    }
+
+    @Test
+    fun image_capture_during_video_capture_external() {
+        val timeStamp = System.currentTimeMillis()
+        val uri = getTestUri(ImageCaptureDeviceTest.DIR_PATH, timeStamp, "mp4")
+        val result =
+            runScenarioTestForResult<MainActivity>(
+                getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
+            ) {
+                // Wait for the capture button to be displayed
+                composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+                }
+
+                composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+                    .assertExists()
+                    .performClick()
+                composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
+                    composeTestRule.onNodeWithTag(
+                        IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+                    ).isDisplayed()
+                }
+                uiDevice.pressBack()
+            }
+        Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+        Truth.assertThat(doesImageFileExist(uri, "image")).isFalse()
+    }
+
+    private fun longClickForVideoRecording() {
+        composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+            .assertExists()
+            .performTouchInput {
+                down(center)
+            }
+        idleForVideoDuration()
+        composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+            .assertExists()
+            .performTouchInput {
+                up()
+            }
+    }
+
+    private fun idleForVideoDuration() {
+        // TODO: replace with a check for the timestamp UI of the video duration
+        try {
+            composeTestRule.waitUntil(timeoutMillis = VIDEO_DURATION_MILLIS) {
+                composeTestRule.onNodeWithTag("dummyTagForLongPress").isDisplayed()
+            }
+        } catch (e: ComposeTimeoutException) {
+        }
     }
 
     companion object {
-        private val TEST_CONTRACT = object : ActivityResultContracts.TakePicture() {
-            override fun createIntent(context: Context, uri: Uri): Intent {
-                return super.createIntent(context, uri).apply {
-                    component = ComponentName(
-                        ApplicationProvider.getApplicationContext(),
-                        MainActivity::class.java
-                    )
-                }
-            }
-        }
+        val DIR_PATH: String =
+            Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES).path
     }
 }
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
index af2c512..782ede2 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
@@ -17,7 +17,10 @@
 
 import android.app.Activity
 import android.app.Instrumentation
+import android.content.ComponentName
 import android.content.Intent
+import android.net.Uri
+import android.provider.MediaStore
 import androidx.compose.ui.semantics.SemanticsProperties
 import androidx.compose.ui.test.isDisplayed
 import androidx.compose.ui.test.junit4.ComposeTestRule
@@ -28,9 +31,13 @@
 import com.google.jetpackcamera.feature.preview.R
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
 import com.google.jetpackcamera.settings.model.LensFacing
+import java.io.File
+import java.net.URLConnection
 
 const val APP_START_TIMEOUT_MILLIS = 10_000L
 const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L
+const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L
+const val VIDEO_DURATION_MILLIS = 2_000L
 
 inline fun <reified T : Activity> runScenarioTest(
     crossinline block: ActivityScenario<T>.() -> Unit
@@ -89,3 +96,54 @@
         }
     }
 }
+
+fun getTestUri(directoryPath: String, timeStamp: Long, suffix: String): Uri {
+    return Uri.fromFile(
+        File(
+            directoryPath,
+            "$timeStamp.$suffix"
+        )
+    )
+}
+
+fun deleteFilesInDirAfterTimestamp(
+    directoryPath: String,
+    instrumentation: Instrumentation,
+    timeStamp: Long
+): Boolean {
+    var hasDeletedFile = false
+    for (file in File(directoryPath).listFiles()) {
+        if (file.lastModified() >= timeStamp) {
+            file.delete()
+            if (file.exists()) {
+                file.getCanonicalFile().delete()
+                if (file.exists()) {
+                    instrumentation.targetContext.applicationContext.deleteFile(file.getName())
+                }
+            }
+            hasDeletedFile = true
+        }
+    }
+    return hasDeletedFile
+}
+
+fun doesImageFileExist(uri: Uri, prefix: String): Boolean {
+    val file = File(uri.path)
+    if (file.exists()) {
+        val mimeType = URLConnection.guessContentTypeFromName(uri.path)
+        return mimeType != null && mimeType.startsWith(prefix)
+    }
+    return false
+}
+
+fun getIntent(uri: Uri, action: String): Intent {
+    val intent = Intent(action)
+    intent.setComponent(
+        ComponentName(
+            "com.google.jetpackcamera",
+            "com.google.jetpackcamera.MainActivity"
+        )
+    )
+    intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
+    return intent
+}
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
index f00aee6..04dfaf9 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
@@ -51,6 +51,7 @@
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.semantics.testTagsAsResourceId
 import androidx.compose.ui.unit.dp
+import androidx.core.content.IntentCompat
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
@@ -70,6 +71,7 @@
 import kotlinx.coroutines.launch
 
 private const val TAG = "MainActivity"
+private const val KEY_DEBUG_MODE = "KEY_DEBUG_MODE"
 
 /**
  * Activity for the JetpackCameraApp.
@@ -136,6 +138,7 @@
                         ) {
                             JcaApp(
                                 previewMode = getPreviewMode(),
+                                isDebugMode = isDebugMode,
                                 openAppSettings = ::openAppSettings,
                                 onRequestWindowColorMode = { colorMode ->
                                     // Window color mode APIs require API level 26+
@@ -159,40 +162,61 @@
         }
     }
 
-    private fun getPreviewMode(): PreviewMode {
-        if (intent == null || MediaStore.ACTION_IMAGE_CAPTURE != intent.action) {
-            return PreviewMode.StandardMode { event ->
-                if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
-                    val intent = Intent(Camera.ACTION_NEW_PICTURE)
-                    intent.setData(event.savedUri)
-                    sendBroadcast(intent)
-                }
-            }
-        } else {
-            var uri = if (intent.extras == null ||
-                !intent.extras!!.containsKey(MediaStore.EXTRA_OUTPUT)
-            ) {
-                null
-            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-                intent.extras!!.getParcelable(
-                    MediaStore.EXTRA_OUTPUT,
-                    Uri::class.java
-                )
-            } else {
-                @Suppress("DEPRECATION")
-                intent.extras!!.getParcelable(MediaStore.EXTRA_OUTPUT)
-            }
-            if (uri == null && intent.clipData != null && intent.clipData!!.itemCount != 0) {
-                uri = intent.clipData!!.getItemAt(0).uri
-            }
-            return PreviewMode.ExternalImageCaptureMode(uri) { event ->
-                if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
-                    setResult(RESULT_OK)
-                    finish()
-                }
+    private val isDebugMode: Boolean
+        get() = intent?.getBooleanExtra(KEY_DEBUG_MODE, false) ?: false
+
+    private fun getStandardMode(): PreviewMode.StandardMode {
+        return PreviewMode.StandardMode { event ->
+            if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
+                val intent = Intent(Camera.ACTION_NEW_PICTURE)
+                intent.setData(event.savedUri)
+                sendBroadcast(intent)
             }
         }
     }
+
+    private fun getExternalCaptureUri(): Uri? {
+        return IntentCompat.getParcelableExtra(
+            intent,
+            MediaStore.EXTRA_OUTPUT,
+            Uri::class.java
+        ) ?: intent?.clipData?.getItemAt(0)?.uri
+    }
+
+    private fun getPreviewMode(): PreviewMode {
+        return intent?.action?.let { action ->
+            when (action) {
+                MediaStore.ACTION_IMAGE_CAPTURE ->
+                    PreviewMode.ExternalImageCaptureMode(getExternalCaptureUri()) { event ->
+                        Log.d(TAG, "onImageCapture, event: $event")
+                        if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
+                            val resultIntent = Intent()
+                            resultIntent.putExtra(MediaStore.EXTRA_OUTPUT, event.savedUri)
+                            setResult(RESULT_OK, resultIntent)
+                            Log.d(TAG, "onImageCapture, finish()")
+                            finish()
+                        }
+                    }
+
+                MediaStore.ACTION_VIDEO_CAPTURE ->
+                    PreviewMode.ExternalVideoCaptureMode(getExternalCaptureUri()) { event ->
+                        Log.d(TAG, "onVideoCapture, event: $event")
+                        if (event is PreviewViewModel.VideoCaptureEvent.VideoSaved) {
+                            val resultIntent = Intent()
+                            resultIntent.putExtra(MediaStore.EXTRA_OUTPUT, event.savedUri)
+                            setResult(RESULT_OK, resultIntent)
+                            Log.d(TAG, "onVideoCapture, finish()")
+                            finish()
+                        }
+                    }
+
+                else -> {
+                    Log.w(TAG, "Ignoring external intent with unknown action.")
+                    getStandardMode()
+                }
+            }
+        } ?: getStandardMode()
+    }
 }
 
 /**
diff --git a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
index 2f2ea31..1e16d5b 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
+++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
@@ -42,11 +42,13 @@
     /*TODO(b/306236646): remove after still capture*/
     previewMode: PreviewMode,
     modifier: Modifier = Modifier,
+    isDebugMode: Boolean,
     onRequestWindowColorMode: (Int) -> Unit,
     onFirstFrameCaptureCompleted: () -> Unit
 ) {
     JetpackCameraNavHost(
         previewMode = previewMode,
+        isDebugMode = isDebugMode,
         onOpenAppSettings = openAppSettings,
         onRequestWindowColorMode = onRequestWindowColorMode,
         onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
@@ -59,6 +61,7 @@
 private fun JetpackCameraNavHost(
     modifier: Modifier = Modifier,
     previewMode: PreviewMode,
+    isDebugMode: Boolean,
     onOpenAppSettings: () -> Unit,
     onRequestWindowColorMode: (Int) -> Unit,
     onFirstFrameCaptureCompleted: () -> Unit,
@@ -102,7 +105,8 @@
                 onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) },
                 onRequestWindowColorMode = onRequestWindowColorMode,
                 onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
-                previewMode = previewMode
+                previewMode = previewMode,
+                isDebugMode = isDebugMode
             )
         }
         composable(SETTINGS_ROUTE) {
diff --git a/core/camera/Android.bp b/core/camera/Android.bp
index fc9c829..b5a8c62 100644
--- a/core/camera/Android.bp
+++ b/core/camera/Android.bp
@@ -21,4 +21,7 @@
     sdk_version: "34",
     min_sdk_version: "21",
     manifest: "src/main/AndroidManifest.xml",
+    kotlincflags: [
+        "-Xcontext-receivers",
+    ],
 }
diff --git a/core/camera/build.gradle.kts b/core/camera/build.gradle.kts
index 4fb048a..cc471c3 100644
--- a/core/camera/build.gradle.kts
+++ b/core/camera/build.gradle.kts
@@ -84,6 +84,10 @@
     kotlin {
         jvmToolchain(17)
     }
+
+    kotlinOptions {
+        freeCompilerArgs += "-Xcontext-receivers"
+    }
 }
 
 dependencies {
@@ -94,10 +98,16 @@
     testImplementation(libs.mockito.core)
     androidTestImplementation(libs.androidx.junit)
     androidTestImplementation(libs.androidx.espresso.core)
+    androidTestImplementation(libs.kotlinx.coroutines.test)
+    androidTestImplementation(libs.rules)
+    androidTestImplementation(libs.truth)
 
     // Futures
     implementation(libs.futures.ktx)
 
+    // LiveData
+    implementation(libs.androidx.lifecycle.livedata)
+
     // CameraX
     implementation(libs.camera.core)
     implementation(libs.camera.camera2)
diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt
new file mode 100644
index 0000000..5cd9d75
--- /dev/null
+++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt
@@ -0,0 +1,245 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.app.Application
+import android.content.ContentResolver
+import android.graphics.SurfaceTexture
+import android.net.Uri
+import android.view.Surface
+import androidx.concurrent.futures.DirectExecutor
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordError
+import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus
+import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecorded
+import com.google.jetpackcamera.core.camera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.settings.ConstraintsRepository
+import com.google.jetpackcamera.settings.SettableConstraintsRepository
+import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl
+import com.google.jetpackcamera.settings.model.CameraAppSettings
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import java.io.File
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.produceIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.After
+import org.junit.Assert.fail
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class CameraXCameraUseCaseTest {
+
+    companion object {
+        private const val STATUS_VERIFY_COUNT = 5
+        private const val GENERAL_TIMEOUT_MS = 3_000L
+        private const val STATUS_VERIFY_TIMEOUT_MS = 10_000L
+    }
+
+    @get:Rule
+    val permissionsRule: GrantPermissionRule =
+        GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+    private val instrumentation = InstrumentationRegistry.getInstrumentation()
+    private val context = instrumentation.context
+    private val application = context.applicationContext as Application
+    private val videosToDelete = mutableSetOf<Uri>()
+    private lateinit var useCaseScope: CoroutineScope
+
+    @Before
+    fun setup() {
+        useCaseScope = CoroutineScope(Dispatchers.Default)
+    }
+
+    @After
+    fun tearDown() {
+        useCaseScope.cancel()
+        deleteVideos()
+    }
+
+    @Test
+    fun canRecordVideo(): Unit = runBlocking {
+        // Arrange.
+        val cameraUseCase = createAndInitCameraXUseCase()
+        cameraUseCase.runCameraOnMain()
+
+        // Act.
+        val recordEvent = cameraUseCase.startRecordingAndGetEvents()
+
+        // Assert.
+        recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS)
+
+        // Act.
+        cameraUseCase.stopVideoRecording()
+
+        // Assert.
+        recordEvent.onRecorded.await()
+    }
+
+    @Test
+    fun recordVideoWithFlashModeOn_shouldEnableTorch(): Unit = runBlocking {
+        // Arrange.
+        val lensFacing = LensFacing.BACK
+        val constraintsRepository = SettableConstraintsRepositoryImpl()
+        val cameraUseCase = createAndInitCameraXUseCase(
+            constraintsRepository = constraintsRepository
+        )
+        assumeTrue("No flash unit, skip the test.", constraintsRepository.hasFlashUnit(lensFacing))
+        cameraUseCase.runCameraOnMain()
+
+        // Arrange: Create a ReceiveChannel to observe the torch enabled state.
+        val torchEnabled: ReceiveChannel<Boolean> = cameraUseCase.getCurrentCameraState()
+            .map { it.torchEnabled }
+            .produceIn(this)
+
+        // Assert: The initial torch enabled should be false.
+        torchEnabled.awaitValue(false)
+
+        // Act: Start recording with FlashMode.ON
+        cameraUseCase.setFlashMode(FlashMode.ON)
+        val recordEvent = cameraUseCase.startRecordingAndGetEvents()
+
+        // Assert: Torch enabled transitions to true.
+        torchEnabled.awaitValue(true)
+
+        // Act: Ensure enough data is received and stop recording.
+        recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS)
+        cameraUseCase.stopVideoRecording()
+
+        // Assert: Torch enabled transitions to false.
+        torchEnabled.awaitValue(false)
+
+        // Clean-up.
+        torchEnabled.cancel()
+    }
+
+    private suspend fun createAndInitCameraXUseCase(
+        appSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+        constraintsRepository: SettableConstraintsRepository = SettableConstraintsRepositoryImpl()
+    ) = CameraXCameraUseCase(
+        application,
+        useCaseScope,
+        Dispatchers.Default,
+        constraintsRepository
+    ).apply {
+        initialize(appSettings, CameraUseCase.UseCaseMode.STANDARD)
+        providePreviewSurface()
+    }
+
+    private data class RecordEvents(
+        val onRecorded: CompletableDeferred<Unit>,
+        val onRecordStatus: CompletableDeferred<Unit>
+    )
+
+    private suspend fun CompletableDeferred<*>.await(timeoutMs: Long = GENERAL_TIMEOUT_MS) =
+        withTimeoutOrNull(timeoutMs) {
+            await()
+            Unit
+        } ?: fail("Timeout while waiting for the Deferred to complete")
+
+    private suspend fun <T> ReceiveChannel<T>.awaitValue(
+        expectedValue: T,
+        timeoutMs: Long = GENERAL_TIMEOUT_MS
+    ) = withTimeoutOrNull(timeoutMs) {
+        for (value in this@awaitValue) {
+            if (value == expectedValue) return@withTimeoutOrNull
+        }
+    } ?: fail("Timeout while waiting for expected value: $expectedValue")
+
+    private suspend fun CameraXCameraUseCase.startRecordingAndGetEvents(
+        statusVerifyCount: Int = STATUS_VERIFY_COUNT
+    ): RecordEvents {
+        val onRecorded = CompletableDeferred<Unit>()
+        val onRecordStatus = CompletableDeferred<Unit>()
+        var statusCount = 0
+        startVideoRecording(null, false) {
+            when (it) {
+                is OnVideoRecorded -> {
+                    val videoUri = it.savedUri
+                    if (videoUri != Uri.EMPTY) {
+                        videosToDelete.add(videoUri)
+                    }
+                    onRecorded.complete(Unit)
+                }
+                is OnVideoRecordError -> onRecorded.complete(Unit)
+                is OnVideoRecordStatus -> {
+                    statusCount++
+                    if (statusCount == statusVerifyCount) {
+                        onRecordStatus.complete(Unit)
+                    }
+                }
+            }
+        }
+        return RecordEvents(onRecorded, onRecordStatus)
+    }
+
+    private fun CameraXCameraUseCase.providePreviewSurface() {
+        useCaseScope.launch {
+            getSurfaceRequest().filterNotNull().first().let { surfaceRequest ->
+                val surfaceTexture = SurfaceTexture(0)
+                surfaceTexture.setDefaultBufferSize(640, 480)
+                val surface = Surface(surfaceTexture)
+                surfaceRequest.provideSurface(surface, DirectExecutor.INSTANCE) {
+                    surface.release()
+                    surfaceTexture.release()
+                }
+            }
+        }
+    }
+
+    private suspend fun CameraXCameraUseCase.runCameraOnMain() {
+        useCaseScope.launch(Dispatchers.Main) { runCamera() }
+        instrumentation.waitForIdleSync()
+    }
+
+    private suspend fun ConstraintsRepository.hasFlashUnit(lensFacing: LensFacing): Boolean =
+        systemConstraints.first()!!.perLensConstraints[lensFacing]!!.hasFlashUnit
+
+    private fun deleteVideos() {
+        for (uri in videosToDelete) {
+            when (uri.scheme) {
+                ContentResolver.SCHEME_CONTENT -> {
+                    try {
+                        context.contentResolver.delete(uri, null, null)
+                    } catch (e: RuntimeException) {
+                        // Ignore any exception.
+                    }
+                }
+                ContentResolver.SCHEME_FILE -> {
+                    File(uri.path!!).delete()
+                }
+            }
+        }
+    }
+}
diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt
new file mode 100644
index 0000000..509029e
--- /dev/null
+++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera.utils
+
+import android.os.Build
+
+val APP_REQUIRED_PERMISSIONS: List<String> = buildList {
+    add(android.Manifest.permission.CAMERA)
+    add(android.Manifest.permission.RECORD_AUDIO)
+    if (Build.VERSION.SDK_INT <= 28) {
+        add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
+    }
+}
diff --git a/core/camera/src/main/AndroidManifest.xml b/core/camera/src/main/AndroidManifest.xml
index 39bd3ea..150f8d8 100644
--- a/core/camera/src/main/AndroidManifest.xml
+++ b/core/camera/src/main/AndroidManifest.xml
@@ -15,6 +15,11 @@
   ~ limitations under the License.
   -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     package="com.google.jetpackcamera.core.camera">
-    <uses-permission android:name = "android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+        android:maxSdkVersion="28"
+        tools:ignore="ScopedStorage" />
 </manifest>
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
index 27ea15f..0fe8cc6 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
@@ -15,8 +15,6 @@
  */
 package com.google.jetpackcamera.core.camera
 
-import androidx.camera.core.MeteringPoint
-
 /**
  * An event that can be sent to the camera coroutine.
  */
@@ -25,5 +23,5 @@
     /**
      * Represents a focus metering event, that the camera can act on.
      */
-    class FocusMeteringEvent(val meteringPoint: MeteringPoint) : CameraEvent
+    data class FocusMeteringEvent(val x: Float, val y: Float) : CameraEvent
 }
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraExt.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraExt.kt
new file mode 100644
index 0000000..df24af5
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraExt.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.annotation.SuppressLint
+import android.hardware.camera2.CameraCharacteristics
+import androidx.annotation.OptIn
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+import androidx.camera.core.CameraInfo
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.DynamicRange as CXDynamicRange
+import androidx.camera.core.ExperimentalImageCaptureOutputFormat
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.Preview
+import androidx.camera.core.UseCase
+import androidx.camera.core.UseCaseGroup
+import androidx.camera.video.Recorder
+import androidx.camera.video.VideoCapture
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.LensFacing
+
+val CameraInfo.appLensFacing: LensFacing
+    get() = when (this.lensFacing) {
+        CameraSelector.LENS_FACING_FRONT -> LensFacing.FRONT
+        CameraSelector.LENS_FACING_BACK -> LensFacing.BACK
+        else -> throw IllegalArgumentException(
+            "Unknown CameraSelector.LensFacing -> LensFacing mapping. " +
+                "[CameraSelector.LensFacing: ${this.lensFacing}]"
+        )
+    }
+
+fun CXDynamicRange.toSupportedAppDynamicRange(): DynamicRange? {
+    return when (this) {
+        CXDynamicRange.SDR -> DynamicRange.SDR
+        CXDynamicRange.HLG_10_BIT -> DynamicRange.HLG10
+        // All other dynamic ranges unsupported. Return null.
+        else -> null
+    }
+}
+
+fun DynamicRange.toCXDynamicRange(): CXDynamicRange {
+    return when (this) {
+        com.google.jetpackcamera.settings.model.DynamicRange.SDR -> CXDynamicRange.SDR
+        com.google.jetpackcamera.settings.model.DynamicRange.HLG10 -> CXDynamicRange.HLG_10_BIT
+    }
+}
+
+fun LensFacing.toCameraSelector(): CameraSelector = when (this) {
+    LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
+    LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
+}
+
+@SuppressLint("RestrictedApi")
+fun CameraSelector.toAppLensFacing(): LensFacing = when (this.lensFacing) {
+    CameraSelector.LENS_FACING_FRONT -> LensFacing.FRONT
+    CameraSelector.LENS_FACING_BACK -> LensFacing.BACK
+    else -> throw IllegalArgumentException(
+        "Unknown CameraSelector -> LensFacing mapping. [CameraSelector: $this]"
+    )
+}
+
+val CameraInfo.sensorLandscapeRatio: Float
+    @OptIn(ExperimentalCamera2Interop::class)
+    get() = Camera2CameraInfo.from(this)
+        .getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
+        ?.let { sensorRect ->
+            if (sensorRect.width() > sensorRect.height()) {
+                sensorRect.width().toFloat() / sensorRect.height()
+            } else {
+                sensorRect.height().toFloat() / sensorRect.width()
+            }
+        } ?: Float.NaN
+
+@OptIn(ExperimentalImageCaptureOutputFormat::class)
+fun Int.toAppImageFormat(): ImageOutputFormat? {
+    return when (this) {
+        ImageCapture.OUTPUT_FORMAT_JPEG -> ImageOutputFormat.JPEG
+        ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR -> ImageOutputFormat.JPEG_ULTRA_HDR
+        // All other output formats unsupported. Return null.
+        else -> null
+    }
+}
+
+/**
+ * Checks if preview stabilization is supported by the device.
+ *
+ */
+val CameraInfo.isPreviewStabilizationSupported: Boolean
+    get() = Preview.getPreviewCapabilities(this).isStabilizationSupported
+
+/**
+ * Checks if video stabilization is supported by the device.
+ *
+ */
+val CameraInfo.isVideoStabilizationSupported: Boolean
+    get() = Recorder.getVideoCapabilities(this).isStabilizationSupported
+
+fun CameraInfo.filterSupportedFixedFrameRates(desired: Set<Int>): Set<Int> {
+    return buildSet {
+        this@filterSupportedFixedFrameRates.supportedFrameRateRanges.forEach { e ->
+            if (e.upper == e.lower && desired.contains(e.upper)) {
+                add(e.upper)
+            }
+        }
+    }
+}
+
+val CameraInfo.supportedImageFormats: Set<ImageOutputFormat>
+    @OptIn(ExperimentalImageCaptureOutputFormat::class)
+    get() = ImageCapture.getImageCaptureCapabilities(this).supportedOutputFormats
+        .mapNotNull(Int::toAppImageFormat)
+        .toSet()
+
+fun UseCaseGroup.getVideoCapture() = getUseCaseOrNull<VideoCapture<Recorder>>()
+fun UseCaseGroup.getImageCapture() = getUseCaseOrNull<ImageCapture>()
+
+private inline fun <reified T : UseCase> UseCaseGroup.getUseCaseOrNull(): T? {
+    return useCases.filterIsInstance<T>().singleOrNull()
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
new file mode 100644
index 0000000..fbed566
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
@@ -0,0 +1,728 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.Manifest
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.PackageManager
+import android.hardware.camera2.CameraCaptureSession
+import android.hardware.camera2.CaptureRequest
+import android.hardware.camera2.CaptureResult
+import android.hardware.camera2.TotalCaptureResult
+import android.net.Uri
+import android.os.Build
+import android.os.SystemClock
+import android.provider.MediaStore
+import android.util.Log
+import android.util.Range
+import androidx.annotation.OptIn
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.Camera2Interop
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraControl
+import androidx.camera.core.CameraEffect
+import androidx.camera.core.CameraInfo
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ExperimentalImageCaptureOutputFormat
+import androidx.camera.core.FocusMeteringAction
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceOrientedMeteringPointFactory
+import androidx.camera.core.TorchState
+import androidx.camera.core.UseCaseGroup
+import androidx.camera.core.ViewPort
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.video.FileOutputOptions
+import androidx.camera.video.MediaStoreOutputOptions
+import androidx.camera.video.Recorder
+import androidx.camera.video.Recording
+import androidx.camera.video.VideoCapture
+import androidx.camera.video.VideoRecordEvent
+import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
+import androidx.concurrent.futures.await
+import androidx.core.content.ContextCompat
+import androidx.core.content.ContextCompat.checkSelfPermission
+import androidx.lifecycle.asFlow
+import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect
+import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DeviceRotation
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.Stabilization
+import java.io.File
+import java.util.Date
+import java.util.concurrent.Executor
+import kotlin.coroutines.ContinuationInterceptor
+import kotlin.math.abs
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val TAG = "CameraSession"
+
+context(CameraSessionContext)
+internal suspend fun runSingleCameraSession(
+    sessionSettings: PerpetualSessionSettings.SingleCamera,
+    useCaseMode: CameraUseCase.UseCaseMode,
+    // TODO(tm): ImageCapture should go through an event channel like VideoCapture
+    onImageCaptureCreated: (ImageCapture) -> Unit = {}
+) = coroutineScope {
+    val lensFacing = sessionSettings.cameraInfo.appLensFacing
+    Log.d(TAG, "Starting new single camera session for $lensFacing")
+
+    val initialTransientSettings = transientSettings
+        .filterNotNull()
+        .first()
+
+    val useCaseGroup = createUseCaseGroup(
+        cameraInfo = sessionSettings.cameraInfo,
+        initialTransientSettings = initialTransientSettings,
+        stabilizePreviewMode = sessionSettings.stabilizePreviewMode,
+        stabilizeVideoMode = sessionSettings.stabilizeVideoMode,
+        aspectRatio = sessionSettings.aspectRatio,
+        targetFrameRate = sessionSettings.targetFrameRate,
+        dynamicRange = sessionSettings.dynamicRange,
+        imageFormat = sessionSettings.imageFormat,
+        useCaseMode = useCaseMode,
+        effect = when (sessionSettings.captureMode) {
+            CaptureMode.SINGLE_STREAM -> SingleSurfaceForcingEffect(this@coroutineScope)
+            CaptureMode.MULTI_STREAM -> null
+        }
+    ).apply {
+        getImageCapture()?.let(onImageCaptureCreated)
+    }
+
+    cameraProvider.runWith(sessionSettings.cameraInfo.cameraSelector, useCaseGroup) { camera ->
+        Log.d(TAG, "Camera session started")
+
+        launch {
+            processFocusMeteringEvents(camera.cameraControl)
+        }
+
+        launch {
+            processVideoControlEvents(
+                camera,
+                useCaseGroup.getVideoCapture(),
+                captureTypeSuffix = when (sessionSettings.captureMode) {
+                    CaptureMode.MULTI_STREAM -> "MultiStream"
+                    CaptureMode.SINGLE_STREAM -> "SingleStream"
+                }
+            )
+        }
+
+        launch {
+            camera.cameraInfo.torchState.asFlow().collectLatest { torchState ->
+                currentCameraState.update { old ->
+                    old.copy(torchEnabled = torchState == TorchState.ON)
+                }
+            }
+        }
+
+        applyDeviceRotation(initialTransientSettings.deviceRotation, useCaseGroup)
+        processTransientSettingEvents(
+            camera,
+            useCaseGroup,
+            initialTransientSettings,
+            transientSettings
+        )
+    }
+}
+
+context(CameraSessionContext)
+internal suspend fun processTransientSettingEvents(
+    camera: Camera,
+    useCaseGroup: UseCaseGroup,
+    initialTransientSettings: TransientSessionSettings,
+    transientSettings: StateFlow<TransientSessionSettings?>
+) {
+    var prevTransientSettings = initialTransientSettings
+    transientSettings.filterNotNull().collectLatest { newTransientSettings ->
+        // Apply camera control settings
+        if (prevTransientSettings.zoomScale != newTransientSettings.zoomScale) {
+            camera.cameraInfo.zoomState.value?.let { zoomState ->
+                val finalScale =
+                    (zoomState.zoomRatio * newTransientSettings.zoomScale).coerceIn(
+                        zoomState.minZoomRatio,
+                        zoomState.maxZoomRatio
+                    )
+                camera.cameraControl.setZoomRatio(finalScale)
+                currentCameraState.update { old ->
+                    old.copy(zoomScale = finalScale)
+                }
+            }
+        }
+
+        useCaseGroup.getImageCapture()?.let { imageCapture ->
+            if (prevTransientSettings.flashMode != newTransientSettings.flashMode) {
+                setFlashModeInternal(
+                    imageCapture = imageCapture,
+                    flashMode = newTransientSettings.flashMode,
+                    isFrontFacing = camera.cameraInfo.appLensFacing == LensFacing.FRONT
+                )
+            }
+        }
+
+        if (prevTransientSettings.deviceRotation
+            != newTransientSettings.deviceRotation
+        ) {
+            Log.d(
+                TAG,
+                "Updating device rotation from " +
+                    "${prevTransientSettings.deviceRotation} -> " +
+                    "${newTransientSettings.deviceRotation}"
+            )
+            applyDeviceRotation(newTransientSettings.deviceRotation, useCaseGroup)
+        }
+
+        prevTransientSettings = newTransientSettings
+    }
+}
+
+internal fun applyDeviceRotation(deviceRotation: DeviceRotation, useCaseGroup: UseCaseGroup) {
+    val targetRotation = deviceRotation.toUiSurfaceRotation()
+    useCaseGroup.useCases.forEach {
+        when (it) {
+            is Preview -> {
+                // Preview's target rotation should not be updated with device rotation.
+                // Instead, preview rotation should match the display rotation.
+                // When Preview is created, it is initialized with the display rotation.
+                // This will need to be updated separately if the display rotation is not
+                // locked. Currently the app is locked to portrait orientation.
+            }
+
+            is ImageCapture -> {
+                it.targetRotation = targetRotation
+            }
+
+            is VideoCapture<*> -> {
+                it.targetRotation = targetRotation
+            }
+        }
+    }
+}
+
+context(CameraSessionContext)
+internal fun createUseCaseGroup(
+    cameraInfo: CameraInfo,
+    initialTransientSettings: TransientSessionSettings,
+    stabilizePreviewMode: Stabilization,
+    stabilizeVideoMode: Stabilization,
+    aspectRatio: AspectRatio,
+    targetFrameRate: Int,
+    dynamicRange: DynamicRange,
+    imageFormat: ImageOutputFormat,
+    useCaseMode: CameraUseCase.UseCaseMode,
+    effect: CameraEffect? = null
+): UseCaseGroup {
+    val previewUseCase =
+        createPreviewUseCase(
+            cameraInfo,
+            aspectRatio,
+            stabilizePreviewMode
+        )
+    val imageCaptureUseCase = if (useCaseMode != CameraUseCase.UseCaseMode.VIDEO_ONLY) {
+        createImageUseCase(cameraInfo, aspectRatio, dynamicRange, imageFormat)
+    } else {
+        null
+    }
+    val videoCaptureUseCase = if (useCaseMode != CameraUseCase.UseCaseMode.IMAGE_ONLY) {
+        createVideoUseCase(
+            cameraInfo,
+            aspectRatio,
+            targetFrameRate,
+            stabilizeVideoMode,
+            dynamicRange,
+            backgroundDispatcher
+        )
+    } else {
+        null
+    }
+
+    imageCaptureUseCase?.let {
+        setFlashModeInternal(
+            imageCapture = imageCaptureUseCase,
+            flashMode = initialTransientSettings.flashMode,
+            isFrontFacing = cameraInfo.appLensFacing == LensFacing.FRONT
+        )
+    }
+
+    return UseCaseGroup.Builder().apply {
+        Log.d(
+            TAG,
+            "Setting initial device rotation to ${initialTransientSettings.deviceRotation}"
+        )
+        setViewPort(
+            ViewPort.Builder(
+                aspectRatio.ratio,
+                // Initialize rotation to Preview's rotation, which comes from Display rotation
+                previewUseCase.targetRotation
+            ).build()
+        )
+        addUseCase(previewUseCase)
+        imageCaptureUseCase?.let {
+            if (dynamicRange == DynamicRange.SDR ||
+                imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+            ) {
+                addUseCase(imageCaptureUseCase)
+            }
+        }
+
+        // Not to bind VideoCapture when Ultra HDR is enabled to keep the app design simple.
+        videoCaptureUseCase?.let {
+            if (imageFormat == ImageOutputFormat.JPEG) {
+                addUseCase(videoCaptureUseCase)
+            }
+        }
+
+        effect?.let { addEffect(it) }
+    }.build()
+}
+
+@OptIn(ExperimentalImageCaptureOutputFormat::class)
+private fun createImageUseCase(
+    cameraInfo: CameraInfo,
+    aspectRatio: AspectRatio,
+    dynamicRange: DynamicRange,
+    imageFormat: ImageOutputFormat
+): ImageCapture {
+    val builder = ImageCapture.Builder()
+    builder.setResolutionSelector(
+        getResolutionSelector(cameraInfo.sensorLandscapeRatio, aspectRatio)
+    )
+    if (dynamicRange != DynamicRange.SDR && imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+    ) {
+        builder.setOutputFormat(ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR)
+    }
+    return builder.build()
+}
+
+private fun createVideoUseCase(
+    cameraInfo: CameraInfo,
+    aspectRatio: AspectRatio,
+    targetFrameRate: Int,
+    stabilizeVideoMode: Stabilization,
+    dynamicRange: DynamicRange,
+    backgroundDispatcher: CoroutineDispatcher
+): VideoCapture<Recorder> {
+    val sensorLandscapeRatio = cameraInfo.sensorLandscapeRatio
+    val recorder = Recorder.Builder()
+        .setAspectRatio(
+            getAspectRatioForUseCase(sensorLandscapeRatio, aspectRatio)
+        )
+        .setExecutor(backgroundDispatcher.asExecutor()).build()
+    return VideoCapture.Builder(recorder).apply {
+        // set video stabilization
+        if (stabilizeVideoMode == Stabilization.ON) {
+            setVideoStabilizationEnabled(true)
+        }
+        // set target fps
+        if (targetFrameRate != TARGET_FPS_AUTO) {
+            setTargetFrameRate(Range(targetFrameRate, targetFrameRate))
+        }
+
+        setDynamicRange(dynamicRange.toCXDynamicRange())
+    }.build()
+}
+
+private fun getAspectRatioForUseCase(sensorLandscapeRatio: Float, aspectRatio: AspectRatio): Int {
+    return when (aspectRatio) {
+        AspectRatio.THREE_FOUR -> androidx.camera.core.AspectRatio.RATIO_4_3
+        AspectRatio.NINE_SIXTEEN -> androidx.camera.core.AspectRatio.RATIO_16_9
+        else -> {
+            // Choose the aspect ratio which maximizes FOV by being closest to the sensor ratio
+            if (
+                abs(sensorLandscapeRatio - AspectRatio.NINE_SIXTEEN.landscapeRatio.toFloat()) <
+                abs(sensorLandscapeRatio - AspectRatio.THREE_FOUR.landscapeRatio.toFloat())
+            ) {
+                androidx.camera.core.AspectRatio.RATIO_16_9
+            } else {
+                androidx.camera.core.AspectRatio.RATIO_4_3
+            }
+        }
+    }
+}
+
+context(CameraSessionContext)
+private fun createPreviewUseCase(
+    cameraInfo: CameraInfo,
+    aspectRatio: AspectRatio,
+    stabilizePreviewMode: Stabilization
+): Preview = Preview.Builder().apply {
+    updateCameraStateWithCaptureResults(targetCameraInfo = cameraInfo)
+
+    // set preview stabilization
+    if (stabilizePreviewMode == Stabilization.ON) {
+        setPreviewStabilizationEnabled(true)
+    }
+
+    setResolutionSelector(
+        getResolutionSelector(cameraInfo.sensorLandscapeRatio, aspectRatio)
+    )
+}.build()
+    .apply {
+        setSurfaceProvider { surfaceRequest ->
+            surfaceRequests.update { surfaceRequest }
+        }
+    }
+
+private fun getResolutionSelector(
+    sensorLandscapeRatio: Float,
+    aspectRatio: AspectRatio
+): ResolutionSelector {
+    val aspectRatioStrategy = when (aspectRatio) {
+        AspectRatio.THREE_FOUR -> AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
+        AspectRatio.NINE_SIXTEEN -> AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
+        else -> {
+            // Choose the resolution selector strategy which maximizes FOV by being closest
+            // to the sensor aspect ratio
+            if (
+                abs(sensorLandscapeRatio - AspectRatio.NINE_SIXTEEN.landscapeRatio.toFloat()) <
+                abs(sensorLandscapeRatio - AspectRatio.THREE_FOUR.landscapeRatio.toFloat())
+            ) {
+                AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
+            } else {
+                AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
+            }
+        }
+    }
+    return ResolutionSelector.Builder().setAspectRatioStrategy(aspectRatioStrategy).build()
+}
+
+context(CameraSessionContext)
+private fun setFlashModeInternal(
+    imageCapture: ImageCapture,
+    flashMode: FlashMode,
+    isFrontFacing: Boolean
+) {
+    val isScreenFlashRequired =
+        isFrontFacing && (flashMode == FlashMode.ON || flashMode == FlashMode.AUTO)
+
+    if (isScreenFlashRequired) {
+        imageCapture.screenFlash = object : ImageCapture.ScreenFlash {
+            override fun apply(
+                expirationTimeMillis: Long,
+                listener: ImageCapture.ScreenFlashListener
+            ) {
+                Log.d(TAG, "ImageCapture.ScreenFlash: apply")
+                screenFlashEvents.trySend(
+                    CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) {
+                        listener.onCompleted()
+                    }
+                )
+            }
+
+            override fun clear() {
+                Log.d(TAG, "ImageCapture.ScreenFlash: clear")
+                screenFlashEvents.trySend(
+                    CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) {}
+                )
+            }
+        }
+    }
+
+    imageCapture.flashMode = when (flashMode) {
+        FlashMode.OFF -> ImageCapture.FLASH_MODE_OFF // 2
+
+        FlashMode.ON -> if (isScreenFlashRequired) {
+            ImageCapture.FLASH_MODE_SCREEN // 3
+        } else {
+            ImageCapture.FLASH_MODE_ON // 1
+        }
+
+        FlashMode.AUTO -> if (isScreenFlashRequired) {
+            ImageCapture.FLASH_MODE_SCREEN // 3
+        } else {
+            ImageCapture.FLASH_MODE_AUTO // 0
+        }
+    }
+    Log.d(TAG, "Set flash mode to: ${imageCapture.flashMode}")
+}
+
+private suspend fun startVideoRecordingInternal(
+    initialMuted: Boolean,
+    videoCaptureUseCase: VideoCapture<Recorder>,
+    captureTypeSuffix: String,
+    context: Context,
+    videoCaptureUri: Uri?,
+    shouldUseUri: Boolean,
+    onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+): Recording {
+    Log.d(TAG, "recordVideo")
+    // todo(b/336886716): default setting to enable or disable audio when permission is granted
+
+    // ok. there is a difference between MUTING and ENABLING audio
+    // audio must be enabled in order to be muted
+    // if the video recording isnt started with audio enabled, you will not be able to unmute it
+    // the toggle should only affect whether or not the audio is muted.
+    // the permission will determine whether or not the audio is enabled.
+    val audioEnabled = checkSelfPermission(
+        context,
+        Manifest.permission.RECORD_AUDIO
+    ) == PackageManager.PERMISSION_GRANTED
+
+    val pendingRecord = if (shouldUseUri) {
+        val fileOutputOptions = FileOutputOptions.Builder(
+            File(videoCaptureUri!!.path!!)
+        ).build()
+        videoCaptureUseCase.output.prepareRecording(context, fileOutputOptions)
+    } else {
+        val name = "JCA-recording-${Date()}-$captureTypeSuffix.mp4"
+        val contentValues =
+            ContentValues().apply {
+                put(MediaStore.Video.Media.DISPLAY_NAME, name)
+            }
+        val mediaStoreOutput =
+            MediaStoreOutputOptions.Builder(
+                context.contentResolver,
+                MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+            )
+                .setContentValues(contentValues)
+                .build()
+        videoCaptureUseCase.output.prepareRecording(context, mediaStoreOutput)
+    }
+    pendingRecord.apply {
+        if (audioEnabled) {
+            withAudioEnabled()
+        }
+    }
+    val callbackExecutor: Executor =
+        (
+            currentCoroutineContext()[ContinuationInterceptor] as?
+                CoroutineDispatcher
+            )?.asExecutor() ?: ContextCompat.getMainExecutor(context)
+    return pendingRecord.start(callbackExecutor) { onVideoRecordEvent ->
+        Log.d(TAG, onVideoRecordEvent.toString())
+        when (onVideoRecordEvent) {
+            is VideoRecordEvent.Finalize -> {
+                when (onVideoRecordEvent.error) {
+                    ERROR_NONE ->
+                        onVideoRecord(
+                            CameraUseCase.OnVideoRecordEvent.OnVideoRecorded(
+                                onVideoRecordEvent.outputResults.outputUri
+                            )
+                        )
+
+                    else ->
+                        onVideoRecord(
+                            CameraUseCase.OnVideoRecordEvent.OnVideoRecordError(
+                                onVideoRecordEvent.cause
+                            )
+                        )
+                }
+            }
+
+            is VideoRecordEvent.Status -> {
+                onVideoRecord(
+                    CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus(
+                        onVideoRecordEvent.recordingStats.audioStats
+                            .audioAmplitude
+                    )
+                )
+            }
+        }
+    }.apply {
+        mute(initialMuted)
+    }
+}
+
+private suspend fun runVideoRecording(
+    camera: Camera,
+    videoCapture: VideoCapture<Recorder>,
+    captureTypeSuffix: String,
+    context: Context,
+    transientSettings: StateFlow<TransientSessionSettings?>,
+    videoCaptureUri: Uri?,
+    shouldUseUri: Boolean,
+    onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+) {
+    var currentSettings = transientSettings.filterNotNull().first()
+
+    startVideoRecordingInternal(
+        initialMuted = currentSettings.audioMuted,
+        videoCapture,
+        captureTypeSuffix,
+        context,
+        videoCaptureUri,
+        shouldUseUri,
+        onVideoRecord
+    ).use { recording ->
+
+        fun TransientSessionSettings.isFlashModeOn() = flashMode == FlashMode.ON
+        val isFrontCameraSelector =
+            camera.cameraInfo.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
+
+        if (currentSettings.isFlashModeOn()) {
+            if (!isFrontCameraSelector) {
+                camera.cameraControl.enableTorch(true).await()
+            } else {
+                Log.d(TAG, "Unable to enable torch for front camera.")
+            }
+        }
+
+        transientSettings.filterNotNull()
+            .onCompletion {
+                // Could do some fancier tracking of whether the torch was enabled before
+                // calling this.
+                camera.cameraControl.enableTorch(false)
+            }
+            .collectLatest { newTransientSettings ->
+                if (currentSettings.audioMuted != newTransientSettings.audioMuted) {
+                    recording.mute(newTransientSettings.audioMuted)
+                }
+                if (currentSettings.isFlashModeOn() != newTransientSettings.isFlashModeOn()) {
+                    if (!isFrontCameraSelector) {
+                        camera.cameraControl.enableTorch(newTransientSettings.isFlashModeOn())
+                    } else {
+                        Log.d(TAG, "Unable to update torch for front camera.")
+                    }
+                }
+                currentSettings = newTransientSettings
+            }
+    }
+}
+
+context(CameraSessionContext)
+internal suspend fun processFocusMeteringEvents(cameraControl: CameraControl) {
+    surfaceRequests.map { surfaceRequest ->
+        surfaceRequest?.resolution?.run {
+            Log.d(
+                TAG,
+                "Waiting to process focus points for surface with resolution: " +
+                    "$width x $height"
+            )
+            SurfaceOrientedMeteringPointFactory(width.toFloat(), height.toFloat())
+        }
+    }.collectLatest { meteringPointFactory ->
+        for (event in focusMeteringEvents) {
+            meteringPointFactory?.apply {
+                Log.d(TAG, "tapToFocus, processing event: $event")
+                val meteringPoint = createPoint(event.x, event.y)
+                val action = FocusMeteringAction.Builder(meteringPoint).build()
+                cameraControl.startFocusAndMetering(action)
+            } ?: run {
+                Log.w(TAG, "Ignoring event due to no SurfaceRequest: $event")
+            }
+        }
+    }
+}
+
+context(CameraSessionContext)
+internal suspend fun processVideoControlEvents(
+    camera: Camera,
+    videoCapture: VideoCapture<Recorder>?,
+    captureTypeSuffix: String
+) = coroutineScope {
+    var recordingJob: Job? = null
+
+    for (event in videoCaptureControlEvents) {
+        when (event) {
+            is VideoCaptureControlEvent.StartRecordingEvent -> {
+                if (videoCapture == null) {
+                    throw RuntimeException(
+                        "Attempted video recording with null videoCapture"
+                    )
+                }
+
+                recordingJob = launch(start = CoroutineStart.UNDISPATCHED) {
+                    runVideoRecording(
+                        camera,
+                        videoCapture,
+                        captureTypeSuffix,
+                        context,
+                        transientSettings,
+                        event.videoCaptureUri,
+                        event.shouldUseUri,
+                        event.onVideoRecord
+                    )
+                }
+            }
+
+            VideoCaptureControlEvent.StopRecordingEvent -> {
+                recordingJob?.cancel()
+                recordingJob = null
+            }
+        }
+    }
+}
+
+/**
+ * Applies a CaptureCallback to the provided image capture builder
+ */
+context(CameraSessionContext)
+@OptIn(ExperimentalCamera2Interop::class)
+private fun Preview.Builder.updateCameraStateWithCaptureResults(
+    targetCameraInfo: CameraInfo
+): Preview.Builder {
+    val isFirstFrameTimestampUpdated = atomic(false)
+    val targetCameraLogicalId = Camera2CameraInfo.from(targetCameraInfo).cameraId
+    Camera2Interop.Extender(this).setSessionCaptureCallback(
+        object : CameraCaptureSession.CaptureCallback() {
+            override fun onCaptureCompleted(
+                session: CameraCaptureSession,
+                request: CaptureRequest,
+                result: TotalCaptureResult
+            ) {
+                super.onCaptureCompleted(session, request, result)
+                val logicalCameraId = session.device.id
+                if (logicalCameraId != targetCameraLogicalId) return
+                try {
+                    val physicalCameraId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                        result.get(CaptureResult.LOGICAL_MULTI_CAMERA_ACTIVE_PHYSICAL_ID)
+                    } else {
+                        null
+                    }
+                    currentCameraState.update { old ->
+                        if (old.debugInfo.logicalCameraId != logicalCameraId ||
+                            old.debugInfo.physicalCameraId != physicalCameraId
+                        ) {
+                            old.copy(debugInfo = DebugInfo(logicalCameraId, physicalCameraId))
+                        } else {
+                            old
+                        }
+                    }
+                    if (!isFirstFrameTimestampUpdated.value) {
+                        currentCameraState.update { old ->
+                            old.copy(
+                                sessionFirstFrameTimestamp = SystemClock.elapsedRealtimeNanos()
+                            )
+                        }
+                        isFirstFrameTimestampUpdated.value = true
+                    }
+                } catch (_: Exception) {
+                }
+            }
+        }
+    )
+    return this
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt
new file mode 100644
index 0000000..1425bbb
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.content.Context
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.lifecycle.ProcessCameraProvider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Context that can be shared by all functions in a camera session.
+ *
+ * Can be used to confer context (such as reactive state or session-wide parameters)
+ * on context receivers using [with] in a camera session.
+ */
+internal data class CameraSessionContext(
+    val context: Context,
+    val cameraProvider: ProcessCameraProvider,
+    val backgroundDispatcher: CoroutineDispatcher,
+    val screenFlashEvents: SendChannel<CameraUseCase.ScreenFlashEvent>,
+    val focusMeteringEvents: Channel<CameraEvent.FocusMeteringEvent>,
+    val videoCaptureControlEvents: Channel<VideoCaptureControlEvent>,
+    val currentCameraState: MutableStateFlow<CameraState>,
+    val surfaceRequests: MutableStateFlow<SurfaceRequest?>,
+    val transientSettings: StateFlow<TransientSessionSettings?>
+)
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt
new file mode 100644
index 0000000..b96c6a3
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import androidx.camera.core.CameraInfo
+import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DeviceRotation
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.Stabilization
+
+/**
+ * Camera settings that persist as long as a camera is running.
+ *
+ * Any change in these settings will require calling [ProcessCameraProvider.runWith] with
+ * updates [CameraSelector] and/or [UseCaseGroup]
+ */
+internal sealed interface PerpetualSessionSettings {
+    val aspectRatio: AspectRatio
+
+    data class SingleCamera(
+        val cameraInfo: CameraInfo,
+        override val aspectRatio: AspectRatio,
+        val captureMode: CaptureMode,
+        val targetFrameRate: Int,
+        val stabilizePreviewMode: Stabilization,
+        val stabilizeVideoMode: Stabilization,
+        val dynamicRange: DynamicRange,
+        val imageFormat: ImageOutputFormat
+    ) : PerpetualSessionSettings
+
+    data class ConcurrentCamera(
+        val primaryCameraInfo: CameraInfo,
+        val secondaryCameraInfo: CameraInfo,
+        override val aspectRatio: AspectRatio
+    ) : PerpetualSessionSettings
+}
+
+/**
+ * Camera settings that can change while the camera is running.
+ *
+ * Any changes in these settings can be applied either directly to use cases via their
+ * setter methods or to [androidx.camera.core.CameraControl].
+ * The use cases typically will not need to be re-bound.
+ */
+internal data class TransientSessionSettings(
+    val audioMuted: Boolean,
+    val deviceRotation: DeviceRotation,
+    val flashMode: FlashMode,
+    val zoomScale: Float
+)
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
index 4c8fbc8..02477d8 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
@@ -22,13 +22,15 @@
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CameraAppSettings
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DeviceRotation
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.ImageOutputFormat
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.LowLightBoost
-import kotlinx.coroutines.flow.SharedFlow
+import com.google.jetpackcamera.settings.model.Stabilization
+import kotlinx.coroutines.channels.ReceiveChannel
 import kotlinx.coroutines.flow.StateFlow
 
 /**
@@ -40,7 +42,11 @@
      *
      * @return list of available lenses.
      */
-    suspend fun initialize(disableVideoCapture: Boolean)
+    suspend fun initialize(
+        cameraAppSettings: CameraAppSettings,
+        useCaseMode: UseCaseMode,
+        isDebugMode: Boolean = false
+    )
 
     /**
      * Starts the camera.
@@ -66,7 +72,11 @@
         ignoreUri: Boolean = false
     ): ImageCapture.OutputFileResults
 
-    suspend fun startVideoRecording(onVideoRecord: (OnVideoRecordEvent) -> Unit)
+    suspend fun startVideoRecording(
+        videoCaptureUri: Uri?,
+        shouldUseUri: Boolean,
+        onVideoRecord: (OnVideoRecordEvent) -> Unit
+    )
 
     fun stopVideoRecording()
 
@@ -76,7 +86,7 @@
 
     fun getSurfaceRequest(): StateFlow<SurfaceRequest?>
 
-    fun getScreenFlashEvents(): SharedFlow<ScreenFlashEvent>
+    fun getScreenFlashEvents(): ReceiveChannel<ScreenFlashEvent>
 
     fun getCurrentSettings(): StateFlow<CameraAppSettings?>
 
@@ -96,12 +106,20 @@
 
     fun setDeviceRotation(deviceRotation: DeviceRotation)
 
+    suspend fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode)
+
     suspend fun setLowLightBoost(lowLightBoost: LowLightBoost)
 
     suspend fun setImageFormat(imageFormat: ImageOutputFormat)
 
     suspend fun setAudioMuted(isAudioMuted: Boolean)
 
+    suspend fun setVideoCaptureStabilization(videoCaptureStabilization: Stabilization)
+
+    suspend fun setPreviewStabilization(previewStabilization: Stabilization)
+
+    suspend fun setTargetFrameRate(targetFrameRate: Int)
+
     /**
      * Represents the events required for screen flash.
      */
@@ -116,15 +134,25 @@
      * Represents the events for video recording.
      */
     sealed interface OnVideoRecordEvent {
-        object OnVideoRecorded : OnVideoRecordEvent
+        data class OnVideoRecorded(val savedUri: Uri) : OnVideoRecordEvent
 
         data class OnVideoRecordStatus(val audioAmplitude: Double) : OnVideoRecordEvent
 
-        object OnVideoRecordError : OnVideoRecordEvent
+        data class OnVideoRecordError(val error: Throwable?) : OnVideoRecordEvent
+    }
+
+    enum class UseCaseMode {
+        STANDARD,
+        IMAGE_ONLY,
+        VIDEO_ONLY
     }
 }
 
 data class CameraState(
     val zoomScale: Float = 1f,
-    val sessionFirstFrameTimestamp: Long = 0L
+    val sessionFirstFrameTimestamp: Long = 0L,
+    val torchEnabled: Boolean = false,
+    val debugInfo: DebugInfo = DebugInfo(null, null)
 )
+
+data class DebugInfo(val logicalCameraId: String?, val physicalCameraId: String?)
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
index 7561056..2f7f99a 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
@@ -15,63 +15,36 @@
  */
 package com.google.jetpackcamera.core.camera
 
-import android.Manifest
 import android.app.Application
 import android.content.ContentResolver
 import android.content.ContentValues
-import android.content.pm.PackageManager
-import android.hardware.camera2.CameraCaptureSession
-import android.hardware.camera2.CameraCharacteristics
-import android.hardware.camera2.CaptureRequest
-import android.hardware.camera2.TotalCaptureResult
 import android.net.Uri
+import android.os.Build
 import android.os.Environment
-import android.os.SystemClock
+import android.os.Environment.DIRECTORY_DOCUMENTS
 import android.provider.MediaStore
 import android.util.Log
-import android.util.Range
-import androidx.annotation.OptIn
-import androidx.camera.camera2.interop.Camera2CameraInfo
-import androidx.camera.camera2.interop.Camera2Interop
-import androidx.camera.camera2.interop.ExperimentalCamera2Interop
-import androidx.camera.core.AspectRatio.RATIO_16_9
-import androidx.camera.core.AspectRatio.RATIO_4_3
-import androidx.camera.core.CameraEffect
 import androidx.camera.core.CameraInfo
 import androidx.camera.core.CameraSelector
 import androidx.camera.core.DynamicRange as CXDynamicRange
-import androidx.camera.core.ExperimentalImageCaptureOutputFormat
-import androidx.camera.core.FocusMeteringAction
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.ImageCapture.OutputFileOptions
-import androidx.camera.core.ImageCapture.ScreenFlash
 import androidx.camera.core.ImageCaptureException
-import androidx.camera.core.Preview
-import androidx.camera.core.SurfaceOrientedMeteringPointFactory
 import androidx.camera.core.SurfaceRequest
-import androidx.camera.core.UseCaseGroup
-import androidx.camera.core.ViewPort
-import androidx.camera.core.resolutionselector.AspectRatioStrategy
-import androidx.camera.core.resolutionselector.ResolutionSelector
 import androidx.camera.core.takePicture
 import androidx.camera.lifecycle.ProcessCameraProvider
 import androidx.camera.lifecycle.awaitInstance
-import androidx.camera.video.MediaStoreOutputOptions
 import androidx.camera.video.Recorder
-import androidx.camera.video.Recording
-import androidx.camera.video.VideoCapture
-import androidx.camera.video.VideoRecordEvent
-import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
-import androidx.core.content.ContextCompat
-import androidx.core.content.ContextCompat.checkSelfPermission
-import com.google.jetpackcamera.core.camera.CameraUseCase.ScreenFlashEvent.Type
-import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect
+import com.google.jetpackcamera.core.camera.DebugCameraInfoUtil.getAllCamerasPropertiesJSONArray
+import com.google.jetpackcamera.core.camera.DebugCameraInfoUtil.writeFileExternalStorage
+import com.google.jetpackcamera.core.common.DefaultDispatcher
+import com.google.jetpackcamera.core.common.IODispatcher
 import com.google.jetpackcamera.settings.SettableConstraintsRepository
-import com.google.jetpackcamera.settings.SettingsRepository
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CameraAppSettings
 import com.google.jetpackcamera.settings.model.CameraConstraints
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DeviceRotation
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
@@ -82,36 +55,26 @@
 import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
 import com.google.jetpackcamera.settings.model.SystemConstraints
 import dagger.hilt.android.scopes.ViewModelScoped
+import java.io.File
 import java.io.FileNotFoundException
 import java.text.SimpleDateFormat
 import java.util.Calendar
-import java.util.Date
 import java.util.Locale
-import java.util.concurrent.Executor
 import javax.inject.Inject
-import kotlin.coroutines.ContinuationInterceptor
-import kotlin.math.abs
 import kotlin.properties.Delegates
-import kotlinx.atomicfu.atomic
 import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.asExecutor
 import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.trySendBlocking
 import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 private const val TAG = "CameraXCameraUseCase"
 const val TARGET_FPS_AUTO = 0
@@ -127,63 +90,38 @@
 @Inject
 constructor(
     private val application: Application,
-    private val coroutineScope: CoroutineScope,
-    private val defaultDispatcher: CoroutineDispatcher,
-    private val settingsRepository: SettingsRepository,
+    @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
+    @IODispatcher private val iODispatcher: CoroutineDispatcher,
     private val constraintsRepository: SettableConstraintsRepository
 ) : CameraUseCase {
     private lateinit var cameraProvider: ProcessCameraProvider
 
-    private lateinit var imageCaptureUseCase: ImageCapture
+    private var imageCaptureUseCase: ImageCapture? = null
 
-    /**
-     * Applies a CaptureCallback to the provided image capture builder
-     */
-    @OptIn(ExperimentalCamera2Interop::class)
-    private fun setOnCaptureCompletedCallback(previewBuilder: Preview.Builder) {
-        val isFirstFrameTimestampUpdated = atomic(false)
-        val captureCallback = object : CameraCaptureSession.CaptureCallback() {
-            override fun onCaptureCompleted(
-                session: CameraCaptureSession,
-                request: CaptureRequest,
-                result: TotalCaptureResult
-            ) {
-                super.onCaptureCompleted(session, request, result)
-                try {
-                    if (!isFirstFrameTimestampUpdated.value) {
-                        _currentCameraState.update { old ->
-                            old.copy(
-                                sessionFirstFrameTimestamp = SystemClock.elapsedRealtimeNanos()
-                            )
-                        }
-                        isFirstFrameTimestampUpdated.value = true
-                    }
-                } catch (_: Exception) {}
-            }
-        }
-
-        // Create an Extender to attach Camera2 options
-        val imageCaptureExtender = Camera2Interop.Extender(previewBuilder)
-
-        // Attach the Camera2 CaptureCallback
-        imageCaptureExtender.setSessionCaptureCallback(captureCallback)
-    }
-
-    private var videoCaptureUseCase: VideoCapture<Recorder>? = null
-    private var recording: Recording? = null
-    private lateinit var captureMode: CaptureMode
     private lateinit var systemConstraints: SystemConstraints
-    private var disableVideoCapture by Delegates.notNull<Boolean>()
+    private var useCaseMode by Delegates.notNull<CameraUseCase.UseCaseMode>()
 
-    private val screenFlashEvents: MutableSharedFlow<CameraUseCase.ScreenFlashEvent> =
-        MutableSharedFlow()
+    private val screenFlashEvents: Channel<CameraUseCase.ScreenFlashEvent> =
+        Channel(capacity = Channel.UNLIMITED)
     private val focusMeteringEvents =
         Channel<CameraEvent.FocusMeteringEvent>(capacity = Channel.CONFLATED)
+    private val videoCaptureControlEvents = Channel<VideoCaptureControlEvent>()
 
     private val currentSettings = MutableStateFlow<CameraAppSettings?>(null)
 
-    override suspend fun initialize(externalImageCapture: Boolean) {
-        this.disableVideoCapture = externalImageCapture
+    // Could be improved by setting initial value only when camera is initialized
+    private val _currentCameraState = MutableStateFlow(CameraState())
+    override fun getCurrentCameraState(): StateFlow<CameraState> = _currentCameraState.asStateFlow()
+
+    private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
+    override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
+
+    override suspend fun initialize(
+        cameraAppSettings: CameraAppSettings,
+        useCaseMode: CameraUseCase.UseCaseMode,
+        isDebugMode: Boolean
+    ) {
+        this.useCaseMode = useCaseMode
         cameraProvider = ProcessCameraProvider.awaitInstance(application)
 
         // updates values for available cameras
@@ -198,6 +136,10 @@
         // Build and update the system constraints
         systemConstraints = SystemConstraints(
             availableLenses = availableCameraLenses,
+            concurrentCamerasSupported = cameraProvider.availableConcurrentCameraInfos.any {
+                it.map { cameraInfo -> cameraInfo.cameraSelector.toAppLensFacing() }
+                    .toSet() == setOf(LensFacing.FRONT, LensFacing.BACK)
+            },
             perLensConstraints = buildMap {
                 val availableCameraInfos = cameraProvider.availableCameraInfos
                 for (lensFacing in availableCameraLenses) {
@@ -209,17 +151,19 @@
                                 .toSet()
 
                         val supportedStabilizationModes = buildSet {
-                            if (isPreviewStabilizationSupported(camInfo)) {
+                            if (camInfo.isPreviewStabilizationSupported) {
                                 add(SupportedStabilizationMode.ON)
                             }
 
-                            if (isVideoStabilizationSupported(camInfo)) {
+                            if (camInfo.isVideoStabilizationSupported) {
                                 add(SupportedStabilizationMode.HIGH_QUALITY)
                             }
                         }
 
-                        val supportedFixedFrameRates = getSupportedFrameRates(camInfo)
-                        val supportedImageFormats = getSupportedImageFormats(camInfo)
+                        val supportedFixedFrameRates =
+                            camInfo.filterSupportedFixedFrameRates(FIXED_FRAME_RATES)
+                        val supportedImageFormats = camInfo.supportedImageFormats
+                        val hasFlashUnit = camInfo.hasFlashUnit()
 
                         put(
                             lensFacing,
@@ -233,7 +177,8 @@
                                     // Ultra HDR now.
                                     Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
                                     Pair(CaptureMode.MULTI_STREAM, supportedImageFormats)
-                                )
+                                ),
+                                hasFlashUnit = hasFlashUnit
                             )
                         )
                     }
@@ -244,43 +189,27 @@
         constraintsRepository.updateSystemConstraints(systemConstraints)
 
         currentSettings.value =
-            settingsRepository.defaultCameraAppSettings.first()
+            cameraAppSettings
                 .tryApplyDynamicRangeConstraints()
-                .tryApplyAspectRatioForExternalCapture(externalImageCapture)
+                .tryApplyAspectRatioForExternalCapture(this.useCaseMode)
                 .tryApplyImageFormatConstraints()
+                .tryApplyFrameRateConstraints()
+                .tryApplyStabilizationConstraints()
+                .tryApplyConcurrentCameraModeConstraints()
+        if (isDebugMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            withContext(iODispatcher) {
+                val cameraProperties =
+                    getAllCamerasPropertiesJSONArray(cameraProvider.availableCameraInfos).toString()
+                val file = File(
+                    Environment.getExternalStoragePublicDirectory(DIRECTORY_DOCUMENTS),
+                    "JCACameraProperties.json"
+                )
+                writeFileExternalStorage(file, cameraProperties)
+                Log.d(TAG, "JCACameraProperties written to ${file.path}. \n$cameraProperties")
+            }
+        }
     }
 
-    /**
-     * Camera settings that persist as long as a camera is running.
-     *
-     * Any change in these settings will require calling [ProcessCameraProvider.runWith] with
-     * updates [CameraSelector] and/or [UseCaseGroup]
-     */
-    private data class PerpetualSessionSettings(
-        val cameraSelector: CameraSelector,
-        val aspectRatio: AspectRatio,
-        val captureMode: CaptureMode,
-        val targetFrameRate: Int,
-        val stabilizePreviewMode: Stabilization,
-        val stabilizeVideoMode: Stabilization,
-        val dynamicRange: DynamicRange,
-        val imageFormat: ImageOutputFormat
-    )
-
-    /**
-     * Camera settings that can change while the camera is running.
-     *
-     * Any changes in these settings can be applied either directly to use cases via their
-     * setter methods or to [androidx.camera.core.CameraControl].
-     * The use cases typically will not need to be re-bound.
-     */
-    private data class TransientSessionSettings(
-        val audioMuted: Boolean,
-        val deviceRotation: DeviceRotation,
-        val flashMode: FlashMode,
-        val zoomScale: Float
-    )
-
     override suspend fun runCamera() = coroutineScope {
         Log.d(TAG, "runCamera")
 
@@ -295,127 +224,98 @@
                     zoomScale = currentCameraSettings.zoomScale
                 )
 
-                val cameraSelector = when (currentCameraSettings.cameraLensFacing) {
-                    LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
-                    LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
-                }
+                when (currentCameraSettings.concurrentCameraMode) {
+                    ConcurrentCameraMode.OFF -> {
+                        val cameraSelector = when (currentCameraSettings.cameraLensFacing) {
+                            LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
+                            LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
+                        }
 
-                PerpetualSessionSettings(
-                    cameraSelector = cameraSelector,
-                    aspectRatio = currentCameraSettings.aspectRatio,
-                    captureMode = currentCameraSettings.captureMode,
-                    targetFrameRate = currentCameraSettings.targetFrameRate,
-                    stabilizePreviewMode = currentCameraSettings.previewStabilization,
-                    stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
-                    dynamicRange = currentCameraSettings.dynamicRange,
-                    imageFormat = currentCameraSettings.imageFormat
-                )
+                        PerpetualSessionSettings.SingleCamera(
+                            cameraInfo = cameraProvider.getCameraInfo(cameraSelector),
+                            aspectRatio = currentCameraSettings.aspectRatio,
+                            captureMode = currentCameraSettings.captureMode,
+                            targetFrameRate = currentCameraSettings.targetFrameRate,
+                            stabilizePreviewMode = currentCameraSettings.previewStabilization,
+                            stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
+                            dynamicRange = currentCameraSettings.dynamicRange,
+                            imageFormat = currentCameraSettings.imageFormat
+                        )
+                    }
+                    ConcurrentCameraMode.DUAL -> {
+                        val primaryFacing = currentCameraSettings.cameraLensFacing
+                        val secondaryFacing = primaryFacing.flip()
+                        cameraProvider.availableConcurrentCameraInfos.firstNotNullOf {
+                            var primaryCameraInfo: CameraInfo? = null
+                            var secondaryCameraInfo: CameraInfo? = null
+                            it.forEach { cameraInfo ->
+                                if (cameraInfo.appLensFacing == primaryFacing) {
+                                    primaryCameraInfo = cameraInfo
+                                } else if (cameraInfo.appLensFacing == secondaryFacing) {
+                                    secondaryCameraInfo = cameraInfo
+                                }
+                            }
+
+                            primaryCameraInfo?.let { nonNullPrimary ->
+                                secondaryCameraInfo?.let { nonNullSecondary ->
+                                    PerpetualSessionSettings.ConcurrentCamera(
+                                        primaryCameraInfo = nonNullPrimary,
+                                        secondaryCameraInfo = nonNullSecondary,
+                                        aspectRatio = currentCameraSettings.aspectRatio
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
             }.distinctUntilChanged()
             .collectLatest { sessionSettings ->
-                Log.d(TAG, "Starting new camera session")
-                val cameraInfo = sessionSettings.cameraSelector.filter(
-                    cameraProvider.availableCameraInfos
-                ).first()
+                coroutineScope {
+                    with(
+                        CameraSessionContext(
+                            context = application,
+                            cameraProvider = cameraProvider,
+                            backgroundDispatcher = defaultDispatcher,
+                            screenFlashEvents = screenFlashEvents,
+                            focusMeteringEvents = focusMeteringEvents,
+                            videoCaptureControlEvents = videoCaptureControlEvents,
+                            currentCameraState = _currentCameraState,
+                            surfaceRequests = _surfaceRequest,
+                            transientSettings = transientSettings
+                        )
+                    ) {
+                        try {
+                            when (sessionSettings) {
+                                is PerpetualSessionSettings.SingleCamera -> runSingleCameraSession(
+                                    sessionSettings,
+                                    useCaseMode = useCaseMode
+                                ) { imageCapture ->
+                                    imageCaptureUseCase = imageCapture
+                                }
 
-                val lensFacing = sessionSettings.cameraSelector.toAppLensFacing()
-                val cameraConstraints = checkNotNull(
-                    systemConstraints.perLensConstraints[lensFacing]
-                ) {
-                    "Unable to retrieve CameraConstraints for $lensFacing. " +
-                        "Was the use case initialized?"
-                }
-
-                val initialTransientSettings = transientSettings
-                    .filterNotNull()
-                    .first()
-
-                val useCaseGroup = createUseCaseGroup(
-                    cameraInfo,
-                    sessionSettings,
-                    initialTransientSettings,
-                    cameraConstraints.supportedStabilizationModes,
-                    effect = when (sessionSettings.captureMode) {
-                        CaptureMode.SINGLE_STREAM -> SingleSurfaceForcingEffect(coroutineScope)
-                        CaptureMode.MULTI_STREAM -> null
-                    }
-                )
-
-                var prevTransientSettings = initialTransientSettings
-                cameraProvider.runWith(sessionSettings.cameraSelector, useCaseGroup) { camera ->
-                    Log.d(TAG, "Camera session started")
-
-                    launch {
-                        focusMeteringEvents.consumeAsFlow().collect {
-                            val focusMeteringAction =
-                                FocusMeteringAction.Builder(it.meteringPoint).build()
-                            Log.d(TAG, "Starting focus and metering")
-                            camera.cameraControl.startFocusAndMetering(focusMeteringAction)
-                        }
-                    }
-
-                    transientSettings.filterNotNull().collectLatest { newTransientSettings ->
-                        // Apply camera control settings
-                        if (prevTransientSettings.zoomScale != newTransientSettings.zoomScale) {
-                            cameraInfo.zoomState.value?.let { zoomState ->
-                                val finalScale =
-                                    (zoomState.zoomRatio * newTransientSettings.zoomScale).coerceIn(
-                                        zoomState.minZoomRatio,
-                                        zoomState.maxZoomRatio
+                                is PerpetualSessionSettings.ConcurrentCamera ->
+                                    runConcurrentCameraSession(
+                                        sessionSettings,
+                                        useCaseMode = CameraUseCase.UseCaseMode.VIDEO_ONLY
                                     )
-                                camera.cameraControl.setZoomRatio(finalScale)
-                                _currentCameraState.update { old ->
-                                    old.copy(zoomScale = finalScale)
-                                }
                             }
+                        } finally {
+                            // TODO(tm): This shouldn't be necessary. Cancellation of the
+                            //  coroutineScope by collectLatest should cause this to
+                            //  occur naturally.
+                            cameraProvider.unbindAll()
                         }
-
-                        if (prevTransientSettings.flashMode != newTransientSettings.flashMode) {
-                            setFlashModeInternal(
-                                flashMode = newTransientSettings.flashMode,
-                                isFrontFacing = sessionSettings.cameraSelector
-                                    == CameraSelector.DEFAULT_FRONT_CAMERA
-                            )
-                        }
-
-                        if (prevTransientSettings.deviceRotation
-                            != newTransientSettings.deviceRotation
-                        ) {
-                            Log.d(
-                                TAG,
-                                "Updating device rotation from " +
-                                    "${prevTransientSettings.deviceRotation} -> " +
-                                    "${newTransientSettings.deviceRotation}"
-                            )
-                            val targetRotation =
-                                newTransientSettings.deviceRotation.toUiSurfaceRotation()
-                            useCaseGroup.useCases.forEach {
-                                when (it) {
-                                    is Preview -> {
-                                        // Preview rotation should always be natural orientation
-                                        // in order to support seamless handling of orientation
-                                        // configuration changes in UI
-                                    }
-
-                                    is ImageCapture -> {
-                                        it.targetRotation = targetRotation
-                                    }
-
-                                    is VideoCapture<*> -> {
-                                        it.targetRotation = targetRotation
-                                    }
-                                }
-                            }
-                        }
-
-                        prevTransientSettings = newTransientSettings
                     }
                 }
             }
     }
 
     override suspend fun takePicture(onCaptureStarted: (() -> Unit)) {
+        if (imageCaptureUseCase == null) {
+            throw RuntimeException("Attempted take picture with null imageCapture use case")
+        }
         try {
-            val imageProxy = imageCaptureUseCase.takePicture(onCaptureStarted)
+            val imageProxy = imageCaptureUseCase!!.takePicture(onCaptureStarted)
             Log.d(TAG, "onCaptureSuccess")
             imageProxy.close()
         } catch (exception: Exception) {
@@ -431,6 +331,9 @@
         imageCaptureUri: Uri?,
         ignoreUri: Boolean
     ): ImageCapture.OutputFileResults {
+        if (imageCaptureUseCase == null) {
+            throw RuntimeException("Attempted take picture with null imageCapture use case")
+        }
         val eligibleContentValues = getEligibleContentValues()
         val outputFileOptions: OutputFileOptions
         if (ignoreUri) {
@@ -470,7 +373,7 @@
             }
         }
         try {
-            val outputFileResults = imageCaptureUseCase.takePicture(
+            val outputFileResults = imageCaptureUseCase!!.takePicture(
                 outputFileOptions,
                 onCaptureStarted
             )
@@ -502,89 +405,26 @@
     }
 
     override suspend fun startVideoRecording(
+        videoCaptureUri: Uri?,
+        shouldUseUri: Boolean,
         onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
     ) {
-        if (videoCaptureUseCase == null) {
-            throw RuntimeException("Attempted video recording with null videoCapture use case")
+        if (shouldUseUri && videoCaptureUri == null) {
+            val e = RuntimeException("Null Uri is provided.")
+            Log.d(TAG, "takePicture onError: $e")
+            throw e
         }
-        Log.d(TAG, "recordVideo")
-        // todo(b/336886716): default setting to enable or disable audio when permission is granted
-
-        // ok. there is a difference between MUTING and ENABLING audio
-        // audio must be enabled in order to be muted
-        // if the video recording isnt started with audio enabled, you will not be able to unmute it
-        // the toggle should only affect whether or not the audio is muted.
-        // the permission will determine whether or not the audio is enabled.
-        val audioEnabled = (
-            checkSelfPermission(
-                this.application.baseContext,
-                Manifest.permission.RECORD_AUDIO
+        videoCaptureControlEvents.send(
+            VideoCaptureControlEvent.StartRecordingEvent(
+                videoCaptureUri,
+                shouldUseUri,
+                onVideoRecord
             )
-                == PackageManager.PERMISSION_GRANTED
-            )
-        val captureTypeString =
-            when (captureMode) {
-                CaptureMode.MULTI_STREAM -> "MultiStream"
-                CaptureMode.SINGLE_STREAM -> "SingleStream"
-            }
-        val name = "JCA-recording-${Date()}-$captureTypeString.mp4"
-        val contentValues =
-            ContentValues().apply {
-                put(MediaStore.Video.Media.DISPLAY_NAME, name)
-            }
-        val mediaStoreOutput =
-            MediaStoreOutputOptions.Builder(
-                application.contentResolver,
-                MediaStore.Video.Media.EXTERNAL_CONTENT_URI
-            )
-                .setContentValues(contentValues)
-                .build()
-
-        val callbackExecutor: Executor =
-            (
-                currentCoroutineContext()[ContinuationInterceptor] as?
-                    CoroutineDispatcher
-                )?.asExecutor() ?: ContextCompat.getMainExecutor(application)
-        recording =
-            videoCaptureUseCase!!.output
-                .prepareRecording(application, mediaStoreOutput)
-                .apply {
-                    if (audioEnabled) {
-                        withAudioEnabled()
-                    }
-                }
-                .start(callbackExecutor) { onVideoRecordEvent ->
-                    run {
-                        Log.d(TAG, onVideoRecordEvent.toString())
-                        when (onVideoRecordEvent) {
-                            is VideoRecordEvent.Finalize -> {
-                                when (onVideoRecordEvent.error) {
-                                    ERROR_NONE ->
-                                        onVideoRecord(
-                                            CameraUseCase.OnVideoRecordEvent.OnVideoRecorded
-                                        )
-                                    else ->
-                                        onVideoRecord(
-                                            CameraUseCase.OnVideoRecordEvent.OnVideoRecordError
-                                        )
-                                }
-                            }
-                            is VideoRecordEvent.Status -> {
-                                onVideoRecord(
-                                    CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus(
-                                        onVideoRecordEvent.recordingStats.audioStats.audioAmplitude
-                                    )
-                                )
-                            }
-                        }
-                    }
-                }
-        currentSettings.value?.audioMuted?.let { recording?.mute(it) }
+        )
     }
 
     override fun stopVideoRecording() {
-        Log.d(TAG, "stopRecording")
-        recording?.stop()
+        videoCaptureControlEvents.trySendBlocking(VideoCaptureControlEvent.StopRecordingEvent)
     }
 
     override fun setZoomScale(scale: Float) {
@@ -593,13 +433,6 @@
         }
     }
 
-    // Could be improved by setting initial value only when camera is initialized
-    private val _currentCameraState = MutableStateFlow(CameraState())
-    override fun getCurrentCameraState(): StateFlow<CameraState> = _currentCameraState.asStateFlow()
-
-    private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
-    override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
-
     // Sets the camera to the designated lensFacing direction
     override suspend fun setLensFacing(lensFacing: LensFacing) {
         currentSettings.update { old ->
@@ -630,12 +463,16 @@
     }
 
     private fun CameraAppSettings.tryApplyAspectRatioForExternalCapture(
-        externalImageCapture: Boolean
+        useCaseMode: CameraUseCase.UseCaseMode
     ): CameraAppSettings {
-        if (externalImageCapture) {
-            return this.copy(aspectRatio = AspectRatio.THREE_FOUR)
+        return when (useCaseMode) {
+            CameraUseCase.UseCaseMode.STANDARD -> this
+            CameraUseCase.UseCaseMode.IMAGE_ONLY ->
+                this.copy(aspectRatio = AspectRatio.THREE_FOUR)
+
+            CameraUseCase.UseCaseMode.VIDEO_ONLY ->
+                this.copy(aspectRatio = AspectRatio.NINE_SIXTEEN)
         }
-        return this
     }
 
     private fun CameraAppSettings.tryApplyImageFormatConstraints(): CameraAppSettings {
@@ -654,21 +491,71 @@
         } ?: this
     }
 
-    override suspend fun tapToFocus(x: Float, y: Float) {
-        Log.d(TAG, "tapToFocus, sending FocusMeteringEvent")
+    private fun CameraAppSettings.tryApplyFrameRateConstraints(): CameraAppSettings {
+        return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+            with(constraints.supportedFixedFrameRates) {
+                val newTargetFrameRate = if (contains(targetFrameRate)) {
+                    targetFrameRate
+                } else {
+                    TARGET_FPS_AUTO
+                }
 
-        getSurfaceRequest().filterNotNull().map { surfaceRequest ->
-            SurfaceOrientedMeteringPointFactory(
-                surfaceRequest.resolution.width.toFloat(),
-                surfaceRequest.resolution.height.toFloat()
-            )
-        }.collectLatest { meteringPointFactory ->
-            val meteringPoint = meteringPointFactory.createPoint(x, y)
-            focusMeteringEvents.send(CameraEvent.FocusMeteringEvent(meteringPoint))
-        }
+                [email protected](
+                    targetFrameRate = newTargetFrameRate
+                )
+            }
+        } ?: this
     }
 
-    override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow()
+    private fun CameraAppSettings.tryApplyStabilizationConstraints(): CameraAppSettings {
+        return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+            with(constraints.supportedStabilizationModes) {
+                val newVideoStabilization = if (contains(SupportedStabilizationMode.HIGH_QUALITY) &&
+                    (targetFrameRate != TARGET_FPS_60)
+                ) {
+                    // unlike shouldVideoBeStabilized, doesn't check value of previewStabilization
+                    videoCaptureStabilization
+                } else {
+                    Stabilization.UNDEFINED
+                }
+                val newPreviewStabilization = if (contains(SupportedStabilizationMode.ON) &&
+                    (targetFrameRate in setOf(TARGET_FPS_AUTO, TARGET_FPS_30))
+                ) {
+                    previewStabilization
+                } else {
+                    Stabilization.UNDEFINED
+                }
+
+                [email protected](
+                    previewStabilization = newPreviewStabilization,
+                    videoCaptureStabilization = newVideoStabilization
+                )
+            }
+        } ?: this
+    }
+
+    private fun CameraAppSettings.tryApplyConcurrentCameraModeConstraints(): CameraAppSettings =
+        when (concurrentCameraMode) {
+            ConcurrentCameraMode.OFF -> this
+            else ->
+                if (systemConstraints.concurrentCamerasSupported) {
+                    copy(
+                        targetFrameRate = TARGET_FPS_AUTO,
+                        previewStabilization = Stabilization.OFF,
+                        videoCaptureStabilization = Stabilization.OFF,
+                        dynamicRange = DynamicRange.SDR,
+                        captureMode = CaptureMode.MULTI_STREAM
+                    )
+                } else {
+                    copy(concurrentCameraMode = ConcurrentCameraMode.OFF)
+                }
+        }
+
+    override suspend fun tapToFocus(x: Float, y: Float) {
+        focusMeteringEvents.send(CameraEvent.FocusMeteringEvent(x, y))
+    }
+
+    override fun getScreenFlashEvents() = screenFlashEvents
     override fun getCurrentSettings() = currentSettings.asStateFlow()
 
     override fun setFlashMode(flashMode: FlashMode) {
@@ -677,58 +564,9 @@
         }
     }
 
-    private fun setFlashModeInternal(flashMode: FlashMode, isFrontFacing: Boolean) {
-        val isScreenFlashRequired =
-            isFrontFacing && (flashMode == FlashMode.ON || flashMode == FlashMode.AUTO)
-
-        if (isScreenFlashRequired) {
-            imageCaptureUseCase.screenFlash = object : ScreenFlash {
-                override fun apply(
-                    expirationTimeMillis: Long,
-                    listener: ImageCapture.ScreenFlashListener
-                ) {
-                    Log.d(TAG, "ImageCapture.ScreenFlash: apply")
-                    coroutineScope.launch {
-                        screenFlashEvents.emit(
-                            CameraUseCase.ScreenFlashEvent(Type.APPLY_UI) {
-                                listener.onCompleted()
-                            }
-                        )
-                    }
-                }
-
-                override fun clear() {
-                    Log.d(TAG, "ImageCapture.ScreenFlash: clear")
-                    coroutineScope.launch {
-                        screenFlashEvents.emit(
-                            CameraUseCase.ScreenFlashEvent(Type.CLEAR_UI) {}
-                        )
-                    }
-                }
-            }
-        }
-
-        imageCaptureUseCase.flashMode = when (flashMode) {
-            FlashMode.OFF -> ImageCapture.FLASH_MODE_OFF // 2
-
-            FlashMode.ON -> if (isScreenFlashRequired) {
-                ImageCapture.FLASH_MODE_SCREEN // 3
-            } else {
-                ImageCapture.FLASH_MODE_ON // 1
-            }
-
-            FlashMode.AUTO -> if (isScreenFlashRequired) {
-                ImageCapture.FLASH_MODE_SCREEN // 3
-            } else {
-                ImageCapture.FLASH_MODE_AUTO // 0
-            }
-        }
-        Log.d(TAG, "Set flash mode to: ${imageCaptureUseCase.flashMode}")
-    }
-
     override fun isScreenFlashEnabled() =
-        imageCaptureUseCase.flashMode == ImageCapture.FLASH_MODE_SCREEN &&
-            imageCaptureUseCase.screenFlash != null
+        imageCaptureUseCase?.flashMode == ImageCapture.FLASH_MODE_SCREEN &&
+            imageCaptureUseCase?.screenFlash != null
 
     override suspend fun setAspectRatio(aspectRatio: AspectRatio) {
         currentSettings.update { old ->
@@ -738,62 +576,16 @@
 
     override suspend fun setCaptureMode(captureMode: CaptureMode) {
         currentSettings.update { old ->
-            old?.copy(captureMode = captureMode)?.tryApplyImageFormatConstraints()
+            old?.copy(captureMode = captureMode)
+                ?.tryApplyImageFormatConstraints()
+                ?.tryApplyConcurrentCameraModeConstraints()
         }
     }
 
-    private fun createUseCaseGroup(
-        cameraInfo: CameraInfo,
-        sessionSettings: PerpetualSessionSettings,
-        initialTransientSettings: TransientSessionSettings,
-        supportedStabilizationModes: Set<SupportedStabilizationMode>,
-        effect: CameraEffect? = null
-    ): UseCaseGroup {
-        val previewUseCase =
-            createPreviewUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
-        imageCaptureUseCase = createImageUseCase(cameraInfo, sessionSettings)
-        if (!disableVideoCapture) {
-            videoCaptureUseCase =
-                createVideoUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
-        }
-
-        setFlashModeInternal(
-            flashMode = initialTransientSettings.flashMode,
-            isFrontFacing = sessionSettings.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
-        )
-
-        return UseCaseGroup.Builder().apply {
-            Log.d(
-                TAG,
-                "Setting initial device rotation to ${initialTransientSettings.deviceRotation}"
-            )
-            setViewPort(
-                ViewPort.Builder(
-                    sessionSettings.aspectRatio.ratio,
-                    initialTransientSettings.deviceRotation.toUiSurfaceRotation()
-                ).build()
-            )
-            addUseCase(previewUseCase)
-            if (sessionSettings.dynamicRange == DynamicRange.SDR ||
-                sessionSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
-            ) {
-                addUseCase(imageCaptureUseCase)
-            }
-            // Not to bind VideoCapture when Ultra HDR is enabled to keep the app design simple.
-            if (videoCaptureUseCase != null &&
-                sessionSettings.imageFormat == ImageOutputFormat.JPEG
-            ) {
-                addUseCase(videoCaptureUseCase!!)
-            }
-
-            effect?.let { addEffect(it) }
-
-            captureMode = sessionSettings.captureMode
-        }.build()
-    }
     override suspend fun setDynamicRange(dynamicRange: DynamicRange) {
         currentSettings.update { old ->
             old?.copy(dynamicRange = dynamicRange)
+                ?.tryApplyConcurrentCameraModeConstraints()
         }
     }
 
@@ -803,35 +595,42 @@
         }
     }
 
+    override suspend fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {
+        currentSettings.update { old ->
+            old?.copy(concurrentCameraMode = concurrentCameraMode)
+                ?.tryApplyConcurrentCameraModeConstraints()
+        }
+    }
+
     override suspend fun setImageFormat(imageFormat: ImageOutputFormat) {
         currentSettings.update { old ->
             old?.copy(imageFormat = imageFormat)
         }
     }
 
-    @OptIn(ExperimentalImageCaptureOutputFormat::class)
-    private fun getSupportedImageFormats(cameraInfo: CameraInfo): Set<ImageOutputFormat> {
-        return ImageCapture.getImageCaptureCapabilities(cameraInfo).supportedOutputFormats
-            .mapNotNull(Int::toAppImageFormat)
-            .toSet()
+    override suspend fun setPreviewStabilization(previewStabilization: Stabilization) {
+        currentSettings.update { old ->
+            old?.copy(
+                previewStabilization = previewStabilization
+            )?.tryApplyStabilizationConstraints()
+                ?.tryApplyConcurrentCameraModeConstraints()
+        }
     }
 
-    @OptIn(ExperimentalImageCaptureOutputFormat::class)
-    private fun createImageUseCase(
-        cameraInfo: CameraInfo,
-        sessionSettings: PerpetualSessionSettings,
-        onCloseTrace: () -> Unit = {}
-    ): ImageCapture {
-        val builder = ImageCapture.Builder()
-        builder.setResolutionSelector(
-            getResolutionSelector(cameraInfo.sensorLandscapeRatio, sessionSettings.aspectRatio)
-        )
-        if (sessionSettings.dynamicRange != DynamicRange.SDR &&
-            sessionSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
-        ) {
-            builder.setOutputFormat(ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR)
+    override suspend fun setVideoCaptureStabilization(videoCaptureStabilization: Stabilization) {
+        currentSettings.update { old ->
+            old?.copy(
+                videoCaptureStabilization = videoCaptureStabilization
+            )?.tryApplyStabilizationConstraints()
+                ?.tryApplyConcurrentCameraModeConstraints()
         }
-        return builder.build()
+    }
+
+    override suspend fun setTargetFrameRate(targetFrameRate: Int) {
+        currentSettings.update { old ->
+            old?.copy(targetFrameRate = targetFrameRate)?.tryApplyFrameRateConstraints()
+                ?.tryApplyConcurrentCameraModeConstraints()
+        }
     }
 
     override suspend fun setLowLightBoost(lowLightBoost: LowLightBoost) {
@@ -841,222 +640,12 @@
     }
 
     override suspend fun setAudioMuted(isAudioMuted: Boolean) {
-        // toggle mute for current in progress recording
-        recording?.mute(!isAudioMuted)
-
         currentSettings.update { old ->
             old?.copy(audioMuted = isAudioMuted)
         }
     }
 
-    private fun createVideoUseCase(
-        cameraInfo: CameraInfo,
-        sessionSettings: PerpetualSessionSettings,
-        supportedStabilizationMode: Set<SupportedStabilizationMode>
-    ): VideoCapture<Recorder> {
-        val sensorLandscapeRatio = cameraInfo.sensorLandscapeRatio
-        val recorder = Recorder.Builder()
-            .setAspectRatio(
-                getAspectRatioForUseCase(sensorLandscapeRatio, sessionSettings.aspectRatio)
-            )
-            .setExecutor(defaultDispatcher.asExecutor()).build()
-        return VideoCapture.Builder(recorder).apply {
-            // set video stabilization
-            if (shouldVideoBeStabilized(sessionSettings, supportedStabilizationMode)
-            ) {
-                setVideoStabilizationEnabled(true)
-            }
-            // set target fps
-            if (sessionSettings.targetFrameRate != TARGET_FPS_AUTO) {
-                setTargetFrameRate(
-                    Range(sessionSettings.targetFrameRate, sessionSettings.targetFrameRate)
-                )
-            }
-
-            setDynamicRange(sessionSettings.dynamicRange.toCXDynamicRange())
-        }.build()
-    }
-
-    private fun getAspectRatioForUseCase(
-        sensorLandscapeRatio: Float,
-        aspectRatio: AspectRatio
-    ): Int {
-        return when (aspectRatio) {
-            AspectRatio.THREE_FOUR -> RATIO_4_3
-            AspectRatio.NINE_SIXTEEN -> RATIO_16_9
-            else -> {
-                // Choose the aspect ratio which maximizes FOV by being closest to the sensor ratio
-                if (
-                    abs(sensorLandscapeRatio - AspectRatio.NINE_SIXTEEN.landscapeRatio.toFloat()) <
-                    abs(sensorLandscapeRatio - AspectRatio.THREE_FOUR.landscapeRatio.toFloat())
-                ) {
-                    RATIO_16_9
-                } else {
-                    RATIO_4_3
-                }
-            }
-        }
-    }
-
-    private fun shouldVideoBeStabilized(
-        sessionSettings: PerpetualSessionSettings,
-        supportedStabilizationModes: Set<SupportedStabilizationMode>
-    ): Boolean {
-        // video is on and target fps is not 60
-        return (sessionSettings.targetFrameRate != TARGET_FPS_60) &&
-            (supportedStabilizationModes.contains(SupportedStabilizationMode.HIGH_QUALITY)) &&
-            // high quality (video only) selected
-            (
-                sessionSettings.stabilizeVideoMode == Stabilization.ON &&
-                    sessionSettings.stabilizePreviewMode == Stabilization.UNDEFINED
-                )
-    }
-
-    private fun createPreviewUseCase(
-        cameraInfo: CameraInfo,
-        sessionSettings: PerpetualSessionSettings,
-        supportedStabilizationModes: Set<SupportedStabilizationMode>
-    ): Preview {
-        val previewUseCaseBuilder = Preview.Builder().apply {
-            setTargetRotation(DeviceRotation.Natural.toUiSurfaceRotation())
-        }
-
-        setOnCaptureCompletedCallback(previewUseCaseBuilder)
-
-        // set preview stabilization
-        if (shouldPreviewBeStabilized(sessionSettings, supportedStabilizationModes)) {
-            previewUseCaseBuilder.setPreviewStabilizationEnabled(true)
-        }
-
-        previewUseCaseBuilder.setResolutionSelector(
-            getResolutionSelector(cameraInfo.sensorLandscapeRatio, sessionSettings.aspectRatio)
-        )
-
-        return previewUseCaseBuilder.build().apply {
-            setSurfaceProvider { surfaceRequest ->
-                _surfaceRequest.value = surfaceRequest
-            }
-        }
-    }
-
-    private fun getResolutionSelector(
-        sensorLandscapeRatio: Float,
-        aspectRatio: AspectRatio
-    ): ResolutionSelector {
-        val aspectRatioStrategy = when (aspectRatio) {
-            AspectRatio.THREE_FOUR -> AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
-            AspectRatio.NINE_SIXTEEN -> AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
-            else -> {
-                // Choose the resolution selector strategy which maximizes FOV by being closest
-                // to the sensor aspect ratio
-                if (
-                    abs(sensorLandscapeRatio - AspectRatio.NINE_SIXTEEN.landscapeRatio.toFloat()) <
-                    abs(sensorLandscapeRatio - AspectRatio.THREE_FOUR.landscapeRatio.toFloat())
-                ) {
-                    AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
-                } else {
-                    AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
-                }
-            }
-        }
-        return ResolutionSelector.Builder().setAspectRatioStrategy(aspectRatioStrategy).build()
-    }
-
-    private fun shouldPreviewBeStabilized(
-        sessionSettings: PerpetualSessionSettings,
-        supportedStabilizationModes: Set<SupportedStabilizationMode>
-    ): Boolean {
-        // only supported if target fps is 30 or none
-        return (
-            when (sessionSettings.targetFrameRate) {
-                TARGET_FPS_AUTO, TARGET_FPS_30 -> true
-                else -> false
-            }
-            ) &&
-            (
-                supportedStabilizationModes.contains(SupportedStabilizationMode.ON) &&
-                    sessionSettings.stabilizePreviewMode == Stabilization.ON
-                )
-    }
-
     companion object {
         private val FIXED_FRAME_RATES = setOf(TARGET_FPS_15, TARGET_FPS_30, TARGET_FPS_60)
-
-        /**
-         * Checks if preview stabilization is supported by the device.
-         *
-         */
-        private fun isPreviewStabilizationSupported(cameraInfo: CameraInfo): Boolean {
-            return Preview.getPreviewCapabilities(cameraInfo).isStabilizationSupported
-        }
-
-        /**
-         * Checks if video stabilization is supported by the device.
-         *
-         */
-        private fun isVideoStabilizationSupported(cameraInfo: CameraInfo): Boolean {
-            return Recorder.getVideoCapabilities(cameraInfo).isStabilizationSupported
-        }
-
-        private fun getSupportedFrameRates(camInfo: CameraInfo): Set<Int> {
-            return buildSet {
-                camInfo.supportedFrameRateRanges.forEach { e ->
-                    if (e.upper == e.lower && FIXED_FRAME_RATES.contains(e.upper)) {
-                        add(e.upper)
-                    }
-                }
-            }
-        }
-    }
-}
-
-private fun CXDynamicRange.toSupportedAppDynamicRange(): DynamicRange? {
-    return when (this) {
-        CXDynamicRange.SDR -> DynamicRange.SDR
-        CXDynamicRange.HLG_10_BIT -> DynamicRange.HLG10
-        // All other dynamic ranges unsupported. Return null.
-        else -> null
-    }
-}
-
-private fun DynamicRange.toCXDynamicRange(): CXDynamicRange {
-    return when (this) {
-        DynamicRange.SDR -> CXDynamicRange.SDR
-        DynamicRange.HLG10 -> CXDynamicRange.HLG_10_BIT
-    }
-}
-
-private fun LensFacing.toCameraSelector(): CameraSelector = when (this) {
-    LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
-    LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
-}
-
-private fun CameraSelector.toAppLensFacing(): LensFacing = when (this) {
-    CameraSelector.DEFAULT_FRONT_CAMERA -> LensFacing.FRONT
-    CameraSelector.DEFAULT_BACK_CAMERA -> LensFacing.BACK
-    else -> throw IllegalArgumentException(
-        "Unknown CameraSelector -> LensFacing mapping. [CameraSelector: $this]"
-    )
-}
-
-private val CameraInfo.sensorLandscapeRatio: Float
-    @OptIn(ExperimentalCamera2Interop::class)
-    get() = Camera2CameraInfo.from(this)
-        .getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
-        ?.let { sensorRect ->
-            if (sensorRect.width() > sensorRect.height()) {
-                sensorRect.width().toFloat() / sensorRect.height()
-            } else {
-                sensorRect.height().toFloat() / sensorRect.width()
-            }
-        } ?: Float.NaN
-
-@OptIn(ExperimentalImageCaptureOutputFormat::class)
-private fun Int.toAppImageFormat(): ImageOutputFormat? {
-    return when (this) {
-        ImageCapture.OUTPUT_FORMAT_JPEG -> ImageOutputFormat.JPEG
-        ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR -> ImageOutputFormat.JPEG_ULTRA_HDR
-        // All other output formats unsupported. Return null.
-        else -> null
     }
 }
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
new file mode 100644
index 0000000..1ea84a1
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.annotation.SuppressLint
+import android.util.Log
+import androidx.camera.core.CompositionSettings
+import androidx.camera.core.TorchState
+import androidx.lifecycle.asFlow
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.Stabilization
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val TAG = "ConcurrentCameraSession"
+
+context(CameraSessionContext)
+@SuppressLint("RestrictedApi")
+internal suspend fun runConcurrentCameraSession(
+    sessionSettings: PerpetualSessionSettings.ConcurrentCamera,
+    useCaseMode: CameraUseCase.UseCaseMode
+) = coroutineScope {
+    val primaryLensFacing = sessionSettings.primaryCameraInfo.appLensFacing
+    val secondaryLensFacing = sessionSettings.secondaryCameraInfo.appLensFacing
+    Log.d(
+        TAG,
+        "Starting new concurrent camera session " +
+            "[primary: $primaryLensFacing, secondary: $secondaryLensFacing]"
+    )
+
+    val initialTransientSettings = transientSettings
+        .filterNotNull()
+        .first()
+
+    val useCaseGroup = createUseCaseGroup(
+        cameraInfo = sessionSettings.primaryCameraInfo,
+        initialTransientSettings = initialTransientSettings,
+        stabilizePreviewMode = Stabilization.OFF,
+        stabilizeVideoMode = Stabilization.OFF,
+        aspectRatio = sessionSettings.aspectRatio,
+        targetFrameRate = TARGET_FPS_AUTO,
+        dynamicRange = DynamicRange.SDR,
+        imageFormat = ImageOutputFormat.JPEG,
+        useCaseMode = useCaseMode
+    )
+
+    val cameraConfigs = listOf(
+        Pair(
+            sessionSettings.primaryCameraInfo.cameraSelector,
+            CompositionSettings.Builder()
+                .setAlpha(1.0f)
+                .setOffset(0.0f, 0.0f)
+                .setScale(1.0f, 1.0f)
+                .build()
+        ),
+        Pair(
+            sessionSettings.secondaryCameraInfo.cameraSelector,
+            CompositionSettings.Builder()
+                .setAlpha(1.0f)
+                .setOffset(2 / 3f - 0.1f, -2 / 3f + 0.1f)
+                .setScale(1 / 3f, 1 / 3f)
+                .build()
+        )
+    )
+
+    cameraProvider.runWithConcurrent(cameraConfigs, useCaseGroup) { concurrentCamera ->
+        Log.d(TAG, "Concurrent camera session started")
+        val primaryCamera = concurrentCamera.cameras.first {
+            it.cameraInfo.appLensFacing == sessionSettings.primaryCameraInfo.appLensFacing
+        }
+
+        launch {
+            processFocusMeteringEvents(primaryCamera.cameraControl)
+        }
+
+        launch {
+            processVideoControlEvents(
+                primaryCamera,
+                useCaseGroup.getVideoCapture(),
+                captureTypeSuffix = "DualCam"
+            )
+        }
+
+        launch {
+            sessionSettings.primaryCameraInfo.torchState.asFlow().collectLatest { torchState ->
+                currentCameraState.update { old ->
+                    old.copy(torchEnabled = torchState == TorchState.ON)
+                }
+            }
+        }
+
+        applyDeviceRotation(initialTransientSettings.deviceRotation, useCaseGroup)
+        processTransientSettingEvents(
+            primaryCamera,
+            useCaseGroup,
+            initialTransientSettings,
+            transientSettings
+        )
+    }
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
index e6f98ea..a6a032f 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
@@ -15,14 +15,19 @@
  */
 package com.google.jetpackcamera.core.camera
 
+import android.annotation.SuppressLint
 import androidx.camera.core.Camera
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.CompositionSettings
+import androidx.camera.core.ConcurrentCamera
+import androidx.camera.core.ConcurrentCamera.SingleCameraConfig
 import androidx.camera.core.UseCaseGroup
 import androidx.camera.lifecycle.ProcessCameraProvider
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LifecycleRegistry
 import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.coroutineScope
 
@@ -36,12 +41,25 @@
 suspend fun <R> ProcessCameraProvider.runWith(
     cameraSelector: CameraSelector,
     useCases: UseCaseGroup,
-    block: suspend (Camera) -> R
+    block: suspend CoroutineScope.(Camera) -> R
 ): R = coroutineScope {
     val scopedLifecycle = CoroutineLifecycleOwner(coroutineContext)
     block([email protected](scopedLifecycle, cameraSelector, useCases))
 }
 
+@SuppressLint("RestrictedApi")
+suspend fun <R> ProcessCameraProvider.runWithConcurrent(
+    cameraConfigs: List<Pair<CameraSelector, CompositionSettings>>,
+    useCaseGroup: UseCaseGroup,
+    block: suspend CoroutineScope.(ConcurrentCamera) -> R
+): R = coroutineScope {
+    val scopedLifecycle = CoroutineLifecycleOwner(coroutineContext)
+    val singleCameraConfigs = cameraConfigs.map {
+        SingleCameraConfig(it.first, useCaseGroup, it.second, scopedLifecycle)
+    }
+    block([email protected](singleCameraConfigs))
+}
+
 /**
  * A [LifecycleOwner] that follows the lifecycle of a coroutine.
  *
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/DebugCameraInfoUtil.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/DebugCameraInfoUtil.kt
new file mode 100644
index 0000000..cb3645e
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/DebugCameraInfoUtil.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.hardware.camera2.CameraCharacteristics
+import android.os.Build
+import android.os.Environment
+import androidx.annotation.OptIn
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+import androidx.camera.core.CameraInfo
+import java.io.File
+import java.io.FileOutputStream
+import org.json.JSONArray
+import org.json.JSONObject
+
+private const val TAG = "DebugCameraInfoUtil"
+object DebugCameraInfoUtil {
+    @OptIn(ExperimentalCamera2Interop::class)
+    @RequiresApi(Build.VERSION_CODES.P)
+    fun getAllCamerasPropertiesJSONArray(cameraInfos: List<CameraInfo>): JSONArray {
+        val result = JSONArray()
+        for (cameraInfo in cameraInfos) {
+            var camera2CameraInfo = Camera2CameraInfo.from(cameraInfo)
+            val logicalCameraId = camera2CameraInfo.cameraId
+            val logicalCameraData = JSONObject()
+            logicalCameraData.put(
+                "logical-$logicalCameraId",
+                getCameraPropertiesJSONObject(camera2CameraInfo)
+            )
+            for (physicalCameraInfo in cameraInfo.physicalCameraInfos) {
+                camera2CameraInfo = Camera2CameraInfo.from(physicalCameraInfo)
+                val physicalCameraId = camera2CameraInfo.cameraId
+                logicalCameraData.put(
+                    "physical-$physicalCameraId",
+                    getCameraPropertiesJSONObject(camera2CameraInfo)
+                )
+            }
+            result.put(logicalCameraData)
+        }
+        return result
+    }
+
+    @OptIn(ExperimentalCamera2Interop::class)
+    private fun getCameraPropertiesJSONObject(cameraInfo: Camera2CameraInfo): JSONObject {
+        val jsonObject = JSONObject()
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_POSE_ROTATION)
+                ?.let {
+                    jsonObject.put(
+                        CameraCharacteristics.LENS_POSE_ROTATION.name,
+                        it.contentToString()
+                    )
+                }
+            cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_POSE_TRANSLATION)
+                ?.let {
+                    jsonObject.put(
+                        CameraCharacteristics.LENS_POSE_TRANSLATION.name,
+                        it.contentToString()
+                    )
+                }
+            cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)
+                ?.let {
+                    jsonObject.put(
+                        CameraCharacteristics.LENS_INTRINSIC_CALIBRATION.name,
+                        it.contentToString()
+                    )
+                }
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_DISTORTION)
+                ?.let {
+                    jsonObject.put(
+                        CameraCharacteristics.LENS_DISTORTION.name,
+                        it.contentToString()
+                    )
+                }
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            cameraInfo.getCameraCharacteristic(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)
+                ?.let { jsonObject.put(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE.name, it) }
+        }
+        cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
+            ?.let {
+                jsonObject.put(
+                    CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS.name,
+                    it.contentToString()
+                )
+            }
+        cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE)
+            ?.let {
+                jsonObject.put(
+                    CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE.name,
+                    it
+                )
+            }
+        cameraInfo.getCameraCharacteristic(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
+            ?.let {
+                jsonObject.put(
+                    CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES.name,
+                    it.contentToString()
+                )
+            }
+
+        return jsonObject
+    }
+
+    fun writeFileExternalStorage(file: File, textToWrite: String) {
+        // Checking the availability state of the External Storage.
+        val state = Environment.getExternalStorageState()
+        if (Environment.MEDIA_MOUNTED != state) {
+            // If it isn't mounted - we can't write into it.
+            return
+        }
+
+        file.createNewFile()
+        FileOutputStream(file).use { outputStream ->
+            outputStream.write(textToWrite.toByteArray())
+        }
+    }
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt
new file mode 100644
index 0000000..822c5cd
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.core.camera
+
+import android.net.Uri
+
+/**
+ * Represents events that control video capture operations.
+ */
+sealed interface VideoCaptureControlEvent {
+
+    /**
+     * Starts video recording.
+     *
+     * @param onVideoRecord Callback to handle video recording events.
+     */
+    class StartRecordingEvent(
+        val videoCaptureUri: Uri?,
+        val shouldUseUri: Boolean,
+        val onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+    ) : VideoCaptureControlEvent
+
+    /**
+     * Stops video recording.
+     */
+    data object StopRecordingEvent : VideoCaptureControlEvent
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
index fc32ecd..0677b23 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
@@ -490,8 +490,7 @@
         private const val EGL_GL_COLORSPACE_BT2020_HLG_EXT = 0x3540
 
         private val TEN_BIT_REQUIRED_EGL_EXTENSIONS = listOf(
-            "EGL_EXT_gl_colorspace_bt2020_hlg",
-            "EGL_EXT_yuv_surface"
+            "EGL_EXT_gl_colorspace_bt2020_hlg"
         )
 
         private fun FloatArray.toBuffer(): FloatBuffer {
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
index 4245f59..f865a63 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
@@ -25,27 +25,24 @@
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CameraAppSettings
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DeviceRotation
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.ImageOutputFormat
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.LowLightBoost
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableSharedFlow
+import com.google.jetpackcamera.settings.model.Stabilization
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
 
 class FakeCameraUseCase(
-    private val coroutineScope: CoroutineScope =
-        CoroutineScope(SupervisorJob() + Dispatchers.Default),
     defaultCameraSettings: CameraAppSettings = CameraAppSettings()
 ) : CameraUseCase {
     private val availableLenses = listOf(LensFacing.FRONT, LensFacing.BACK)
@@ -60,11 +57,15 @@
     var isLensFacingFront = false
 
     private var isScreenFlash = true
-    private var screenFlashEvents = MutableSharedFlow<CameraUseCase.ScreenFlashEvent>()
+    private var screenFlashEvents = Channel<CameraUseCase.ScreenFlashEvent>(capacity = UNLIMITED)
 
     private val currentSettings = MutableStateFlow(defaultCameraSettings)
 
-    override suspend fun initialize(disableVideoCapture: Boolean) {
+    override suspend fun initialize(
+        cameraAppSettings: CameraAppSettings,
+        useCaseMode: CameraUseCase.UseCaseMode,
+        isDebugMode: Boolean
+    ) {
         initialized = true
     }
 
@@ -103,14 +104,12 @@
             throw IllegalStateException("Usecases not bound")
         }
         if (isScreenFlash) {
-            coroutineScope.launch {
-                screenFlashEvents.emit(
-                    CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) { }
-                )
-                screenFlashEvents.emit(
-                    CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
-                )
-            }
+            screenFlashEvents.trySend(
+                CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) { }
+            )
+            screenFlashEvents.trySend(
+                CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
+            )
         }
         numPicturesTaken += 1
     }
@@ -127,12 +126,12 @@
     }
 
     fun emitScreenFlashEvent(event: CameraUseCase.ScreenFlashEvent) {
-        coroutineScope.launch {
-            screenFlashEvents.emit(event)
-        }
+        screenFlashEvents.trySend(event)
     }
 
     override suspend fun startVideoRecording(
+        videoCaptureUri: Uri?,
+        shouldUseUri: Boolean,
         onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
     ) {
         if (!useCasesBinded) {
@@ -203,6 +202,12 @@
         }
     }
 
+    override suspend fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {
+        currentSettings.update { old ->
+            old.copy(concurrentCameraMode = concurrentCameraMode)
+        }
+    }
+
     override suspend fun setImageFormat(imageFormat: ImageOutputFormat) {
         currentSettings.update { old ->
             old.copy(imageFormat = imageFormat)
@@ -220,4 +225,22 @@
             old.copy(audioMuted = isAudioMuted)
         }
     }
+
+    override suspend fun setVideoCaptureStabilization(videoCaptureStabilization: Stabilization) {
+        currentSettings.update { old ->
+            old.copy(videoCaptureStabilization = videoCaptureStabilization)
+        }
+    }
+
+    override suspend fun setPreviewStabilization(previewStabilization: Stabilization) {
+        currentSettings.update { old ->
+            old.copy(previewStabilization = previewStabilization)
+        }
+    }
+
+    override suspend fun setTargetFrameRate(targetFrameRate: Int) {
+        currentSettings.update { old ->
+            old.copy(targetFrameRate = targetFrameRate)
+        }
+    }
 }
diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
index ad47e41..00cedf3 100644
--- a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
+++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
@@ -17,10 +17,12 @@
 
 import com.google.common.truth.Truth
 import com.google.jetpackcamera.core.camera.CameraUseCase
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.StandardTestDispatcher
@@ -39,7 +41,7 @@
     private val testScope = TestScope()
     private val testDispatcher = StandardTestDispatcher(testScope.testScheduler)
 
-    private val cameraUseCase = FakeCameraUseCase(testScope)
+    private val cameraUseCase = FakeCameraUseCase()
 
     @Before
     fun setup() {
@@ -53,7 +55,10 @@
 
     @Test
     fun canInitialize() = runTest(testDispatcher) {
-        cameraUseCase.initialize(false)
+        cameraUseCase.initialize(
+            cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+            useCaseMode = CameraUseCase.UseCaseMode.STANDARD
+        )
     }
 
     @Test
@@ -124,7 +129,7 @@
         initAndRunCamera()
         val events = mutableListOf<CameraUseCase.ScreenFlashEvent>()
         backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
-            cameraUseCase.getScreenFlashEvents().toList(events)
+            cameraUseCase.getScreenFlashEvents().consumeAsFlow().toList(events)
         }
 
         // FlashMode.ON in front facing camera automatically enables screen flash
@@ -144,7 +149,10 @@
 
     private fun TestScope.initAndRunCamera() {
         backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
-            cameraUseCase.initialize(false)
+            cameraUseCase.initialize(
+                cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+                useCaseMode = CameraUseCase.UseCaseMode.STANDARD
+            )
             cameraUseCase.runCamera()
         }
     }
diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
index 519f90f..2f93743 100644
--- a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
+++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
@@ -19,6 +19,7 @@
 import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
 import javax.inject.Singleton
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
@@ -32,9 +33,22 @@
 @InstallIn(SingletonComponent::class)
 class CommonModule {
     @Provides
+    @DefaultDispatcher
     fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
 
+    @Provides
+    @IODispatcher
+    fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
+
     @Singleton
     @Provides
     fun providesCoroutineScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
 }
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DefaultDispatcher
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class IODispatcher
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt
index 8183542..fb10dc2 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt
@@ -38,9 +38,7 @@
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 
-const val TARGET_FPS_NONE = 0
 const val TARGET_FPS_15 = 15
-const val TARGET_FPS_30 = 30
 const val TARGET_FPS_60 = 60
 
 /**
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt
index 712a0cc..1daa078 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt
@@ -35,7 +35,8 @@
     val targetFrameRate: Int = TARGET_FPS_AUTO,
     val imageFormat: ImageOutputFormat = ImageOutputFormat.JPEG,
     val audioMuted: Boolean = false,
-    val deviceRotation: DeviceRotation = DeviceRotation.Natural
+    val deviceRotation: DeviceRotation = DeviceRotation.Natural,
+    val concurrentCameraMode: ConcurrentCameraMode = ConcurrentCameraMode.OFF
 )
 
 fun SystemConstraints.forCurrentLens(cameraAppSettings: CameraAppSettings): CameraConstraints? {
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ConcurrentCameraMode.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ConcurrentCameraMode.kt
new file mode 100644
index 0000000..621296a
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ConcurrentCameraMode.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.settings.model
+
+enum class ConcurrentCameraMode {
+    OFF,
+    DUAL
+}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt
index 51a1eb4..8b75351 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt
@@ -17,6 +17,7 @@
 
 data class SystemConstraints(
     val availableLenses: List<LensFacing>,
+    val concurrentCamerasSupported: Boolean,
     val perLensConstraints: Map<LensFacing, CameraConstraints>
 )
 
@@ -24,7 +25,8 @@
     val supportedStabilizationModes: Set<SupportedStabilizationMode>,
     val supportedFixedFrameRates: Set<Int>,
     val supportedDynamicRanges: Set<DynamicRange>,
-    val supportedImageFormatsMap: Map<CaptureMode, Set<ImageOutputFormat>>
+    val supportedImageFormatsMap: Map<CaptureMode, Set<ImageOutputFormat>>,
+    val hasFlashUnit: Boolean
 )
 
 /**
@@ -33,6 +35,7 @@
 val TYPICAL_SYSTEM_CONSTRAINTS =
     SystemConstraints(
         availableLenses = listOf(LensFacing.FRONT, LensFacing.BACK),
+        concurrentCamerasSupported = false,
         perLensConstraints = buildMap {
             for (lensFacing in listOf(LensFacing.FRONT, LensFacing.BACK)) {
                 put(
@@ -44,7 +47,8 @@
                         supportedImageFormatsMap = mapOf(
                             Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
                             Pair(CaptureMode.MULTI_STREAM, setOf(ImageOutputFormat.JPEG))
-                        )
+                        ),
+                        hasFlashUnit = lensFacing == LensFacing.BACK
                     )
                 )
             }
diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp
index a146711..a3d8366 100644
--- a/feature/preview/Android.bp
+++ b/feature/preview/Android.bp
@@ -19,6 +19,7 @@
         "hilt_android",
         "androidx.hilt_hilt-navigation-compose",
         "androidx.compose.ui_ui-tooling",
+        "kotlin-reflect",
         "kotlinx_coroutines_guava",
         "androidx.datastore_datastore",
         "libprotobuf-java-lite",
diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts
index 81a3ddb..5ba5f3a 100644
--- a/feature/preview/build.gradle.kts
+++ b/feature/preview/build.gradle.kts
@@ -89,6 +89,8 @@
 }
 
 dependencies {
+    // Reflect
+    implementation(libs.kotlin.reflect)
     // Compose
     val composeBom = platform(libs.compose.bom)
     implementation(composeBom)
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt
index 86e084f..04b7a5e 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt
@@ -21,6 +21,8 @@
 import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG
 import com.google.jetpackcamera.feature.preview.ui.HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG
 import com.google.jetpackcamera.feature.preview.ui.HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG
 import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
 
 sealed interface CaptureModeToggleUiState {
@@ -43,6 +45,15 @@
             VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG,
             R.string.toast_video_capture_external_unsupported
         ),
+        IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED(
+            IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG,
+            R.string.toast_image_capture_external_unsupported
+
+        ),
+        IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA(
+            IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG,
+            R.string.toast_image_capture_unsupported_concurrent_camera
+        ),
         HDR_VIDEO_UNSUPPORTED_ON_DEVICE(
             HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG,
             R.string.toast_hdr_video_unsupported_on_device
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
index de1c7d8..dc3f8e7 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
@@ -36,4 +36,12 @@
         val imageCaptureUri: Uri?,
         val onImageCapture: (PreviewViewModel.ImageCaptureEvent) -> Unit
     ) : PreviewMode
+
+    /**
+     * Under this mode, the app is launched by an external intent to capture a video.
+     */
+    data class ExternalVideoCaptureMode(
+        val videoCaptureUri: Uri?,
+        val onVideoCapture: (PreviewViewModel.VideoCaptureEvent) -> Unit
+    ) : PreviewMode
 }
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
index f4c7e34..55583a2 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
@@ -59,6 +59,7 @@
 import com.google.jetpackcamera.feature.preview.ui.debouncedOrientationFlow
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
@@ -77,11 +78,12 @@
 fun PreviewScreen(
     onNavigateToSettings: () -> Unit,
     previewMode: PreviewMode,
+    isDebugMode: Boolean,
     modifier: Modifier = Modifier,
     onRequestWindowColorMode: (Int) -> Unit = {},
     onFirstFrameCaptureCompleted: () -> Unit = {},
     viewModel: PreviewViewModel = hiltViewModel<PreviewViewModel, PreviewViewModel.Factory>
-        { factory -> factory.create(previewMode) }
+        { factory -> factory.create(previewMode, isDebugMode) }
 ) {
     Log.d(TAG, "PreviewScreen")
 
@@ -140,6 +142,7 @@
                 onChangeAspectRatio = viewModel::setAspectRatio,
                 onChangeCaptureMode = viewModel::setCaptureMode,
                 onChangeDynamicRange = viewModel::setDynamicRange,
+                onChangeConcurrentCameraMode = viewModel::setConcurrentCameraMode,
                 onLowLightBoost = viewModel::setLowLightBoost,
                 onChangeImageFormat = viewModel::setImageFormat,
                 onToggleWhenDisabled = viewModel::showSnackBarForDisabledHdrToggle,
@@ -173,6 +176,7 @@
     onChangeAspectRatio: (AspectRatio) -> Unit = {},
     onChangeCaptureMode: (CaptureMode) -> Unit = {},
     onChangeDynamicRange: (DynamicRange) -> Unit = {},
+    onChangeConcurrentCameraMode: (ConcurrentCameraMode) -> Unit = {},
     onLowLightBoost: (LowLightBoost) -> Unit = {},
     onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
     onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
@@ -185,7 +189,11 @@
         Boolean,
         (PreviewViewModel.ImageCaptureEvent) -> Unit
     ) -> Unit = { _, _, _, _ -> },
-    onStartVideoRecording: () -> Unit = {},
+    onStartVideoRecording: (
+        Uri?,
+        Boolean,
+        (PreviewViewModel.VideoCaptureEvent) -> Unit
+    ) -> Unit = { _, _, _ -> },
     onStopVideoRecording: () -> Unit = {},
     onToastShown: () -> Unit = {},
     onRequestWindowColorMode: (Int) -> Unit = {},
@@ -232,13 +240,13 @@
                 isOpen = previewUiState.quickSettingsIsOpen,
                 toggleIsOpen = onToggleQuickSettings,
                 currentCameraSettings = previewUiState.currentCameraSettings,
-                systemConstraints = previewUiState.systemConstraints,
                 onLensFaceClick = onSetLensFacing,
                 onFlashModeClick = onChangeFlash,
                 onAspectRatioClick = onChangeAspectRatio,
                 onCaptureModeClick = onChangeCaptureMode,
                 onDynamicRangeClick = onChangeDynamicRange,
                 onImageOutputFormatClick = onChangeImageFormat,
+                onConcurrentCameraModeClick = onChangeConcurrentCameraMode,
                 onLowLightBoostClick = onLowLightBoost
             )
             // relative-grid style overlay on top of preview display
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt
index dfd20d1..5152bbe 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt
@@ -42,7 +42,10 @@
         val lastBlinkTimeStamp: Long = 0,
         val previewMode: PreviewMode,
         val captureModeToggleUiState: CaptureModeToggleUiState,
-        val sessionFirstFrameTimestamp: Long = 0L
+        val sessionFirstFrameTimestamp: Long = 0L,
+        val currentPhysicalCameraId: String? = null,
+        val currentLogicalCameraId: String? = null,
+        val isDebugMode: Boolean = false
     ) : PreviewUiState
 }
 
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
index 7142a1a..fef3aa1 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
@@ -26,27 +26,35 @@
 import androidx.tracing.traceAsync
 import com.google.jetpackcamera.core.camera.CameraUseCase
 import com.google.jetpackcamera.core.common.traceFirstFramePreview
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG
 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
 import com.google.jetpackcamera.feature.preview.ui.SnackbarData
 import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_FAILURE_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG
 import com.google.jetpackcamera.settings.ConstraintsRepository
+import com.google.jetpackcamera.settings.SettingsRepository
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CameraAppSettings
 import com.google.jetpackcamera.settings.model.CameraConstraints
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DeviceRotation
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.ImageOutputFormat
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.LowLightBoost
+import com.google.jetpackcamera.settings.model.Stabilization
 import com.google.jetpackcamera.settings.model.SystemConstraints
 import com.google.jetpackcamera.settings.model.forCurrentLens
 import dagger.assisted.Assisted
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlin.reflect.KProperty
+import kotlin.reflect.full.memberProperties
 import kotlin.time.Duration.Companion.seconds
 import kotlinx.atomicfu.atomic
 import kotlinx.coroutines.CoroutineStart
@@ -59,6 +67,8 @@
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.transform
 import kotlinx.coroutines.flow.transformWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
@@ -72,7 +82,9 @@
 @HiltViewModel(assistedFactory = PreviewViewModel.Factory::class)
 class PreviewViewModel @AssistedInject constructor(
     @Assisted val previewMode: PreviewMode,
+    @Assisted val isDebugMode: Boolean,
     private val cameraUseCase: CameraUseCase,
+    private val settingsRepository: SettingsRepository,
     private val constraintsRepository: ConstraintsRepository
 ) : ViewModel() {
     private val _previewUiState: MutableStateFlow<PreviewUiState> =
@@ -95,11 +107,27 @@
     // Eagerly initialize the CameraUseCase and encapsulate in a Deferred that can be
     // used to ensure we don't start the camera before initialization is complete.
     private var initializationDeferred: Deferred<Unit> = viewModelScope.async {
-        cameraUseCase.initialize(previewMode is PreviewMode.ExternalImageCaptureMode)
+        cameraUseCase.initialize(
+            cameraAppSettings = settingsRepository.defaultCameraAppSettings.first(),
+            previewMode.toUseCaseMode(),
+            isDebugMode
+        )
     }
 
     init {
         viewModelScope.launch {
+            launch {
+                var oldCameraAppSettings: CameraAppSettings? = null
+                settingsRepository.defaultCameraAppSettings.transform { new ->
+                    val old = oldCameraAppSettings
+                    if (old != null) {
+                        emit(getSettingsDiff(old, new))
+                    }
+                    oldCameraAppSettings = new
+                }.collect { diffQueue ->
+                    applySettingsDiff(diffQueue)
+                }
+            }
             combine(
                 cameraUseCase.getCurrentSettings().filterNotNull(),
                 constraintsRepository.systemConstraints.filterNotNull(),
@@ -116,7 +144,10 @@
                                 captureModeToggleUiState = getCaptureToggleUiState(
                                     systemConstraints,
                                     cameraAppSettings
-                                )
+                                ),
+                                isDebugMode = isDebugMode,
+                                currentLogicalCameraId = cameraState.debugInfo.logicalCameraId,
+                                currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId
                             )
 
                         is PreviewUiState.NotReady ->
@@ -129,7 +160,10 @@
                                 captureModeToggleUiState = getCaptureToggleUiState(
                                     systemConstraints,
                                     cameraAppSettings
-                                )
+                                ),
+                                isDebugMode = isDebugMode,
+                                currentLogicalCameraId = cameraState.debugInfo.logicalCameraId,
+                                currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId
                             )
                     }
                 }
@@ -137,6 +171,70 @@
         }
     }
 
+    private fun PreviewMode.toUseCaseMode() = when (this) {
+        is PreviewMode.ExternalImageCaptureMode -> CameraUseCase.UseCaseMode.IMAGE_ONLY
+        is PreviewMode.ExternalVideoCaptureMode -> CameraUseCase.UseCaseMode.VIDEO_ONLY
+        is PreviewMode.StandardMode -> CameraUseCase.UseCaseMode.STANDARD
+    }
+
+    /**
+     * Returns the difference between two [CameraAppSettings] as a mapping of <[KProperty], [Any]>.
+     */
+    private fun getSettingsDiff(
+        oldCameraAppSettings: CameraAppSettings,
+        newCameraAppSettings: CameraAppSettings
+    ): Map<KProperty<Any?>, Any?> = buildMap<KProperty<Any?>, Any?> {
+        CameraAppSettings::class.memberProperties.forEach { property ->
+            if (property.get(oldCameraAppSettings) != property.get(newCameraAppSettings)) {
+                put(property, property.get(newCameraAppSettings))
+            }
+        }
+    }
+
+    /**
+     * Iterates through a queue of [Pair]<[KProperty], [Any]> and attempt to apply them to
+     * [CameraUseCase].
+     */
+    private suspend fun applySettingsDiff(diffSettingsMap: Map<KProperty<Any?>, Any?>) {
+        diffSettingsMap.entries.forEach { entry ->
+            when (entry.key) {
+                CameraAppSettings::cameraLensFacing -> {
+                    cameraUseCase.setLensFacing(entry.value as LensFacing)
+                }
+
+                CameraAppSettings::flashMode -> {
+                    cameraUseCase.setFlashMode(entry.value as FlashMode)
+                }
+
+                CameraAppSettings::captureMode -> {
+                    cameraUseCase.setCaptureMode(entry.value as CaptureMode)
+                }
+
+                CameraAppSettings::aspectRatio -> {
+                    cameraUseCase.setAspectRatio(entry.value as AspectRatio)
+                }
+
+                CameraAppSettings::previewStabilization -> {
+                    cameraUseCase.setPreviewStabilization(entry.value as Stabilization)
+                }
+
+                CameraAppSettings::videoCaptureStabilization -> {
+                    cameraUseCase.setVideoCaptureStabilization(
+                        entry.value as Stabilization
+                    )
+                }
+
+                CameraAppSettings::targetFrameRate -> {
+                    cameraUseCase.setTargetFrameRate(entry.value as Int)
+                }
+
+                CameraAppSettings::darkMode -> {}
+
+                else -> TODO("Unhandled CameraAppSetting $entry")
+            }
+        }
+    }
+
     private fun getCaptureToggleUiState(
         systemConstraints: SystemConstraints,
         cameraAppSettings: CameraAppSettings
@@ -152,12 +250,19 @@
                 it.size > 1
             } ?: false
         val isShown = previewMode is PreviewMode.ExternalImageCaptureMode ||
+            previewMode is PreviewMode.ExternalVideoCaptureMode ||
             cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR ||
-            cameraAppSettings.dynamicRange == DynamicRange.HLG10
+            cameraAppSettings.dynamicRange == DynamicRange.HLG10 ||
+            cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.DUAL
         val enabled = previewMode !is PreviewMode.ExternalImageCaptureMode &&
-            hdrDynamicRangeSupported && hdrImageFormatSupported
+            previewMode !is PreviewMode.ExternalVideoCaptureMode &&
+            hdrDynamicRangeSupported &&
+            hdrImageFormatSupported &&
+            cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF
         return if (isShown) {
-            val currentMode = if (previewMode is PreviewMode.ExternalImageCaptureMode ||
+            val currentMode = if (
+                cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF &&
+                previewMode is PreviewMode.ExternalImageCaptureMode ||
                 cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
             ) {
                 CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE
@@ -170,11 +275,13 @@
                 CaptureModeToggleUiState.Disabled(
                     currentMode,
                     getCaptureToggleUiStateDisabledReason(
+                        currentMode,
                         hdrDynamicRangeSupported,
                         hdrImageFormatSupported,
                         systemConstraints,
                         cameraAppSettings.cameraLensFacing,
-                        cameraAppSettings.captureMode
+                        cameraAppSettings.captureMode,
+                        cameraAppSettings.concurrentCameraMode
                     )
                 )
             }
@@ -184,69 +291,96 @@
     }
 
     private fun getCaptureToggleUiStateDisabledReason(
+        captureModeToggleUiState: CaptureModeToggleUiState.ToggleMode,
         hdrDynamicRangeSupported: Boolean,
         hdrImageFormatSupported: Boolean,
         systemConstraints: SystemConstraints,
         currentLensFacing: LensFacing,
-        currentCaptureMode: CaptureMode
+        currentCaptureMode: CaptureMode,
+        concurrentCameraMode: ConcurrentCameraMode
     ): CaptureModeToggleUiState.DisabledReason {
-        if (previewMode is PreviewMode.ExternalImageCaptureMode) {
-            return CaptureModeToggleUiState.DisabledReason.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED
-        }
-        if (!hdrImageFormatSupported) {
-            // First assume HDR image is only unsupported on this capture mode
-            var disabledReason = when (currentCaptureMode) {
-                CaptureMode.MULTI_STREAM ->
-                    CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM
-                CaptureMode.SINGLE_STREAM ->
-                    CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM
-            }
-            // Check if other capture modes supports HDR image on this lens
-            systemConstraints
-                .perLensConstraints[currentLensFacing]
-                ?.supportedImageFormatsMap
-                ?.filterKeys { it != currentCaptureMode }
-                ?.values
-                ?.forEach { supportedFormats ->
-                    if (supportedFormats.size > 1) {
-                        // Found another capture mode that supports HDR image,
-                        // return previously discovered disabledReason
-                        return disabledReason
-                    }
+        when (captureModeToggleUiState) {
+            CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO -> {
+                if (previewMode is PreviewMode.ExternalVideoCaptureMode) {
+                    return CaptureModeToggleUiState.DisabledReason
+                        .IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED
                 }
-            // HDR image is not supported by this lens
-            disabledReason = CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS
-            // Check if any other lens supports HDR image
-            systemConstraints
-                .perLensConstraints
-                .filterKeys { it != currentLensFacing }
-                .values
-                .forEach { constraints ->
-                    constraints.supportedImageFormatsMap.values.forEach { supportedFormats ->
-                        if (supportedFormats.size > 1) {
-                            // Found another lens that supports HDR image,
-                            // return previously discovered disabledReason
-                            return disabledReason
+
+                if (concurrentCameraMode == ConcurrentCameraMode.DUAL) {
+                    return CaptureModeToggleUiState.DisabledReason
+                        .IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA
+                }
+
+                if (!hdrImageFormatSupported) {
+                    // First check if Ultra HDR image is supported on other capture modes
+                    if (systemConstraints
+                            .perLensConstraints[currentLensFacing]
+                            ?.supportedImageFormatsMap
+                            ?.anySupportsUltraHdr { it != currentCaptureMode } == true
+                    ) {
+                        return when (currentCaptureMode) {
+                            CaptureMode.MULTI_STREAM ->
+                                CaptureModeToggleUiState.DisabledReason
+                                    .HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM
+
+                            CaptureMode.SINGLE_STREAM ->
+                                CaptureModeToggleUiState.DisabledReason
+                                    .HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM
                         }
                     }
+
+                    // Check if any other lens supports HDR image
+                    if (systemConstraints.anySupportsUltraHdr { it != currentLensFacing }) {
+                        return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS
+                    }
+
+                    // No lenses support HDR image on device
+                    return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE
                 }
-            // No lenses support HDR image on device
-            return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE
-        } else if (!hdrDynamicRangeSupported) {
-            systemConstraints.perLensConstraints.forEach { entry ->
-                if (entry.key != currentLensFacing) {
-                    val cameraConstraints = systemConstraints.perLensConstraints[entry.key]
-                    if (cameraConstraints?.let { it.supportedDynamicRanges.size > 1 } == true) {
+
+                throw RuntimeException("Unknown DisabledReason for video mode.")
+            }
+
+            CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE -> {
+                if (previewMode is PreviewMode.ExternalImageCaptureMode) {
+                    return CaptureModeToggleUiState.DisabledReason
+                        .VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED
+                }
+
+                if (!hdrDynamicRangeSupported) {
+                    if (systemConstraints.anySupportsHdrDynamicRange { it != currentLensFacing }) {
                         return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS
                     }
+                    return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE
                 }
+
+                throw RuntimeException("Unknown DisabledReason for image mode.")
             }
-            return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE
-        } else {
-            throw RuntimeException("Unknown CaptureModeUnsupportedReason.")
         }
     }
 
+    private fun SystemConstraints.anySupportsHdrDynamicRange(
+        lensFilter: (LensFacing) -> Boolean
+    ): Boolean = perLensConstraints.asSequence().firstOrNull {
+        lensFilter(it.key) && it.value.supportedDynamicRanges.size > 1
+    } != null
+
+    private fun Map<CaptureMode, Set<ImageOutputFormat>>.anySupportsUltraHdr(
+        captureModeFilter: (CaptureMode) -> Boolean
+    ): Boolean = asSequence().firstOrNull {
+        captureModeFilter(it.key) && it.value.contains(ImageOutputFormat.JPEG_ULTRA_HDR)
+    } != null
+
+    private fun SystemConstraints.anySupportsUltraHdr(
+        captureModeFilter: (CaptureMode) -> Boolean = { true },
+        lensFilter: (LensFacing) -> Boolean
+    ): Boolean = perLensConstraints.asSequence().firstOrNull { lensConstraints ->
+        lensFilter(lensConstraints.key) &&
+            lensConstraints.value.supportedImageFormatsMap.anySupportsUltraHdr {
+                captureModeFilter(it)
+            }
+    } != null
+
     fun startCamera() {
         Log.d(TAG, "startCamera")
         stopCamera()
@@ -325,7 +459,29 @@
         )
     }
 
+    private fun showExternalVideoCaptureUnsupportedToast() {
+        viewModelScope.launch {
+            _previewUiState.update { old ->
+                (old as? PreviewUiState.Ready)?.copy(
+                    snackBarToShow = SnackbarData(
+                        cookie = "Image-ExternalVideoCaptureMode",
+                        stringResource = R.string.toast_image_capture_external_unsupported,
+                        withDismissAction = true,
+                        testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+                    )
+                ) ?: old
+            }
+        }
+    }
+
     fun captureImage() {
+        if (previewUiState.value is PreviewUiState.Ready &&
+            (previewUiState.value as PreviewUiState.Ready).previewMode is
+                PreviewMode.ExternalVideoCaptureMode
+        ) {
+            showExternalVideoCaptureUnsupportedToast()
+            return
+        }
         Log.d(TAG, "captureImage")
         viewModelScope.launch {
             captureImageInternal(
@@ -348,6 +504,32 @@
         ignoreUri: Boolean = false,
         onImageCapture: (ImageCaptureEvent) -> Unit
     ) {
+        if (previewUiState.value is PreviewUiState.Ready &&
+            (previewUiState.value as PreviewUiState.Ready).previewMode is
+                PreviewMode.ExternalVideoCaptureMode
+        ) {
+            showExternalVideoCaptureUnsupportedToast()
+            return
+        }
+
+        if (previewUiState.value is PreviewUiState.Ready &&
+            (previewUiState.value as PreviewUiState.Ready).previewMode is
+                PreviewMode.ExternalVideoCaptureMode
+        ) {
+            viewModelScope.launch {
+                _previewUiState.update { old ->
+                    (old as? PreviewUiState.Ready)?.copy(
+                        snackBarToShow = SnackbarData(
+                            cookie = "Image-ExternalVideoCaptureMode",
+                            stringResource = R.string.toast_image_capture_external_unsupported,
+                            withDismissAction = true,
+                            testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+                        )
+                    ) ?: old
+                }
+            }
+            return
+        }
         Log.d(TAG, "captureImageWithUri")
         viewModelScope.launch {
             captureImageInternal(
@@ -424,7 +606,11 @@
         }
     }
 
-    fun startVideoRecording() {
+    fun startVideoRecording(
+        videoCaptureUri: Uri?,
+        shouldUseUri: Boolean,
+        onVideoCapture: (VideoCaptureEvent) -> Unit
+    ) {
         if (previewUiState.value is PreviewUiState.Ready &&
             (previewUiState.value as PreviewUiState.Ready).previewMode is
                 PreviewMode.ExternalImageCaptureMode
@@ -448,23 +634,29 @@
         recordingJob = viewModelScope.launch {
             val cookie = "Video-${videoCaptureStartedCount.incrementAndGet()}"
             try {
-                cameraUseCase.startVideoRecording {
+                cameraUseCase.startVideoRecording(videoCaptureUri, shouldUseUri) {
                     var audioAmplitude = 0.0
                     var snackbarToShow: SnackbarData? = null
                     when (it) {
-                        CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> {
+                        is CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> {
+                            Log.d(TAG, "cameraUseCase.startRecording OnVideoRecorded")
+                            onVideoCapture(VideoCaptureEvent.VideoSaved(it.savedUri))
                             snackbarToShow = SnackbarData(
                                 cookie = cookie,
                                 stringResource = R.string.toast_video_capture_success,
-                                withDismissAction = true
+                                withDismissAction = true,
+                                testTag = VIDEO_CAPTURE_SUCCESS_TAG
                             )
                         }
 
-                        CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> {
+                        is CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> {
+                            Log.d(TAG, "cameraUseCase.startRecording OnVideoRecordError")
+                            onVideoCapture(VideoCaptureEvent.VideoCaptureError(it.error))
                             snackbarToShow = SnackbarData(
                                 cookie = cookie,
                                 stringResource = R.string.toast_video_capture_failure,
-                                withDismissAction = true
+                                withDismissAction = true,
+                                testTag = VIDEO_CAPTURE_FAILURE_TAG
                             )
                         }
 
@@ -517,6 +709,12 @@
         }
     }
 
+    fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {
+        viewModelScope.launch {
+            cameraUseCase.setConcurrentCameraMode(concurrentCameraMode)
+        }
+    }
+
     fun setLowLightBoost(lowLightBoost: LowLightBoost) {
         viewModelScope.launch {
             cameraUseCase.setLowLightBoost(lowLightBoost)
@@ -585,7 +783,7 @@
 
     @AssistedFactory
     interface Factory {
-        fun create(previewMode: PreviewMode): PreviewViewModel
+        fun create(previewMode: PreviewMode, isDebugMode: Boolean): PreviewViewModel
     }
 
     sealed interface ImageCaptureEvent {
@@ -597,4 +795,14 @@
             val exception: Exception
         ) : ImageCaptureEvent
     }
+
+    sealed interface VideoCaptureEvent {
+        data class VideoSaved(
+            val savedUri: Uri
+        ) : VideoCaptureEvent
+
+        data class VideoCaptureError(
+            val error: Throwable?
+        ) : VideoCaptureEvent
+    }
 }
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt
index 7cd1a4f..7bd075a 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt
@@ -45,7 +45,7 @@
 
     init {
         scope.launch {
-            cameraUseCase.getScreenFlashEvents().collect { event ->
+            for (event in cameraUseCase.getScreenFlashEvents()) {
                 _screenFlashUiState.emit(
                     when (event.type) {
                         CameraUseCase.ScreenFlashEvent.Type.APPLY_UI ->
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt
index db13cd2..2ee1e78 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt
@@ -26,6 +26,7 @@
 import androidx.compose.material.icons.filled.HdrOff
 import androidx.compose.material.icons.filled.HdrOn
 import androidx.compose.material.icons.filled.Nightlight
+import androidx.compose.material.icons.filled.PictureInPicture
 import androidx.compose.material.icons.outlined.Nightlight
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.graphics.painter.Painter
@@ -167,3 +168,20 @@
             R.string.quick_settings_lowlightboost_disabled_description
     }
 }
+
+enum class CameraConcurrentCameraMode : QuickSettingsEnum {
+    OFF {
+        override fun getDrawableResId() = R.drawable.picture_in_picture_off_icon
+        override fun getImageVector() = null
+        override fun getTextResId() = R.string.quick_settings_concurrent_camera_off
+        override fun getDescriptionResId() =
+            R.string.quick_settings_concurrent_camera_off_description
+    },
+    DUAL {
+        override fun getDrawableResId() = null
+        override fun getImageVector() = Icons.Filled.PictureInPicture
+        override fun getTextResId() = R.string.quick_settings_concurrent_camera_dual
+        override fun getDescriptionResId() =
+            R.string.quick_settings_concurrent_camera_dual_description
+    }
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt
index 59e97bc..7dbb474 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt
@@ -44,27 +44,28 @@
 import com.google.jetpackcamera.feature.preview.R
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.ExpandedQuickSetRatio
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CAPTURE_MODE_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLASH_BUTTON
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_HDR_BUTTON
-import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_LOW_LIGHT_BOOST_BUTTON
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_BUTTON
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickFlipCamera
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetCaptureMode
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetConcurrentCamera
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetFlash
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetHdr
-import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetLowLightBoost
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetRatio
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSettingsGrid
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CameraAppSettings
+import com.google.jetpackcamera.settings.model.CameraConstraints
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.ImageOutputFormat
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.LowLightBoost
-import com.google.jetpackcamera.settings.model.SystemConstraints
 import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
 import com.google.jetpackcamera.settings.model.forCurrentLens
 
@@ -75,7 +76,6 @@
 fun QuickSettingsScreenOverlay(
     previewUiState: PreviewUiState.Ready,
     currentCameraSettings: CameraAppSettings,
-    systemConstraints: SystemConstraints,
     toggleIsOpen: () -> Unit,
     onLensFaceClick: (lensFace: LensFacing) -> Unit,
     onFlashModeClick: (flashMode: FlashMode) -> Unit,
@@ -83,6 +83,7 @@
     onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
     onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
     onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+    onConcurrentCameraModeClick: (concurrentCameraMode: ConcurrentCameraMode) -> Unit,
     onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit,
     modifier: Modifier = Modifier,
     isOpen: Boolean = false
@@ -125,7 +126,6 @@
             ExpandedQuickSettingsUi(
                 previewUiState = previewUiState,
                 currentCameraSettings = currentCameraSettings,
-                systemConstraints = systemConstraints,
                 shouldShowQuickSetting = shouldShowQuickSetting,
                 setVisibleQuickSetting = { enum: IsExpandedQuickSetting ->
                     shouldShowQuickSetting = enum
@@ -136,6 +136,7 @@
                 onCaptureModeClick = onCaptureModeClick,
                 onDynamicRangeClick = onDynamicRangeClick,
                 onImageOutputFormatClick = onImageOutputFormatClick,
+                onConcurrentCameraModeClick = onConcurrentCameraModeClick,
                 onLowLightBoostClick = onLowLightBoostClick
             )
         }
@@ -157,7 +158,6 @@
 private fun ExpandedQuickSettingsUi(
     previewUiState: PreviewUiState.Ready,
     currentCameraSettings: CameraAppSettings,
-    systemConstraints: SystemConstraints,
     onLensFaceClick: (newLensFace: LensFacing) -> Unit,
     onFlashModeClick: (flashMode: FlashMode) -> Unit,
     onAspectRatioClick: (aspectRation: AspectRatio) -> Unit,
@@ -166,6 +166,7 @@
     setVisibleQuickSetting: (IsExpandedQuickSetting) -> Unit,
     onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
     onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+    onConcurrentCameraModeClick: (concurrentCameraMode: ConcurrentCameraMode) -> Unit,
     onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit
 ) {
     Column(
@@ -216,7 +217,9 @@
                             QuickSetCaptureMode(
                                 modifier = Modifier.testTag(QUICK_SETTINGS_CAPTURE_MODE_BUTTON),
                                 setCaptureMode = { c: CaptureMode -> onCaptureModeClick(c) },
-                                currentCaptureMode = currentCameraSettings.captureMode
+                                currentCaptureMode = currentCameraSettings.captureMode,
+                                enabled = currentCameraSettings.concurrentCameraMode ==
+                                    ConcurrentCameraMode.OFF
                             )
                         }
 
@@ -224,6 +227,24 @@
                             currentCameraSettings
                         )
                         add {
+                            fun CameraConstraints.hdrDynamicRangeSupported(): Boolean =
+                                this.supportedDynamicRanges.size > 1
+
+                            fun CameraConstraints.hdrImageFormatSupported(): Boolean =
+                                supportedImageFormatsMap[currentCameraSettings.captureMode]
+                                    ?.let { it.size > 1 } ?: false
+
+                            // TODO(tm): Move this to PreviewUiState
+                            fun shouldEnable(): Boolean = when {
+                                currentCameraSettings.concurrentCameraMode !=
+                                    ConcurrentCameraMode.OFF -> false
+                                else -> (
+                                    cameraConstraints?.hdrDynamicRangeSupported() == true &&
+                                        previewUiState.previewMode is PreviewMode.StandardMode
+                                    ) ||
+                                    cameraConstraints?.hdrImageFormatSupported() == true
+                            }
+
                             QuickSetHdr(
                                 modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON),
                                 onClick = { d: DynamicRange, i: ImageOutputFormat ->
@@ -234,24 +255,26 @@
                                 selectedImageOutputFormat = currentCameraSettings.imageFormat,
                                 hdrDynamicRange = currentCameraSettings.defaultHdrDynamicRange,
                                 hdrImageFormat = currentCameraSettings.defaultHdrImageOutputFormat,
-                                hdrDynamicRangeSupported = cameraConstraints?.let
-                                    { it.supportedDynamicRanges.size > 1 } ?: false,
-                                hdrImageFormatSupported =
-                                cameraConstraints?.supportedImageFormatsMap?.get(
-                                    currentCameraSettings.captureMode
-                                )?.let { it.size > 1 } ?: false,
-                                previewMode = previewUiState.previewMode
+                                hdrDynamicRangeSupported =
+                                cameraConstraints?.hdrDynamicRangeSupported() ?: false,
+                                previewMode = previewUiState.previewMode,
+                                enabled = shouldEnable()
                             )
                         }
 
                         add {
-                            QuickSetLowLightBoost(
-                                modifier = Modifier.testTag(QUICK_SETTINGS_LOW_LIGHT_BOOST_BUTTON),
-                                onClick = {
-                                        l: LowLightBoost ->
-                                    onLowLightBoostClick(l)
+                            QuickSetConcurrentCamera(
+                                modifier =
+                                Modifier.testTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON),
+                                setConcurrentCameraMode = { c: ConcurrentCameraMode ->
+                                    onConcurrentCameraModeClick(c)
                                 },
-                                selectedLowLightBoost = currentCameraSettings.lowLightBoost
+                                currentConcurrentCameraMode =
+                                currentCameraSettings.concurrentCameraMode,
+                                enabled =
+                                previewUiState.systemConstraints.concurrentCamerasSupported &&
+                                    previewUiState.previewMode
+                                        !is PreviewMode.ExternalImageCaptureMode
                             )
                         }
                     }
@@ -280,7 +303,6 @@
                 captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             currentCameraSettings = CameraAppSettings(),
-            systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
             onLensFaceClick = { },
             onFlashModeClick = { },
             shouldShowQuickSetting = IsExpandedQuickSetting.NONE,
@@ -289,6 +311,7 @@
             onCaptureModeClick = { },
             onDynamicRangeClick = { },
             onImageOutputFormatClick = { },
+            onConcurrentCameraModeClick = { },
             onLowLightBoostClick = { }
         )
     }
@@ -306,7 +329,6 @@
                 captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             currentCameraSettings = CameraAppSettings(dynamicRange = DynamicRange.HLG10),
-            systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS_WITH_HDR,
             onLensFaceClick = { },
             onFlashModeClick = { },
             shouldShowQuickSetting = IsExpandedQuickSetting.NONE,
@@ -315,6 +337,7 @@
             onCaptureModeClick = { },
             onDynamicRangeClick = { },
             onImageOutputFormatClick = { },
+            onConcurrentCameraModeClick = { },
             onLowLightBoostClick = { }
         )
     }
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
index 0e6b85f..66e2bc7 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
@@ -49,6 +49,7 @@
 import com.google.jetpackcamera.feature.preview.R
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraAspectRatio
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraCaptureMode
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraConcurrentCameraMode
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraDynamicRange
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraFlashMode
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraLensFace
@@ -56,6 +57,7 @@
 import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsEnum
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.ConcurrentCameraMode
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.ImageOutputFormat
@@ -113,8 +115,8 @@
     hdrDynamicRange: DynamicRange,
     hdrImageFormat: ImageOutputFormat,
     hdrDynamicRangeSupported: Boolean,
-    hdrImageFormatSupported: Boolean,
-    previewMode: PreviewMode
+    previewMode: PreviewMode,
+    enabled: Boolean
 ) {
     val enum =
         if (selectedDynamicRange == hdrDynamicRange ||
@@ -146,8 +148,7 @@
             onClick(newDynamicRange, newImageOutputFormat)
         },
         isHighLighted = (selectedDynamicRange != DynamicRange.SDR),
-        enabled = (hdrDynamicRangeSupported && previewMode is PreviewMode.StandardMode) ||
-            hdrImageFormatSupported
+        enabled = enabled
     )
 }
 
@@ -250,7 +251,8 @@
 fun QuickSetCaptureMode(
     setCaptureMode: (CaptureMode) -> Unit,
     currentCaptureMode: CaptureMode,
-    modifier: Modifier = Modifier
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true
 ) {
     val enum: CameraCaptureMode =
         when (currentCaptureMode) {
@@ -265,7 +267,33 @@
                 CaptureMode.MULTI_STREAM -> setCaptureMode(CaptureMode.SINGLE_STREAM)
                 CaptureMode.SINGLE_STREAM -> setCaptureMode(CaptureMode.MULTI_STREAM)
             }
+        },
+        enabled = enabled
+    )
+}
+
+@Composable
+fun QuickSetConcurrentCamera(
+    setConcurrentCameraMode: (ConcurrentCameraMode) -> Unit,
+    currentConcurrentCameraMode: ConcurrentCameraMode,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true
+) {
+    val enum: CameraConcurrentCameraMode =
+        when (currentConcurrentCameraMode) {
+            ConcurrentCameraMode.OFF -> CameraConcurrentCameraMode.OFF
+            ConcurrentCameraMode.DUAL -> CameraConcurrentCameraMode.DUAL
         }
+    QuickSettingUiItem(
+        modifier = modifier,
+        enum = enum,
+        onClick = {
+            when (currentConcurrentCameraMode) {
+                ConcurrentCameraMode.OFF -> setConcurrentCameraMode(ConcurrentCameraMode.DUAL)
+                ConcurrentCameraMode.DUAL -> setConcurrentCameraMode(ConcurrentCameraMode.OFF)
+            }
+        },
+        enabled = enabled
     )
 }
 
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt
index 7334407..5a226e6 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt
@@ -16,6 +16,7 @@
 package com.google.jetpackcamera.feature.preview.quicksettings.ui
 
 const val QUICK_SETTINGS_CAPTURE_MODE_BUTTON = "QuickSettingsCaptureModeButton"
+const val QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON = "QuickSettingsConcurrentCameraModeButton"
 const val QUICK_SETTINGS_DROP_DOWN = "QuickSettingsDropDown"
 const val QUICK_SETTINGS_HDR_BUTTON = "QuickSettingsHdrButton"
 const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSettingsFlashButton"
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt
index a8c8a3b..9563db9 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt
@@ -33,6 +33,7 @@
 import androidx.compose.material.icons.outlined.CameraAlt
 import androidx.compose.material.icons.outlined.Videocam
 import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.LaunchedEffect
@@ -48,6 +49,7 @@
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import androidx.core.util.Preconditions
 import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState
 import com.google.jetpackcamera.feature.preview.MultipleEventsCutter
@@ -96,7 +98,11 @@
         Boolean,
         (PreviewViewModel.ImageCaptureEvent) -> Unit
     ) -> Unit = { _, _, _, _ -> },
-    onStartVideoRecording: () -> Unit = {},
+    onStartVideoRecording: (
+        Uri?,
+        Boolean,
+        (PreviewViewModel.VideoCaptureEvent) -> Unit
+    ) -> Unit = { _, _, _ -> },
     onStopVideoRecording: () -> Unit = {}
 ) {
     // Show the current zoom level for a short period of time, only when the level changes.
@@ -131,6 +137,8 @@
                 previewUiState = previewUiState,
                 audioAmplitude = previewUiState.audioAmplitude,
                 zoomLevel = previewUiState.zoomScale,
+                physicalCameraId = previewUiState.currentPhysicalCameraId,
+                logicalCameraId = previewUiState.currentLogicalCameraId,
                 showZoomLevel = zoomLevelDisplayState.showZoomLevel,
                 isQuickSettingsOpen = previewUiState.quickSettingsIsOpen,
                 currentCameraSettings = previewUiState.currentCameraSettings,
@@ -200,6 +208,8 @@
     modifier: Modifier = Modifier,
     audioAmplitude: Double,
     previewUiState: PreviewUiState.Ready,
+    physicalCameraId: String? = null,
+    logicalCameraId: String? = null,
     zoomLevel: Float,
     showZoomLevel: Boolean,
     isQuickSettingsOpen: Boolean,
@@ -218,12 +228,25 @@
     onToggleAudioMuted: () -> Unit = {},
     onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
     onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
-    onStartVideoRecording: () -> Unit = {},
+    onStartVideoRecording: (
+        Uri?,
+        Boolean,
+        (PreviewViewModel.VideoCaptureEvent) -> Unit
+    ) -> Unit = { _, _, _ -> },
     onStopVideoRecording: () -> Unit = {}
 ) {
     Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
-        if (showZoomLevel) {
-            ZoomScaleText(zoomLevel)
+        CompositionLocalProvider(
+            LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 20.sp)
+        ) {
+            Column(horizontalAlignment = Alignment.CenterHorizontally) {
+                if (showZoomLevel) {
+                    ZoomScaleText(zoomLevel)
+                }
+                if (previewUiState.isDebugMode) {
+                    CurrentCameraIdText(physicalCameraId, logicalCameraId)
+                }
+            }
         }
 
         Row(
@@ -292,7 +315,11 @@
         (PreviewViewModel.ImageCaptureEvent) -> Unit
     ) -> Unit = { _, _, _, _ -> },
     onToggleQuickSettings: () -> Unit = {},
-    onStartVideoRecording: () -> Unit = {},
+    onStartVideoRecording: (
+        Uri?,
+        Boolean,
+        (PreviewViewModel.VideoCaptureEvent) -> Unit
+    ) -> Unit = { _, _, _ -> },
     onStopVideoRecording: () -> Unit = {}
 ) {
     val multipleEventsCutter = remember { MultipleEventsCutter() }
@@ -319,6 +346,14 @@
                             previewUiState.previewMode.onImageCapture
                         )
                     }
+
+                    else -> {
+                        onCaptureImageWithUri(
+                            context.contentResolver,
+                            null,
+                            false
+                        ) {}
+                    }
                 }
             }
             if (isQuickSettingsOpen) {
@@ -326,12 +361,30 @@
             }
         },
         onLongPress = {
-            onStartVideoRecording()
+            when (previewUiState.previewMode) {
+                is PreviewMode.StandardMode -> {
+                    onStartVideoRecording(null, false) {}
+                }
+
+                is PreviewMode.ExternalVideoCaptureMode -> {
+                    onStartVideoRecording(
+                        previewUiState.previewMode.videoCaptureUri,
+                        true,
+                        previewUiState.previewMode.onVideoCapture
+                    )
+                }
+
+                else -> {
+                    onStartVideoRecording(null, false) {}
+                }
+            }
             if (isQuickSettingsOpen) {
                 onToggleQuickSettings()
             }
         },
-        onRelease = { onStopVideoRecording() },
+        onRelease = {
+            onStopVideoRecording()
+        },
         videoRecordingState = videoRecordingState
     )
 }
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
index 4995e97..25a0f28 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
@@ -35,6 +35,7 @@
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxHeight
@@ -89,7 +90,6 @@
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
 import com.google.jetpackcamera.feature.preview.PreviewUiState
 import com.google.jetpackcamera.feature.preview.R
 import com.google.jetpackcamera.feature.preview.VideoRecordingState
@@ -422,20 +422,41 @@
 }
 
 @Composable
-fun ZoomScaleText(zoomScale: Float, modifier: Modifier = Modifier) {
+fun ZoomScaleText(zoomScale: Float) {
     val contentAlpha = animateFloatAsState(
         targetValue = 10f,
         label = "zoomScaleAlphaAnimation",
         animationSpec = tween()
     )
     Text(
-        modifier = Modifier.alpha(contentAlpha.value),
-        text = "%.1fx".format(zoomScale),
-        fontSize = 20.sp
+        modifier = Modifier
+            .alpha(contentAlpha.value)
+            .testTag(ZOOM_RATIO_TAG),
+        text = stringResource(id = R.string.zoom_scale_text, zoomScale)
     )
 }
 
 @Composable
+fun CurrentCameraIdText(physicalCameraId: String?, logicalCameraId: String?) {
+    Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        Row {
+            Text(text = stringResource(R.string.debug_text_logical_camera_id_prefix))
+            Text(
+                modifier = Modifier.testTag(LOGICAL_CAMERA_ID_TAG),
+                text = logicalCameraId ?: "---"
+            )
+        }
+        Row {
+            Text(text = stringResource(R.string.debug_text_physical_camera_id_prefix))
+            Text(
+                modifier = Modifier.testTag(PHYSICAL_CAMERA_ID_TAG),
+                text = physicalCameraId ?: "---"
+            )
+        }
+    }
+}
+
+@Composable
 fun CaptureButton(
     onClick: () -> Unit,
     onLongPress: () -> Unit,
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt
index 5391125..077a971 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt
@@ -19,7 +19,12 @@
 const val FLIP_CAMERA_BUTTON = "FlipCameraButton"
 const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag"
 const val IMAGE_CAPTURE_FAILURE_TAG = "ImageCaptureFailureTag"
-const val VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "ImageCaptureExternalUnsupportedTag"
+const val IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "ImageCaptureExternalUnsupportedTag"
+const val IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG =
+    "ImageCaptureUnsupportedConcurrentCameraTag"
+const val VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "VideoCaptureExternalUnsupportedTag"
+const val VIDEO_CAPTURE_SUCCESS_TAG = "VideoCaptureSuccessTag"
+const val VIDEO_CAPTURE_FAILURE_TAG = "VideoCaptureFailureTag"
 const val PREVIEW_DISPLAY = "PreviewDisplay"
 const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay"
 const val SETTINGS_BUTTON = "SettingsButton"
@@ -31,3 +36,6 @@
 const val HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG = "HdrImageUnsupportedOnMultiStreamTag"
 const val HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG = "HdrVideoUnsupportedOnDeviceTag"
 const val HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG = "HdrVideoUnsupportedOnDeviceTag"
+const val ZOOM_RATIO_TAG = "ZoomRatioTag"
+const val LOGICAL_CAMERA_ID_TAG = "LogicalCameraIdTag"
+const val PHYSICAL_CAMERA_ID_TAG = "PhysicalCameraIdTag"
diff --git a/feature/preview/src/main/res/drawable/picture_in_picture_off_icon.xml b/feature/preview/src/main/res/drawable/picture_in_picture_off_icon.xml
new file mode 100644
index 0000000..3c394b1
--- /dev/null
+++ b/feature/preview/src/main/res/drawable/picture_in_picture_off_icon.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2024 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.
+  -->
+<vector android:height="72dp" android:tint="#000000"
+    android:viewportHeight="960" android:viewportWidth="960"
+    android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M700,520Q725,520 742.5,502.5Q760,485 760,460L760,340Q760,315 742.5,297.5Q725,280 700,280L480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360L680,360L680,360L680,440L640,440Q623,440 611.5,451.5Q600,463 600,480Q600,497 612,508.5Q624,520 641,520L700,520ZM840,720Q825,720 812.5,709.5Q800,699 800,679L800,240Q800,240 800,240Q800,240 800,240L361,240Q341,240 331,227.5Q321,215 321,200Q321,185 331,172.5Q341,160 361,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,680Q880,700 867.5,710Q855,720 840,720ZM577,463L577,463L577,463L577,463Q577,463 577,463Q577,463 577,463ZM383,497L383,497Q383,497 383,497Q383,497 383,497L383,497Q383,497 383,497Q383,497 383,497L383,497ZM790,903L686,800L160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L160,160L240,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L606,720L54,168Q42,156 42,139.5Q42,123 54,111Q66,99 82.5,99Q99,99 111,111L847,847Q859,859 859,875Q859,891 847,903Q835,915 818.5,915Q802,915 790,903Z"/>
+</vector>
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index fc81069..77d80e0 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -20,6 +20,10 @@
     <string name="flip_camera_content_description">Flip Camera</string>
 
     <string name="audio_visualizer_icon">An icon of a microphone</string>
+    <string name="zoom_scale_text">%1$.2fx</string>
+
+    <string name="debug_text_physical_camera_id_prefix">Physical ID: </string>
+    <string name="debug_text_logical_camera_id_prefix">Logical ID: </string>
 
     <string name="toast_image_capture_success">Image Capture Success</string>
     <string name="toast_video_capture_success">Video Capture Success</string>
@@ -27,6 +31,8 @@
     <string name="toast_capture_failure">Image Capture Failure</string>
     <string name="toast_video_capture_failure">Video Capture Failure</string>
     <string name="toast_video_capture_external_unsupported">Video not supported while app is in image-only capture mode</string>
+    <string name="toast_image_capture_external_unsupported">Image capture not supported while app is in video-only capture mode</string>
+    <string name="toast_image_capture_unsupported_concurrent_camera">Image capture not supported in dual camera mode</string>
     <string name="stabilization_icon_description_preview_and_video">Preview is Stabilized</string>
     <string name="stabilization_icon_description_video_only">Only Video is Stabilized</string>
     <string name="toast_hdr_photo_unsupported_on_device">Ultra HDR photos not supported on this device</string>
@@ -74,4 +80,9 @@
     <string name="quick_settings_lowlightboost_disabled">Low light boost off</string>
     <string name="quick_settings_lowlightboost_enabled_description">Low light boost on</string>
     <string name="quick_settings_lowlightboost_disabled_description">Low light boost off</string>
+
+    <string name="quick_settings_concurrent_camera_off">SINGLE</string>
+    <string name="quick_settings_concurrent_camera_dual">DUAL</string>
+    <string name="quick_settings_concurrent_camera_off_description">Concurrent cameras off</string>
+    <string name="quick_settings_concurrent_camera_dual_description">Concurrent dual camera on</string>
 </resources>
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
index ee4bc9e..2d40334 100644
--- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
@@ -22,6 +22,7 @@
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
+import com.google.jetpackcamera.settings.test.FakeSettingsRepository
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.StandardTestDispatcher
@@ -47,8 +48,10 @@
         Dispatchers.setMain(StandardTestDispatcher())
         previewViewModel = PreviewViewModel(
             PreviewMode.StandardMode {},
-            cameraUseCase,
-            constraintsRepository
+            false,
+            cameraUseCase = cameraUseCase,
+            constraintsRepository = constraintsRepository,
+            settingsRepository = FakeSettingsRepository
         )
         advanceUntilIdle()
     }
@@ -87,7 +90,7 @@
     @Test
     fun startVideoRecording() = runTest(StandardTestDispatcher()) {
         previewViewModel.startCameraUntilRunning()
-        previewViewModel.startVideoRecording()
+        previewViewModel.startVideoRecording(null, false) {}
         advanceUntilIdle()
         assertThat(cameraUseCase.recordingInProgress).isTrue()
     }
@@ -95,7 +98,7 @@
     @Test
     fun stopVideoRecording() = runTest(StandardTestDispatcher()) {
         previewViewModel.startCameraUntilRunning()
-        previewViewModel.startVideoRecording()
+        previewViewModel.startVideoRecording(null, false) {}
         advanceUntilIdle()
         previewViewModel.stopVideoRecording()
         assertThat(cameraUseCase.recordingInProgress).isFalse()
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt
index 9dd08cc..536e90e 100644
--- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt
@@ -20,6 +20,7 @@
 import com.google.jetpackcamera.core.camera.CameraUseCase
 import com.google.jetpackcamera.core.camera.test.FakeCameraUseCase
 import com.google.jetpackcamera.feature.preview.rules.MainDispatcherRule
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -43,7 +44,7 @@
     @get:Rule
     val mainDispatcherRule = MainDispatcherRule(testDispatcher)
 
-    private val cameraUseCase = FakeCameraUseCase(testScope)
+    private val cameraUseCase = FakeCameraUseCase()
     private lateinit var screenFlash: ScreenFlash
 
     @Before
@@ -109,7 +110,10 @@
 
     private fun runCameraTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {
         backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
-            cameraUseCase.initialize(false)
+            cameraUseCase.initialize(
+                DEFAULT_CAMERA_APP_SETTINGS,
+                CameraUseCase.UseCaseMode.STANDARD
+            )
             cameraUseCase.runCamera()
         }
 
diff --git a/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt b/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
index ae1a0b0..dbbc72b 100644
--- a/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
+++ b/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
@@ -22,7 +22,6 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.platform.app.InstrumentationRegistry
 import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
 import com.google.jetpackcamera.settings.model.DarkMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
@@ -85,10 +84,7 @@
         }
 
         assertThat(uiState).isEqualTo(
-            SettingsUiState.Enabled(
-                cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
-                systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS
-            )
+            TYPICAL_SETTINGS_UISTATE
         )
     }
 
@@ -99,8 +95,8 @@
             it is SettingsUiState.Enabled
         }
 
-        val initialCameraLensFacing = assertIsEnabled(initialState)
-            .cameraAppSettings.cameraLensFacing
+        val initialCameraLensFacing =
+            assertIsEnabled(initialState).lensFlipUiState.currentLensFacing
         val nextCameraLensFacing = if (initialCameraLensFacing == LensFacing.FRONT) {
             LensFacing.BACK
         } else {
@@ -111,7 +107,7 @@
         advanceUntilIdle()
 
         assertIsEnabled(settingsViewModel.settingsUiState.value).also {
-            assertThat(it.cameraAppSettings.cameraLensFacing).isEqualTo(nextCameraLensFacing)
+            assertThat(it.lensFlipUiState.currentLensFacing).isEqualTo(nextCameraLensFacing)
         }
     }
 
@@ -122,14 +118,20 @@
             it is SettingsUiState.Enabled
         }
 
-        val initialDarkMode = assertIsEnabled(initialState).cameraAppSettings.darkMode
+        val initialDarkMode =
+            (assertIsEnabled(initialState).darkModeUiState as DarkModeUiState.Enabled)
+                .currentDarkMode
 
         settingsViewModel.setDarkMode(DarkMode.DARK)
 
         advanceUntilIdle()
 
-        val newDarkMode = assertIsEnabled(settingsViewModel.settingsUiState.value)
-            .cameraAppSettings.darkMode
+        val newDarkMode =
+            (
+                assertIsEnabled(settingsViewModel.settingsUiState.value)
+                    .darkModeUiState as DarkModeUiState.Enabled
+                )
+                .currentDarkMode
 
         assertEquals(initialDarkMode, DarkMode.SYSTEM)
         assertEquals(DarkMode.DARK, newDarkMode)
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt
index d5ebc0e..a3ab00e 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt
@@ -30,12 +30,10 @@
 import androidx.hilt.navigation.compose.hiltViewModel
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CaptureMode
-import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
 import com.google.jetpackcamera.settings.model.DarkMode
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.Stabilization
-import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
 import com.google.jetpackcamera.settings.ui.AspectRatioSetting
 import com.google.jetpackcamera.settings.ui.CaptureModeSetting
 import com.google.jetpackcamera.settings.ui.DarkModeSetting
@@ -130,47 +128,32 @@
     SectionHeader(title = stringResource(id = R.string.section_title_camera_settings))
 
     DefaultCameraFacing(
-        settingValue = (uiState.cameraAppSettings.cameraLensFacing == LensFacing.FRONT),
-        enabled = with(uiState.systemConstraints.availableLenses) {
-            size > 1 && contains(LensFacing.FRONT)
-        },
+        lensUiState = uiState.lensFlipUiState,
         setDefaultLensFacing = setDefaultLensFacing
     )
 
     FlashModeSetting(
-        currentFlashMode = uiState.cameraAppSettings.flashMode,
+        flashUiState = uiState.flashUiState,
         setFlashMode = setFlashMode
     )
 
     TargetFpsSetting(
-        currentTargetFps = uiState.cameraAppSettings.targetFrameRate,
-        supportedFps = uiState.systemConstraints.perLensConstraints.values.fold(emptySet()) {
-                union, constraints ->
-            union + constraints.supportedFixedFrameRates
-        },
+        fpsUiState = uiState.fpsUiState,
         setTargetFps = setTargetFrameRate
     )
 
     AspectRatioSetting(
-        currentAspectRatio = uiState.cameraAppSettings.aspectRatio,
+        aspectRatioUiState = uiState.aspectRatioUiState,
         setAspectRatio = setAspectRatio
     )
 
     CaptureModeSetting(
-        currentCaptureMode = uiState.cameraAppSettings.captureMode,
+        captureModeUiState = uiState.captureModeUiState,
         setCaptureMode = setCaptureMode
     )
 
     StabilizationSetting(
-        currentVideoStabilization = uiState.cameraAppSettings.videoCaptureStabilization,
-        currentPreviewStabilization = uiState.cameraAppSettings.previewStabilization,
-        currentTargetFps = uiState.cameraAppSettings.targetFrameRate,
-        supportedStabilizationMode = uiState.systemConstraints.perLensConstraints.values.fold(
-            emptySet()
-        ) {
-                union, constraints ->
-            union + constraints.supportedStabilizationModes
-        },
+        stabilizationUiState = uiState.stabilizationUiState,
         setVideoStabilization = setVideoStabilization,
         setPreviewStabilization = setPreviewStabilization
     )
@@ -178,7 +161,7 @@
     SectionHeader(title = stringResource(id = R.string.section_title_app_settings))
 
     DarkModeSetting(
-        currentDarkMode = uiState.cameraAppSettings.darkMode,
+        darkModeUiState = uiState.darkModeUiState,
         setDarkMode = setDarkMode
     )
 
@@ -190,6 +173,8 @@
     )
 }
 
+// will allow you to open stabilization popup or give disabled rationale
+
 data class VersionInfoHolder(
     val versionName: String,
     val buildType: String
@@ -201,10 +186,7 @@
 private fun Preview_SettingsScreen() {
     SettingsPreviewTheme {
         SettingsScreen(
-            uiState = SettingsUiState.Enabled(
-                DEFAULT_CAMERA_APP_SETTINGS,
-                TYPICAL_SYSTEM_CONSTRAINTS
-            ),
+            uiState = TYPICAL_SETTINGS_UISTATE,
             versionInfo = VersionInfoHolder(
                 versionName = "1.0.0",
                 buildType = "release"
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
index 13cf5f0..7f882c3 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
@@ -15,16 +15,199 @@
  */
 package com.google.jetpackcamera.settings
 
-import com.google.jetpackcamera.settings.model.CameraAppSettings
-import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.DisabledRationale.DeviceUnsupportedRationale
+import com.google.jetpackcamera.settings.DisabledRationale.LensUnsupportedRationale
+import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
+import com.google.jetpackcamera.settings.model.DarkMode
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.ui.DEVICE_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ui.FPS_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ui.LENS_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ui.STABILIZATION_UNSUPPORTED_TAG
 
 /**
  * Defines the current state of the [SettingsScreen].
  */
 sealed interface SettingsUiState {
-    object Disabled : SettingsUiState
+    data object Disabled : SettingsUiState
     data class Enabled(
-        val cameraAppSettings: CameraAppSettings,
-        val systemConstraints: SystemConstraints
+        val aspectRatioUiState: AspectRatioUiState,
+        val captureModeUiState: CaptureModeUiState,
+        val darkModeUiState: DarkModeUiState,
+        val flashUiState: FlashUiState,
+        val fpsUiState: FpsUiState,
+        val lensFlipUiState: FlipLensUiState,
+        val stabilizationUiState: StabilizationUiState
     ) : SettingsUiState
 }
+
+/** State for the individual options on Popup dialog settings */
+sealed interface SingleSelectableState {
+    data object Selectable : SingleSelectableState
+    data class Disabled(val disabledRationale: DisabledRationale) : SingleSelectableState
+}
+
+/** Contains information on why a setting is disabled */
+// TODO(b/360921588): Display information on UI regarding disabled rationale
+sealed interface DisabledRationale {
+    val affectedSettingNameResId: Int
+    val reasonTextResId: Int
+    val testTag: String
+
+    /**
+     * Text will be [affectedSettingNameResId] is [R.string.device_unsupported]
+     */
+    data class DeviceUnsupportedRationale(override val affectedSettingNameResId: Int) :
+        DisabledRationale {
+        override val reasonTextResId: Int = R.string.device_unsupported
+        override val testTag = DEVICE_UNSUPPORTED_TAG
+    }
+
+    data class FpsUnsupportedRationale(
+        override val affectedSettingNameResId: Int,
+        val currentFps: Int
+    ) : DisabledRationale {
+        override val reasonTextResId: Int = R.string.fps_unsupported
+        override val testTag = FPS_UNSUPPORTED_TAG
+    }
+
+    data class StabilizationUnsupportedRationale(override val affectedSettingNameResId: Int) :
+        DisabledRationale {
+        override val reasonTextResId = R.string.stabilization_unsupported
+        override val testTag = STABILIZATION_UNSUPPORTED_TAG
+    }
+
+    sealed interface LensUnsupportedRationale : DisabledRationale {
+        data class FrontLensUnsupportedRationale(override val affectedSettingNameResId: Int) :
+            LensUnsupportedRationale {
+            override val reasonTextResId: Int = R.string.front_lens_unsupported
+            override val testTag = LENS_UNSUPPORTED_TAG
+        }
+
+        data class RearLensUnsupportedRationale(override val affectedSettingNameResId: Int) :
+            LensUnsupportedRationale {
+            override val reasonTextResId: Int = R.string.rear_lens_unsupported
+            override val testTag = LENS_UNSUPPORTED_TAG
+        }
+    }
+}
+
+fun getLensUnsupportedRationale(
+    lensFacing: LensFacing,
+    affectedSettingNameResId: Int
+): LensUnsupportedRationale {
+    return when (lensFacing) {
+        LensFacing.BACK -> LensUnsupportedRationale.RearLensUnsupportedRationale(
+            affectedSettingNameResId
+        )
+
+        LensFacing.FRONT -> LensUnsupportedRationale.FrontLensUnsupportedRationale(
+            affectedSettingNameResId
+        )
+    }
+}
+
+/* Settings that currently have constraints **/
+
+sealed interface FpsUiState {
+    data class Enabled(
+        val currentSelection: Int,
+        val fpsAutoState: SingleSelectableState,
+        val fpsFifteenState: SingleSelectableState,
+        val fpsThirtyState: SingleSelectableState,
+        val fpsSixtyState: SingleSelectableState,
+        // Contains text like "Selected FPS only supported by rear lens"
+        val additionalContext: String = ""
+    ) : FpsUiState
+
+    // FPS selection completely disabled. Cannot open dialog.
+    data class Disabled(val disabledRationale: DisabledRationale) : FpsUiState
+}
+
+sealed interface FlipLensUiState {
+    val currentLensFacing: LensFacing
+
+    data class Enabled(
+        override val currentLensFacing: LensFacing
+    ) : FlipLensUiState
+
+    data class Disabled(
+        override val currentLensFacing: LensFacing,
+        val disabledRationale: DisabledRationale
+    ) : FlipLensUiState
+}
+
+sealed interface StabilizationUiState {
+    data class Enabled(
+        val currentPreviewStabilization: Stabilization,
+        val currentVideoStabilization: Stabilization,
+        val stabilizationOnState: SingleSelectableState,
+        val stabilizationHighQualityState: SingleSelectableState,
+        // Contains text like "Selected stabilization mode only supported by rear lens"
+        val additionalContext: String = ""
+    ) : StabilizationUiState
+
+    // Stabilization selection completely disabled. Cannot open dialog.
+    data class Disabled(val disabledRationale: DisabledRationale) : StabilizationUiState
+}
+
+/* Settings that don't currently depend on constraints */
+
+// this could be constrained w/ a check to see if a torch is available?
+sealed interface FlashUiState {
+    data class Enabled(
+        val currentFlashMode: FlashMode,
+        val additionalContext: String = ""
+    ) : FlashUiState
+}
+
+sealed interface AspectRatioUiState {
+    data class Enabled(
+        val currentAspectRatio: AspectRatio,
+        val additionalContext: String = ""
+    ) : AspectRatioUiState
+}
+
+sealed interface CaptureModeUiState {
+    data class Enabled(
+        val currentCaptureMode: CaptureMode,
+        val additionalContext: String = ""
+    ) : CaptureModeUiState
+}
+
+sealed interface DarkModeUiState {
+    data class Enabled(
+        val currentDarkMode: DarkMode,
+        val additionalContext: String = ""
+    ) : DarkModeUiState
+}
+
+/**
+ * Settings Ui State for testing, based on Typical System Constraints.
+ * @see[com.google.jetpackcamera.settings.model.SystemConstraints]
+ */
+val TYPICAL_SETTINGS_UISTATE = SettingsUiState.Enabled(
+    aspectRatioUiState = AspectRatioUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.aspectRatio),
+    captureModeUiState = CaptureModeUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.captureMode),
+    darkModeUiState = DarkModeUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.darkMode),
+    flashUiState =
+    FlashUiState.Enabled(currentFlashMode = DEFAULT_CAMERA_APP_SETTINGS.flashMode),
+    fpsUiState = FpsUiState.Enabled(
+        currentSelection = DEFAULT_CAMERA_APP_SETTINGS.targetFrameRate,
+        fpsAutoState = SingleSelectableState.Selectable,
+        fpsFifteenState = SingleSelectableState.Selectable,
+        fpsThirtyState = SingleSelectableState.Selectable,
+        fpsSixtyState = SingleSelectableState.Disabled(
+            DeviceUnsupportedRationale(R.string.fps_rationale_prefix)
+        )
+    ),
+    lensFlipUiState = FlipLensUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.cameraLensFacing),
+    stabilizationUiState =
+    StabilizationUiState.Disabled(
+        DeviceUnsupportedRationale(R.string.stabilization_rationale_prefix)
+    )
+)
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt
index ea8caf7..43e7a50 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt
@@ -18,12 +18,22 @@
 import android.util.Log
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import com.google.jetpackcamera.settings.DisabledRationale.DeviceUnsupportedRationale
+import com.google.jetpackcamera.settings.DisabledRationale.FpsUnsupportedRationale
+import com.google.jetpackcamera.settings.DisabledRationale.StabilizationUnsupportedRationale
 import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.CameraAppSettings
 import com.google.jetpackcamera.settings.model.CaptureMode
 import com.google.jetpackcamera.settings.model.DarkMode
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
+import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.ui.FPS_15
+import com.google.jetpackcamera.settings.ui.FPS_30
+import com.google.jetpackcamera.settings.ui.FPS_60
+import com.google.jetpackcamera.settings.ui.FPS_AUTO
 import dagger.hilt.android.lifecycle.HiltViewModel
 import javax.inject.Inject
 import kotlinx.coroutines.flow.SharingStarted
@@ -34,6 +44,7 @@
 import kotlinx.coroutines.launch
 
 private const val TAG = "SettingsViewModel"
+private val fpsOptions = setOf(FPS_15, FPS_30, FPS_60)
 
 /**
  * [ViewModel] for [SettingsScreen].
@@ -50,8 +61,14 @@
             constraintsRepository.systemConstraints.filterNotNull()
         ) { updatedSettings, constraints ->
             SettingsUiState.Enabled(
-                cameraAppSettings = updatedSettings,
-                systemConstraints = constraints
+                aspectRatioUiState = AspectRatioUiState.Enabled(updatedSettings.aspectRatio),
+                captureModeUiState = CaptureModeUiState.Enabled(updatedSettings.captureMode),
+                darkModeUiState = DarkModeUiState.Enabled(updatedSettings.darkMode),
+                flashUiState = FlashUiState.Enabled(updatedSettings.flashMode),
+                fpsUiState = getFpsUiState(constraints, updatedSettings),
+                lensFlipUiState = getLensFlipUiState(constraints, updatedSettings),
+                stabilizationUiState = getStabilizationUiState(constraints, updatedSettings)
+
             )
         }.stateIn(
             scope = viewModelScope,
@@ -59,6 +76,317 @@
             initialValue = SettingsUiState.Disabled
         )
 
+    private fun getStabilizationUiState(
+        systemConstraints: SystemConstraints,
+        cameraAppSettings: CameraAppSettings
+    ): StabilizationUiState {
+        val deviceStabilizations: Set<SupportedStabilizationMode> =
+            systemConstraints
+                .perLensConstraints[cameraAppSettings.cameraLensFacing]
+                ?.supportedStabilizationModes
+                ?: emptySet()
+
+        // if no lens supports
+        if (deviceStabilizations.isEmpty()) {
+            return StabilizationUiState.Disabled(
+                DeviceUnsupportedRationale(
+                    R.string.stabilization_rationale_prefix
+                )
+            )
+        }
+
+        // if a lens supports but it isn't the current
+        if (systemConstraints.perLensConstraints[cameraAppSettings.cameraLensFacing]
+                ?.supportedStabilizationModes?.isEmpty() == true
+        ) {
+            return StabilizationUiState.Disabled(
+                getLensUnsupportedRationale(
+                    cameraAppSettings.cameraLensFacing,
+                    R.string.stabilization_rationale_prefix
+                )
+            )
+        }
+
+        // if fps is too high for any stabilization
+        if (cameraAppSettings.targetFrameRate >= TARGET_FPS_60) {
+            return StabilizationUiState.Disabled(
+                FpsUnsupportedRationale(
+                    R.string.stabilization_rationale_prefix,
+                    FPS_60
+                )
+            )
+        }
+
+        return StabilizationUiState.Enabled(
+            currentPreviewStabilization = cameraAppSettings.previewStabilization,
+            currentVideoStabilization = cameraAppSettings.videoCaptureStabilization,
+            stabilizationOnState = getPreviewStabilizationState(
+                currentFrameRate = cameraAppSettings.targetFrameRate,
+                defaultLensFacing = cameraAppSettings.cameraLensFacing,
+                deviceStabilizations = deviceStabilizations,
+                currentLensStabilizations = systemConstraints
+                    .perLensConstraints[cameraAppSettings.cameraLensFacing]
+                    ?.supportedStabilizationModes
+            ),
+            stabilizationHighQualityState =
+            getVideoStabilizationState(
+                currentFrameRate = cameraAppSettings.targetFrameRate,
+                deviceStabilizations = deviceStabilizations,
+                defaultLensFacing = cameraAppSettings.cameraLensFacing,
+                currentLensStabilizations = systemConstraints
+                    .perLensConstraints[cameraAppSettings.cameraLensFacing]
+                    ?.supportedStabilizationModes
+            )
+        )
+    }
+
+    private fun getPreviewStabilizationState(
+        currentFrameRate: Int,
+        defaultLensFacing: LensFacing,
+        deviceStabilizations: Set<SupportedStabilizationMode>,
+        currentLensStabilizations: Set<SupportedStabilizationMode>?
+    ): SingleSelectableState {
+        // if unsupported by device
+        if (!deviceStabilizations.contains(SupportedStabilizationMode.ON)) {
+            return SingleSelectableState.Disabled(
+                disabledRationale =
+                DeviceUnsupportedRationale(R.string.stabilization_rationale_prefix)
+            )
+        }
+
+        // if unsupported by by current lens
+        if (currentLensStabilizations?.contains(SupportedStabilizationMode.ON) == false) {
+            return SingleSelectableState.Disabled(
+                getLensUnsupportedRationale(
+                    defaultLensFacing,
+                    R.string.stabilization_rationale_prefix
+                )
+            )
+        }
+
+        // if fps is unsupported by preview stabilization
+        if (currentFrameRate == TARGET_FPS_60 || currentFrameRate == TARGET_FPS_15) {
+            return SingleSelectableState.Disabled(
+                FpsUnsupportedRationale(
+                    R.string.stabilization_rationale_prefix,
+                    currentFrameRate
+                )
+            )
+        }
+
+        return SingleSelectableState.Selectable
+    }
+
+    private fun getVideoStabilizationState(
+        currentFrameRate: Int,
+        defaultLensFacing: LensFacing,
+        deviceStabilizations: Set<SupportedStabilizationMode>,
+        currentLensStabilizations: Set<SupportedStabilizationMode>?
+    ): SingleSelectableState {
+        // if unsupported by device
+        if (!deviceStabilizations.contains(SupportedStabilizationMode.ON)) {
+            return SingleSelectableState.Disabled(
+                disabledRationale =
+                DeviceUnsupportedRationale(R.string.stabilization_rationale_prefix)
+            )
+        }
+
+        // if unsupported by by current lens
+        if (currentLensStabilizations?.contains(SupportedStabilizationMode.HIGH_QUALITY) == false) {
+            return SingleSelectableState.Disabled(
+                getLensUnsupportedRationale(
+                    defaultLensFacing,
+                    R.string.stabilization_rationale_prefix
+                )
+            )
+        }
+        // if fps is unsupported by preview stabilization
+        if (currentFrameRate == TARGET_FPS_60) {
+            return SingleSelectableState.Disabled(
+                FpsUnsupportedRationale(
+                    R.string.stabilization_rationale_prefix,
+                    currentFrameRate
+                )
+            )
+        }
+
+        return SingleSelectableState.Selectable
+    }
+
+    /**
+     * Enables or disables default camera switch based on:
+     * - number of cameras available
+     * - if there is a front and rear camera, the camera that the setting would switch to must also
+     * support the other settings
+     * */
+    private fun getLensFlipUiState(
+        systemConstraints: SystemConstraints,
+        currentSettings: CameraAppSettings
+    ): FlipLensUiState {
+        // if there is only one lens, stop here
+        if (!with(systemConstraints.availableLenses) {
+                size > 1 && contains(com.google.jetpackcamera.settings.model.LensFacing.FRONT)
+            }
+        ) {
+            return FlipLensUiState.Disabled(
+                currentLensFacing = currentSettings.cameraLensFacing,
+                disabledRationale =
+                DeviceUnsupportedRationale(
+                    // display the lens that isnt supported
+                    when (currentSettings.cameraLensFacing) {
+                        LensFacing.BACK -> R.string.front_lens_rationale_prefix
+                        LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+                    }
+                )
+            )
+        }
+
+        // If multiple lens available, continue
+        val newLensFacing = if (currentSettings.cameraLensFacing == LensFacing.FRONT) {
+            LensFacing.BACK
+        } else {
+            LensFacing.FRONT
+        }
+        val newLensConstraints = systemConstraints.perLensConstraints[newLensFacing]!!
+        // make sure all current settings wont break constraint when changing new default lens
+
+        // if new lens won't support current fps
+        if (currentSettings.targetFrameRate != FPS_AUTO &&
+            !newLensConstraints.supportedFixedFrameRates
+                .contains(currentSettings.targetFrameRate)
+        ) {
+            return FlipLensUiState.Disabled(
+                currentLensFacing = currentSettings.cameraLensFacing,
+                disabledRationale = FpsUnsupportedRationale(
+                    when (currentSettings.cameraLensFacing) {
+                        LensFacing.BACK -> R.string.front_lens_rationale_prefix
+                        LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+                    },
+                    currentSettings.targetFrameRate
+                )
+            )
+        }
+
+        // if preview stabilization is currently on and the other lens won't support it
+        if (currentSettings.previewStabilization == Stabilization.ON) {
+            if (!newLensConstraints.supportedStabilizationModes.contains(
+                    SupportedStabilizationMode.ON
+                )
+            ) {
+                return FlipLensUiState.Disabled(
+                    currentLensFacing = currentSettings.cameraLensFacing,
+                    disabledRationale = StabilizationUnsupportedRationale(
+                        when (currentSettings.cameraLensFacing) {
+                            LensFacing.BACK -> R.string.front_lens_rationale_prefix
+                            LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+                        }
+                    )
+                )
+            }
+        }
+        // if video stabilization is currently on and the other lens won't support it
+        if (currentSettings.videoCaptureStabilization == Stabilization.ON) {
+            if (!newLensConstraints.supportedStabilizationModes
+                    .contains(SupportedStabilizationMode.HIGH_QUALITY)
+            ) {
+                return FlipLensUiState.Disabled(
+                    currentLensFacing = currentSettings.cameraLensFacing,
+                    disabledRationale = StabilizationUnsupportedRationale(
+                        when (currentSettings.cameraLensFacing) {
+                            LensFacing.BACK -> R.string.front_lens_rationale_prefix
+                            LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+                        }
+                    )
+                )
+            }
+        }
+
+        return FlipLensUiState.Enabled(currentLensFacing = currentSettings.cameraLensFacing)
+    }
+
+    private fun getFpsUiState(
+        systemConstraints: SystemConstraints,
+        cameraAppSettings: CameraAppSettings
+    ): FpsUiState {
+        val optionConstraintRationale: MutableMap<Int, SingleSelectableState> = mutableMapOf()
+
+        val currentLensFrameRates: Set<Int> = systemConstraints
+            .perLensConstraints[cameraAppSettings.cameraLensFacing]
+            ?.supportedFixedFrameRates ?: emptySet()
+
+        // if device supports no fixed frame rates, disable
+        if (currentLensFrameRates.isEmpty()) {
+            return FpsUiState.Disabled(
+                DeviceUnsupportedRationale(R.string.no_fixed_fps_rationale_prefix)
+            )
+        }
+
+        // provide selectable states for each of the fps options
+        fpsOptions.forEach { fpsOption ->
+            val fpsUiState = isFpsOptionEnabled(
+                fpsOption,
+                cameraAppSettings.cameraLensFacing,
+                currentLensFrameRates,
+                systemConstraints.perLensConstraints[cameraAppSettings.cameraLensFacing]
+                    ?.supportedFixedFrameRates ?: emptySet(),
+                cameraAppSettings.previewStabilization,
+                cameraAppSettings.videoCaptureStabilization
+            )
+            if (fpsUiState is SingleSelectableState.Disabled) {
+                Log.d(TAG, "fps option $fpsOption disabled. ${fpsUiState.disabledRationale::class}")
+            }
+            optionConstraintRationale[fpsOption] = fpsUiState
+        }
+        return FpsUiState.Enabled(
+            currentSelection = cameraAppSettings.targetFrameRate,
+            fpsAutoState = SingleSelectableState.Selectable,
+            fpsFifteenState = optionConstraintRationale[FPS_15]!!,
+            fpsThirtyState = optionConstraintRationale[FPS_30]!!,
+            fpsSixtyState = optionConstraintRationale[FPS_60]!!
+        )
+    }
+
+    /**
+     * Auxiliary function to determine if an FPS option should be disabled or not
+     */
+    private fun isFpsOptionEnabled(
+        fpsOption: Int,
+        defaultLensFacing: LensFacing,
+        deviceFrameRates: Set<Int>,
+        lensFrameRates: Set<Int>,
+        previewStabilization: Stabilization,
+        videoStabilization: Stabilization
+    ): SingleSelectableState {
+        // if device doesnt support the fps option, disable
+        if (!deviceFrameRates.contains(fpsOption)) {
+            return SingleSelectableState.Disabled(
+                disabledRationale = DeviceUnsupportedRationale(R.string.fps_rationale_prefix)
+            )
+        }
+        // if the current lens doesnt support the fps, disable
+        if (!lensFrameRates.contains(fpsOption)) {
+            Log.d(TAG, "FPS disabled for current lens")
+
+            return SingleSelectableState.Disabled(
+                getLensUnsupportedRationale(defaultLensFacing, R.string.fps_rationale_prefix)
+            )
+        }
+
+        // if stabilization is on and the option is incompatible, disable
+        if ((
+                previewStabilization == Stabilization.ON &&
+                    (fpsOption == FPS_15 || fpsOption == FPS_60)
+                ) ||
+            (videoStabilization == Stabilization.ON && fpsOption == FPS_60)
+        ) {
+            return SingleSelectableState.Disabled(
+                StabilizationUnsupportedRationale(R.string.fps_rationale_prefix)
+            )
+        }
+
+        return SingleSelectableState.Selectable
+    }
+
     fun setDefaultLensFacing(lensFacing: LensFacing) {
         viewModelScope.launch {
             settingsRepository.updateDefaultLensFacing(lensFacing)
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt
index 559f24f..e8c02fb 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt
@@ -38,7 +38,7 @@
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBar
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
@@ -52,14 +52,22 @@
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import com.google.jetpackcamera.settings.AspectRatioUiState
+import com.google.jetpackcamera.settings.CaptureModeUiState
+import com.google.jetpackcamera.settings.DarkModeUiState
+import com.google.jetpackcamera.settings.DisabledRationale
+import com.google.jetpackcamera.settings.FlashUiState
+import com.google.jetpackcamera.settings.FlipLensUiState
+import com.google.jetpackcamera.settings.FpsUiState
 import com.google.jetpackcamera.settings.R
+import com.google.jetpackcamera.settings.SingleSelectableState
+import com.google.jetpackcamera.settings.StabilizationUiState
 import com.google.jetpackcamera.settings.model.AspectRatio
 import com.google.jetpackcamera.settings.model.CaptureMode
 import com.google.jetpackcamera.settings.model.DarkMode
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.Stabilization
-import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
 import com.google.jetpackcamera.settings.ui.theme.SettingsPreviewTheme
 
 const val FPS_AUTO = 0
@@ -107,27 +115,38 @@
 
 @Composable
 fun DefaultCameraFacing(
-    settingValue: Boolean,
-    enabled: Boolean,
-    setDefaultLensFacing: (LensFacing) -> Unit,
-    modifier: Modifier = Modifier
+    modifier: Modifier = Modifier,
+    lensUiState: FlipLensUiState,
+    setDefaultLensFacing: (LensFacing) -> Unit
 ) {
     SwitchSettingUI(
-        modifier = modifier,
+        modifier = modifier.apply {
+            if (lensUiState is FlipLensUiState.Disabled) {
+                testTag(lensUiState.disabledRationale.testTag)
+            }
+        },
         title = stringResource(id = R.string.default_facing_camera_title),
-        description = null,
+        description = when (lensUiState) {
+            is FlipLensUiState.Disabled -> {
+                disabledRationaleString(disabledRationale = lensUiState.disabledRationale)
+            }
+
+            is FlipLensUiState.Enabled -> {
+                null
+            }
+        },
         leadingIcon = null,
         onSwitchChanged = { on ->
             setDefaultLensFacing(if (on) LensFacing.FRONT else LensFacing.BACK)
         },
-        settingValue = settingValue,
-        enabled = enabled
+        settingValue = lensUiState.currentLensFacing == LensFacing.FRONT,
+        enabled = lensUiState is FlipLensUiState.Enabled
     )
 }
 
 @Composable
 fun DarkModeSetting(
-    currentDarkMode: DarkMode,
+    darkModeUiState: DarkModeUiState,
     setDarkMode: (DarkMode) -> Unit,
     modifier: Modifier = Modifier
 ) {
@@ -135,26 +154,34 @@
         modifier = modifier,
         title = stringResource(id = R.string.dark_mode_title),
         leadingIcon = null,
-        description = when (currentDarkMode) {
-            DarkMode.SYSTEM -> stringResource(id = R.string.dark_mode_description_system)
-            DarkMode.DARK -> stringResource(id = R.string.dark_mode_description_dark)
-            DarkMode.LIGHT -> stringResource(id = R.string.dark_mode_description_light)
+        enabled = true,
+        description = when (darkModeUiState) {
+            is DarkModeUiState.Enabled -> {
+                when (darkModeUiState.currentDarkMode) {
+                    DarkMode.SYSTEM -> stringResource(id = R.string.dark_mode_description_system)
+                    DarkMode.DARK -> stringResource(id = R.string.dark_mode_description_dark)
+                    DarkMode.LIGHT -> stringResource(id = R.string.dark_mode_description_light)
+                }
+            }
         },
         popupContents = {
             Column(Modifier.selectableGroup()) {
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.dark_mode_selector_dark),
-                    selected = currentDarkMode == DarkMode.DARK,
+                    selected = darkModeUiState.currentDarkMode == DarkMode.DARK,
+                    enabled = true,
                     onClick = { setDarkMode(DarkMode.DARK) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.dark_mode_selector_light),
-                    selected = currentDarkMode == DarkMode.LIGHT,
+                    selected = darkModeUiState.currentDarkMode == DarkMode.LIGHT,
+                    enabled = true,
                     onClick = { setDarkMode(DarkMode.LIGHT) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.dark_mode_selector_system),
-                    selected = currentDarkMode == DarkMode.SYSTEM,
+                    selected = darkModeUiState.currentDarkMode == DarkMode.SYSTEM,
+                    enabled = true,
                     onClick = { setDarkMode(DarkMode.SYSTEM) }
                 )
             }
@@ -164,7 +191,7 @@
 
 @Composable
 fun FlashModeSetting(
-    currentFlashMode: FlashMode,
+    flashUiState: FlashUiState,
     setFlashMode: (FlashMode) -> Unit,
     modifier: Modifier = Modifier
 ) {
@@ -172,26 +199,35 @@
         modifier = modifier,
         title = stringResource(id = R.string.flash_mode_title),
         leadingIcon = null,
-        description = when (currentFlashMode) {
-            FlashMode.AUTO -> stringResource(id = R.string.flash_mode_description_auto)
-            FlashMode.ON -> stringResource(id = R.string.flash_mode_description_on)
-            FlashMode.OFF -> stringResource(id = R.string.flash_mode_description_off)
+        enabled = true,
+        description =
+        if (flashUiState is FlashUiState.Enabled) {
+            when (flashUiState.currentFlashMode) {
+                FlashMode.AUTO -> stringResource(id = R.string.flash_mode_description_auto)
+                FlashMode.ON -> stringResource(id = R.string.flash_mode_description_on)
+                FlashMode.OFF -> stringResource(id = R.string.flash_mode_description_off)
+            }
+        } else {
+            TODO("flash mode currently has no disabled criteria")
         },
         popupContents = {
             Column(Modifier.selectableGroup()) {
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.flash_mode_selector_auto),
-                    selected = currentFlashMode == FlashMode.AUTO,
+                    selected = flashUiState.currentFlashMode == FlashMode.AUTO,
+                    enabled = true,
                     onClick = { setFlashMode(FlashMode.AUTO) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.flash_mode_selector_on),
-                    selected = currentFlashMode == FlashMode.ON,
+                    selected = flashUiState.currentFlashMode == FlashMode.ON,
+                    enabled = true,
                     onClick = { setFlashMode(FlashMode.ON) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.flash_mode_selector_off),
-                    selected = currentFlashMode == FlashMode.OFF,
+                    selected = flashUiState.currentFlashMode == FlashMode.OFF,
+                    enabled = true,
                     onClick = { setFlashMode(FlashMode.OFF) }
                 )
             }
@@ -201,7 +237,7 @@
 
 @Composable
 fun AspectRatioSetting(
-    currentAspectRatio: AspectRatio,
+    aspectRatioUiState: AspectRatioUiState,
     setAspectRatio: (AspectRatio) -> Unit,
     modifier: Modifier = Modifier
 ) {
@@ -209,26 +245,38 @@
         modifier = modifier,
         title = stringResource(id = R.string.aspect_ratio_title),
         leadingIcon = null,
-        description = when (currentAspectRatio) {
-            AspectRatio.NINE_SIXTEEN -> stringResource(id = R.string.aspect_ratio_description_9_16)
-            AspectRatio.THREE_FOUR -> stringResource(id = R.string.aspect_ratio_description_3_4)
-            AspectRatio.ONE_ONE -> stringResource(id = R.string.aspect_ratio_description_1_1)
+        description =
+        if (aspectRatioUiState is AspectRatioUiState.Enabled) {
+            when (aspectRatioUiState.currentAspectRatio) {
+                AspectRatio.NINE_SIXTEEN -> stringResource(
+                    id = R.string.aspect_ratio_description_9_16
+                )
+
+                AspectRatio.THREE_FOUR -> stringResource(id = R.string.aspect_ratio_description_3_4)
+                AspectRatio.ONE_ONE -> stringResource(id = R.string.aspect_ratio_description_1_1)
+            }
+        } else {
+            TODO("aspect ratio currently has no disabled criteria")
         },
+        enabled = true,
         popupContents = {
             Column(Modifier.selectableGroup()) {
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.aspect_ratio_selector_9_16),
-                    selected = currentAspectRatio == AspectRatio.NINE_SIXTEEN,
+                    selected = aspectRatioUiState.currentAspectRatio == AspectRatio.NINE_SIXTEEN,
+                    enabled = true,
                     onClick = { setAspectRatio(AspectRatio.NINE_SIXTEEN) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.aspect_ratio_selector_3_4),
-                    selected = currentAspectRatio == AspectRatio.THREE_FOUR,
+                    selected = aspectRatioUiState.currentAspectRatio == AspectRatio.THREE_FOUR,
+                    enabled = true,
                     onClick = { setAspectRatio(AspectRatio.THREE_FOUR) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.aspect_ratio_selector_1_1),
-                    selected = currentAspectRatio == AspectRatio.ONE_ONE,
+                    selected = aspectRatioUiState.currentAspectRatio == AspectRatio.ONE_ONE,
+                    enabled = true,
                     onClick = { setAspectRatio(AspectRatio.ONE_ONE) }
                 )
             }
@@ -238,7 +286,7 @@
 
 @Composable
 fun CaptureModeSetting(
-    currentCaptureMode: CaptureMode,
+    captureModeUiState: CaptureModeUiState,
     setCaptureMode: (CaptureMode) -> Unit,
     modifier: Modifier = Modifier
 ) {
@@ -246,25 +294,33 @@
         modifier = modifier,
         title = stringResource(R.string.capture_mode_title),
         leadingIcon = null,
-        description = when (currentCaptureMode) {
-            CaptureMode.MULTI_STREAM -> stringResource(
-                id = R.string.capture_mode_description_multi_stream
-            )
+        enabled = true,
+        description =
+        if (captureModeUiState is CaptureModeUiState.Enabled) {
+            when (captureModeUiState.currentCaptureMode) {
+                CaptureMode.MULTI_STREAM -> stringResource(
+                    id = R.string.capture_mode_description_multi_stream
+                )
 
-            CaptureMode.SINGLE_STREAM -> stringResource(
-                id = R.string.capture_mode_description_single_stream
-            )
+                CaptureMode.SINGLE_STREAM -> stringResource(
+                    id = R.string.capture_mode_description_single_stream
+                )
+            }
+        } else {
+            TODO("capture mode currently has no disabled criteria")
         },
         popupContents = {
             Column(Modifier.selectableGroup()) {
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.capture_mode_selector_multi_stream),
-                    selected = currentCaptureMode == CaptureMode.MULTI_STREAM,
+                    selected = captureModeUiState.currentCaptureMode == CaptureMode.MULTI_STREAM,
+                    enabled = true,
                     onClick = { setCaptureMode(CaptureMode.MULTI_STREAM) }
                 )
                 SingleChoiceSelector(
                     text = stringResource(id = R.string.capture_mode_description_single_stream),
-                    selected = currentCaptureMode == CaptureMode.SINGLE_STREAM,
+                    selected = captureModeUiState.currentCaptureMode == CaptureMode.SINGLE_STREAM,
+                    enabled = true,
                     onClick = { setCaptureMode(CaptureMode.SINGLE_STREAM) }
                 )
             }
@@ -274,20 +330,21 @@
 
 @Composable
 fun TargetFpsSetting(
-    currentTargetFps: Int,
-    supportedFps: Set<Int>,
+    fpsUiState: FpsUiState,
     setTargetFps: (Int) -> Unit,
     modifier: Modifier = Modifier
 ) {
     BasicPopupSetting(
-        modifier = modifier,
+        modifier = modifier.apply {
+            if (fpsUiState is FpsUiState.Disabled) {
+                testTag(fpsUiState.disabledRationale.testTag)
+            }
+        },
         title = stringResource(id = R.string.fps_title),
-        enabled = supportedFps.isNotEmpty(),
+        enabled = fpsUiState is FpsUiState.Enabled,
         leadingIcon = null,
-        description = if (supportedFps.isEmpty()) {
-            stringResource(id = R.string.fps_description_unavailable)
-        } else {
-            when (currentTargetFps) {
+        description = if (fpsUiState is FpsUiState.Enabled) {
+            when (fpsUiState.currentSelection) {
                 FPS_15 -> stringResource(id = R.string.fps_description, FPS_15)
                 FPS_30 -> stringResource(id = R.string.fps_description, FPS_30)
                 FPS_60 -> stringResource(id = R.string.fps_description, FPS_60)
@@ -295,27 +352,46 @@
                     id = R.string.fps_description_auto
                 )
             }
+        } else {
+            disabledRationaleString((fpsUiState as FpsUiState.Disabled).disabledRationale)
         },
         popupContents = {
-            Column(Modifier.selectableGroup()) {
-                Text(
-                    text = stringResource(id = R.string.fps_stabilization_disclaimer),
-                    fontStyle = FontStyle.Italic,
-                    color = MaterialTheme.colorScheme.onPrimaryContainer
-                )
-
-                SingleChoiceSelector(
-                    text = stringResource(id = R.string.fps_selector_auto),
-                    selected = currentTargetFps == FPS_AUTO,
-                    onClick = { setTargetFps(FPS_AUTO) }
-                )
-                listOf(FPS_15, FPS_30, FPS_60).forEach { fpsOption ->
-                    SingleChoiceSelector(
-                        text = "%d".format(fpsOption),
-                        selected = currentTargetFps == fpsOption,
-                        onClick = { setTargetFps(fpsOption) },
-                        enabled = supportedFps.contains(fpsOption)
+            if (fpsUiState is FpsUiState.Enabled) {
+                Column(Modifier.selectableGroup()) {
+                    Text(
+                        text = stringResource(id = R.string.fps_stabilization_disclaimer),
+                        fontStyle = FontStyle.Italic,
+                        color = MaterialTheme.colorScheme.onPrimaryContainer
                     )
+
+                    SingleChoiceSelector(
+                        text = stringResource(id = R.string.fps_selector_auto),
+                        selected = fpsUiState.currentSelection == FPS_AUTO,
+                        onClick = { setTargetFps(FPS_AUTO) },
+                        enabled = fpsUiState.fpsAutoState is SingleSelectableState.Selectable
+                    )
+                    listOf(FPS_15, FPS_30, FPS_60).forEach { fpsOption ->
+                        SingleChoiceSelector(
+                            text = "%d".format(fpsOption),
+                            selected = fpsUiState.currentSelection == fpsOption,
+                            onClick = { setTargetFps(fpsOption) },
+                            enabled = when (fpsOption) {
+                                FPS_15 ->
+                                    fpsUiState.fpsFifteenState is
+                                        SingleSelectableState.Selectable
+
+                                FPS_30 ->
+                                    fpsUiState.fpsThirtyState is
+                                        SingleSelectableState.Selectable
+
+                                FPS_60 ->
+                                    fpsUiState.fpsSixtyState is
+                                        SingleSelectableState.Selectable
+
+                                else -> false
+                            }
+                        )
+                    }
                 }
             }
         }
@@ -352,49 +428,44 @@
  * HIGH_QUALITY - Video will be stabilized, preview might be stabilized, depending on the device.
  * OFF - Preview and video stabilization is disabled.
  *
- * @param supportedStabilizationMode the enabled condition for this setting.
+ * @param stabilizationUiState the state for this setting.
  */
 @Composable
 fun StabilizationSetting(
-    currentPreviewStabilization: Stabilization,
-    currentVideoStabilization: Stabilization,
-    currentTargetFps: Int,
-    supportedStabilizationMode: Set<SupportedStabilizationMode>,
+    stabilizationUiState: StabilizationUiState,
     setVideoStabilization: (Stabilization) -> Unit,
     setPreviewStabilization: (Stabilization) -> Unit,
     modifier: Modifier = Modifier
 ) {
-    // if the preview stabilization was left ON and the target frame rate was set to 15,
-    // this setting needs to be reset to OFF
-    LaunchedEffect(key1 = currentTargetFps, key2 = currentPreviewStabilization) {
-        if (currentTargetFps == FPS_15 &&
-            currentPreviewStabilization == Stabilization.ON
-        ) {
-            setPreviewStabilization(Stabilization.UNDEFINED)
-        }
-    }
     // entire setting disabled when no available fps or target fps = 60
     // stabilization is unsupported >30 fps
     BasicPopupSetting(
-        modifier = modifier,
+        modifier = modifier.apply {
+            when (stabilizationUiState) {
+                is StabilizationUiState.Disabled ->
+                    testTag(stabilizationUiState.disabledRationale.testTag)
+
+                else -> {}
+            }
+        },
         title = stringResource(R.string.video_stabilization_title),
         leadingIcon = null,
-        enabled = (
-            supportedStabilizationMode.isNotEmpty() &&
-                currentTargetFps != FPS_60
-            ),
-        description = if (supportedStabilizationMode.isEmpty()) {
-            stringResource(id = R.string.stabilization_description_unsupported_device)
-        } else if (currentTargetFps == FPS_60) {
-            stringResource(id = R.string.stabilization_description_unsupported_fps)
-        } else {
-            stringResource(
-                id = getStabilizationStringRes(
-                    previewStabilization = currentPreviewStabilization,
-                    videoStabilization = currentVideoStabilization
+        enabled = stabilizationUiState is StabilizationUiState.Enabled,
+        description = when (stabilizationUiState) {
+            is StabilizationUiState.Enabled ->
+                stringResource(
+                    id = getStabilizationStringRes(
+                        previewStabilization = stabilizationUiState.currentPreviewStabilization,
+                        videoStabilization = stabilizationUiState.currentVideoStabilization
+                    )
                 )
-            )
+
+            is StabilizationUiState.Disabled -> {
+                // disabled setting description
+                disabledRationaleString(stabilizationUiState.disabledRationale)
+            }
         },
+
         popupContents = {
             Column(Modifier.selectableGroup()) {
                 Text(
@@ -406,55 +477,96 @@
                 // on (preview) selector
                 // disabled if target fps != (30 or off)
                 // TODO(b/328223562): device always resolves to 30fps when using preview stabilization
-                SingleChoiceSelector(
-                    text = stringResource(id = R.string.stabilization_selector_on),
-                    secondaryText = stringResource(id = R.string.stabilization_selector_on_info),
-                    enabled =
-                    (
-                        when (currentTargetFps) {
-                            FPS_AUTO, FPS_30 -> true
-                            else -> false
-                        }
-                        ) &&
-                        supportedStabilizationMode.contains(SupportedStabilizationMode.ON),
-                    selected = (currentPreviewStabilization == Stabilization.ON) &&
-                        (currentVideoStabilization != Stabilization.OFF),
-                    onClick = {
-                        setVideoStabilization(Stabilization.UNDEFINED)
-                        setPreviewStabilization(Stabilization.ON)
-                    }
-                )
+                when (stabilizationUiState) {
+                    is StabilizationUiState.Enabled -> {
+                        SingleChoiceSelector(
+                            modifier = Modifier.apply {
+                                if (stabilizationUiState.stabilizationOnState
+                                        is SingleSelectableState.Disabled
+                                ) {
+                                    testTag(
+                                        stabilizationUiState.stabilizationOnState
+                                            .disabledRationale.testTag
+                                    )
+                                }
+                            },
+                            text = stringResource(id = R.string.stabilization_selector_on),
+                            secondaryText = stringResource(
+                                id = R.string.stabilization_selector_on_info
+                            ),
+                            enabled = stabilizationUiState.stabilizationOnState is
+                                SingleSelectableState.Selectable,
+                            selected = (
+                                stabilizationUiState.currentPreviewStabilization
+                                    == Stabilization.ON
+                                ) &&
+                                (
+                                    stabilizationUiState.currentVideoStabilization
+                                        != Stabilization.OFF
+                                    ),
+                            onClick = {
+                                setVideoStabilization(Stabilization.UNDEFINED)
+                                setPreviewStabilization(Stabilization.ON)
+                            }
+                        )
 
-                // high quality selector
-                // disabled if target fps = 60 (see VideoCapabilities.isStabilizationSupported)
-                SingleChoiceSelector(
-                    text = stringResource(id = R.string.stabilization_selector_high_quality),
-                    secondaryText = stringResource(
-                        id = R.string.stabilization_selector_high_quality_info
-                    ),
-                    enabled = (currentTargetFps != FPS_60) &&
-                        supportedStabilizationMode.contains(
-                            SupportedStabilizationMode.HIGH_QUALITY
-                        ),
+                        // high quality selector
+                        // disabled if target fps = 60 (see VideoCapabilities.isStabilizationSupported)
+                        SingleChoiceSelector(
+                            modifier = Modifier.apply {
+                                if (stabilizationUiState.stabilizationHighQualityState
+                                        is SingleSelectableState.Disabled
+                                ) {
+                                    testTag(
+                                        stabilizationUiState.stabilizationHighQualityState
+                                            .disabledRationale.testTag
+                                    )
+                                }
+                            },
+                            text = stringResource(
+                                id = R.string.stabilization_selector_high_quality
+                            ),
+                            secondaryText = stringResource(
+                                id = R.string.stabilization_selector_high_quality_info
+                            ),
+                            enabled = stabilizationUiState.stabilizationHighQualityState
+                                == SingleSelectableState.Selectable,
 
-                    selected = (currentPreviewStabilization == Stabilization.UNDEFINED) &&
-                        (currentVideoStabilization == Stabilization.ON),
-                    onClick = {
-                        setVideoStabilization(Stabilization.ON)
-                        setPreviewStabilization(Stabilization.UNDEFINED)
-                    }
-                )
+                            selected = (
+                                stabilizationUiState.currentPreviewStabilization
+                                    == Stabilization.UNDEFINED
+                                ) &&
+                                (
+                                    stabilizationUiState.currentVideoStabilization
+                                        == Stabilization.ON
+                                    ),
+                            onClick = {
+                                setVideoStabilization(Stabilization.ON)
+                                setPreviewStabilization(Stabilization.UNDEFINED)
+                            }
+                        )
 
-                // off selector
-                SingleChoiceSelector(
-                    text = stringResource(id = R.string.stabilization_selector_off),
-                    selected = (currentPreviewStabilization != Stabilization.ON) &&
-                        (currentVideoStabilization != Stabilization.ON),
-                    onClick = {
-                        setVideoStabilization(Stabilization.OFF)
-                        setPreviewStabilization(Stabilization.OFF)
+                        // off selector
+                        SingleChoiceSelector(
+                            text = stringResource(id = R.string.stabilization_selector_off),
+                            selected = (
+                                stabilizationUiState.currentPreviewStabilization
+                                    != Stabilization.ON
+                                ) &&
+                                (
+                                    stabilizationUiState.currentVideoStabilization
+                                        != Stabilization.ON
+                                    ),
+                            onClick = {
+                                setVideoStabilization(Stabilization.OFF)
+                                setPreviewStabilization(Stabilization.OFF)
+                            },
+                            enabled = true
+                        )
                     }
-                )
+
+                    else -> {}
+                }
             }
         }
     )
@@ -465,7 +577,8 @@
     SettingUI(
         modifier = modifier,
         title = stringResource(id = R.string.version_info_title),
-        leadingIcon = null
+        leadingIcon = null,
+        enabled = true
     ) {
         val versionString = versionName +
             if (buildType.isNotEmpty()) {
@@ -492,7 +605,7 @@
     leadingIcon: @Composable (() -> Unit)?,
     popupContents: @Composable () -> Unit,
     modifier: Modifier = Modifier,
-    enabled: Boolean = true
+    enabled: Boolean
 ) {
     val popupStatus = remember { mutableStateOf(false) }
     SettingUI(
@@ -565,25 +678,25 @@
     title: String,
     leadingIcon: @Composable (() -> Unit)?,
     modifier: Modifier = Modifier,
-    enabled: Boolean = true,
+    enabled: Boolean,
     description: String? = null,
     trailingContent: @Composable (() -> Unit)?
 ) {
     ListItem(
         modifier = modifier,
         headlineContent = {
-            when (enabled) {
-                true -> Text(title)
-                false -> {
-                    Text(text = title, color = LocalContentColor.current.copy(alpha = .7f))
-                }
+            if (enabled) {
+                Text(title)
+            } else {
+                Text(text = title, color = LocalContentColor.current.copy(alpha = .7f))
             }
         },
         supportingContent = {
             if (description != null) {
-                when (enabled) {
-                    true -> Text(description)
-                    false -> Text(
+                if (enabled) {
+                    Text(description)
+                } else {
+                    Text(
                         text = description,
                         color = LocalContentColor.current.copy(alpha = .7f)
                     )
@@ -605,7 +718,7 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     secondaryText: String? = null,
-    enabled: Boolean = true
+    enabled: Boolean
 ) {
     Row(
         modifier
@@ -634,6 +747,34 @@
     }
 }
 
+@Composable
+@ReadOnlyComposable
+fun disabledRationaleString(disabledRationale: DisabledRationale): String {
+    return when (disabledRationale) {
+        is DisabledRationale.DeviceUnsupportedRationale -> stringResource(
+
+            disabledRationale.reasonTextResId,
+            stringResource(disabledRationale.affectedSettingNameResId)
+        )
+
+        is DisabledRationale.FpsUnsupportedRationale -> stringResource(
+            disabledRationale.reasonTextResId,
+            stringResource(disabledRationale.affectedSettingNameResId),
+            disabledRationale.currentFps
+        )
+
+        is DisabledRationale.LensUnsupportedRationale -> stringResource(
+            disabledRationale.reasonTextResId,
+            stringResource(disabledRationale.affectedSettingNameResId)
+        )
+
+        is DisabledRationale.StabilizationUnsupportedRationale -> stringResource(
+            disabledRationale.reasonTextResId,
+            stringResource(disabledRationale.affectedSettingNameResId)
+        )
+    }
+}
+
 @Preview(name = "Light Mode")
 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
 @Composable
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
index ef11b24..8253fc1 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
@@ -16,3 +16,9 @@
 package com.google.jetpackcamera.settings.ui
 
 const val BACK_BUTTON = "BackButton"
+
+// unsupported rationale tags
+const val DEVICE_UNSUPPORTED_TAG = "DeviceUnsupportedTag"
+const val STABILIZATION_UNSUPPORTED_TAG = "StabilizationUnsupportedTag"
+const val LENS_UNSUPPORTED_TAG = "LensUnsupportedTag"
+const val FPS_UNSUPPORTED_TAG = "FpsUnsupportedTag"
diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml
index ad8aff6..e41f4fd 100644
--- a/feature/settings/src/main/res/values/strings.xml
+++ b/feature/settings/src/main/res/values/strings.xml
@@ -101,6 +101,24 @@
     <string name="fps_stabilization_disclaimer">*Available stabilization modes may change due to selected frame rate.</string>
     <string name="lens_stabilization_disclaimer">*Some devices may not support stabilization on both lens.</string>
 
+    <!-- disabled rationale strings-->
+    <string name="device_unsupported">%1$s is unsupported by the device</string>
+    <string name="fps_unsupported"> %1$s is unsupported at %2$d fps</string>
+    <string name="stabilization_unsupported">%$1s is unsupported by the current stabilization</string>
+    <string name="current_lens_unsupported">%$s is unsupported by the current lens</string>
+    <string name="rear_lens_unsupported">%$s is unsupported by the rear lens</string>
+    <string name="front_lens_unsupported">%$s is unsupported by the front lens</string>
+
+
+    <!-- Rationale prefixes -->
+    <string name="stabilization_rationale_prefix">Stabilization</string>
+    <string name="lens_rationale_prefix">Lens flip</string>
+    <string name="fps_rationale_prefix">Fps</string>
+
+    <string name="front_lens_rationale_prefix">Front lens</string>
+    <string name="rear_lens_rationale_prefix">Rear lens</string>
+    <string name="no_fixed_fps_rationale_prefix">Fixed frame rate</string>
+
 
     <!-- Version info strings -->
     <string name="version_info_title">Version</string>
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 930f838..36ea132 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,6 +47,7 @@
 protobuf = "3.25.2"
 robolectric = "4.11.1"
 truth = "1.4.2"
+rules = "1.6.1"
 
 [libraries]
 accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
@@ -59,6 +60,7 @@
 androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
 androidx-graphics-core = { module = "androidx.graphics:graphics-core", version.ref = "androidxGraphicsCore" }
 androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" }
+androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidxLifecycle" }
 androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
 androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
 androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" }
@@ -84,6 +86,7 @@
 futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "androidxConcurrentFutures" }
 hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
 junit = { module = "junit:junit", version.ref = "junit" }
+kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinPlugin" }
 kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" }
 kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
 kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
@@ -92,6 +95,7 @@
 protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
 robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
 truth = { module = "com.google.truth:truth", version.ref = "truth" }
+rules = { group = "androidx.test", name = "rules", version.ref = "rules" }
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index b021ba1..7c7842f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,7 +26,7 @@
     repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
     repositories {
         maven {
-            setUrl("https://androidx.dev/snapshots/builds/11932573/artifacts/repository")
+            setUrl("https://androidx.dev/snapshots/builds/12167802/artifacts/repository")
         }
         google()
         mavenCentral()