Merge remote-tracking branch 'aosp/upstream-main' into main am: cd16728b23

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

Change-Id: Idbc38ddd973d397058c99201d8aa546ff197a458
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.github/workflows/MergeToMainWorkflow.yaml b/.github/workflows/MergeToMainWorkflow.yaml
index ad4954e..9aa6590 100644
--- a/.github/workflows/MergeToMainWorkflow.yaml
+++ b/.github/workflows/MergeToMainWorkflow.yaml
@@ -16,30 +16,30 @@
 jobs:
   build:
     name: Build
-    runs-on: ubuntu-latest
+    runs-on: ${{ vars.RUNNER }}
     timeout-minutes: 120
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Validate Gradle Wrapper
-        uses: gradle/wrapper-validation-action@v1
+        uses: gradle/actions/wrapper-validation@v3
 
       - name: Set up JDK
-        uses: actions/setup-java@v3
+        uses: actions/setup-java@v4
         with:
           distribution: ${{ env.DISTRIBUTION }}
           java-version: ${{ env.JDK_VERSION }}
           cache: gradle
 
       - name: Setup Gradle
-        uses: gradle/gradle-build-action@v2
+        uses: gradle/actions/setup-gradle@v3
 
       - name: Build all build type and flavor permutations
         run: ./gradlew assemble --parallel --build-cache
 
       - name: Upload build outputs (APKs)
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: build-outputs
           path: app/build/outputs
@@ -47,7 +47,7 @@
       - name: Upload build reports
         if: always()
         continue-on-error: true
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: build-reports
           path: "*/build/reports"
diff --git a/.github/workflows/PullRequestWorkflow.yaml b/.github/workflows/PullRequestWorkflow.yaml
index 20e1582..ed83887 100644
--- a/.github/workflows/PullRequestWorkflow.yaml
+++ b/.github/workflows/PullRequestWorkflow.yaml
@@ -13,14 +13,14 @@
 jobs:
   build:
     name: Build
-    runs-on: ubuntu-latest
+    runs-on: ${{ vars.RUNNER }}
     timeout-minutes: 120
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
       - name: Validate Gradle Wrapper
-        uses: gradle/wrapper-validation-action@v2
+        uses: gradle/actions/wrapper-validation@v3
 
       - name: Set up JDK
         uses: actions/setup-java@v4
@@ -30,10 +30,10 @@
           cache: gradle
 
       - name: Setup Gradle
-        uses: gradle/gradle-build-action@v3
+        uses: gradle/actions/setup-gradle@v3
 
-      - name: Build all build type and flavor permutations
-        run: ./gradlew assemble --parallel --build-cache
+      - name: Build stable debug gradle target
+        run: ./gradlew assembleStableDebug --parallel --build-cache
 
       - name: Upload build outputs (APKs)
         uses: actions/upload-artifact@v4
@@ -51,14 +51,14 @@
 
   test:
     name: Unit Tests
-    runs-on: ubuntu-latest
+    runs-on: ${{ vars.RUNNER }}
     timeout-minutes: 120
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
       - name: Validate Gradle Wrapper
-        uses: gradle/wrapper-validation-action@v2
+        uses: gradle/actions/wrapper-validation@v3
 
       - name: Set up JDK
         uses: actions/setup-java@v4
@@ -68,7 +68,7 @@
           cache: gradle
 
       - name: Setup Gradle
-        uses: gradle/gradle-build-action@v3
+        uses: gradle/actions/setup-gradle@v3
         continue-on-error: true
 
       - name: Run local tests
@@ -83,7 +83,7 @@
 
   android-test:
     name: Instrumentation Tests (${{ matrix.device.name }})
-    runs-on: ubuntu-latest
+    runs-on: ${{ vars.RUNNER }}
     timeout-minutes: 30
     strategy:
       fail-fast: false
@@ -117,9 +117,9 @@
         run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
 
       - name: Run instrumentation tests
-        uses: gradle/gradle-build-action@v3
+        uses: gradle/actions/setup-gradle@v3
         with:
-          arguments: ${{ matrix.device.name }}DebugAndroidTest
+          arguments: ${{ matrix.device.name }}StableDebugAndroidTest
 
       - name: Upload instrumentation test reports and logs on failure
         if: failure()
@@ -132,7 +132,7 @@
 
   spotless:
     name: Spotless Check
-    runs-on: ubuntu-latest
+    runs-on: ${{ vars.RUNNER }}
     timeout-minutes: 60
     steps:
       - name: Checkout
@@ -141,7 +141,7 @@
           fetch-depth: 0
 
       - name: Validate Gradle Wrapper
-        uses: gradle/wrapper-validation-action@v2
+        uses: gradle/actions/wrapper-validation@v3
 
       - name: Set up JDK
         uses: actions/setup-java@v4
@@ -151,7 +151,7 @@
           cache: gradle
 
       - name: Setup Gradle
-        uses: gradle/gradle-build-action@v3
+        uses: gradle/actions/setup-gradle@v3
 
       - name: Spotless Check
         run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --parallel --build-cache
diff --git a/.gitignore b/.gitignore
index 8b3881d..4e44987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,4 @@
 local.properties
 .idea/deploymentTargetDropDown.xml
 .idea/gradle.xml
+.idea/deploymentTargetSelector.xml
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
index 24b6073..c64c910 100644
--- a/.idea/androidTestResultsUserPreferences.xml
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -3,65 +3,14 @@
   <component name="AndroidTestResultsUserPreferences">
     <option name="androidTestResultsTableState">
       <map>
-        <entry key="-1168588695">
+        <entry key="811462001">
           <value>
             <AndroidTestResultsTableState>
               <option name="preferredColumnWidths">
                 <map>
                   <entry key="Duration" value="90" />
                   <entry key="Pixel_7_Pro_API_34" value="120" />
-                  <entry key="Tests" value="360" />
-                </map>
-              </option>
-            </AndroidTestResultsTableState>
-          </value>
-        </entry>
-        <entry key="401594821">
-          <value>
-            <AndroidTestResultsTableState>
-              <option name="preferredColumnWidths">
-                <map>
-                  <entry key="Duration" value="90" />
-                  <entry key="Pixel_6_Pro_API_30" value="120" />
-                  <entry key="Tests" value="360" />
-                </map>
-              </option>
-            </AndroidTestResultsTableState>
-          </value>
-        </entry>
-        <entry key="571770275">
-          <value>
-            <AndroidTestResultsTableState>
-              <option name="preferredColumnWidths">
-                <map>
-                  <entry key="Duration" value="90" />
-                  <entry key="Pixel_7_Pro_API_34" value="120" />
-                  <entry key="Tests" value="360" />
-                </map>
-              </option>
-            </AndroidTestResultsTableState>
-          </value>
-        </entry>
-        <entry key="632950842">
-          <value>
-            <AndroidTestResultsTableState>
-              <option name="preferredColumnWidths">
-                <map>
-                  <entry key="Duration" value="90" />
-                  <entry key="Tests" value="360" />
-                  <entry key="samsung SM-G990U1" value="120" />
-                </map>
-              </option>
-            </AndroidTestResultsTableState>
-          </value>
-        </entry>
-        <entry key="2043991187">
-          <value>
-            <AndroidTestResultsTableState>
-              <option name="preferredColumnWidths">
-                <map>
-                  <entry key="Duration" value="90" />
-                  <entry key="Pixel_6_Pro_API_30" value="120" />
+                  <entry key="Pixel_C_API_34" value="120" />
                   <entry key="Tests" value="360" />
                 </map>
               </option>
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 37f7544..ce85930 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,25 +4,21 @@
   <component name="GradleSettings">
     <option name="linkedExternalProjectsSettings">
       <GradleProjectSettings>
-        <option name="testRunner" value="GRADLE" />
-        <option name="distributionType" value="DEFAULT_WRAPPED" />
         <option name="externalProjectPath" value="$PROJECT_DIR$" />
-        <option name="gradleJvm" value="Android Studio default JDK" />
+        <option name="gradleJvm" value="jbr-17" />
         <option name="modules">
           <set>
             <option value="$PROJECT_DIR$" />
             <option value="$PROJECT_DIR$/app" />
             <option value="$PROJECT_DIR$/benchmark" />
-            <option value="$PROJECT_DIR$/camera-viewfinder-compose" />
             <option value="$PROJECT_DIR$/core" />
+            <option value="$PROJECT_DIR$/core/camera" />
             <option value="$PROJECT_DIR$/core/common" />
             <option value="$PROJECT_DIR$/data" />
             <option value="$PROJECT_DIR$/data/settings" />
-            <option value="$PROJECT_DIR$/domain" />
-            <option value="$PROJECT_DIR$/domain/camera" />
             <option value="$PROJECT_DIR$/feature" />
+            <option value="$PROJECT_DIR$/feature/permissions" />
             <option value="$PROJECT_DIR$/feature/preview" />
-            <option value="$PROJECT_DIR$/feature/quicksettings" />
             <option value="$PROJECT_DIR$/feature/settings" />
           </set>
         </option>
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 2b8a50f..8d81632 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="KotlinJpsPluginSettings">
-    <option name="version" value="1.8.0" />
+    <option name="version" value="1.9.22" />
   </component>
 </project>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index e67ad2b..0ff99b3 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,3 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="ExternalStorageConfigurationManager" enabled="true" />
   <component name="NullableNotNullManager">
@@ -5,7 +6,7 @@
     <option name="myDefaultNotNull" value="androidx.annotation.NonNull" />
     <option name="myNullables">
       <value>
-        <list size="17">
+        <list size="18">
           <item index="0" class="java.lang.String" itemvalue="com.android.annotations.Nullable" />
           <item index="1" class="java.lang.String" itemvalue="org.jspecify.nullness.Nullable" />
           <item index="2" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" />
@@ -23,12 +24,13 @@
           <item index="14" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" />
           <item index="15" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" />
           <item index="16" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" />
+          <item index="17" class="java.lang.String" itemvalue="jakarta.annotation.Nullable" />
         </list>
       </value>
     </option>
     <option name="myNotNulls">
       <value>
-        <list size="16">
+        <list size="17">
           <item index="0" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
           <item index="1" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
           <item index="2" class="java.lang.String" itemvalue="org.jspecify.nullness.NonNull" />
@@ -45,11 +47,12 @@
           <item index="13" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" />
           <item index="14" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" />
           <item index="15" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
+          <item index="16" class="java.lang.String" itemvalue="jakarta.annotation.Nonnull" />
         </list>
       </value>
     </option>
   </component>
-  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
     <output url="file://$PROJECT_DIR$/build/classes" />
   </component>
   <component name="ProjectType">
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6f57fec..8686bda 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,6 +23,8 @@
 
 android {
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
+
     namespace = "com.google.jetpackcamera"
 
     defaultConfig {
@@ -48,6 +50,20 @@
             matchingFallbacks += listOf("release")
         }
     }
+
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+            isDefault = true
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_17
         targetCompatibility = JavaVersion.VERSION_17
@@ -90,6 +106,8 @@
 }
 
 dependencies {
+    implementation(libs.androidx.tracing)
+    implementation(project(":core:common"))
     // Compose
     val composeBom = platform(libs.compose.bom)
     implementation(composeBom)
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
index 0cf12c1..747650f 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
@@ -34,6 +34,9 @@
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_1_1_BUTTON
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_BUTTON
 import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.runScenarioTest
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
index e33f19e..0e57a00 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
@@ -27,6 +27,7 @@
 import androidx.test.uiautomator.UiDevice
 import com.google.common.truth.Truth.assertThat
 import com.google.common.truth.TruthJUnit.assume
+import com.google.jetpackcamera.feature.preview.R
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_DROP_DOWN
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLASH_BUTTON
 import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
@@ -34,6 +35,13 @@
 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
 import com.google.jetpackcamera.feature.preview.ui.SCREEN_FLASH_OVERLAY
 import com.google.jetpackcamera.settings.model.LensFacing
+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.assume
+import com.google.jetpackcamera.utils.getCurrentLensFacing
+import com.google.jetpackcamera.utils.onNodeWithContentDescription
+import com.google.jetpackcamera.utils.runScenarioTest
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -76,7 +84,7 @@
         composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
             .assertExists()
         composeTestRule.onNodeWithContentDescription(
-            com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_on_description
+            R.string.quick_settings_flash_on_description
         )
     }
 
@@ -99,7 +107,7 @@
             .performClick()
 
         composeTestRule.onNodeWithContentDescription(
-            com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_auto_description
+            R.string.quick_settings_flash_auto_description
         )
     }
 
@@ -111,7 +119,7 @@
         }
 
         composeTestRule.onNodeWithContentDescription(
-            com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_off_description
+            R.string.quick_settings_flash_off_description
         )
 
         // Navigate to quick settings
@@ -127,7 +135,7 @@
             .performClick()
 
         composeTestRule.onNodeWithContentDescription(
-            com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_off_description
+            R.string.quick_settings_flash_off_description
         )
     }
 
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
index e9abd40..3d86388 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
@@ -15,28 +15,28 @@
  */
 package com.google.jetpackcamera
 
-import android.app.Instrumentation
+import android.app.Activity
 import android.content.ComponentName
-import android.content.Context
 import android.content.Intent
 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.isDisplayed
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
 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.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 kotlinx.coroutines.test.runTest
+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.runScenarioTest
+import com.google.jetpackcamera.utils.runScenarioTestForResult
 import java.io.File
 import java.net.URLConnection
 import org.junit.Rule
@@ -51,70 +51,70 @@
     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)
-    private val context = InstrumentationRegistry.getInstrumentation().targetContext
 
     @Test
-    fun image_capture() = runTest {
+    fun image_capture() = runScenarioTest<MainActivity> {
         val timeStamp = System.currentTimeMillis()
-        activityScenario = ActivityScenario.launch(MainActivity::class.java)
-        uiDevice.wait(
-            Until.findObject(By.res(CAPTURE_BUTTON)),
-            5000
-        )
-        uiDevice.findObject(By.res(CAPTURE_BUTTON)).click()
-        uiDevice.wait(
-            Until.findObject(By.res(IMAGE_CAPTURE_SUCCESS_TAG)),
-            5000
-        )
-        assert(deleteFilesInDirAfterTimestamp(timeStamp))
-    }
+        // Wait for the capture button to be displayed
+        composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+            composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+        }
 
-    @Test
-    fun image_capture_external() = runTest {
-        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)).click()
-            uiDevice.wait(
-                Until.findObject(By.res(IMAGE_CAPTURE_SUCCESS_TAG)),
-                5000
-            )
-            activityScenario!!.result
-        }.register("key", TEST_CONTRACT) { result ->
-            assert(result)
-            assert(doesImageFileExist(uri))
-        }.launch(uri)
+        composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+            .assertExists()
+            .performClick()
+        composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
+            composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed()
+        }
+        assert(File(DIR_PATH).lastModified() > timeStamp)
         deleteFilesInDirAfterTimestamp(timeStamp)
     }
 
     @Test
-    fun image_capture_external_illegal_uri() = run {
+    fun image_capture_external() {
         val timeStamp = System.currentTimeMillis()
-        val inputUri = Uri.parse("asdfasdf")
-        getTestRegistry {
-            activityScenario = ActivityScenario.launchActivityForResult(it)
-            uiDevice.wait(
-                Until.findObject(By.res(CAPTURE_BUTTON)),
-                5000
-            )
-            uiDevice.findObject(By.res(CAPTURE_BUTTON)).click()
-            uiDevice.wait(
-                Until.findObject(By.res(IMAGE_CAPTURE_FAILURE_TAG)),
-                5000
-            )
-            uiDevice.pressBack()
-            activityScenario!!.result
-        }.register("key_illegal_uri", TEST_CONTRACT) { result ->
-            assert(!result)
-        }.launch(inputUri)
+        val uri = getTestUri(timeStamp)
+        val result =
+            runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+                // 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()
+            }
+        assert(result?.resultCode == Activity.RESULT_OK)
+        assert(doesImageFileExist(uri))
+        deleteFilesInDirAfterTimestamp(timeStamp)
+    }
+
+    @Test
+    fun image_capture_external_illegal_uri() {
+        val uri = Uri.parse("asdfasdf")
+        val result =
+            runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+                // 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_FAILURE_TAG).isDisplayed()
+                }
+                uiDevice.pressBack()
+            }
+        assert(result?.resultCode == Activity.RESULT_CANCELED)
+        assert(!doesImageFileExist(uri))
     }
 
     private fun doesImageFileExist(uri: Uri): Boolean {
@@ -143,28 +143,6 @@
         return hasDeletedFile
     }
 
-    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)
-            }
-        }
-        return testRegistry
-    }
-
     private fun getTestUri(timeStamp: Long): Uri {
         return Uri.fromFile(
             File(
@@ -174,19 +152,20 @@
         )
     }
 
+    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
+    }
+
     companion object {
         val DIR_PATH: String =
             Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path
-
-        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
-                    )
-                }
-            }
-        }
     }
 }
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
index d06904d..b3e82ab 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
@@ -30,7 +30,13 @@
 import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
 import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON
 import com.google.jetpackcamera.feature.preview.ui.SETTINGS_BUTTON
+import com.google.jetpackcamera.settings.R
 import com.google.jetpackcamera.settings.ui.BACK_BUTTON
+import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.assume
+import com.google.jetpackcamera.utils.onNodeWithText
+import com.google.jetpackcamera.utils.runScenarioTest
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -72,7 +78,7 @@
 
         // Assert we do not see the settings screen based on the title
         composeTestRule.onNodeWithText(
-            com.google.jetpackcamera.settings.R.string.settings_title
+            R.string.settings_title
         ).assertDoesNotExist()
     }
 
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
index 1727db5..5d732a9 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
@@ -32,6 +32,10 @@
 import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON
 import com.google.jetpackcamera.feature.preview.ui.PREVIEW_DISPLAY
 import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.assume
+import com.google.jetpackcamera.utils.getCurrentLensFacing
+import com.google.jetpackcamera.utils.runScenarioTest
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt
new file mode 100644
index 0000000..0cfbd73
--- /dev/null
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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
+
+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.performTouchInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.RequiresDevice
+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.assertThat
+import com.google.jetpackcamera.feature.preview.ui.AMPLITUDE_HOT_TAG
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.runScenarioTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@RequiresDevice
+class VideoAudioTest {
+    @get:Rule
+    val permissionsRule: GrantPermissionRule =
+        GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+    @get:Rule
+    val composeTestRule = createEmptyComposeRule()
+
+    private val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @Before
+    fun setUp() {
+        assertThat(uiDevice.isScreenOn).isTrue()
+    }
+
+    @Test
+    fun audioIncomingWhenEnabled() {
+        runScenarioTest<MainActivity> {
+            // check audio visualizer composable for muted/unmuted icon.
+            // icon will only be unmuted if audio is nonzero
+            composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+                composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+            }
+
+            // record video
+            composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+                .assertExists().performTouchInput { longClick(durationMillis = 5000) }
+
+            // assert hot amplitude tag visible
+            uiDevice.wait(
+                Until.findObject(By.res(AMPLITUDE_HOT_TAG)),
+                5000
+            )
+        }
+    }
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
index 437f9a4..ee81021 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
@@ -35,6 +35,7 @@
 import androidx.test.uiautomator.Until
 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.utils.APP_REQUIRED_PERMISSIONS
 import java.io.File
 import org.junit.Rule
 import org.junit.Test
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt
similarity index 95%
rename from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
rename to app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt
index b68f8e6..0d1e8d6 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera
+package com.google.jetpackcamera.utils
 
 import android.os.Build
 
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
similarity index 98%
rename from app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt
rename to app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
index 1e1bca9..e3be80e 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera
+package com.google.jetpackcamera.utils
 
 import android.content.Context
 import androidx.annotation.StringRes
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
similarity index 84%
rename from app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
rename to app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
index 118bd7b..af2c512 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2023 The Android Open Source Project
+ * 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.
@@ -13,15 +13,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera
+package com.google.jetpackcamera.utils
 
 import android.app.Activity
+import android.app.Instrumentation
+import android.content.Intent
 import androidx.compose.ui.semantics.SemanticsProperties
 import androidx.compose.ui.test.isDisplayed
 import androidx.compose.ui.test.junit4.ComposeTestRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performClick
 import androidx.test.core.app.ActivityScenario
+import com.google.jetpackcamera.MainActivity
 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
@@ -37,6 +40,16 @@
     }
 }
 
+inline fun <reified T : Activity> runScenarioTestForResult(
+    intent: Intent,
+    crossinline block: ActivityScenario<T>.() -> Unit
+): Instrumentation.ActivityResult? {
+    ActivityScenario.launchActivityForResult<T>(intent).use { scenario ->
+        scenario.apply(block)
+        return scenario.result
+    }
+}
+
 context(ActivityScenario<MainActivity>)
 fun ComposeTestRule.getCurrentLensFacing(): LensFacing {
     var needReturnFromQuickSettings = false
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
index 1ac223c..f00aee6 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
@@ -54,14 +54,17 @@
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
+import androidx.tracing.Trace
 import com.google.jetpackcamera.MainActivityUiState.Loading
 import com.google.jetpackcamera.MainActivityUiState.Success
+import com.google.jetpackcamera.core.common.traceFirstFrameMainActivity
 import com.google.jetpackcamera.feature.preview.PreviewMode
 import com.google.jetpackcamera.feature.preview.PreviewViewModel
 import com.google.jetpackcamera.settings.model.DarkMode
 import com.google.jetpackcamera.ui.JcaApp
 import com.google.jetpackcamera.ui.theme.JetpackCameraTheme
 import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
@@ -90,6 +93,18 @@
                     .collect()
             }
         }
+
+        var firstFrameComplete: CompletableDeferred<Unit>? = null
+        if (Trace.isEnabled()) {
+            firstFrameComplete = CompletableDeferred()
+            // start trace between app starting and the earliest possible completed capture
+            lifecycleScope.launch {
+                traceFirstFrameMainActivity(cookie = 0) {
+                    firstFrameComplete.await()
+                }
+            }
+        }
+
         setContent {
             when (uiState) {
                 Loading -> {
@@ -132,6 +147,9 @@
                                         )
                                         window?.colorMode = colorMode
                                     }
+                                },
+                                onFirstFrameCaptureCompleted = {
+                                    firstFrameComplete?.complete(Unit)
                                 }
                             )
                         }
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 1e7add4..2f2ea31 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
+++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
@@ -36,19 +36,20 @@
 import com.google.jetpackcamera.ui.Routes.PREVIEW_ROUTE
 import com.google.jetpackcamera.ui.Routes.SETTINGS_ROUTE
 
-@OptIn(ExperimentalPermissionsApi::class)
 @Composable
 fun JcaApp(
     openAppSettings: () -> Unit,
     /*TODO(b/306236646): remove after still capture*/
     previewMode: PreviewMode,
+    modifier: Modifier = Modifier,
     onRequestWindowColorMode: (Int) -> Unit,
-    modifier: Modifier = Modifier
+    onFirstFrameCaptureCompleted: () -> Unit
 ) {
     JetpackCameraNavHost(
         previewMode = previewMode,
         onOpenAppSettings = openAppSettings,
         onRequestWindowColorMode = onRequestWindowColorMode,
+        onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
         modifier = modifier
     )
 }
@@ -60,6 +61,7 @@
     previewMode: PreviewMode,
     onOpenAppSettings: () -> Unit,
     onRequestWindowColorMode: (Int) -> Unit,
+    onFirstFrameCaptureCompleted: () -> Unit,
     navController: NavHostController = rememberNavController()
 ) {
     NavHost(
@@ -69,6 +71,7 @@
     ) {
         composable(PERMISSIONS_ROUTE) {
             PermissionsScreen(
+                shouldRequestAudioPermission = previewMode is PreviewMode.StandardMode,
                 onNavigateToPreview = {
                     navController.navigate(PREVIEW_ROUTE) {
                         // cannot navigate back to permissions after leaving
@@ -98,6 +101,7 @@
             PreviewScreen(
                 onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) },
                 onRequestWindowColorMode = onRequestWindowColorMode,
+                onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
                 previewMode = previewMode
             )
         }
@@ -112,4 +116,3 @@
         }
     }
 }
-
diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts
index 72fb9ec..a923cc4 100644
--- a/benchmark/build.gradle.kts
+++ b/benchmark/build.gradle.kts
@@ -22,6 +22,7 @@
 android {
     namespace = "com.google.jetpackcamera.benchmark"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_1_8
@@ -55,6 +56,18 @@
         }
     }
 
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
+    }
+
     targetProjectPath = ":app"
     // required for benchmark:
     // self instrumentation required for the tests to be able to compile, start, or kill the app
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/FirstFrameBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/FirstFrameBenchmark.kt
new file mode 100644
index 0000000..3ed5ae4
--- /dev/null
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/FirstFrameBenchmark.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.benchmark
+
+import android.content.Intent
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.TraceSectionMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.benchmark.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.benchmark.utils.DEFAULT_TEST_ITERATIONS
+import com.google.jetpackcamera.benchmark.utils.FIRST_FRAME_TRACE_MAIN_ACTIVITY
+import com.google.jetpackcamera.benchmark.utils.FIRST_FRAME_TRACE_PREVIEW
+import com.google.jetpackcamera.benchmark.utils.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.benchmark.utils.JCA_PACKAGE_NAME
+import com.google.jetpackcamera.benchmark.utils.allowAllRequiredPerms
+import com.google.jetpackcamera.benchmark.utils.clickCaptureButton
+import com.google.jetpackcamera.benchmark.utils.findObjectByRes
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FirstFrameBenchmark {
+    @get:Rule
+    val benchmarkRule = MacrobenchmarkRule()
+
+    @Test
+    fun timeToFirstFrameDefaultSettingsColdStartup() {
+        benchmarkFirstFrame(setupBlock = {
+            allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray())
+        })
+    }
+
+    @Test
+    fun timeToFirstFrameDefaultSettingsHotStartup() {
+        benchmarkFirstFrame(startupMode = StartupMode.HOT, setupBlock = {
+            allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray())
+        })
+    }
+
+    /**
+     * The benchmark for first frame tracks the amount of time it takes from preview loading on the
+     * screen to when the use case is able to start capturing frames.
+     *
+     * Note that the trace this benchmark tracks is the earliest point in which a frame is captured
+     * and sent to a surface. This does not necessarily mean the frame is visible on screen.
+     *
+     * @param startupMode the designated startup mode, either [StartupMode.COLD] or [StartupMode.HOT]
+     * @param timeout option to change the default timeout length after clicking the Image Capture
+     *  button.
+     *
+     */
+    @OptIn(ExperimentalMetricApi::class)
+    private fun benchmarkFirstFrame(
+        startupMode: StartupMode? = StartupMode.COLD,
+        iterations: Int = DEFAULT_TEST_ITERATIONS,
+        timeout: Long = 15000,
+        intent: Intent? = null,
+        setupBlock: MacrobenchmarkScope.() -> Unit = {}
+    ) {
+        benchmarkRule.measureRepeated(
+            packageName = JCA_PACKAGE_NAME,
+            metrics = buildList {
+                add(StartupTimingMetric())
+                if (startupMode == StartupMode.COLD) {
+                    add(
+                        TraceSectionMetric(
+                            sectionName = FIRST_FRAME_TRACE_MAIN_ACTIVITY,
+                            targetPackageOnly = false,
+                            mode = TraceSectionMetric.Mode.First
+                        )
+                    )
+                }
+                add(
+                    TraceSectionMetric(
+                        sectionName = FIRST_FRAME_TRACE_PREVIEW,
+                        targetPackageOnly = false,
+                        mode = TraceSectionMetric.Mode.First
+                    )
+                )
+            },
+            iterations = iterations,
+            startupMode = startupMode,
+            setupBlock = setupBlock
+        ) {
+            pressHome()
+            if (intent == null) startActivityAndWait() else startActivityAndWait(intent)
+            device.waitForIdle()
+
+            clickCaptureButton(device)
+
+            // ensure trace is closed
+            findObjectByRes(
+                device = device,
+                testTag = IMAGE_CAPTURE_SUCCESS_TAG,
+                timeout = timeout,
+                shouldFailIfNotFound = true
+            )
+        }
+    }
+}
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
index 2096265..787e16f 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
@@ -20,6 +20,17 @@
 import androidx.benchmark.macro.TraceSectionMetric
 import androidx.benchmark.macro.junit4.MacrobenchmarkRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.benchmark.utils.DEFAULT_TEST_ITERATIONS
+import com.google.jetpackcamera.benchmark.utils.FlashMode
+import com.google.jetpackcamera.benchmark.utils.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.benchmark.utils.IMAGE_CAPTURE_TRACE
+import com.google.jetpackcamera.benchmark.utils.JCA_PACKAGE_NAME
+import com.google.jetpackcamera.benchmark.utils.allowCamera
+import com.google.jetpackcamera.benchmark.utils.clickCaptureButton
+import com.google.jetpackcamera.benchmark.utils.findObjectByRes
+import com.google.jetpackcamera.benchmark.utils.setQuickFrontFacingCamera
+import com.google.jetpackcamera.benchmark.utils.setQuickSetFlash
+import com.google.jetpackcamera.benchmark.utils.toggleQuickSettings
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -97,7 +108,7 @@
             // ensure trace is closed
             findObjectByRes(
                 device = device,
-                testTag = IMAGE_CAPTURE_SUCCESS_TOAST,
+                testTag = IMAGE_CAPTURE_SUCCESS_TAG,
                 timeout = timeout,
                 shouldFailIfNotFound = true
             )
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
index c97a45f..3c1275c 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
@@ -20,6 +20,10 @@
 import androidx.benchmark.macro.StartupTimingMetric
 import androidx.benchmark.macro.junit4.MacrobenchmarkRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.benchmark.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.benchmark.utils.DEFAULT_TEST_ITERATIONS
+import com.google.jetpackcamera.benchmark.utils.JCA_PACKAGE_NAME
+import com.google.jetpackcamera.benchmark.utils.allowAllRequiredPerms
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -34,33 +38,33 @@
     val benchmarkRule = MacrobenchmarkRule()
 
     @Test
-    fun startupColdWithoutCameraPermission() {
+    fun startupColdWithPermissionRequest() {
         benchmarkStartup()
     }
 
     @Test
-    fun startupCold() {
+    fun startupColdNoPermissionRequest() {
         benchmarkStartup(
             setupBlock =
-            { allowCamera() }
+            { allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray()) }
         )
     }
 
     @Test
-    fun startupWarm() {
+    fun startupWarmNoPermissionRequest() {
         benchmarkStartup(
             startupMode = StartupMode.WARM,
             setupBlock =
-            { allowCamera() }
+            { allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray()) }
         )
     }
 
     @Test
-    fun startupHot() {
+    fun startupHotNoPermissionRequest() {
         benchmarkStartup(
             startupMode = StartupMode.HOT,
             setupBlock =
-            { allowCamera() }
+            { allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray()) }
         )
     }
 
@@ -74,7 +78,6 @@
             iterations = DEFAULT_TEST_ITERATIONS,
             startupMode = startupMode,
             setupBlock = setupBlock
-
         ) {
             pressHome()
             startActivityAndWait()
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Permissions.kt
similarity index 61%
copy from benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
copy to benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Permissions.kt
index fbe4594..af0455c 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Permissions.kt
@@ -13,14 +13,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.benchmark
+package com.google.jetpackcamera.benchmark.utils
 
 import android.Manifest.permission
+import android.os.Build
 import androidx.benchmark.macro.MacrobenchmarkScope
-import org.junit.Assert
 
+val APP_REQUIRED_PERMISSIONS: List<String> = buildList {
+    add(permission.CAMERA)
+    add(permission.RECORD_AUDIO)
+    if (Build.VERSION.SDK_INT <= 28) {
+        add(permission.WRITE_EXTERNAL_STORAGE)
+    }
+}
 fun MacrobenchmarkScope.allowCamera() {
     val command = "pm grant $packageName ${permission.CAMERA}"
-    val output = device.executeShellCommand(command)
-    Assert.assertEquals("", output)
+    device.executeShellCommand(command)
+}
+
+fun MacrobenchmarkScope.allowAllRequiredPerms(vararg perms: String) {
+    val command = "pm grant $packageName"
+    perms.forEach { perm -> device.executeShellCommand("$command $perm") }
 }
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Utils.kt
similarity index 94%
rename from benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
rename to benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Utils.kt
index 1204001..10b030e 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Utils.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.benchmark
+package com.google.jetpackcamera.benchmark.utils
 
 import androidx.test.uiautomator.By
 import androidx.test.uiautomator.UiDevice
@@ -29,7 +29,7 @@
 const val QUICK_SETTINGS_DROP_DOWN_BUTTON = "QuickSettingsDropDown"
 const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSettingsFlashButton"
 const val QUICK_SETTINGS_FLIP_CAMERA_BUTTON = "QuickSettingsFlipCameraButton"
-const val IMAGE_CAPTURE_SUCCESS_TOAST = "ImageCaptureSuccessToast"
+const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag"
 
 // test descriptions
 const val QUICK_SETTINGS_FLASH_OFF = "QUICK SETTINGS FLASH IS OFF"
@@ -40,6 +40,9 @@
 // trace tags
 const val IMAGE_CAPTURE_TRACE = "JCA Image Capture"
 
+const val FIRST_FRAME_TRACE_PREVIEW = "firstFrameTracePreview"
+const val FIRST_FRAME_TRACE_MAIN_ACTIVITY = "firstFrameTraceMainActivity"
+
 // enums
 enum class FlashMode {
     ON,
diff --git a/domain/camera/.gitignore b/core/camera/.gitignore
similarity index 100%
rename from domain/camera/.gitignore
rename to core/camera/.gitignore
diff --git a/domain/camera/Android.bp b/core/camera/Android.bp
similarity index 87%
rename from domain/camera/Android.bp
rename to core/camera/Android.bp
index 0872d4f..fc9c829 100644
--- a/domain/camera/Android.bp
+++ b/core/camera/Android.bp
@@ -5,13 +5,12 @@
 }
 
 android_library {
-    name: "jetpack-camera-app_domain_camera",
+    name: "jetpack-camera-app_core_camera",
     srcs: ["src/main/**/*.kt"],
     static_libs: [
         "androidx.concurrent_concurrent-futures-ktx",
         "hilt_android",
         "androidx.camera_camera-core",
-        "androidx.camera_camera-viewfinder",
         "androidx.camera_camera-video",
         "androidx.camera_camera-camera2",
         "androidx.camera_camera-lifecycle",
diff --git a/domain/camera/build.gradle.kts b/core/camera/build.gradle.kts
similarity index 61%
rename from domain/camera/build.gradle.kts
rename to core/camera/build.gradle.kts
index c79cf4b..4fb048a 100644
--- a/domain/camera/build.gradle.kts
+++ b/core/camera/build.gradle.kts
@@ -22,8 +22,9 @@
 }
 
 android {
-    namespace = "com.google.jetpackcamera.data.camera"
+    namespace = "com.google.jetpackcamera.core.camera"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     defaultConfig {
         minSdk = libs.versions.minSdk.get().toInt()
@@ -31,6 +32,49 @@
         lint.targetSdk = libs.versions.targetSdk.get().toInt()
 
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+        @Suppress("UnstableApiUsage")
+        externalNativeBuild {
+            val versionScript = file("src/main/cpp/jni.lds").absolutePath
+            cmake {
+                cppFlags += listOf(
+                    "-std=c++17",
+                    "-O3",
+                    "-flto",
+                    "-fPIC",
+                    "-fno-exceptions",
+                    "-fno-rtti",
+                    "-fomit-frame-pointer",
+                    "-fdata-sections",
+                    "-ffunction-sections"
+                )
+                arguments += listOf(
+                    "-DCMAKE_VERBOSE_MAKEFILE=ON",
+                    "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,--gc-sections " +
+                        "-Wl,--version-script=${versionScript}"
+                )
+            }
+        }
+    }
+
+    externalNativeBuild {
+        cmake {
+            version = libs.versions.cmake.get()
+            path = file("src/main/cpp/CMakeLists.txt")
+        }
+    }
+
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+            isDefault = true
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
     }
 
     compileOptions {
@@ -66,6 +110,7 @@
 
     // Tracing
     implementation(libs.androidx.tracing)
+    implementation(libs.kotlinx.atomicfu)
 
     // Graphics libraries
     implementation(libs.androidx.graphics.core)
diff --git a/domain/camera/src/main/AndroidManifest.xml b/core/camera/src/main/AndroidManifest.xml
similarity index 87%
rename from domain/camera/src/main/AndroidManifest.xml
rename to core/camera/src/main/AndroidManifest.xml
index ea4043c..39bd3ea 100644
--- a/domain/camera/src/main/AndroidManifest.xml
+++ b/core/camera/src/main/AndroidManifest.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (C) 2023 The Android Open Source Project
+  ~ 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.
@@ -15,7 +15,6 @@
   ~ limitations under the License.
   -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-  package="com.google.jetpackcamera.domain.camera">
+    package="com.google.jetpackcamera.core.camera">
     <uses-permission android:name = "android.permission.RECORD_AUDIO" />
 </manifest>
-
diff --git a/core/camera/src/main/cpp/CMakeLists.txt b/core/camera/src/main/cpp/CMakeLists.txt
new file mode 100644
index 0000000..008a8a2
--- /dev/null
+++ b/core/camera/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+cmake_minimum_required(VERSION 3.22.1)
+
+project(core_camera_jni)
+
+add_library(
+        opengl_debug_lib
+        SHARED
+        opengl_debug_jni.cpp
+        jni_hooks.cpp
+)
+
+find_library(log-lib log)
+find_library(opengles3-lib GLESv3)
+target_link_libraries(opengl_debug_lib PRIVATE ${log-lib} ${opengles3-lib})
+target_link_options(
+        opengl_debug_lib
+        PRIVATE
+        "-Wl,-z,max-page-size=16384"
+)
diff --git a/core/camera/src/main/cpp/jni.lds b/core/camera/src/main/cpp/jni.lds
new file mode 100644
index 0000000..c619e88
--- /dev/null
+++ b/core/camera/src/main/cpp/jni.lds
@@ -0,0 +1,10 @@
+VERS_1.0 {
+  # Export JNI symbols.
+  global:
+    Java_*;
+    JNI_OnLoad;
+
+  # Hide everything else.
+  local:
+    *;
+};
diff --git a/core/camera/src/main/cpp/jni_hooks.cpp b/core/camera/src/main/cpp/jni_hooks.cpp
new file mode 100644
index 0000000..ee1cc2c
--- /dev/null
+++ b/core/camera/src/main/cpp/jni_hooks.cpp
@@ -0,0 +1,23 @@
+/*
+ * Copyright 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.
+ */
+
+#include <jni.h>
+
+extern "C" {
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) {
+    return JNI_VERSION_1_6;
+}
+}
diff --git a/core/camera/src/main/cpp/opengl_debug_jni.cpp b/core/camera/src/main/cpp/opengl_debug_jni.cpp
new file mode 100644
index 0000000..58e5a86
--- /dev/null
+++ b/core/camera/src/main/cpp/opengl_debug_jni.cpp
@@ -0,0 +1,44 @@
+/*
+ * Copyright 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.
+ */
+
+#include <android/log.h>
+
+#define GL_GLEXT_PROTOTYPES
+#include <GLES2/gl2.h>
+#include <GLES2/gl2ext.h>
+#include <jni.h>
+
+namespace {
+    auto constexpr LOG_TAG = "OpenGLDebugLib";
+
+    void gl_debug_cb(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length,
+                     const GLchar* message, const void* userParam) {
+        if (type == GL_DEBUG_TYPE_ERROR_KHR) {
+            __android_log_print(ANDROID_LOG_ERROR, LOG_TAG,
+                                "GL ERROR:\n %s.",
+                                message);
+        }
+    }
+} // namespace
+
+extern "C" {
+JNIEXPORT void JNICALL
+Java_com_google_jetpackcamera_core_camera_effects_GLDebug_enableES3DebugErrorLogging(
+        JNIEnv *env, jobject clazz) {
+    glDebugMessageCallbackKHR(gl_debug_cb, nullptr);
+    glEnable(GL_DEBUG_OUTPUT_KHR);
+}
+}
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
similarity index 63%
rename from benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
index fbe4594..27ea15f 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
@@ -13,14 +13,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.benchmark
+package com.google.jetpackcamera.core.camera
 
-import android.Manifest.permission
-import androidx.benchmark.macro.MacrobenchmarkScope
-import org.junit.Assert
+import androidx.camera.core.MeteringPoint
 
-fun MacrobenchmarkScope.allowCamera() {
-    val command = "pm grant $packageName ${permission.CAMERA}"
-    val output = device.executeShellCommand(command)
-    Assert.assertEquals("", output)
+/**
+ * An event that can be sent to the camera coroutine.
+ */
+sealed interface CameraEvent {
+
+    /**
+     * Represents a focus metering event, that the camera can act on.
+     */
+    class FocusMeteringEvent(val meteringPoint: MeteringPoint) : CameraEvent
 }
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraModule.kt
similarity index 94%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraModule.kt
index a2348ac..db2e167 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraModule.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
 
 import dagger.Binds
 import dagger.Module
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
similarity index 83%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
index 9cf1fc4..4c8fbc8 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
@@ -13,19 +13,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
 
 import android.content.ContentResolver
 import android.net.Uri
-import android.view.Display
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.SurfaceRequest
 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.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 kotlinx.coroutines.flow.StateFlow
 
@@ -70,7 +72,7 @@
 
     fun setZoomScale(scale: Float)
 
-    fun getZoomScale(): StateFlow<Float>
+    fun getCurrentCameraState(): StateFlow<CameraState>
 
     fun getSurfaceRequest(): StateFlow<SurfaceRequest?>
 
@@ -86,12 +88,20 @@
 
     suspend fun setLensFacing(lensFacing: LensFacing)
 
-    fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float)
+    suspend fun tapToFocus(x: Float, y: Float)
 
     suspend fun setCaptureMode(captureMode: CaptureMode)
 
     suspend fun setDynamicRange(dynamicRange: DynamicRange)
 
+    fun setDeviceRotation(deviceRotation: DeviceRotation)
+
+    suspend fun setLowLightBoost(lowLightBoost: LowLightBoost)
+
+    suspend fun setImageFormat(imageFormat: ImageOutputFormat)
+
+    suspend fun setAudioMuted(isAudioMuted: Boolean)
+
     /**
      * Represents the events required for screen flash.
      */
@@ -113,3 +123,8 @@
         object OnVideoRecordError : OnVideoRecordEvent
     }
 }
+
+data class CameraState(
+    val zoomScale: Float = 1f,
+    val sessionFirstFrameTimestamp: Long = 0L
+)
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
similarity index 69%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
index a20c4a0..7561056 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
@@ -13,31 +13,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera
+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.Environment
+import android.os.SystemClock
 import android.provider.MediaStore
 import android.util.Log
 import android.util.Range
-import android.view.Display
+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.AspectRatio.RATIO_DEFAULT
 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
@@ -54,23 +64,25 @@
 import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
 import androidx.core.content.ContextCompat
 import androidx.core.content.ContextCompat.checkSelfPermission
-import com.google.jetpackcamera.domain.camera.CameraUseCase.ScreenFlashEvent.Type
-import com.google.jetpackcamera.domain.camera.effects.SingleSurfaceForcingEffect
+import com.google.jetpackcamera.core.camera.CameraUseCase.ScreenFlashEvent.Type
+import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect
 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.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.SupportedStabilizationMode
 import com.google.jetpackcamera.settings.model.SystemConstraints
 import dagger.hilt.android.scopes.ViewModelScoped
 import java.io.FileNotFoundException
-import java.lang.IllegalArgumentException
 import java.text.SimpleDateFormat
 import java.util.Calendar
 import java.util.Date
@@ -78,10 +90,13 @@
 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.coroutineScope
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -90,6 +105,7 @@
 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
@@ -120,6 +136,39 @@
 
     private lateinit var imageCaptureUseCase: ImageCapture
 
+    /**
+     * 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
@@ -128,6 +177,8 @@
 
     private val screenFlashEvents: MutableSharedFlow<CameraUseCase.ScreenFlashEvent> =
         MutableSharedFlow()
+    private val focusMeteringEvents =
+        Channel<CameraEvent.FocusMeteringEvent>(capacity = Channel.CONFLATED)
 
     private val currentSettings = MutableStateFlow<CameraAppSettings?>(null)
 
@@ -168,13 +219,21 @@
                         }
 
                         val supportedFixedFrameRates = getSupportedFrameRates(camInfo)
+                        val supportedImageFormats = getSupportedImageFormats(camInfo)
 
                         put(
                             lensFacing,
                             CameraConstraints(
                                 supportedStabilizationModes = supportedStabilizationModes,
                                 supportedFixedFrameRates = supportedFixedFrameRates,
-                                supportedDynamicRanges = supportedDynamicRanges
+                                supportedDynamicRanges = supportedDynamicRanges,
+                                supportedImageFormatsMap = mapOf(
+                                    // Only JPEG is supported in single-stream mode, since
+                                    // single-stream mode uses CameraEffect, which does not support
+                                    // Ultra HDR now.
+                                    Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
+                                    Pair(CaptureMode.MULTI_STREAM, supportedImageFormats)
+                                )
                             )
                         )
                     }
@@ -188,30 +247,7 @@
             settingsRepository.defaultCameraAppSettings.first()
                 .tryApplyDynamicRangeConstraints()
                 .tryApplyAspectRatioForExternalCapture(externalImageCapture)
-
-        imageCaptureUseCase = ImageCapture.Builder()
-            .setResolutionSelector(
-                getResolutionSelector(
-                    settingsRepository.defaultCameraAppSettings.first().aspectRatio
-                )
-            ).build()
-    }
-
-    /**
-     * Returns the union of supported stabilization modes for a device's cameras
-     */
-    private fun getDeviceSupportedStabilizations(): Set<SupportedStabilizationMode> {
-        val deviceSupportedStabilizationModes = mutableSetOf<SupportedStabilizationMode>()
-
-        cameraProvider.availableCameraInfos.forEach { cameraInfo ->
-            if (isPreviewStabilizationSupported(cameraInfo)) {
-                deviceSupportedStabilizationModes.add(SupportedStabilizationMode.ON)
-            }
-            if (isVideoStabilizationSupported(cameraInfo)) {
-                deviceSupportedStabilizationModes.add(SupportedStabilizationMode.HIGH_QUALITY)
-            }
-        }
-        return deviceSupportedStabilizationModes
+                .tryApplyImageFormatConstraints()
     }
 
     /**
@@ -227,7 +263,8 @@
         val targetFrameRate: Int,
         val stabilizePreviewMode: Stabilization,
         val stabilizeVideoMode: Stabilization,
-        val dynamicRange: DynamicRange
+        val dynamicRange: DynamicRange,
+        val imageFormat: ImageOutputFormat
     )
 
     /**
@@ -238,6 +275,8 @@
      * 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
     )
@@ -250,6 +289,8 @@
             .filterNotNull()
             .map { currentCameraSettings ->
                 transientSettings.value = TransientSessionSettings(
+                    audioMuted = currentCameraSettings.audioMuted,
+                    deviceRotation = currentCameraSettings.deviceRotation,
                     flashMode = currentCameraSettings.flashMode,
                     zoomScale = currentCameraSettings.zoomScale
                 )
@@ -266,7 +307,8 @@
                     targetFrameRate = currentCameraSettings.targetFrameRate,
                     stabilizePreviewMode = currentCameraSettings.previewStabilization,
                     stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
-                    dynamicRange = currentCameraSettings.dynamicRange
+                    dynamicRange = currentCameraSettings.dynamicRange,
+                    imageFormat = currentCameraSettings.imageFormat
                 )
             }.distinctUntilChanged()
             .collectLatest { sessionSettings ->
@@ -288,6 +330,7 @@
                     .first()
 
                 val useCaseGroup = createUseCaseGroup(
+                    cameraInfo,
                     sessionSettings,
                     initialTransientSettings,
                     cameraConstraints.supportedStabilizationModes,
@@ -300,6 +343,16 @@
                 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) {
@@ -310,7 +363,9 @@
                                         zoomState.maxZoomRatio
                                     )
                                 camera.cameraControl.setZoomRatio(finalScale)
-                                _zoomScale.value = finalScale
+                                _currentCameraState.update { old ->
+                                    old.copy(zoomScale = finalScale)
+                                }
                             }
                         }
 
@@ -322,6 +377,36 @@
                             )
                         }
 
+                        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
                     }
                 }
@@ -424,7 +509,12 @@
         }
         Log.d(TAG, "recordVideo")
         // todo(b/336886716): default setting to enable or disable audio when permission is granted
-        // todo(b/336888844): mute/unmute audio while recording is active
+
+        // 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,
@@ -442,7 +532,6 @@
             ContentValues().apply {
                 put(MediaStore.Video.Media.DISPLAY_NAME, name)
             }
-
         val mediaStoreOutput =
             MediaStoreOutputOptions.Builder(
                 application.contentResolver,
@@ -459,7 +548,11 @@
         recording =
             videoCaptureUseCase!!.output
                 .prepareRecording(application, mediaStoreOutput)
-                .apply { if (audioEnabled) withAudioEnabled() }
+                .apply {
+                    if (audioEnabled) {
+                        withAudioEnabled()
+                    }
+                }
                 .start(callbackExecutor) { onVideoRecordEvent ->
                     run {
                         Log.d(TAG, onVideoRecordEvent.toString())
@@ -486,6 +579,7 @@
                         }
                     }
                 }
+        currentSettings.value?.audioMuted?.let { recording?.mute(it) }
     }
 
     override fun stopVideoRecording() {
@@ -500,8 +594,8 @@
     }
 
     // Could be improved by setting initial value only when camera is initialized
-    private val _zoomScale = MutableStateFlow(1f)
-    override fun getZoomScale(): StateFlow<Float> = _zoomScale.asStateFlow()
+    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()
@@ -512,6 +606,7 @@
             if (systemConstraints.availableLenses.contains(lensFacing)) {
                 old?.copy(cameraLensFacing = lensFacing)
                     ?.tryApplyDynamicRangeConstraints()
+                    ?.tryApplyImageFormatConstraints()
             } else {
                 old
             }
@@ -543,15 +638,34 @@
         return this
     }
 
-    override fun tapToFocus(
-        display: Display,
-        surfaceWidth: Int,
-        surfaceHeight: Int,
-        x: Float,
-        y: Float
-    ) {
-        // TODO(tm):Convert API to use SurfaceOrientedMeteringPointFactory and
-        // use a Channel to get result of FocusMeteringAction
+    private fun CameraAppSettings.tryApplyImageFormatConstraints(): CameraAppSettings {
+        return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+            with(constraints.supportedImageFormatsMap[captureMode]) {
+                val newImageFormat = if (this != null && contains(imageFormat)) {
+                    imageFormat
+                } else {
+                    ImageOutputFormat.JPEG
+                }
+
+                [email protected](
+                    imageFormat = newImageFormat
+                )
+            }
+        } ?: this
+    }
+
+    override suspend fun tapToFocus(x: Float, y: Float) {
+        Log.d(TAG, "tapToFocus, sending FocusMeteringEvent")
+
+        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))
+        }
     }
 
     override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow()
@@ -624,40 +738,51 @@
 
     override suspend fun setCaptureMode(captureMode: CaptureMode) {
         currentSettings.update { old ->
-            old?.copy(captureMode = captureMode)
+            old?.copy(captureMode = captureMode)?.tryApplyImageFormatConstraints()
         }
     }
 
     private fun createUseCaseGroup(
+        cameraInfo: CameraInfo,
         sessionSettings: PerpetualSessionSettings,
         initialTransientSettings: TransientSessionSettings,
         supportedStabilizationModes: Set<SupportedStabilizationMode>,
         effect: CameraEffect? = null
     ): UseCaseGroup {
-        val previewUseCase = createPreviewUseCase(sessionSettings, supportedStabilizationModes)
+        val previewUseCase =
+            createPreviewUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
+        imageCaptureUseCase = createImageUseCase(cameraInfo, sessionSettings)
         if (!disableVideoCapture) {
-            videoCaptureUseCase = createVideoUseCase(sessionSettings, supportedStabilizationModes)
+            videoCaptureUseCase =
+                createVideoUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
         }
 
         setFlashModeInternal(
             flashMode = initialTransientSettings.flashMode,
             isFrontFacing = sessionSettings.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
         )
-        imageCaptureUseCase = ImageCapture.Builder()
-            .setResolutionSelector(getResolutionSelector(sessionSettings.aspectRatio)).build()
 
         return UseCaseGroup.Builder().apply {
+            Log.d(
+                TAG,
+                "Setting initial device rotation to ${initialTransientSettings.deviceRotation}"
+            )
             setViewPort(
                 ViewPort.Builder(
                     sessionSettings.aspectRatio.ratio,
-                    previewUseCase.targetRotation
+                    initialTransientSettings.deviceRotation.toUiSurfaceRotation()
                 ).build()
             )
             addUseCase(previewUseCase)
-            if (sessionSettings.dynamicRange == DynamicRange.SDR) {
+            if (sessionSettings.dynamicRange == DynamicRange.SDR ||
+                sessionSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+            ) {
                 addUseCase(imageCaptureUseCase)
             }
-            if (videoCaptureUseCase != null) {
+            // Not to bind VideoCapture when Ultra HDR is enabled to keep the app design simple.
+            if (videoCaptureUseCase != null &&
+                sessionSettings.imageFormat == ImageOutputFormat.JPEG
+            ) {
                 addUseCase(videoCaptureUseCase!!)
             }
 
@@ -672,12 +797,68 @@
         }
     }
 
+    override fun setDeviceRotation(deviceRotation: DeviceRotation) {
+        currentSettings.update { old ->
+            old?.copy(deviceRotation = deviceRotation)
+        }
+    }
+
+    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()
+    }
+
+    @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)
+        }
+        return builder.build()
+    }
+
+    override suspend fun setLowLightBoost(lowLightBoost: LowLightBoost) {
+        currentSettings.update { old ->
+            old?.copy(lowLightBoost = lowLightBoost)
+        }
+    }
+
+    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(sessionSettings.aspectRatio))
+            .setAspectRatio(
+                getAspectRatioForUseCase(sensorLandscapeRatio, sessionSettings.aspectRatio)
+            )
             .setExecutor(defaultDispatcher.asExecutor()).build()
         return VideoCapture.Builder(recorder).apply {
             // set video stabilization
@@ -696,11 +877,24 @@
         }.build()
     }
 
-    private fun getAspectRatioForUseCase(aspectRatio: AspectRatio): Int {
+    private fun getAspectRatioForUseCase(
+        sensorLandscapeRatio: Float,
+        aspectRatio: AspectRatio
+    ): Int {
         return when (aspectRatio) {
             AspectRatio.THREE_FOUR -> RATIO_4_3
             AspectRatio.NINE_SIXTEEN -> RATIO_16_9
-            else -> RATIO_DEFAULT
+            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
+                }
+            }
         }
     }
 
@@ -719,17 +913,23 @@
     }
 
     private fun createPreviewUseCase(
+        cameraInfo: CameraInfo,
         sessionSettings: PerpetualSessionSettings,
         supportedStabilizationModes: Set<SupportedStabilizationMode>
     ): Preview {
-        val previewUseCaseBuilder = Preview.Builder()
+        val previewUseCaseBuilder = Preview.Builder().apply {
+            setTargetRotation(DeviceRotation.Natural.toUiSurfaceRotation())
+        }
+
+        setOnCaptureCompletedCallback(previewUseCaseBuilder)
+
         // set preview stabilization
         if (shouldPreviewBeStabilized(sessionSettings, supportedStabilizationModes)) {
             previewUseCaseBuilder.setPreviewStabilizationEnabled(true)
         }
 
         previewUseCaseBuilder.setResolutionSelector(
-            getResolutionSelector(sessionSettings.aspectRatio)
+            getResolutionSelector(cameraInfo.sensorLandscapeRatio, sessionSettings.aspectRatio)
         )
 
         return previewUseCaseBuilder.build().apply {
@@ -739,11 +939,25 @@
         }
     }
 
-    private fun getResolutionSelector(aspectRatio: AspectRatio): ResolutionSelector {
+    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 -> 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()
     }
@@ -825,3 +1039,24 @@
     )
 }
 
+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/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
similarity index 97%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
index a3c6c33..e6f98ea 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
 
 import androidx.camera.core.Camera
 import androidx.camera.core.CameraSelector
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/CopyingSurfaceProcessor.kt
similarity index 99%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/CopyingSurfaceProcessor.kt
index 6f626c1..8f1fd6c 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/CopyingSurfaceProcessor.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
 
 import android.graphics.SurfaceTexture
 import android.opengl.EGL14
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/EGLSpecV14ES3.kt
similarity index 96%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/EGLSpecV14ES3.kt
index 97918ed..bf6e3ca 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/EGLSpecV14ES3.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
 
 import android.opengl.EGL14
 import android.opengl.EGLConfig
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/GLDebug.kt
similarity index 66%
copy from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
copy to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/GLDebug.kt
index b68f8e6..d9e3fe1 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/GLDebug.kt
@@ -13,14 +13,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera
+package com.google.jetpackcamera.core.camera.effects
 
-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)
+object GLDebug {
+    init {
+        System.loadLibrary("opengl_debug_lib")
     }
+
+    external fun enableES3DebugErrorLogging()
 }
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
similarity index 86%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
index e3aff12..fc32ecd 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
 
 import android.graphics.SurfaceTexture
 import android.opengl.EGL14
@@ -21,6 +21,7 @@
 import android.opengl.EGLExt
 import android.opengl.GLES11Ext
 import android.opengl.GLES20
+import android.opengl.GLES30
 import android.util.Log
 import android.view.Surface
 import androidx.annotation.WorkerThread
@@ -38,8 +39,17 @@
     private var externalTextureId: Int = -1
     private var programHandle = -1
     private var texMatrixLoc = -1
+    private var samplerLoc = -1
     private var positionLoc = -1
     private var texCoordLoc = -1
+    private val glExtensions: Set<String> by lazy {
+        checkGlThread()
+        buildSet {
+            GLES20.glGetString(GLES20.GL_EXTENSIONS)?.split(" ")?.also {
+                addAll(it)
+            }
+        }
+    }
     private val use10bitPipeline: Boolean
         get() = dynamicRange.bitDepth == DynamicRange.BIT_DEPTH_10_BIT
 
@@ -78,6 +88,10 @@
 
     override val initRenderer: () -> Unit
         get() = {
+            if (use10bitPipeline && glExtensions.contains("GL_KHR_debug")) {
+                GLDebug.enableES3DebugErrorLogging()
+            }
+
             createProgram(
                 if (use10bitPipeline) {
                     TEN_BIT_VERTEX_SHADER
@@ -123,6 +137,7 @@
         get() = { outputWidth: Int,
                 outputHeight: Int,
                 surfaceTransform: FloatArray ->
+            checkGlThread()
             GLES20.glViewport(
                 0,
                 0,
@@ -202,14 +217,36 @@
         // Set the texture.
         GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
         GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, externalTextureId)
+        GLES20.glUniform1i(samplerLoc, 0)
+
+        if (use10bitPipeline) {
+            val vaos = IntArray(1)
+            GLES30.glGenVertexArrays(1, vaos, 0)
+            GLES30.glBindVertexArray(vaos[0])
+            checkGlErrorOrThrow("glBindVertexArray")
+        }
+
+        val vbos = IntArray(2)
+        GLES20.glGenBuffers(2, vbos, 0)
+        checkGlErrorOrThrow("glGenBuffers")
+
+        // Connect vertexBuffer to "aPosition".
+        val coordsPerVertex = 2
+        val vertexStride = 0
+        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbos[0])
+        checkGlErrorOrThrow("glBindBuffer")
+        GLES20.glBufferData(
+            GLES20.GL_ARRAY_BUFFER,
+            VERTEX_BUF.capacity() * SIZEOF_FLOAT,
+            VERTEX_BUF,
+            GLES20.GL_STATIC_DRAW
+        )
+        checkGlErrorOrThrow("glBufferData")
 
         // Enable the "aPosition" vertex attribute.
         GLES20.glEnableVertexAttribArray(positionLoc)
         checkGlErrorOrThrow("glEnableVertexAttribArray")
 
-        // Connect vertexBuffer to "aPosition".
-        val coordsPerVertex = 2
-        val vertexStride = 0
         GLES20.glVertexAttribPointer(
             positionLoc,
             coordsPerVertex,
@@ -217,17 +254,28 @@
             /*normalized=*/
             false,
             vertexStride,
-            VERTEX_BUF
+            0
         )
         checkGlErrorOrThrow("glVertexAttribPointer")
 
+        // Connect texBuffer to "aTextureCoord".
+        val coordsPerTex = 2
+        val texStride = 0
+        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbos[1])
+        checkGlErrorOrThrow("glBindBuffer")
+
+        GLES20.glBufferData(
+            GLES20.GL_ARRAY_BUFFER,
+            TEX_BUF.capacity() * SIZEOF_FLOAT,
+            TEX_BUF,
+            GLES20.GL_STATIC_DRAW
+        )
+        checkGlErrorOrThrow("glBufferData")
+
         // Enable the "aTextureCoord" vertex attribute.
         GLES20.glEnableVertexAttribArray(texCoordLoc)
         checkGlErrorOrThrow("glEnableVertexAttribArray")
 
-        // Connect texBuffer to "aTextureCoord".
-        val coordsPerTex = 2
-        val texStride = 0
         GLES20.glVertexAttribPointer(
             texCoordLoc,
             coordsPerTex,
@@ -235,7 +283,7 @@
             /*normalized=*/
             false,
             texStride,
-            TEX_BUF
+            0
         )
         checkGlErrorOrThrow("glVertexAttribPointer")
     }
@@ -299,6 +347,8 @@
         checkLocationOrThrow(texCoordLoc, "aTextureCoord")
         texMatrixLoc = GLES20.glGetUniformLocation(programHandle, "uTexMatrix")
         checkLocationOrThrow(texMatrixLoc, "uTexMatrix")
+        samplerLoc = GLES20.glGetUniformLocation(programHandle, VAR_TEXTURE)
+        checkLocationOrThrow(samplerLoc, VAR_TEXTURE)
     }
 
     @WorkerThread
@@ -419,10 +469,20 @@
         precision mediump float;
         uniform __samplerExternal2DY2YEXT $VAR_TEXTURE;
         in vec2 $VAR_TEXTURE_COORD;
-        layout (yuv) out vec3 outColor;
-
+        out vec3 outColor;
+        
+        vec3 yuvToRgb(vec3 yuv) {
+          const vec3 yuvOffset = vec3(0.0625, 0.5, 0.5);
+          const mat3 yuvToRgbColorTransform = mat3(
+            1.1689f, 1.1689f, 1.1689f,
+            0.0000f, -0.1881f, 2.1502f,
+            1.6853f, -0.6530f, 0.0000f
+          );
+          return clamp(yuvToRgbColorTransform * (yuv - yuvOffset), 0.0, 1.0);
+        }
+        
         void main() {
-          outColor = texture($VAR_TEXTURE, $VAR_TEXTURE_COORD).xyz;
+          outColor = yuvToRgb(texture($VAR_TEXTURE, $VAR_TEXTURE_COORD).xyz);
         }
             """.trimIndent()
 
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/SingleSurfaceForcingEffect.kt
similarity index 95%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/SingleSurfaceForcingEffect.kt
index 6057b89..7748719 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/SingleSurfaceForcingEffect.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
 
 import androidx.camera.core.CameraEffect
 import kotlinx.coroutines.CoroutineScope
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
similarity index 81%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
index 8724bad..4245f59 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
@@ -13,21 +13,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera.test
+package com.google.jetpackcamera.core.camera.test
 
 import android.annotation.SuppressLint
 import android.content.ContentResolver
 import android.net.Uri
-import android.view.Display
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.SurfaceRequest
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.CameraState
+import com.google.jetpackcamera.core.camera.CameraUseCase
 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.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
@@ -89,7 +92,9 @@
                     isLensFacingFront &&
                     (it.flashMode == FlashMode.AUTO || it.flashMode == FlashMode.ON)
 
-                _zoomScale.value = it.zoomScale
+                _currentCameraState.update { old ->
+                    old.copy(zoomScale = it.zoomScale)
+                }
             }
     }
 
@@ -140,13 +145,13 @@
         recordingInProgress = false
     }
 
-    private val _zoomScale = MutableStateFlow(1f)
+    private val _currentCameraState = MutableStateFlow(CameraState())
     override fun setZoomScale(scale: Float) {
         currentSettings.update { old ->
             old.copy(zoomScale = scale)
         }
     }
-    override fun getZoomScale(): StateFlow<Float> = _zoomScale.asStateFlow()
+    override fun getCurrentCameraState(): StateFlow<CameraState> = _currentCameraState.asStateFlow()
 
     private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
     override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
@@ -176,13 +181,7 @@
         }
     }
 
-    override fun tapToFocus(
-        display: Display,
-        surfaceWidth: Int,
-        surfaceHeight: Int,
-        x: Float,
-        y: Float
-    ) {
+    override suspend fun tapToFocus(x: Float, y: Float) {
         TODO("Not yet implemented")
     }
 
@@ -197,4 +196,28 @@
             old.copy(dynamicRange = dynamicRange)
         }
     }
+
+    override fun setDeviceRotation(deviceRotation: DeviceRotation) {
+        currentSettings.update { old ->
+            old.copy(deviceRotation = deviceRotation)
+        }
+    }
+
+    override suspend fun setImageFormat(imageFormat: ImageOutputFormat) {
+        currentSettings.update { old ->
+            old.copy(imageFormat = imageFormat)
+        }
+    }
+
+    override suspend fun setLowLightBoost(lowLightBoost: LowLightBoost) {
+        currentSettings.update { old ->
+            old.copy(lowLightBoost = lowLightBoost)
+        }
+    }
+
+    override suspend fun setAudioMuted(isAudioMuted: Boolean) {
+        currentSettings.update { old ->
+            old.copy(audioMuted = isAudioMuted)
+        }
+    }
 }
diff --git a/domain/camera/src/test/Android.bp b/core/camera/src/test/Android.bp
similarity index 78%
rename from domain/camera/src/test/Android.bp
rename to core/camera/src/test/Android.bp
index 2fc04c2..b969779 100644
--- a/domain/camera/src/test/Android.bp
+++ b/core/camera/src/test/Android.bp
@@ -3,7 +3,7 @@
 }
 
 java_test {
-    name: "jetpack-camera-app_domain_camera-tests",
+    name: "jetpack-camera-app_core_camera-tests",
     team: "trendy_team_camerax",
     srcs: ["java/**/*.kt"],
     static_libs: [
@@ -12,8 +12,7 @@
         "androidx.test.ext.junit",
         "androidx.test.ext.truth",
         "mockito-core",
-        "jetpack-camera-app_domain_camera",
-
+        "jetpack-camera-app_core_camera",
     ],
     min_sdk_version: "21",
 }
diff --git a/domain/camera/src/test/AndroidManifest.xml b/core/camera/src/test/AndroidManifest.xml
similarity index 78%
rename from domain/camera/src/test/AndroidManifest.xml
rename to core/camera/src/test/AndroidManifest.xml
index e84b7b1..91b7229 100644
--- a/domain/camera/src/test/AndroidManifest.xml
+++ b/core/camera/src/test/AndroidManifest.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (C) 2015 The Android Open Source Project
+  ~ 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.
@@ -16,15 +16,15 @@
   -->
 
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.google.jetpackcamera.domain.camera.test" >
+    package="com.google.jetpackcamera.core.camera.test" >
 
     <instrumentation
         android:name="androidx.test.runner.AndroidJUnitRunner"
-        android:label="Domain Camera Unit Tests"
-        android:targetPackage="com.google.jetpackcamera.domain.camera" />
+        android:label="Core Camera Unit Tests"
+        android:targetPackage="com.google.jetpackcamera.core.camera" />
 
     <application>
         <uses-library android:name="android.test.runner" />
     </application>
 
-</manifest>
+</manifest>
\ No newline at end of file
diff --git a/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
similarity index 86%
rename from domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt
rename to core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
index c6eaf59..ad47e41 100644
--- a/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt
+++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
@@ -13,10 +13,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera.domain.camera.test
+package com.google.jetpackcamera.core.camera.test
 
-import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.common.truth.Truth
+import com.google.jetpackcamera.core.camera.CameraUseCase
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
 import kotlinx.coroutines.Dispatchers
@@ -59,7 +59,7 @@
     @Test
     fun canRunCamera() = runTest(testDispatcher) {
         initAndRunCamera()
-        assertThat(cameraUseCase.isPreviewStarted())
+        Truth.assertThat(cameraUseCase.isPreviewStarted())
     }
 
     @Test
@@ -70,7 +70,7 @@
         cameraUseCase.setFlashMode(flashMode = FlashMode.OFF)
         advanceUntilIdle()
 
-        assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
+        Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
     }
 
     @Test
@@ -81,7 +81,7 @@
         cameraUseCase.setFlashMode(flashMode = FlashMode.ON)
         advanceUntilIdle()
 
-        assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
+        Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
     }
 
     @Test
@@ -92,7 +92,7 @@
         cameraUseCase.setFlashMode(flashMode = FlashMode.AUTO)
         advanceUntilIdle()
 
-        assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
+        Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
     }
 
     @Test
@@ -103,7 +103,7 @@
         cameraUseCase.setFlashMode(flashMode = FlashMode.ON)
         advanceUntilIdle()
 
-        assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
+        Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
     }
 
     @Test
@@ -114,7 +114,7 @@
         cameraUseCase.setFlashMode(flashMode = FlashMode.AUTO)
         advanceUntilIdle()
 
-        assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
+        Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
     }
 
     @Test
@@ -134,7 +134,7 @@
         cameraUseCase.takePicture()
 
         advanceUntilIdle()
-        assertThat(events.map { it.type }).containsExactlyElementsIn(
+        Truth.assertThat(events.map { it.type }).containsExactlyElementsIn(
             listOf(
                 CameraUseCase.ScreenFlashEvent.Type.APPLY_UI,
                 CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI
diff --git a/core/common/Android.bp b/core/common/Android.bp
index 552b888..b5ccf9b 100644
--- a/core/common/Android.bp
+++ b/core/common/Android.bp
@@ -9,11 +9,12 @@
     srcs: ["src/main/**/*.kt"],
     static_libs: [
         "androidx.core_core-ktx",
-	"hilt_android",
+        "androidx.tracing_tracing-ktx",
+        "hilt_android",
         "androidx.appcompat_appcompat",
-	"com.google.android.material_material",
+        "com.google.android.material_material",
     ],
     sdk_version: "34",
     min_sdk_version: "21",
-    manifest:"src/main/AndroidManifest.xml"
+    manifest: "src/main/AndroidManifest.xml",
 }
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 1eba073..2a81639 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -24,6 +24,7 @@
 android {
     namespace = "com.google.jetpackcamera.core.common"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     defaultConfig {
         minSdk = libs.versions.minSdk.get().toInt()
@@ -41,7 +42,24 @@
                     "proguard-rules.pro"
             )
         }
+        create("benchmark") {
+            initWith(buildTypes.getByName("release"))
+        }
     }
+
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+            isDefault = true
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_17
         targetCompatibility = JavaVersion.VERSION_17
@@ -57,6 +75,7 @@
     implementation(libs.androidx.appcompat)
     implementation(libs.android.material)
     implementation(libs.kotlinx.atomicfu)
+    implementation(libs.androidx.tracing)
 
     testImplementation(libs.junit)
     testImplementation(libs.truth)
diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/TraceManager.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/TraceManager.kt
new file mode 100644
index 0000000..6079362
--- /dev/null
+++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/TraceManager.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.common
+
+import androidx.tracing.traceAsync
+
+const val FIRST_FRAME_TRACE_PREVIEW = "firstFrameTracePreview"
+const val FIRST_FRAME_TRACE_MAIN_ACTIVITY = "firstFrameTraceMainActivity"
+
+suspend inline fun traceFirstFramePreview(cookie: Int, crossinline block: suspend () -> Unit) {
+    traceAsync(FIRST_FRAME_TRACE_PREVIEW, cookie) {
+        block()
+    }
+}
+suspend inline fun traceFirstFrameMainActivity(cookie: Int, crossinline block: suspend () -> Unit) {
+    traceAsync(FIRST_FRAME_TRACE_MAIN_ACTIVITY, cookie) {
+        block()
+    }
+}
diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts
index 60c9aa0..e0edd40 100644
--- a/data/settings/build.gradle.kts
+++ b/data/settings/build.gradle.kts
@@ -25,6 +25,7 @@
 android {
     namespace = "com.google.jetpackcamera.data.settings"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     defaultConfig {
         minSdk = libs.versions.minSdk.get().toInt()
@@ -35,6 +36,19 @@
         consumerProguardFiles("consumer-rules.pro")
     }
 
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+            isDefault = true
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_17
         targetCompatibility = JavaVersion.VERSION_17
diff --git a/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt b/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt
index 44846ac..2c67114 100644
--- a/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt
+++ b/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt
@@ -28,6 +28,7 @@
 import com.google.jetpackcamera.settings.model.DarkMode
 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 java.io.File
 import kotlinx.coroutines.CoroutineScope
@@ -147,4 +148,18 @@
         assertThat(initialDynamicRange).isEqualTo(DynamicRange.SDR)
         assertThat(newDynamicRange).isEqualTo(DynamicRange.HLG10)
     }
+
+    @Test
+    fun can_update_image_format() = runTest {
+        val initialImageFormat = repository.getCurrentDefaultCameraAppSettings().imageFormat
+
+        repository.updateImageFormat(imageFormat = ImageOutputFormat.JPEG_ULTRA_HDR)
+
+        advanceUntilIdle()
+
+        val newImageFormat = repository.getCurrentDefaultCameraAppSettings().imageFormat
+
+        assertThat(initialImageFormat).isEqualTo(ImageOutputFormat.JPEG)
+        assertThat(newImageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR)
+    }
 }
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt
index e0196eb..1a07af2 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt
@@ -32,6 +32,7 @@
         .setStabilizePreview(PreviewStabilization.PREVIEW_STABILIZATION_UNDEFINED)
         .setStabilizeVideo(VideoStabilization.VIDEO_STABILIZATION_UNDEFINED)
         .setDynamicRangeStatus(DynamicRange.DYNAMIC_RANGE_UNSPECIFIED)
+        .setImageFormatStatus(ImageOutputFormat.IMAGE_OUTPUT_FORMAT_JPEG)
         .build()
 
     override suspend fun readFrom(input: InputStream): JcaSettings {
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 80d6e00..8183542 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
@@ -29,6 +29,8 @@
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.DynamicRange.Companion.toProto
 import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.ImageOutputFormat.Companion.toProto
 import com.google.jetpackcamera.settings.model.LensFacing
 import com.google.jetpackcamera.settings.model.LensFacing.Companion.toProto
 import com.google.jetpackcamera.settings.model.Stabilization
@@ -73,7 +75,8 @@
                     CaptureModeProto.CAPTURE_MODE_MULTI_STREAM -> CaptureMode.MULTI_STREAM
                     else -> CaptureMode.MULTI_STREAM
                 },
-                dynamicRange = DynamicRange.fromProto(it.dynamicRangeStatus)
+                dynamicRange = DynamicRange.fromProto(it.dynamicRangeStatus),
+                imageFormat = ImageOutputFormat.fromProto(it.imageFormatStatus)
             )
         }
 
@@ -180,4 +183,12 @@
                 .build()
         }
     }
+
+    override suspend fun updateImageFormat(imageFormat: ImageOutputFormat) {
+        jcaSettings.updateData { currentSettings ->
+            currentSettings.toBuilder()
+                .setImageFormatStatus(imageFormat.toProto())
+                .build()
+        }
+    }
 }
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt
index 6e0fb3d..2631d7f 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt
@@ -21,6 +21,7 @@
 import com.google.jetpackcamera.settings.model.DarkMode
 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 kotlinx.coroutines.flow.Flow
@@ -51,4 +52,6 @@
     suspend fun updateDynamicRange(dynamicRange: DynamicRange)
 
     suspend fun updateTargetFrameRate(targetFrameRate: Int)
+
+    suspend fun updateImageFormat(imageFormat: ImageOutputFormat)
 }
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt
index 8de6ea3..2725bf5 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt
@@ -23,6 +23,10 @@
     NINE_SIXTEEN(Rational(9, 16)),
     ONE_ONE(Rational(1, 1));
 
+    val landscapeRatio: Rational by lazy {
+        Rational(ratio.denominator, ratio.numerator)
+    }
+
     companion object {
 
         /** returns the AspectRatio enum equivalent of a provided AspectRatioProto */
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 0bb37c2..712a0cc 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
@@ -29,8 +29,13 @@
     val videoCaptureStabilization: Stabilization = Stabilization.UNDEFINED,
     val dynamicRange: DynamicRange = DynamicRange.SDR,
     val defaultHdrDynamicRange: DynamicRange = DynamicRange.HLG10,
+    val defaultHdrImageOutputFormat: ImageOutputFormat = ImageOutputFormat.JPEG_ULTRA_HDR,
+    val lowLightBoost: LowLightBoost = LowLightBoost.DISABLED,
     val zoomScale: Float = 1f,
-    val targetFrameRate: Int = TARGET_FPS_AUTO
+    val targetFrameRate: Int = TARGET_FPS_AUTO,
+    val imageFormat: ImageOutputFormat = ImageOutputFormat.JPEG,
+    val audioMuted: Boolean = false,
+    val deviceRotation: DeviceRotation = DeviceRotation.Natural
 )
 
 fun SystemConstraints.forCurrentLens(cameraAppSettings: CameraAppSettings): CameraConstraints? {
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 d4f7364..51a1eb4 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
@@ -23,7 +23,8 @@
 data class CameraConstraints(
     val supportedStabilizationModes: Set<SupportedStabilizationMode>,
     val supportedFixedFrameRates: Set<Int>,
-    val supportedDynamicRanges: Set<DynamicRange>
+    val supportedDynamicRanges: Set<DynamicRange>,
+    val supportedImageFormatsMap: Map<CaptureMode, Set<ImageOutputFormat>>
 )
 
 /**
@@ -39,7 +40,11 @@
                     CameraConstraints(
                         supportedFixedFrameRates = setOf(15, 30),
                         supportedStabilizationModes = emptySet(),
-                        supportedDynamicRanges = setOf(DynamicRange.SDR)
+                        supportedDynamicRanges = setOf(DynamicRange.SDR),
+                        supportedImageFormatsMap = mapOf(
+                            Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
+                            Pair(CaptureMode.MULTI_STREAM, setOf(ImageOutputFormat.JPEG))
+                        )
                     )
                 )
             }
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DeviceRotation.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DeviceRotation.kt
new file mode 100644
index 0000000..95e3c06
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DeviceRotation.kt
@@ -0,0 +1,70 @@
+/*
+ * 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
+
+import android.view.Surface
+
+enum class DeviceRotation {
+    Natural,
+    Rotated90,
+    Rotated180,
+    Rotated270;
+
+    /**
+     * Returns the rotation of the UI, expressed as a [Surface] rotation constant, needed to
+     * compensate for device rotation.
+     *
+     * These values do not match up with the device rotation angle. When the device is rotated,
+     * the UI must rotate in the opposite direction to compensate, so the angles 90 and 270 will
+     * be swapped in UI rotation compared to device rotation.
+     */
+    fun toUiSurfaceRotation(): Int {
+        return when (this) {
+            Natural -> Surface.ROTATION_0
+            Rotated90 -> Surface.ROTATION_270
+            Rotated180 -> Surface.ROTATION_180
+            Rotated270 -> Surface.ROTATION_90
+        }
+    }
+
+    fun toClockwiseRotationDegrees(): Int {
+        return when (this) {
+            Natural -> 0
+            Rotated90 -> 90
+            Rotated180 -> 180
+            Rotated270 -> 270
+        }
+    }
+
+    companion object {
+        fun snapFrom(degrees: Int): DeviceRotation {
+            check(degrees in 0..359) {
+                "Degrees must be in the range [0, 360)"
+            }
+
+            return when (val snappedDegrees = ((degrees + 45) / 90 * 90) % 360) {
+                0 -> Natural
+                90 -> Rotated90
+                180 -> Rotated180
+                270 -> Rotated270
+                else -> throw IllegalStateException(
+                    "Unexpected snapped degrees: $snappedDegrees" +
+                        ". Should be one of 0, 90, 180 or 270."
+                )
+            }
+        }
+    }
+}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ImageOutputFormat.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ImageOutputFormat.kt
new file mode 100644
index 0000000..9f9cf46
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ImageOutputFormat.kt
@@ -0,0 +1,44 @@
+/*
+ * 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
+
+import com.google.jetpackcamera.settings.ImageOutputFormat as ImageOutputFormatProto
+
+enum class ImageOutputFormat {
+    JPEG,
+    JPEG_ULTRA_HDR;
+
+    companion object {
+
+        /** returns the DynamicRangeType enum equivalent of a provided DynamicRangeTypeProto */
+        fun fromProto(imageOutputFormatProto: ImageOutputFormatProto): ImageOutputFormat {
+            return when (imageOutputFormatProto) {
+                ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR -> JPEG_ULTRA_HDR
+
+                // Treat unrecognized as JPEG as a fallback
+                ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG,
+                ImageOutputFormatProto.UNRECOGNIZED -> JPEG
+            }
+        }
+
+        fun ImageOutputFormat.toProto(): ImageOutputFormatProto {
+            return when (this) {
+                JPEG -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG
+                JPEG_ULTRA_HDR -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR
+            }
+        }
+    }
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LowLightBoost.kt
similarity index 65%
copy from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
copy to data/settings/src/main/java/com/google/jetpackcamera/settings/model/LowLightBoost.kt
index b68f8e6..8fd7221 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LowLightBoost.kt
@@ -13,14 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera
+package com.google.jetpackcamera.settings.model
 
-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)
-    }
+enum class LowLightBoost {
+    DISABLED,
+    ENABLED
 }
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt
index c7ed7b7..fce599f 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt
@@ -23,6 +23,7 @@
 import com.google.jetpackcamera.settings.model.DarkMode
 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 kotlinx.coroutines.flow.Flow
@@ -78,4 +79,8 @@
         currentCameraSettings =
             currentCameraSettings.copy(targetFrameRate = targetFrameRate)
     }
+
+    override suspend fun updateImageFormat(imageFormat: ImageOutputFormat) {
+        currentCameraSettings = currentCameraSettings.copy(imageFormat = imageFormat)
+    }
 }
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/data/settings/src/main/proto/com/google/jetpackcamera/settings/image_output_format.proto
similarity index 65%
copy from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
copy to data/settings/src/main/proto/com/google/jetpackcamera/settings/image_output_format.proto
index b68f8e6..dedeb4f 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/image_output_format.proto
@@ -13,14 +13,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.jetpackcamera
 
-import android.os.Build
+syntax = "proto3";
 
-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)
-    }
-}
+option java_package = "com.google.jetpackcamera.settings";
+option java_multiple_files = true;
+
+enum ImageOutputFormat {
+  IMAGE_OUTPUT_FORMAT_JPEG = 0;
+  IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR = 1;
+}
\ No newline at end of file
diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto
index cc87e43..03aeb6d 100644
--- a/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto
+++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto
@@ -21,6 +21,7 @@
 import "com/google/jetpackcamera/settings/dark_mode.proto";
 import "com/google/jetpackcamera/settings/dynamic_range.proto";
 import "com/google/jetpackcamera/settings/flash_mode.proto";
+import "com/google/jetpackcamera/settings/image_output_format.proto";
 import "com/google/jetpackcamera/settings/lens_facing.proto";
 import "com/google/jetpackcamera/settings/preview_stabilization.proto";
 import "com/google/jetpackcamera/settings/video_stabilization.proto";
@@ -39,6 +40,7 @@
   PreviewStabilization stabilize_preview = 6;
   VideoStabilization stabilize_video = 7;
   DynamicRange dynamic_range_status = 8;
+  ImageOutputFormat image_format_status = 10;
 
   // Non-camera app settings
   DarkMode dark_mode_status = 9;
diff --git a/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt b/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
index a598530..95503bc 100644
--- a/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
+++ b/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
@@ -17,8 +17,11 @@
 
 import com.google.common.truth.Truth.assertThat
 import com.google.jetpackcamera.settings.DynamicRange as DynamicRangeProto
+import com.google.jetpackcamera.settings.ImageOutputFormat as ImageOutputFormatProto
 import com.google.jetpackcamera.settings.model.DynamicRange
 import com.google.jetpackcamera.settings.model.DynamicRange.Companion.toProto
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.ImageOutputFormat.Companion.toProto
 import org.junit.Test
 
 class ProtoConversionTest {
@@ -61,4 +64,44 @@
             assertThat(correctConversions(it)).isEqualTo(DynamicRange.fromProto(it))
         }
     }
+
+    @Test
+    fun imageOutputFormat_convertsToCorrectProto() {
+        val correctConversions = { imageOutputFormat: ImageOutputFormat ->
+            when (imageOutputFormat) {
+                ImageOutputFormat.JPEG -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG
+                ImageOutputFormat.JPEG_ULTRA_HDR
+                -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR
+                else -> TODO(
+                    "Test does not yet contain correct conversion for image output format " +
+                        "type: ${imageOutputFormat.name}"
+                )
+            }
+        }
+
+        enumValues<ImageOutputFormat>().forEach {
+            assertThat(correctConversions(it)).isEqualTo(it.toProto())
+        }
+    }
+
+    @Test
+    fun imageOutputFormatProto_convertsToCorrectImageOutputFormat() {
+        val correctConversions = { imageOutputFormatProto: ImageOutputFormatProto ->
+            when (imageOutputFormatProto) {
+                ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG,
+                ImageOutputFormatProto.UNRECOGNIZED
+                -> ImageOutputFormat.JPEG
+                ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR
+                -> ImageOutputFormat.JPEG_ULTRA_HDR
+                else -> TODO(
+                    "Test does not yet contain correct conversion for image output format " +
+                        "proto type: ${imageOutputFormatProto.name}"
+                )
+            }
+        }
+
+        enumValues<ImageOutputFormatProto>().forEach {
+            assertThat(correctConversions(it)).isEqualTo(ImageOutputFormat.fromProto(it))
+        }
+    }
 }
diff --git a/feature/permissions/build.gradle.kts b/feature/permissions/build.gradle.kts
index a14d8cc..66fadf3 100644
--- a/feature/permissions/build.gradle.kts
+++ b/feature/permissions/build.gradle.kts
@@ -24,6 +24,7 @@
 android {
     namespace = "com.google.jetpackcamera.permissions"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     defaultConfig {
         minSdk = libs.versions.minSdk.get().toInt()
diff --git a/feature/permissions/src/main/AndroidManifest.xml b/feature/permissions/src/main/AndroidManifest.xml
index 88a4e8b..926ca9b 100644
--- a/feature/permissions/src/main/AndroidManifest.xml
+++ b/feature/permissions/src/main/AndroidManifest.xml
@@ -17,3 +17,4 @@
 <manifest package="com.google.jetpackcamera.permissions">
 
 </manifest>
+
diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
index af17e30..ad67fda 100644
--- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
@@ -34,12 +34,22 @@
 
 @OptIn(ExperimentalPermissionsApi::class)
 @Composable
-fun PermissionsScreen(onNavigateToPreview: () -> Unit, openAppSettings: () -> Unit) {
+fun PermissionsScreen(
+    shouldRequestAudioPermission: Boolean,
+    onNavigateToPreview: () -> Unit,
+    openAppSettings: () -> Unit
+) {
     val permissionStates = rememberMultiplePermissionsState(
-        permissions = listOf(
-            Manifest.permission.CAMERA,
-            Manifest.permission.RECORD_AUDIO
-        )
+        permissions = if (shouldRequestAudioPermission) {
+            listOf(
+                Manifest.permission.CAMERA,
+                Manifest.permission.RECORD_AUDIO
+            )
+        } else {
+            listOf(
+                Manifest.permission.CAMERA
+            )
+        }
     )
     PermissionsScreen(
         permissionStates = permissionStates,
diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
index 047442f..ac538aa 100644
--- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
@@ -25,6 +25,7 @@
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlin.collections.removeFirst as ktRemoveFirst // alias must be used now. see https://issuetracker.google.com/348683480
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -63,7 +64,7 @@
 
     fun dismissPermission() {
         if (permissionQueue.isNotEmpty()) {
-            permissionQueue.removeFirst()
+            permissionQueue.ktRemoveFirst()
         }
         _permissionsUiState.update {
             (getCurrentPermission())
diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp
index b4920b1..a146711 100644
--- a/feature/preview/Android.bp
+++ b/feature/preview/Android.bp
@@ -25,7 +25,8 @@
         "androidx.camera_camera-core",
         "androidx.camera_camera-viewfinder",
         "jetpack-camera-app_data_settings",
-        "jetpack-camera-app_domain_camera",
+        "jetpack-camera-app_core_camera",
+        "jetpack-camera-app_core_common",
         "androidx.camera_camera-viewfinder-compose",
         "androidx.compose.ui_ui-tooling",
 
diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts
index a5f0793..81a3ddb 100644
--- a/feature/preview/build.gradle.kts
+++ b/feature/preview/build.gradle.kts
@@ -24,6 +24,7 @@
 android {
     namespace = "com.google.jetpackcamera.feature.preview"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     defaultConfig {
         minSdk = libs.versions.minSdk.get().toInt()
@@ -33,6 +34,19 @@
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
     }
 
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+            isDefault = true
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_17
         targetCompatibility = JavaVersion.VERSION_17
@@ -131,7 +145,9 @@
 
     // Project dependencies
     implementation(project(":data:settings"))
-    implementation(project(":domain:camera"))
+    implementation(project(":core:camera"))
+    implementation(project(":core:common"))
+    testImplementation(project(":core:common"))
 }
 
 // Allow references to generated code
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
new file mode 100644
index 0000000..86e084f
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.feature.preview
+
+import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG
+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.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+
+sealed interface CaptureModeToggleUiState {
+
+    data object Invisible : CaptureModeToggleUiState
+
+    sealed interface Visible : CaptureModeToggleUiState {
+        val currentMode: ToggleMode
+    }
+
+    data class Enabled(override val currentMode: ToggleMode) : Visible
+
+    data class Disabled(
+        override val currentMode: ToggleMode,
+        val disabledReason: DisabledReason
+    ) : Visible
+
+    enum class DisabledReason(val testTag: String, val reasonTextResId: Int) {
+        VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED(
+            VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG,
+            R.string.toast_video_capture_external_unsupported
+        ),
+        HDR_VIDEO_UNSUPPORTED_ON_DEVICE(
+            HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG,
+            R.string.toast_hdr_video_unsupported_on_device
+        ),
+        HDR_VIDEO_UNSUPPORTED_ON_LENS(
+            HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG,
+            R.string.toast_hdr_video_unsupported_on_lens
+        ),
+        HDR_IMAGE_UNSUPPORTED_ON_DEVICE(
+            HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG,
+            R.string.toast_hdr_photo_unsupported_on_device
+        ),
+        HDR_IMAGE_UNSUPPORTED_ON_LENS(
+            HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG,
+            R.string.toast_hdr_photo_unsupported_on_lens
+        ),
+        HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM(
+            HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG,
+            R.string.toast_hdr_photo_unsupported_on_lens_single_stream
+        ),
+        HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM(
+            HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG,
+            R.string.toast_hdr_photo_unsupported_on_lens_multi_stream
+        )
+    }
+
+    enum class ToggleMode {
+        CAPTURE_TOGGLE_IMAGE,
+        CAPTURE_TOGGLE_VIDEO
+    }
+}
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 90eb918..f4c7e34 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
@@ -19,7 +19,6 @@
 import android.content.ContentResolver
 import android.net.Uri
 import android.util.Log
-import android.view.Display
 import androidx.camera.core.SurfaceRequest
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Arrangement
@@ -35,31 +34,39 @@
 import androidx.compose.material3.Text
 import androidx.compose.material3.darkColorScheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.hilt.navigation.compose.hiltViewModel
 import androidx.lifecycle.compose.LifecycleStartEffect
+import androidx.tracing.Trace
 import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsScreenOverlay
 import com.google.jetpackcamera.feature.preview.ui.CameraControlsOverlay
 import com.google.jetpackcamera.feature.preview.ui.PreviewDisplay
 import com.google.jetpackcamera.feature.preview.ui.ScreenFlashScreen
 import com.google.jetpackcamera.feature.preview.ui.TestableSnackbar
 import com.google.jetpackcamera.feature.preview.ui.TestableToast
+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.DEFAULT_CAMERA_APP_SETTINGS
 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.TYPICAL_SYSTEM_CONSTRAINTS
+import kotlinx.coroutines.flow.transformWhile
 
 private const val TAG = "PreviewScreen"
 
@@ -72,6 +79,7 @@
     previewMode: PreviewMode,
     modifier: Modifier = Modifier,
     onRequestWindowColorMode: (Int) -> Unit = {},
+    onFirstFrameCaptureCompleted: () -> Unit = {},
     viewModel: PreviewViewModel = hiltViewModel<PreviewViewModel, PreviewViewModel.Factory>
         { factory -> factory.create(previewMode) }
 ) {
@@ -92,31 +100,60 @@
         }
     }
 
+    if (Trace.isEnabled()) {
+        LaunchedEffect(onFirstFrameCaptureCompleted) {
+            snapshotFlow { previewUiState }
+                .transformWhile {
+                    var continueCollecting = true
+                    (it as? PreviewUiState.Ready)?.let { ready ->
+                        if (ready.sessionFirstFrameTimestamp > 0) {
+                            emit(Unit)
+                            continueCollecting = false
+                        }
+                    }
+                    continueCollecting
+                }.collect {
+                    onFirstFrameCaptureCompleted()
+                }
+        }
+    }
+
     when (val currentUiState = previewUiState) {
         is PreviewUiState.NotReady -> LoadingScreen()
-        is PreviewUiState.Ready -> ContentScreen(
-            modifier = modifier,
-            previewUiState = currentUiState,
-            screenFlashUiState = screenFlashUiState,
-            surfaceRequest = surfaceRequest,
-            onNavigateToSettings = onNavigateToSettings,
-            onClearUiScreenBrightness = viewModel.screenFlash::setClearUiScreenBrightness,
-            onSetLensFacing = viewModel::setLensFacing,
-            onTapToFocus = viewModel::tapToFocus,
-            onChangeZoomScale = viewModel::setZoomScale,
-            onChangeFlash = viewModel::setFlash,
-            onChangeAspectRatio = viewModel::setAspectRatio,
-            onChangeCaptureMode = viewModel::setCaptureMode,
-            onChangeDynamicRange = viewModel::setDynamicRange,
-            onToggleQuickSettings = viewModel::toggleQuickSettings,
-            onCaptureImage = viewModel::captureImage,
-            onCaptureImageWithUri = viewModel::captureImageWithUri,
-            onStartVideoRecording = viewModel::startVideoRecording,
-            onStopVideoRecording = viewModel::stopVideoRecording,
-            onToastShown = viewModel::onToastShown,
-            onRequestWindowColorMode = onRequestWindowColorMode,
-            onSnackBarResult = viewModel::onSnackBarResult
-        )
+        is PreviewUiState.Ready -> {
+            val context = LocalContext.current
+            LaunchedEffect(Unit) {
+                debouncedOrientationFlow(context).collect(viewModel::setDisplayRotation)
+            }
+
+            ContentScreen(
+                modifier = modifier,
+                previewUiState = currentUiState,
+                screenFlashUiState = screenFlashUiState,
+                surfaceRequest = surfaceRequest,
+                onNavigateToSettings = onNavigateToSettings,
+                onClearUiScreenBrightness = viewModel.screenFlash::setClearUiScreenBrightness,
+                onSetLensFacing = viewModel::setLensFacing,
+                onTapToFocus = viewModel::tapToFocus,
+                onChangeZoomScale = viewModel::setZoomScale,
+                onChangeFlash = viewModel::setFlash,
+                onChangeAspectRatio = viewModel::setAspectRatio,
+                onChangeCaptureMode = viewModel::setCaptureMode,
+                onChangeDynamicRange = viewModel::setDynamicRange,
+                onLowLightBoost = viewModel::setLowLightBoost,
+                onChangeImageFormat = viewModel::setImageFormat,
+                onToggleWhenDisabled = viewModel::showSnackBarForDisabledHdrToggle,
+                onToggleQuickSettings = viewModel::toggleQuickSettings,
+                onMuteAudio = viewModel::setAudioMuted,
+                onCaptureImage = viewModel::captureImage,
+                onCaptureImageWithUri = viewModel::captureImageWithUri,
+                onStartVideoRecording = viewModel::startVideoRecording,
+                onStopVideoRecording = viewModel::stopVideoRecording,
+                onToastShown = viewModel::onToastShown,
+                onRequestWindowColorMode = onRequestWindowColorMode,
+                onSnackBarResult = viewModel::onSnackBarResult
+            )
+        }
     }
 }
 
@@ -130,13 +167,17 @@
     onNavigateToSettings: () -> Unit = {},
     onClearUiScreenBrightness: (Float) -> Unit = {},
     onSetLensFacing: (newLensFacing: LensFacing) -> Unit = {},
-    onTapToFocus: (Display, Int, Int, Float, Float) -> Unit = { _, _, _, _, _ -> },
+    onTapToFocus: (x: Float, y: Float) -> Unit = { _, _ -> },
     onChangeZoomScale: (Float) -> Unit = {},
     onChangeFlash: (FlashMode) -> Unit = {},
     onChangeAspectRatio: (AspectRatio) -> Unit = {},
     onChangeCaptureMode: (CaptureMode) -> Unit = {},
     onChangeDynamicRange: (DynamicRange) -> Unit = {},
+    onLowLightBoost: (LowLightBoost) -> Unit = {},
+    onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
+    onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
     onToggleQuickSettings: () -> Unit = {},
+    onMuteAudio: (Boolean) -> Unit = {},
     onCaptureImage: () -> Unit = {},
     onCaptureImageWithUri: (
         ContentResolver,
@@ -164,6 +205,15 @@
             }
         }
 
+        val isMuted = remember(previewUiState) {
+            previewUiState.currentCameraSettings.audioMuted
+        }
+        val onToggleMuteAudio = remember(isMuted) {
+            {
+                onMuteAudio(!isMuted)
+            }
+        }
+
         Box(modifier.fillMaxSize()) {
             // display camera feed. this stays behind everything else
             PreviewDisplay(
@@ -187,7 +237,9 @@
                 onFlashModeClick = onChangeFlash,
                 onAspectRatioClick = onChangeAspectRatio,
                 onCaptureModeClick = onChangeCaptureMode,
-                onDynamicRangeClick = onChangeDynamicRange // onTimerClick = {}/*TODO*/
+                onDynamicRangeClick = onChangeDynamicRange,
+                onImageOutputFormatClick = onChangeImageFormat,
+                onLowLightBoostClick = onLowLightBoost
             )
             // relative-grid style overlay on top of preview display
             CameraControlsOverlay(
@@ -195,7 +247,10 @@
                 onNavigateToSettings = onNavigateToSettings,
                 onFlipCamera = onFlipCamera,
                 onChangeFlash = onChangeFlash,
+                onMuteAudio = onToggleMuteAudio,
                 onToggleQuickSettings = onToggleQuickSettings,
+                onChangeImageFormat = onChangeImageFormat,
+                onToggleWhenDisabled = onToggleWhenDisabled,
                 onCaptureImage = onCaptureImage,
                 onCaptureImageWithUri = onCaptureImageWithUri,
                 onStartVideoRecording = onStartVideoRecording,
@@ -274,5 +329,6 @@
 private val FAKE_PREVIEW_UI_STATE_READY = PreviewUiState.Ready(
     currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS,
     systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-    previewMode = PreviewMode.StandardMode {}
+    previewMode = PreviewMode.StandardMode {},
+    captureModeToggleUiState = CaptureModeToggleUiState.Invisible
 )
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 3bc1750..dfd20d1 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
@@ -24,7 +24,7 @@
  * Defines the current state of the [PreviewScreen].
  */
 sealed interface PreviewUiState {
-    object NotReady : PreviewUiState
+    data object NotReady : PreviewUiState
 
     data class Ready(
         // "quick" settings
@@ -34,12 +34,15 @@
         val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE,
         val quickSettingsIsOpen: Boolean = false,
         val audioAmplitude: Double = 0.0,
+        val audioMuted: Boolean = false,
 
         // todo: remove after implementing post capture screen
         val toastMessageToShow: ToastMessage? = null,
         val snackBarToShow: SnackbarData? = null,
         val lastBlinkTimeStamp: Long = 0,
-        val previewMode: PreviewMode
+        val previewMode: PreviewMode,
+        val captureModeToggleUiState: CaptureModeToggleUiState,
+        val sessionFirstFrameTimestamp: Long = 0L
     ) : 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 ca138a8..7142a1a 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
@@ -17,29 +17,39 @@
 
 import android.content.ContentResolver
 import android.net.Uri
+import android.os.SystemClock
 import android.util.Log
-import android.view.Display
 import androidx.camera.core.SurfaceRequest
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import androidx.tracing.Trace
 import androidx.tracing.traceAsync
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.CameraUseCase
+import com.google.jetpackcamera.core.common.traceFirstFramePreview
 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.settings.ConstraintsRepository
 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.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.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.time.Duration.Companion.seconds
 import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.async
@@ -49,6 +59,7 @@
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.transformWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
@@ -60,10 +71,9 @@
  */
 @HiltViewModel(assistedFactory = PreviewViewModel.Factory::class)
 class PreviewViewModel @AssistedInject constructor(
-    @Assisted previewMode: PreviewMode,
+    @Assisted val previewMode: PreviewMode,
     private val cameraUseCase: CameraUseCase,
     private val constraintsRepository: ConstraintsRepository
-
 ) : ViewModel() {
     private val _previewUiState: MutableStateFlow<PreviewUiState> =
         MutableStateFlow(PreviewUiState.NotReady)
@@ -79,7 +89,7 @@
 
     val screenFlash = ScreenFlash(cameraUseCase, viewModelScope)
 
-    private val imageCaptureCalledCount = atomic(0)
+    private val snackBarCount = atomic(0)
     private val videoCaptureStartedCount = atomic(0)
 
     // Eagerly initialize the CameraUseCase and encapsulate in a Deferred that can be
@@ -93,24 +103,33 @@
             combine(
                 cameraUseCase.getCurrentSettings().filterNotNull(),
                 constraintsRepository.systemConstraints.filterNotNull(),
-                cameraUseCase.getZoomScale()
-            ) { cameraAppSettings, systemConstraints, zoomScale ->
+                cameraUseCase.getCurrentCameraState()
+            ) { cameraAppSettings, systemConstraints, cameraState ->
                 _previewUiState.update { old ->
                     when (old) {
                         is PreviewUiState.Ready ->
                             old.copy(
                                 currentCameraSettings = cameraAppSettings,
                                 systemConstraints = systemConstraints,
-                                zoomScale = zoomScale,
-                                previewMode = previewMode
+                                zoomScale = cameraState.zoomScale,
+                                sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp,
+                                captureModeToggleUiState = getCaptureToggleUiState(
+                                    systemConstraints,
+                                    cameraAppSettings
+                                )
                             )
 
                         is PreviewUiState.NotReady ->
                             PreviewUiState.Ready(
                                 currentCameraSettings = cameraAppSettings,
                                 systemConstraints = systemConstraints,
-                                zoomScale = zoomScale,
-                                previewMode = previewMode
+                                zoomScale = cameraState.zoomScale,
+                                sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp,
+                                previewMode = previewMode,
+                                captureModeToggleUiState = getCaptureToggleUiState(
+                                    systemConstraints,
+                                    cameraAppSettings
+                                )
                             )
                     }
                 }
@@ -118,10 +137,137 @@
         }
     }
 
+    private fun getCaptureToggleUiState(
+        systemConstraints: SystemConstraints,
+        cameraAppSettings: CameraAppSettings
+    ): CaptureModeToggleUiState {
+        val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens(
+            cameraAppSettings
+        )
+        val hdrDynamicRangeSupported = cameraConstraints?.let {
+            it.supportedDynamicRanges.size > 1
+        } ?: false
+        val hdrImageFormatSupported =
+            cameraConstraints?.supportedImageFormatsMap?.get(cameraAppSettings.captureMode)?.let {
+                it.size > 1
+            } ?: false
+        val isShown = previewMode is PreviewMode.ExternalImageCaptureMode ||
+            cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR ||
+            cameraAppSettings.dynamicRange == DynamicRange.HLG10
+        val enabled = previewMode !is PreviewMode.ExternalImageCaptureMode &&
+            hdrDynamicRangeSupported && hdrImageFormatSupported
+        return if (isShown) {
+            val currentMode = if (previewMode is PreviewMode.ExternalImageCaptureMode ||
+                cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+            ) {
+                CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE
+            } else {
+                CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO
+            }
+            if (enabled) {
+                CaptureModeToggleUiState.Enabled(currentMode)
+            } else {
+                CaptureModeToggleUiState.Disabled(
+                    currentMode,
+                    getCaptureToggleUiStateDisabledReason(
+                        hdrDynamicRangeSupported,
+                        hdrImageFormatSupported,
+                        systemConstraints,
+                        cameraAppSettings.cameraLensFacing,
+                        cameraAppSettings.captureMode
+                    )
+                )
+            }
+        } else {
+            CaptureModeToggleUiState.Invisible
+        }
+    }
+
+    private fun getCaptureToggleUiStateDisabledReason(
+        hdrDynamicRangeSupported: Boolean,
+        hdrImageFormatSupported: Boolean,
+        systemConstraints: SystemConstraints,
+        currentLensFacing: LensFacing,
+        currentCaptureMode: CaptureMode
+    ): 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
+                    }
+                }
+            // 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
+                        }
+                    }
+                }
+            // 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) {
+                        return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS
+                    }
+                }
+            }
+            return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE
+        } else {
+            throw RuntimeException("Unknown CaptureModeUnsupportedReason.")
+        }
+    }
+
     fun startCamera() {
         Log.d(TAG, "startCamera")
         stopCamera()
         runningCameraJob = viewModelScope.launch {
+            if (Trace.isEnabled()) {
+                launch(start = CoroutineStart.UNDISPATCHED) {
+                    val startTraceTimestamp: Long = SystemClock.elapsedRealtimeNanos()
+                    traceFirstFramePreview(cookie = 1) {
+                        _previewUiState.transformWhile {
+                            var continueCollecting = true
+                            (it as? PreviewUiState.Ready)?.let { uiState ->
+                                if (uiState.sessionFirstFrameTimestamp > startTraceTimestamp) {
+                                    emit(Unit)
+                                    continueCollecting = false
+                                }
+                            }
+                            continueCollecting
+                        }.collect {}
+                    }
+                }
+            }
             // Ensure CameraUseCase is initialized before starting camera
             initializationDeferred.await()
             // TODO(yasith): Handle Exceptions from binding use cases
@@ -153,7 +299,6 @@
 
     fun setCaptureMode(captureMode: CaptureMode) {
         viewModelScope.launch {
-            // apply to cameraUseCase
             cameraUseCase.setCaptureMode(captureMode)
         }
     }
@@ -166,6 +311,20 @@
         }
     }
 
+    fun setAudioMuted(shouldMuteAudio: Boolean) {
+        viewModelScope.launch {
+            cameraUseCase.setAudioMuted(shouldMuteAudio)
+        }
+
+        Log.d(
+            TAG,
+            "Toggle Audio ${
+                (previewUiState.value as PreviewUiState.Ready)
+                    .currentCameraSettings.audioMuted
+            }"
+        )
+    }
+
     fun captureImage() {
         Log.d(TAG, "captureImage")
         viewModelScope.launch {
@@ -214,7 +373,7 @@
         onSuccess: (T) -> Unit = {},
         onFailure: (exception: Exception) -> Unit = {}
     ) {
-        val cookieInt = imageCaptureCalledCount.incrementAndGet()
+        val cookieInt = snackBarCount.incrementAndGet()
         val cookie = "Image-$cookieInt"
         try {
             traceAsync(IMAGE_CAPTURE_TRACE, cookieInt) {
@@ -248,6 +407,23 @@
         }
     }
 
+    fun showSnackBarForDisabledHdrToggle(disabledReason: CaptureModeToggleUiState.DisabledReason) {
+        val cookieInt = snackBarCount.incrementAndGet()
+        val cookie = "DisabledHdrToggle-$cookieInt"
+        viewModelScope.launch {
+            _previewUiState.update { old ->
+                (old as? PreviewUiState.Ready)?.copy(
+                    snackBarToShow = SnackbarData(
+                        cookie = cookie,
+                        stringResource = disabledReason.reasonTextResId,
+                        withDismissAction = true,
+                        testTag = disabledReason.testTag
+                    )
+                ) ?: old
+            }
+        }
+    }
+
     fun startVideoRecording() {
         if (previewUiState.value is PreviewUiState.Ready &&
             (previewUiState.value as PreviewUiState.Ready).previewMode is
@@ -341,6 +517,18 @@
         }
     }
 
+    fun setLowLightBoost(lowLightBoost: LowLightBoost) {
+        viewModelScope.launch {
+            cameraUseCase.setLowLightBoost(lowLightBoost)
+        }
+    }
+
+    fun setImageFormat(imageFormat: ImageOutputFormat) {
+        viewModelScope.launch {
+            cameraUseCase.setImageFormat(imageFormat)
+        }
+    }
+
     // modify ui values
     fun toggleQuickSettings() {
         viewModelScope.launch {
@@ -352,14 +540,11 @@
         }
     }
 
-    fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float) {
-        cameraUseCase.tapToFocus(
-            display = display,
-            surfaceWidth = surfaceWidth,
-            surfaceHeight = surfaceHeight,
-            x = x,
-            y = y
-        )
+    fun tapToFocus(x: Float, y: Float) {
+        Log.d(TAG, "tapToFocus")
+        viewModelScope.launch {
+            cameraUseCase.tapToFocus(x, y)
+        }
     }
 
     /**
@@ -392,6 +577,12 @@
         }
     }
 
+    fun setDisplayRotation(deviceRotation: DeviceRotation) {
+        viewModelScope.launch {
+            cameraUseCase.setDeviceRotation(deviceRotation)
+        }
+    }
+
     @AssistedFactory
     interface Factory {
         fun create(previewMode: PreviewMode): PreviewViewModel
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 e7ea585..7cd1a4f 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
@@ -15,7 +15,7 @@
  */
 package com.google.jetpackcamera.feature.preview
 
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.CameraUseCase
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
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 a569a8d..db13cd2 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
@@ -25,6 +25,8 @@
 import androidx.compose.material.icons.filled.FlashOn
 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.outlined.Nightlight
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.graphics.painter.Painter
 import androidx.compose.ui.graphics.vector.ImageVector
@@ -139,10 +141,29 @@
         override fun getTextResId() = R.string.quick_settings_dynamic_range_sdr
         override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_sdr_description
     },
-    HLG10 {
+    HDR {
         override fun getDrawableResId() = null
         override fun getImageVector() = Icons.Filled.HdrOn
-        override fun getTextResId() = R.string.quick_settings_dynamic_range_hlg10
-        override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_hlg10_description
+        override fun getTextResId() = R.string.quick_settings_dynamic_range_hdr
+        override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_hdr_description
+    }
+}
+
+enum class CameraLowLightBoost : QuickSettingsEnum {
+
+    ENABLED {
+        override fun getDrawableResId() = null
+        override fun getImageVector() = Icons.Filled.Nightlight
+        override fun getTextResId() = R.string.quick_settings_lowlightboost_enabled
+        override fun getDescriptionResId() =
+            R.string.quick_settings_lowlightboost_enabled_description
+    },
+
+    DISABLED {
+        override fun getDrawableResId() = null
+        override fun getImageVector() = Icons.Outlined.Nightlight
+        override fun getTextResId() = R.string.quick_settings_lowlightboost_disabled
+        override fun getDescriptionResId() =
+            R.string.quick_settings_lowlightboost_disabled_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 1eafa89..59e97bc 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
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2024 The Android Open Source Project
+ * Copyright (C) 2023 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.res.dimensionResource
 import androidx.compose.ui.tooling.preview.Preview
+import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState
 import com.google.jetpackcamera.feature.preview.PreviewMode
 import com.google.jetpackcamera.feature.preview.PreviewUiState
 import com.google.jetpackcamera.feature.preview.R
@@ -46,11 +47,13 @@
 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.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
@@ -58,7 +61,9 @@
 import com.google.jetpackcamera.settings.model.CaptureMode
 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
@@ -77,6 +82,8 @@
     onAspectRatioClick: (aspectRation: AspectRatio) -> Unit,
     onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
     onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
+    onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+    onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit,
     modifier: Modifier = Modifier,
     isOpen: Boolean = false
 ) {
@@ -127,7 +134,9 @@
                 onFlashModeClick = onFlashModeClick,
                 onAspectRatioClick = onAspectRatioClick,
                 onCaptureModeClick = onCaptureModeClick,
-                onDynamicRangeClick = onDynamicRangeClick
+                onDynamicRangeClick = onDynamicRangeClick,
+                onImageOutputFormatClick = onImageOutputFormatClick,
+                onLowLightBoostClick = onLowLightBoostClick
             )
         }
     } else {
@@ -155,7 +164,9 @@
     onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
     shouldShowQuickSetting: IsExpandedQuickSetting,
     setVisibleQuickSetting: (IsExpandedQuickSetting) -> Unit,
-    onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit
+    onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
+    onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+    onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit
 ) {
     Column(
         modifier =
@@ -209,18 +220,38 @@
                             )
                         }
 
+                        val cameraConstraints = previewUiState.systemConstraints.forCurrentLens(
+                            currentCameraSettings
+                        )
                         add {
                             QuickSetHdr(
                                 modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON),
-                                onClick = { d: DynamicRange -> onDynamicRangeClick(d) },
+                                onClick = { d: DynamicRange, i: ImageOutputFormat ->
+                                    onDynamicRangeClick(d)
+                                    onImageOutputFormatClick(i)
+                                },
                                 selectedDynamicRange = currentCameraSettings.dynamicRange,
+                                selectedImageOutputFormat = currentCameraSettings.imageFormat,
                                 hdrDynamicRange = currentCameraSettings.defaultHdrDynamicRange,
-                                enabled = previewUiState.previewMode !is
-                                    PreviewMode.ExternalImageCaptureMode &&
-                                    previewUiState.systemConstraints.forCurrentLens(
-                                        currentCameraSettings
-                                    )
-                                        ?.let { it.supportedDynamicRanges.size > 1 } ?: false
+                                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
+                            )
+                        }
+
+                        add {
+                            QuickSetLowLightBoost(
+                                modifier = Modifier.testTag(QUICK_SETTINGS_LOW_LIGHT_BOOST_BUTTON),
+                                onClick = {
+                                        l: LowLightBoost ->
+                                    onLowLightBoostClick(l)
+                                },
+                                selectedLowLightBoost = currentCameraSettings.lowLightBoost
                             )
                         }
                     }
@@ -245,7 +276,8 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             currentCameraSettings = CameraAppSettings(),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
@@ -255,7 +287,9 @@
             setVisibleQuickSetting = { },
             onAspectRatioClick = { },
             onCaptureModeClick = { },
-            onDynamicRangeClick = { }
+            onDynamicRangeClick = { },
+            onImageOutputFormatClick = { },
+            onLowLightBoostClick = { }
         )
     }
 }
@@ -268,7 +302,8 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             currentCameraSettings = CameraAppSettings(dynamicRange = DynamicRange.HLG10),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS_WITH_HDR,
@@ -278,7 +313,9 @@
             setVisibleQuickSetting = { },
             onAspectRatioClick = { },
             onCaptureModeClick = { },
-            onDynamicRangeClick = { }
+            onDynamicRangeClick = { },
+            onImageOutputFormatClick = { },
+            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 f7af6e0..0e6b85f 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
@@ -45,18 +45,22 @@
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
+import com.google.jetpackcamera.feature.preview.PreviewMode
 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.CameraDynamicRange
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraFlashMode
 import com.google.jetpackcamera.feature.preview.quicksettings.CameraLensFace
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraLowLightBoost
 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.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 kotlin.math.min
 
 // completed components ready to go into preview screen
@@ -103,29 +107,71 @@
 @Composable
 fun QuickSetHdr(
     modifier: Modifier = Modifier,
-    onClick: (dynamicRange: DynamicRange) -> Unit,
+    onClick: (dynamicRange: DynamicRange, imageOutputFormat: ImageOutputFormat) -> Unit,
     selectedDynamicRange: DynamicRange,
+    selectedImageOutputFormat: ImageOutputFormat,
     hdrDynamicRange: DynamicRange,
-    enabled: Boolean = true
+    hdrImageFormat: ImageOutputFormat,
+    hdrDynamicRangeSupported: Boolean,
+    hdrImageFormatSupported: Boolean,
+    previewMode: PreviewMode
 ) {
     val enum =
-        when (selectedDynamicRange) {
-            DynamicRange.SDR -> CameraDynamicRange.SDR
-            DynamicRange.HLG10 -> CameraDynamicRange.HLG10
+        if (selectedDynamicRange == hdrDynamicRange ||
+            selectedImageOutputFormat == hdrImageFormat
+        ) {
+            CameraDynamicRange.HDR
+        } else {
+            CameraDynamicRange.SDR
         }
+
     QuickSettingUiItem(
         modifier = modifier,
         enum = enum,
         onClick = {
-            val newDynamicRange = if (selectedDynamicRange == DynamicRange.SDR) {
-                hdrDynamicRange
-            } else {
-                DynamicRange.SDR
-            }
-            onClick(newDynamicRange)
+            val newDynamicRange =
+                if (selectedDynamicRange == DynamicRange.SDR && hdrDynamicRangeSupported) {
+                    hdrDynamicRange
+                } else {
+                    DynamicRange.SDR
+                }
+            val newImageOutputFormat =
+                if (!hdrDynamicRangeSupported ||
+                    previewMode is PreviewMode.ExternalImageCaptureMode
+                ) {
+                    hdrImageFormat
+                } else {
+                    ImageOutputFormat.JPEG
+                }
+            onClick(newDynamicRange, newImageOutputFormat)
         },
         isHighLighted = (selectedDynamicRange != DynamicRange.SDR),
-        enabled = enabled
+        enabled = (hdrDynamicRangeSupported && previewMode is PreviewMode.StandardMode) ||
+            hdrImageFormatSupported
+    )
+}
+
+@Composable
+fun QuickSetLowLightBoost(
+    modifier: Modifier = Modifier,
+    onClick: (lowLightBoost: LowLightBoost) -> Unit,
+    selectedLowLightBoost: LowLightBoost
+) {
+    val enum = when (selectedLowLightBoost) {
+        LowLightBoost.DISABLED -> CameraLowLightBoost.DISABLED
+        LowLightBoost.ENABLED -> CameraLowLightBoost.ENABLED
+    }
+
+    QuickSettingUiItem(
+        modifier = modifier,
+        enum = enum,
+        onClick = {
+            when (selectedLowLightBoost) {
+                LowLightBoost.DISABLED -> onClick(LowLightBoost.ENABLED)
+                LowLightBoost.ENABLED -> onClick(LowLightBoost.DISABLED)
+            }
+        },
+        isHighLighted = false
     )
 }
 
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 f89fb15..7334407 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
@@ -19,6 +19,7 @@
 const val QUICK_SETTINGS_DROP_DOWN = "QuickSettingsDropDown"
 const val QUICK_SETTINGS_HDR_BUTTON = "QuickSettingsHdrButton"
 const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSettingsFlashButton"
+const val QUICK_SETTINGS_LOW_LIGHT_BOOST_BUTTON = "QuickSettingsLowLightBoostButton"
 const val QUICK_SETTINGS_FLIP_CAMERA_BUTTON = "QuickSettingsFlipCameraButton"
 const val QUICK_SETTINGS_RATIO_3_4_BUTTON = "QuickSettingsRatio3:4Button"
 const val QUICK_SETTINGS_RATIO_9_16_BUTTON = "QuickSettingsRatio9:16Button"
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 f8e1d54..a8c8a3b 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
@@ -15,6 +15,7 @@
  */
 package com.google.jetpackcamera.feature.preview.ui
 
+import android.annotation.SuppressLint
 import android.content.ContentResolver
 import android.net.Uri
 import androidx.compose.foundation.layout.Arrangement
@@ -26,6 +27,11 @@
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.outlined.CameraAlt
+import androidx.compose.material.icons.outlined.Videocam
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
@@ -37,10 +43,13 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
+import androidx.core.util.Preconditions
+import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState
 import com.google.jetpackcamera.feature.preview.MultipleEventsCutter
 import com.google.jetpackcamera.feature.preview.PreviewMode
 import com.google.jetpackcamera.feature.preview.PreviewUiState
@@ -50,6 +59,7 @@
 import com.google.jetpackcamera.feature.preview.quicksettings.ui.ToggleQuickSettingsButton
 import com.google.jetpackcamera.settings.model.CameraAppSettings
 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 com.google.jetpackcamera.settings.model.SystemConstraints
@@ -75,7 +85,10 @@
     onNavigateToSettings: () -> Unit = {},
     onFlipCamera: () -> Unit = {},
     onChangeFlash: (FlashMode) -> Unit = {},
+    onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
+    onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
     onToggleQuickSettings: () -> Unit = {},
+    onMuteAudio: () -> Unit = {},
     onCaptureImage: () -> Unit = {},
     onCaptureImageWithUri: (
         ContentResolver,
@@ -120,12 +133,16 @@
                 zoomLevel = previewUiState.zoomScale,
                 showZoomLevel = zoomLevelDisplayState.showZoomLevel,
                 isQuickSettingsOpen = previewUiState.quickSettingsIsOpen,
+                currentCameraSettings = previewUiState.currentCameraSettings,
                 systemConstraints = previewUiState.systemConstraints,
                 videoRecordingState = previewUiState.videoRecordingState,
                 onFlipCamera = onFlipCamera,
                 onCaptureImage = onCaptureImage,
                 onCaptureImageWithUri = onCaptureImageWithUri,
                 onToggleQuickSettings = onToggleQuickSettings,
+                onToggleAudioMuted = onMuteAudio,
+                onChangeImageFormat = onChangeImageFormat,
+                onToggleWhenDisabled = onToggleWhenDisabled,
                 onStartVideoRecording = onStartVideoRecording,
                 onStopVideoRecording = onStopVideoRecording
             )
@@ -171,6 +188,9 @@
                 videoStabilization = currentCameraSettings.videoCaptureStabilization,
                 previewStabilization = currentCameraSettings.previewStabilization
             )
+            LowLightBoostIcon(
+                lowLightBoost = currentCameraSettings.lowLightBoost
+            )
         }
     }
 }
@@ -183,6 +203,7 @@
     zoomLevel: Float,
     showZoomLevel: Boolean,
     isQuickSettingsOpen: Boolean,
+    currentCameraSettings: CameraAppSettings,
     systemConstraints: SystemConstraints,
     videoRecordingState: VideoRecordingState,
     onFlipCamera: () -> Unit = {},
@@ -194,6 +215,9 @@
         (PreviewViewModel.ImageCaptureEvent) -> Unit
     ) -> Unit = { _, _, _, _ -> },
     onToggleQuickSettings: () -> Unit = {},
+    onToggleAudioMuted: () -> Unit = {},
+    onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
+    onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
     onStartVideoRecording: () -> Unit = {},
     onStopVideoRecording: () -> Unit = {}
 ) {
@@ -228,15 +252,26 @@
                 onStartVideoRecording = onStartVideoRecording,
                 onStopVideoRecording = onStopVideoRecording
             )
-            Row(Modifier.weight(1f)) {
+            Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) {
                 if (videoRecordingState == VideoRecordingState.ACTIVE) {
                     AmplitudeVisualizer(
                         modifier = Modifier
                             .weight(1f)
                             .fillMaxSize(),
+                        onToggleMute = onToggleAudioMuted,
                         size = 75,
                         audioAmplitude = audioAmplitude
                     )
+                } else {
+                    if (!isQuickSettingsOpen &&
+                        previewUiState.captureModeToggleUiState is CaptureModeToggleUiState.Visible
+                    ) {
+                        CaptureModeToggleButton(
+                            uiState = previewUiState.captureModeToggleUiState,
+                            onChangeImageFormat = onChangeImageFormat,
+                            onToggleWhenDisabled = onToggleWhenDisabled
+                        )
+                    }
                 }
             }
         }
@@ -301,6 +336,50 @@
     )
 }
 
+@SuppressLint("RestrictedApi")
+@Composable
+private fun CaptureModeToggleButton(
+    uiState: CaptureModeToggleUiState.Visible,
+    onChangeImageFormat: (ImageOutputFormat) -> Unit,
+    onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit
+) {
+    // Captures hdr image (left) when output format is UltraHdr, else captures hdr video (right).
+    val initialState =
+        when (uiState.currentMode) {
+            CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE -> ToggleState.Left
+            CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO -> ToggleState.Right
+        }
+    ToggleButton(
+        leftIcon = if (uiState.currentMode ==
+            CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE
+        ) {
+            rememberVectorPainter(image = Icons.Filled.CameraAlt)
+        } else {
+            rememberVectorPainter(image = Icons.Outlined.CameraAlt)
+        },
+        rightIcon = if (uiState.currentMode ==
+            CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO
+        ) {
+            rememberVectorPainter(image = Icons.Filled.Videocam)
+        } else {
+            rememberVectorPainter(image = Icons.Outlined.Videocam)
+        },
+        initialState = initialState,
+        onToggleStateChanged = {
+            val imageFormat = when (it) {
+                ToggleState.Left -> ImageOutputFormat.JPEG_ULTRA_HDR
+                ToggleState.Right -> ImageOutputFormat.JPEG
+            }
+            onChangeImageFormat(imageFormat)
+        },
+        onToggleWhenDisabled = {
+            Preconditions.checkArgument(uiState is CaptureModeToggleUiState.Disabled)
+            onToggleWhenDisabled((uiState as CaptureModeToggleUiState.Disabled).disabledReason)
+        },
+        enabled = uiState is CaptureModeToggleUiState.Enabled
+    )
+}
+
 @Preview(backgroundColor = 0xFF000000, showBackground = true)
 @Composable
 private fun Preview_ControlsTop_QuickSettingsOpen() {
@@ -367,11 +446,13 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             zoomLevel = 1.3f,
             showZoomLevel = true,
             isQuickSettingsOpen = false,
+            currentCameraSettings = CameraAppSettings(),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
             videoRecordingState = VideoRecordingState.INACTIVE,
             audioAmplitude = 0.0
@@ -387,11 +468,13 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             zoomLevel = 1.3f,
             showZoomLevel = false,
             isQuickSettingsOpen = false,
+            currentCameraSettings = CameraAppSettings(),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
             videoRecordingState = VideoRecordingState.INACTIVE,
             audioAmplitude = 0.0
@@ -407,15 +490,16 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             zoomLevel = 1.3f,
             showZoomLevel = true,
             isQuickSettingsOpen = true,
+            currentCameraSettings = CameraAppSettings(),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
             videoRecordingState = VideoRecordingState.INACTIVE,
             audioAmplitude = 0.0
-
         )
     }
 }
@@ -428,11 +512,13 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             zoomLevel = 1.3f,
             showZoomLevel = true,
             isQuickSettingsOpen = false,
+            currentCameraSettings = CameraAppSettings(),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS.copy(
                 availableLenses = listOf(LensFacing.FRONT),
                 perLensConstraints = mapOf(
@@ -442,7 +528,6 @@
             ),
             videoRecordingState = VideoRecordingState.INACTIVE,
             audioAmplitude = 0.0
-
         )
     }
 }
@@ -455,11 +540,13 @@
             previewUiState = PreviewUiState.Ready(
                 currentCameraSettings = CameraAppSettings(),
                 systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
-                previewMode = PreviewMode.StandardMode {}
+                previewMode = PreviewMode.StandardMode {},
+                captureModeToggleUiState = CaptureModeToggleUiState.Invisible
             ),
             zoomLevel = 1.3f,
             showZoomLevel = true,
             isQuickSettingsOpen = false,
+            currentCameraSettings = CameraAppSettings(),
             systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
             videoRecordingState = VideoRecordingState.ACTIVE,
             audioAmplitude = 0.9
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt
index 523efdd..2cf49ad 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt
@@ -17,14 +17,17 @@
 
 import android.content.pm.ActivityInfo
 import android.os.Build
+import android.util.Log
 import androidx.camera.core.DynamicRange
 import androidx.camera.core.Preview
 import androidx.camera.core.SurfaceRequest
 import androidx.camera.core.SurfaceRequest.TransformationInfo as CXTransformationInfo
+import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
 import androidx.camera.viewfinder.compose.Viewfinder
 import androidx.camera.viewfinder.surface.ImplementationMode
 import androidx.camera.viewfinder.surface.TransformationInfo
 import androidx.camera.viewfinder.surface.ViewfinderSurfaceRequest
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -33,6 +36,7 @@
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Runnable
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,6 +51,8 @@
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.launch
 
+private const val TAG = "CameraXViewfinder"
+
 /**
  * A composable viewfinder that adapts CameraX's [Preview.SurfaceProvider] to [Viewfinder]
  *
@@ -63,7 +69,8 @@
     surfaceRequest: SurfaceRequest,
     modifier: Modifier = Modifier,
     implementationMode: ImplementationMode = ImplementationMode.EXTERNAL,
-    onRequestWindowColorMode: (Int) -> Unit = {}
+    onRequestWindowColorMode: (Int) -> Unit = {},
+    onTap: (x: Float, y: Float) -> Unit = { _, _ -> }
 ) {
     val currentImplementationMode by rememberUpdatedState(implementationMode)
     val currentOnRequestWindowColorMode by rememberUpdatedState(onRequestWindowColorMode)
@@ -151,12 +158,23 @@
         }
     }
 
+    val coordinateTransformer = MutableCoordinateTransformer()
+
     viewfinderArgs?.let { args ->
         Viewfinder(
             surfaceRequest = args.viewfinderSurfaceRequest,
             implementationMode = args.implementationMode,
             transformationInfo = args.transformationInfo,
-            modifier = modifier.fillMaxSize()
+            modifier = modifier.fillMaxSize().pointerInput(Unit) {
+                detectTapGestures {
+                    with(coordinateTransformer) {
+                        val tapOffset = it.transform()
+                        Log.d(TAG, "onTap: $tapOffset")
+                        onTap(tapOffset.x, tapOffset.y)
+                    }
+                }
+            },
+            coordinateTransformer = coordinateTransformer
         )
     }
 }
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/DebouncedOrientationFlow.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/DebouncedOrientationFlow.kt
new file mode 100644
index 0000000..7add7b2
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/DebouncedOrientationFlow.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.feature.preview.ui
+
+import android.content.Context
+import android.view.OrientationEventListener
+import android.view.OrientationEventListener.ORIENTATION_UNKNOWN
+import com.google.jetpackcamera.settings.model.DeviceRotation
+import kotlin.math.abs
+import kotlin.math.min
+import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.runningFold
+
+/** Orientation hysteresis amount used in rounding, in degrees. */
+private const val ORIENTATION_HYSTERESIS = 5
+
+fun debouncedOrientationFlow(context: Context) = callbackFlow {
+    val orientationListener = object : OrientationEventListener(context) {
+        override fun onOrientationChanged(orientation: Int) {
+            trySend(orientation)
+        }
+    }
+
+    orientationListener.enable()
+
+    awaitClose {
+        orientationListener.disable()
+    }
+}.buffer(capacity = CONFLATED)
+    .runningFold(initial = DeviceRotation.Natural) { prevSnap, newDegrees ->
+        if (
+            newDegrees != ORIENTATION_UNKNOWN &&
+            abs(prevSnap.toClockwiseRotationDegrees() - newDegrees).let { min(it, 360 - it) } >=
+            45 + ORIENTATION_HYSTERESIS
+        ) {
+            DeviceRotation.snapFrom(newDegrees)
+        } else {
+            prevSnap
+        }
+    }.distinctUntilChanged()
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 688def4..4995e97 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
@@ -18,7 +18,6 @@
 import android.content.res.Configuration
 import android.os.Build
 import android.util.Log
-import android.view.Display
 import android.widget.Toast
 import androidx.camera.core.SurfaceRequest
 import androidx.camera.viewfinder.surface.ImplementationMode
@@ -51,10 +50,12 @@
 import androidx.compose.material.icons.filled.FlipCameraAndroid
 import androidx.compose.material.icons.filled.Mic
 import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material.icons.filled.Nightlight
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.VideoStable
 import androidx.compose.material.icons.filled.Videocam
 import androidx.compose.material.icons.outlined.CameraAlt
+import androidx.compose.material.icons.outlined.Nightlight
 import androidx.compose.material.icons.outlined.Videocam
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
@@ -94,6 +95,7 @@
 import com.google.jetpackcamera.feature.preview.VideoRecordingState
 import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme
 import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.LowLightBoost
 import com.google.jetpackcamera.settings.model.Stabilization
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
@@ -102,13 +104,18 @@
 private const val BLINK_TIME = 100L
 
 @Composable
-fun AmplitudeVisualizer(modifier: Modifier = Modifier, size: Int = 100, audioAmplitude: Double) {
+fun AmplitudeVisualizer(
+    modifier: Modifier = Modifier,
+    size: Int = 100,
+    audioAmplitude: Double,
+    onToggleMute: () -> Unit
+) {
     // Tweak the multiplier to amplitude to adjust the visualizer sensitivity
     val animatedScaling by animateFloatAsState(
         targetValue = EaseOutExpo.transform(1 + (1.75f * audioAmplitude.toFloat())),
         label = "AudioAnimation"
     )
-    Box(modifier = modifier) {
+    Box(modifier = modifier.clickable { onToggleMute() }) {
         // animated circle
         Canvas(
             modifier = Modifier
@@ -138,7 +145,14 @@
         Icon(
             modifier = Modifier
                 .align(Alignment.Center)
-                .size((0.5 * size).dp),
+                .size((0.5 * size).dp)
+                .apply {
+                    if (audioAmplitude != 0.0) {
+                        testTag(AMPLITUDE_HOT_TAG)
+                    } else {
+                        testTag(AMPLITUDE_NONE_TAG)
+                    }
+                },
             tint = Color.Black,
             imageVector = if (audioAmplitude != 0.0) {
                 Icons.Filled.Mic
@@ -236,7 +250,7 @@
 @Composable
 fun PreviewDisplay(
     previewUiState: PreviewUiState.Ready,
-    onTapToFocus: (Display, Int, Int, Float, Float) -> Unit,
+    onTapToFocus: (x: Float, y: Float) -> Unit,
     onFlipCamera: () -> Unit,
     onZoomChange: (Float) -> Unit,
     onRequestWindowColorMode: (Int) -> Unit,
@@ -300,6 +314,7 @@
                     .height(height)
                     .transformable(state = transformableState)
                     .alpha(imageAlpha)
+                    .clip(RoundedCornerShape(16.dp))
             ) {
                 CameraXViewfinder(
                     modifier = Modifier.fillMaxSize(),
@@ -308,7 +323,8 @@
                         Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
                         else -> ImplementationMode.EMBEDDED
                     },
-                    onRequestWindowColorMode = onRequestWindowColorMode
+                    onRequestWindowColorMode = onRequestWindowColorMode,
+                    onTap = { x, y -> onTapToFocus(x, y) }
                 )
             }
         }
@@ -337,6 +353,28 @@
 }
 
 /**
+ * LowLightBoostIcon has 3 states
+ * - disabled: hidden
+ * - enabled and inactive: outline
+ * - enabled and active: filled
+ */
+@Composable
+fun LowLightBoostIcon(lowLightBoost: LowLightBoost, modifier: Modifier = Modifier) {
+    when (lowLightBoost) {
+        LowLightBoost.ENABLED -> {
+            Icon(
+                imageVector = Icons.Outlined.Nightlight,
+                contentDescription =
+                stringResource(id = R.string.quick_settings_lowlightboost_enabled),
+                modifier = modifier.alpha(0.5f)
+            )
+        }
+        LowLightBoost.DISABLED -> {
+        }
+    }
+}
+
+/**
  * A temporary button that can be added to preview for quick testing purposes
  */
 @Composable
@@ -455,9 +493,12 @@
 fun ToggleButton(
     leftIcon: Painter,
     rightIcon: Painter,
-    modifier: Modifier = Modifier.width(64.dp).height(32.dp),
+    modifier: Modifier = Modifier
+        .width(64.dp)
+        .height(32.dp),
     initialState: ToggleState = ToggleState.Left,
     onToggleStateChanged: (newState: ToggleState) -> Unit = {},
+    onToggleWhenDisabled: () -> Unit = {},
     enabled: Boolean = true,
     leftIconDescription: String = "leftIcon",
     rightIconDescription: String = "rightIcon",
@@ -483,18 +524,18 @@
         modifier = modifier
             .clip(shape = RoundedCornerShape(50))
             .then(
-                if (enabled) {
-                    Modifier.clickable {
-                        scope.launch {
+                Modifier.clickable {
+                    scope.launch {
+                        if (enabled) {
                             toggleState = when (toggleState) {
                                 ToggleState.Left -> ToggleState.Right
                                 ToggleState.Right -> ToggleState.Left
                             }
                             onToggleStateChanged(toggleState)
+                        } else {
+                            onToggleWhenDisabled()
                         }
                     }
-                } else {
-                    Modifier
                 }
             ),
         color = backgroundColor
@@ -521,9 +562,11 @@
                 )
             }
             Row(
-                modifier = Modifier.matchParentSize().then(
-                    if (enabled) Modifier else Modifier.alpha(0.38f)
-                ),
+                modifier = Modifier
+                    .matchParentSize()
+                    .then(
+                        if (enabled) Modifier else Modifier.alpha(0.38f)
+                    ),
                 verticalAlignment = Alignment.CenterVertically,
                 horizontalArrangement = Arrangement.SpaceBetween
             ) {
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 974619b..5391125 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
@@ -23,3 +23,11 @@
 const val PREVIEW_DISPLAY = "PreviewDisplay"
 const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay"
 const val SETTINGS_BUTTON = "SettingsButton"
+const val AMPLITUDE_NONE_TAG = "AmplitudeNoneTag"
+const val AMPLITUDE_HOT_TAG = "AmplitudeHotTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG = "HdrImageUnsupportedOnDeviceTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG = "HdrImageUnsupportedOnLensTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG = "HdrImageUnsupportedOnSingleStreamTag"
+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"
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index 3bf1eec..fc81069 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -26,9 +26,16 @@
 
     <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">External video capture not supported</string>
+    <string name="toast_video_capture_external_unsupported">Video not supported while app is in image-only capture 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>
+    <string name="toast_hdr_photo_unsupported_on_lens">Ultra HDR photos not supported by current lens</string>
+    <string name="toast_hdr_photo_unsupported_on_lens_single_stream">Single-stream mode does not support UltraHDR photo capture for current lens</string>
+    <string name="toast_hdr_photo_unsupported_on_lens_multi_stream">Multi-stream mode does not support UltraHDR photo capture for current lens</string>
+    <string name="toast_hdr_video_unsupported_on_device">HDR video not supported on this device</string>
+    <string name="toast_hdr_video_unsupported_on_lens">HDR video not supported by current lens</string>
+
 
     <string name="quick_settings_front_camera_text">FRONT</string>
     <string name="quick_settings_back_camera_text">BACK</string>
@@ -40,9 +47,9 @@
     <string name="quick_settings_aspect_ratio_1_1">1:1</string>
 
     <string name="quick_settings_dynamic_range_sdr">SDR</string>
-    <string name="quick_settings_dynamic_range_hlg10">HLG10</string>
+    <string name="quick_settings_dynamic_range_hdr">HDR</string>
     <string name="quick_settings_dynamic_range_sdr_description">Standard dynamic range</string>
-    <string name="quick_settings_dynamic_range_hlg10_description">10-bit Hybrid Log Gamma dynamic range</string>
+    <string name="quick_settings_dynamic_range_hdr_description">High dynamic range</string>
 
     <string name="quick_settings_aspect_ratio_3_4_description">3 to 4 aspect ratio</string>
     <string name="quick_settings_aspect_ratio_9_16_description">9 to 16 aspect ratio</string>
@@ -62,4 +69,9 @@
 
     <string name="quick_settings_dropdown_open_description">Quick settings open</string>
     <string name="quick_settings_dropdown_closed_description">Quick settings closed</string>
+
+    <string name="quick_settings_lowlightboost_enabled">Low light boost on</string>
+    <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>
 </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 1f515c6..ee4bc9e 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
@@ -17,7 +17,7 @@
 
 import android.content.ContentResolver
 import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
+import com.google.jetpackcamera.core.camera.test.FakeCameraUseCase
 import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl
 import com.google.jetpackcamera.settings.model.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
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 ea8b395..9dd08cc 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
@@ -17,8 +17,8 @@
 
 import android.content.ContentResolver
 import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.domain.camera.CameraUseCase
-import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
+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.FlashMode
 import com.google.jetpackcamera.settings.model.LensFacing
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 2bb1842..0be4f1b 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -24,6 +24,7 @@
 android {
     namespace = "com.google.jetpackcamera.settings"
     compileSdk = libs.versions.compileSdk.get().toInt()
+    compileSdkPreview = libs.versions.compileSdkPreview.get()
 
     defaultConfig {
         minSdk = libs.versions.minSdk.get().toInt()
@@ -33,6 +34,19 @@
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
     }
 
+    flavorDimensions += "flavor"
+    productFlavors {
+        create("stable") {
+            dimension = "flavor"
+            isDefault = true
+        }
+
+        create("preview") {
+            dimension = "flavor"
+            targetSdkPreview = libs.versions.targetSdkPreview.get()
+        }
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_17
         targetCompatibility = JavaVersion.VERSION_17
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a650cf0..930f838 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,10 @@
 [versions]
 # Used directly in build.gradle.kts files
 compileSdk = "34"
+compileSdkPreview = "VanillaIceCream"
 minSdk = "21"
 targetSdk = "34"
+targetSdkPreview = "VanillaIceCream"
 composeCompiler = "1.5.10"
 
 # Used below in dependency definitions
@@ -13,7 +15,7 @@
 # kotlinPlugin and composeCompiler are linked
 # See https://developer.android.com/jetpack/androidx/releases/compose-kotlin
 kotlinPlugin = "1.9.22"
-androidGradlePlugin = "8.4.0-rc01"
+androidGradlePlugin = "8.4.2"
 protobufPlugin = "0.9.4"
 
 androidxActivityCompose = "1.8.2"
@@ -35,6 +37,7 @@
 androidxTestRules = "1.5.0"
 androidxTestUiautomator = "2.3.0"
 androidxTracing = "1.2.0"
+cmake = "3.22.1"
 kotlinxAtomicfu = "0.23.2"
 kotlinxCoroutines = "1.8.0"
 hilt = "2.51"
diff --git a/settings.gradle.kts b/settings.gradle.kts
index dd158c4..b021ba1 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/11790852/artifacts/repository")
+            setUrl("https://androidx.dev/snapshots/builds/11932573/artifacts/repository")
         }
         google()
         mavenCentral()
@@ -35,7 +35,7 @@
 rootProject.name = "Jetpack Camera"
 include(":app")
 include(":feature:preview")
-include(":domain:camera")
+include(":core:camera")
 include(":feature:settings")
 include(":data:settings")
 include(":core:common")