Merge "[FocusAndMetering] Document exceptions for startFocusAndMetering to align with other CameraControl APIs" into androidx-main
diff --git a/buildSrc-tests/src/test/kotlin/androidx/build/testConfiguration/TestApkSha256ReportTest.kt b/buildSrc-tests/src/test/kotlin/androidx/build/testConfiguration/TestApkSha256ReportTest.kt
deleted file mode 100644
index b513ba9..0000000
--- a/buildSrc-tests/src/test/kotlin/androidx/build/testConfiguration/TestApkSha256ReportTest.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.build.testConfiguration
-
-import com.google.common.truth.Truth.assertThat
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TemporaryFolder
-
-class TestApkSha256ReportTest {
-    @get:Rule
-    val temporaryFolder = TemporaryFolder()
-
-    @Test
-    fun writeShaXml() {
-        val file1 = temporaryFolder.newFile(
-            "foo.txt"
-        ).also {
-            it.writeText(
-                "test-file-1"
-            )
-        }
-        // validated via an external tool so we don't rely on what we generate.
-        val file1Sha = "db7bb0ef3ae21cafba57068bab4bcdd5129ba8a25ef5f8c16ad33fc686c7467e"
-        val output = temporaryFolder.newFile("output.xml")
-        val subject = TestApkSha256Report()
-        subject.addFile("renamed.txt", file1)
-        subject.writeToFile(output)
-        assertThat(
-            output.readText(Charsets.UTF_8).trimIndent()
-        ).isEqualTo(
-            """
-            <?xml version="1.0" encoding="UTF-8" standalone="no"?>
-            <sha256Report>
-                <file name="renamed.txt" sha256="$file1Sha"/>
-            </sha256Report>
-        """.trimIndent()
-        )
-    }
-
-    @Test
-    fun conflictingFileName() {
-        val file1 = temporaryFolder.newFile(
-            "foo.txt"
-        ).also {
-            it.writeText(
-                "test-file-1"
-            )
-        }
-        val file2 = temporaryFolder.newFile(
-            "foo2.txt"
-        ).also {
-            it.writeText(
-                "test-file-2"
-            )
-        }
-        val subject = TestApkSha256Report()
-        subject.addFile("file1.txt", file1)
-        val result = runCatching {
-            // intentionally use the same name for a different file
-            subject.addFile("file1.txt", file2)
-        }
-        assertThat(
-            result.exceptionOrNull()
-        ).hasMessageThat().contains(
-            "Same file name sent with different sha256 values"
-        )
-    }
-}
\ No newline at end of file
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/ListTaskOutputsTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/ListTaskOutputsTask.kt
index 4e9b6b1e..49a1ff3 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/ListTaskOutputsTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/ListTaskOutputsTask.kt
@@ -128,6 +128,9 @@
     "generateReleaseProtos",
     // Release APKs
     "copyReleaseApk",
+    // The following tests intentionally have the same output of golden images
+    "updateGoldenDesktopTest",
+    "updateGoldenDebugUnitTest"
 )
 
 val taskTypesKnownToDuplicateOutputs = setOf(
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/paparazzi/AndroidXPaparazziImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/paparazzi/AndroidXPaparazziImplPlugin.kt
index 89e34eb..8137fdd 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/paparazzi/AndroidXPaparazziImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/paparazzi/AndroidXPaparazziImplPlugin.kt
@@ -50,7 +50,11 @@
         val paparazziNative = project.createUnzippedPaparazziNativeDependency()
         project.afterEvaluate { it.addTestUtilsDependency() }
         project.tasks.register("updateGolden")
-        project.tasks.withType<Test>().configureEach { it.configureTestTask(paparazziNative) }
+        project.afterEvaluate {
+            // need to be inside of afterEvaluate because we read android.namespace
+            // ideally, we refactor to use a lazy API
+            project.tasks.withType<Test>().configureEach { it.configureTestTask(paparazziNative) }
+        }
         project.tasks.withType<Test>().whenTaskAdded { project.registerUpdateGoldenTask(it) }
     }
 
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt
index f7bcd5c..f9ce277 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt
@@ -98,17 +98,7 @@
             .append(APK_INSTALL_OPTION.replace("APK_NAME", testApkName))
         if (!appApkName.isNullOrEmpty())
             sb.append(APK_INSTALL_OPTION.replace("APK_NAME", appApkName!!))
-        // Temporary hardcoded hack for b/181810492
-        else if (applicationId == "androidx.benchmark.macro.test") {
-            sb.append(
-                APK_INSTALL_OPTION.replace(
-                    "APK_NAME",
-                    /* ktlint-disable max-line-length */
-                    "benchmark-integration-tests-macrobenchmark-target_macrobenchmark-target-release.apk"
-                    /* ktlint-enable max-line-length */
-                )
-            )
-        }
+
         sb.append(TARGET_PREPARER_CLOSE)
             .append(TEST_BLOCK_OPEN)
             .append(RUNNER_OPTION.replace("TEST_RUNNER", testRunner))
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt
index b9861d0..a1b8cb3 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt
@@ -19,9 +19,11 @@
 import androidx.build.dependencyTracker.ProjectSubset
 import androidx.build.renameApkForTesting
 import com.android.build.api.variant.BuiltArtifactsLoader
+import java.io.File
 import org.gradle.api.DefaultTask
 import org.gradle.api.file.DirectoryProperty
 import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
 import org.gradle.api.provider.Property
 import org.gradle.api.tasks.CacheableTask
 import org.gradle.api.tasks.Input
@@ -32,8 +34,6 @@
 import org.gradle.api.tasks.PathSensitive
 import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
-import java.io.File
-import org.gradle.api.provider.ListProperty
 
 /**
  * Writes a configuration file in
@@ -94,41 +94,23 @@
     @get:OutputFile
     abstract val outputJson: RegularFileProperty
 
-    /**
-     * Output file where we write the sha256 for each APK file we reference
-     */
-    @get:OutputFile
-    abstract val shaReportOutput: RegularFileProperty
-
     @get:OutputFile
     abstract val constrainedOutputXml: RegularFileProperty
 
-    /**
-     * Output file where we write the sha256 for each APK file we reference in constrained setup
-     */
-    @get:OutputFile
-    abstract val constrainedShaReportOutput: RegularFileProperty
-
     @TaskAction
     fun generateAndroidTestZip() {
-        val testApkSha256Report = TestApkSha256Report()
         writeConfigFileContent(
             outputFile = constrainedOutputXml,
-            testApkSha256Report = testApkSha256Report,
             isConstrained = true,
         )
         writeConfigFileContent(
             outputFile = outputXml,
-            testApkSha256Report = testApkSha256Report,
             isConstrained = false,
         )
-        testApkSha256Report.writeToFile(shaReportOutput.get().asFile)
-        testApkSha256Report.writeToFile(constrainedShaReportOutput.get().asFile)
     }
 
     private fun writeConfigFileContent(
         outputFile: RegularFileProperty,
-        testApkSha256Report: TestApkSha256Report,
         isConstrained: Boolean = false
     ) {
         /*
@@ -148,7 +130,6 @@
                 .renameApkForTesting(appProjectPath.get(), hasBenchmarkPlugin = false)
             configBuilder.appApkName(appName)
                 .appApkSha256(sha256(File(appApkBuiltArtifact.outputFile)))
-            testApkSha256Report.addFile(appName, appApkBuiltArtifact)
         }
         configBuilder.additionalApkKeys(additionalApkKeys.get())
         val isPresubmit = presubmit.get()
@@ -216,7 +197,6 @@
             .minSdk(minSdk.get().toString())
             .testRunner(testRunner.get())
             .testApkSha256(sha256(File(testApkBuiltArtifact.outputFile)))
-        testApkSha256Report.addFile(testName, testApkBuiltArtifact)
         createOrFail(outputFile).writeText(configBuilder.buildXml())
         if (!isConstrained) {
             createOrFail(outputJson).writeText(configBuilder.buildJson())
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestApkSha256Report.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestApkSha256Report.kt
index 0c971b8..7fd7d05 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestApkSha256Report.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestApkSha256Report.kt
@@ -16,80 +16,9 @@
 
 package androidx.build.testConfiguration
 
-import com.android.build.api.variant.BuiltArtifact
-import com.google.common.annotations.VisibleForTesting
 import com.google.common.hash.Hashing
 import com.google.common.io.BaseEncoding
 import java.io.File
-import java.io.FileOutputStream
-import javax.xml.parsers.DocumentBuilderFactory
-import javax.xml.transform.OutputKeys
-import javax.xml.transform.TransformerFactory
-import javax.xml.transform.dom.DOMSource
-import javax.xml.transform.stream.StreamResult
-
-/**
- * Helper class to record sha256 of APK files referenced in tests.
- *
- * It hashes the files the same way androidx-ci-action does to utilize the APK cache in Google Cloud
- * Storage (GCP). This hashing helps us avoid uploading the same APK multiple times to GCP.
- *
- * https://github.com/androidx/androidx-ci-action/blob/main/AndroidXCI/lib/src/main/kotlin/dev/androidx/ci/util/HashUtil.kt#L21
- */
-@VisibleForTesting
-class TestApkSha256Report {
-    private val files = mutableMapOf<String, String>()
-
-    /**
-     * Adds the given builtArtifact to the list of shas after calculating its sha256 hash.
-     */
-    fun addFile(name: String, builtArtifact: BuiltArtifact) {
-        addFile(name, File(builtArtifact.outputFile))
-    }
-
-    fun addFile(name: String, file: File) {
-        require(file.exists()) {
-            "Cannot find file ${file.path}"
-        }
-        val hash = sha256(file)
-        val existing = files[name]
-        require(existing == null || existing == hash) {
-            "Same file name sent with different sha256 values. $name"
-        }
-        files[name] = hash
-    }
-
-    /**
-     * Writes the [TestApkSha256Report] in XML format into the given [file].
-     */
-    fun writeToFile(file: File) {
-        if (file.exists()) {
-            file.delete()
-        }
-        file.parentFile.mkdirs()
-        val factory = DocumentBuilderFactory.newInstance()
-        val builder = factory.newDocumentBuilder()
-        val doc = builder.newDocument()
-        val root = doc.createElement("sha256Report")
-        doc.appendChild(root)
-        files.entries.sortedBy {
-            it.key
-        }.forEach { (fileName, hash) ->
-            val elm = doc.createElement("file")
-            elm.setAttribute("name", fileName)
-            elm.setAttribute("sha256", hash)
-            root.appendChild(elm)
-        }
-        val transformerFactory = TransformerFactory.newInstance()
-        val transformer = transformerFactory.newTransformer()
-        transformer.setOutputProperty(OutputKeys.INDENT, "yes")
-        val source = DOMSource(doc)
-        FileOutputStream(file).use { fileOutput ->
-            val result = StreamResult(fileOutput)
-            transformer.transform(source, result)
-        }
-    }
-}
 
 @Suppress("UnstableApiUsage") // guava Hashing is marked as @Beta
 internal fun sha256(file: File): String {
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
index 37e6689..3bc7114 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
@@ -63,7 +63,6 @@
 ) {
     val xmlName = "${path.asFilenamePrefix()}$variantName.xml"
     val jsonName = "${path.asFilenamePrefix()}$variantName.json"
-    val sha256XmlName = "${path.asFilenamePrefix()}$variantName$SHA_256_FILE_SUFFIX"
     rootProject.tasks.named("createModuleInfo").configure {
         it as ModuleInfoGenerator
         it.testModules.add(
@@ -84,11 +83,7 @@
         task.additionalApkKeys.set(androidXExtension.additionalDeviceTestApkKeys)
         task.outputXml.fileValue(File(getTestConfigDirectory(), xmlName))
         task.outputJson.fileValue(File(getTestConfigDirectory(), jsonName))
-        task.shaReportOutput.fileValue(File(getTestConfigDirectory(), sha256XmlName))
         task.constrainedOutputXml.fileValue(File(getConstrainedTestConfigDirectory(), xmlName))
-        task.constrainedShaReportOutput.fileValue(
-            File(getConstrainedTestConfigDirectory(), sha256XmlName)
-        )
         task.presubmit.set(isPresubmitBuild())
         // Disable work tests on < API 18: b/178127496
         if (path.startsWith(":work:")) {
@@ -135,6 +130,25 @@
  */
 fun Project.addAppApkToTestConfigGeneration(overrideProject: Project = this) {
     if (project.isMacrobenchmarkTarget()) {
+        if (path == ":benchmark:integration-tests:macrobenchmark-target") {
+            // Really ugly workaround for b/178776319 and b/181810492 where we hardcode that
+            // :benchmark:integration-tests:macrobenchmark-target needs to be installed
+            // for :benchmark:benchmark-macro tests to work.
+            extensions.getByType<ApplicationAndroidComponentsExtension>().apply {
+                onVariants(selector().withBuildType("release")) { appVariant ->
+                    project(":benchmark:benchmark-macro").tasks.withType(
+                        GenerateTestConfigurationTask::class.java
+                    ).named(
+                        "${AndroidXImplPlugin.GENERATE_TEST_CONFIGURATION_TASK}debugAndroidTest"
+                    ).configure { configTask ->
+                        configTask as GenerateTestConfigurationTask
+                        configTask.appFolder.set(appVariant.artifacts.get(SingleArtifact.APK))
+                        configTask.appLoader.set(appVariant.artifacts.getBuiltArtifactsLoader())
+                        configTask.appProjectPath.set(overrideProject.path)
+                    }
+                }
+            }
+        }
         return
     }
 
@@ -359,20 +373,6 @@
     testRunner: String
 ) {
     val configTask = getOrCreateMacrobenchmarkConfigTask(variantName)
-    if (path == ":benchmark:integration-tests:macrobenchmark-target" && variantName == "release") {
-        // Really ugly workaround for b/178776319 where we hardcode that
-        // :benchmark:integration-tests:macrobenchmark-target needs to be installed
-        // for :benchmark:benchmark-macro tests to work.
-        project(":benchmark:benchmark-macro").tasks.withType(
-            GenerateTestConfigurationTask::class.java
-        ).named(
-            "${AndroidXImplPlugin.GENERATE_TEST_CONFIGURATION_TASK}$variantName"
-        ).configure { task ->
-            task.appFolder.set(artifacts.get(SingleArtifact.APK))
-            task.appLoader.set(artifacts.getBuiltArtifactsLoader())
-            task.appProjectPath.set(path)
-        }
-    }
     if (path.endsWith("macrobenchmark")) {
         configTask.configure { task ->
             val androidXExtension = extensions.getByType<AndroidXExtension>()
@@ -386,24 +386,12 @@
             task.outputJson.fileValue(
                 File(getTestConfigDirectory(), "$fileNamePrefix.json")
             )
-            task.shaReportOutput.fileValue(
-                File(
-                    this.getTestConfigDirectory(),
-                    "${this.path.asFilenamePrefix()}$variantName$SHA_256_FILE_SUFFIX"
-                )
-            )
             task.constrainedOutputXml.fileValue(
                 File(
                     this.getTestConfigDirectory(),
                     "${this.path.asFilenamePrefix()}$variantName.xml"
                 )
             )
-            task.constrainedShaReportOutput.fileValue(
-                File(
-                    this.getTestConfigDirectory(),
-                    "${this.path.asFilenamePrefix()}$variantName$SHA_256_FILE_SUFFIX"
-                )
-            )
             task.minSdk.set(minSdk)
             task.hasBenchmarkPlugin.set(this.hasBenchmarkPlugin())
             task.testRunner.set(testRunner)
@@ -503,9 +491,3 @@
         }
     }
 }
-
-/**
- * Suffix to add for xml files which include the SHA256 of referred APKs.
- * see: [TestApkSha256Report].
- */
-private const val SHA_256_FILE_SUFFIX = "-sha256Report.xml"
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt
new file mode 100644
index 0000000..c61aa16
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration
+
+import android.media.CamcorderProfile
+import android.media.EncoderProfiles.VideoProfile.HDR_NONE
+import android.media.EncoderProfiles.VideoProfile.YUV_420
+import android.os.Build
+import androidx.camera.camera2.pipe.integration.adapter.EncoderProfilesProviderAdapter
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy.BIT_DEPTH_8
+import androidx.camera.testing.CameraUtil
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assume
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+@SmallTest
+@SdkSuppress(minSdkVersion = 21)
+class EncoderProfilesProviderAdapterDeviceTest(private val quality: Int) {
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters
+        fun data(): Array<Array<Int>> = arrayOf(
+            arrayOf(CamcorderProfile.QUALITY_LOW),
+            arrayOf(CamcorderProfile.QUALITY_HIGH),
+            arrayOf(CamcorderProfile.QUALITY_QCIF),
+            arrayOf(CamcorderProfile.QUALITY_CIF),
+            arrayOf(CamcorderProfile.QUALITY_480P),
+            arrayOf(CamcorderProfile.QUALITY_720P),
+            arrayOf(CamcorderProfile.QUALITY_1080P),
+            arrayOf(CamcorderProfile.QUALITY_QVGA),
+            arrayOf(CamcorderProfile.QUALITY_2160P),
+            arrayOf(CamcorderProfile.QUALITY_VGA),
+            arrayOf(CamcorderProfile.QUALITY_4KDCI),
+            arrayOf(CamcorderProfile.QUALITY_QHD),
+            arrayOf(CamcorderProfile.QUALITY_2K)
+        )
+    }
+
+    private lateinit var encoderProfilesProvider: EncoderProfilesProviderAdapter
+    private var cameraId = ""
+    private var intCameraId = -1
+
+    @get:Rule
+    val useCamera = CameraUtil.grantCameraPermissionAndPreTest()
+
+    @Before
+    fun setup() {
+        skipTestOnProblematicBuildsOfCuttlefishApi33()
+        Assume.assumeTrue(CameraUtil.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK))
+
+        cameraId = CameraUtil.getCameraIdWithLensFacing(CameraSelector.LENS_FACING_BACK)!!
+        intCameraId = cameraId.toInt()
+
+        encoderProfilesProvider = EncoderProfilesProviderAdapter(cameraId)
+    }
+
+    @Test
+    fun hasProfile_returnSameResult() {
+        assertThat(encoderProfilesProvider.hasProfile(quality))
+            .isEqualTo(CamcorderProfile.hasProfile(intCameraId, quality))
+    }
+
+    @Test
+    fun hasProfile_getReturnNonNull() {
+        Assume.assumeTrue(CamcorderProfile.hasProfile(intCameraId, quality))
+
+        assertThat(encoderProfilesProvider.getAll(quality)).isNotNull()
+    }
+
+    @Test
+    fun notHasProfile_getReturnNull() {
+        Assume.assumeTrue(!CamcorderProfile.hasProfile(intCameraId, quality))
+
+        assertThat(encoderProfilesProvider.getAll(quality)).isNull()
+    }
+
+    @Suppress("DEPRECATION")
+    @Test
+    fun hasSameContentAsCamcorderProfile() {
+        Assume.assumeTrue(CamcorderProfile.hasProfile(quality))
+
+        val profile = CamcorderProfile.get(quality)
+        val encoderProfiles = encoderProfilesProvider.getAll(quality)
+        val videoProfile = encoderProfiles!!.videoProfiles[0]
+        val audioProfile = encoderProfiles.audioProfiles[0]
+
+        assertThat(encoderProfiles.defaultDurationSeconds).isEqualTo(profile.duration)
+        assertThat(encoderProfiles.recommendedFileFormat).isEqualTo(profile.fileFormat)
+        assertThat(videoProfile.codec).isEqualTo(profile.videoCodec)
+        assertThat(videoProfile.bitrate).isEqualTo(profile.videoBitRate)
+        assertThat(videoProfile.frameRate).isEqualTo(profile.videoFrameRate)
+        assertThat(videoProfile.width).isEqualTo(profile.videoFrameWidth)
+        assertThat(videoProfile.height).isEqualTo(profile.videoFrameHeight)
+        assertThat(audioProfile.codec).isEqualTo(profile.audioCodec)
+        assertThat(audioProfile.bitrate).isEqualTo(profile.audioBitRate)
+        assertThat(audioProfile.sampleRate).isEqualTo(profile.audioSampleRate)
+        assertThat(audioProfile.channels).isEqualTo(profile.audioChannels)
+    }
+
+    @SdkSuppress(minSdkVersion = 31, maxSdkVersion = 32)
+    @Test
+    fun api31Api32_hasSameContentAsEncoderProfiles() {
+        Assume.assumeTrue(CamcorderProfile.hasProfile(quality))
+
+        val profiles = CamcorderProfile.getAll(cameraId, quality)
+        val video = profiles!!.videoProfiles[0]
+        val audio = profiles.audioProfiles[0]
+        val profilesProxy = encoderProfilesProvider.getAll(quality)
+        val videoProxy = profilesProxy!!.videoProfiles[0]
+        val audioProxy = profilesProxy.audioProfiles[0]
+
+        assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
+        assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
+        assertThat(videoProxy.codec).isEqualTo(video.codec)
+        assertThat(videoProxy.mediaType).isEqualTo(video.mediaType)
+        assertThat(videoProxy.bitrate).isEqualTo(video.bitrate)
+        assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
+        assertThat(videoProxy.width).isEqualTo(video.width)
+        assertThat(videoProxy.height).isEqualTo(video.height)
+        assertThat(videoProxy.profile).isEqualTo(video.profile)
+        assertThat(videoProxy.bitDepth).isEqualTo(BIT_DEPTH_8)
+        assertThat(videoProxy.chromaSubsampling).isEqualTo(YUV_420)
+        assertThat(videoProxy.hdrFormat).isEqualTo(HDR_NONE)
+        assertThat(audioProxy.codec).isEqualTo(audio.codec)
+        assertThat(audioProxy.mediaType).isEqualTo(audio.mediaType)
+        assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
+        assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
+        assertThat(audioProxy.channels).isEqualTo(audio.channels)
+        assertThat(audioProxy.profile).isEqualTo(audio.profile)
+    }
+
+    @SdkSuppress(minSdkVersion = 33)
+    @Test
+    fun afterApi33_hasSameContentAsEncoderProfiles() {
+        Assume.assumeTrue(CamcorderProfile.hasProfile(quality))
+
+        val profiles = CamcorderProfile.getAll(cameraId, quality)
+        val video = profiles!!.videoProfiles[0]
+        val audio = profiles.audioProfiles[0]
+        val profilesProxy = encoderProfilesProvider.getAll(quality)
+        val videoProxy = profilesProxy!!.videoProfiles[0]
+        val audioProxy = profilesProxy.audioProfiles[0]
+
+        assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
+        assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
+        assertThat(videoProxy.codec).isEqualTo(video.codec)
+        assertThat(videoProxy.mediaType).isEqualTo(video.mediaType)
+        assertThat(videoProxy.bitrate).isEqualTo(video.bitrate)
+        assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
+        assertThat(videoProxy.width).isEqualTo(video.width)
+        assertThat(videoProxy.height).isEqualTo(video.height)
+        assertThat(videoProxy.profile).isEqualTo(video.profile)
+        assertThat(videoProxy.bitDepth).isEqualTo(video.bitDepth)
+        assertThat(videoProxy.chromaSubsampling).isEqualTo(video.chromaSubsampling)
+        assertThat(videoProxy.hdrFormat).isEqualTo(video.hdrFormat)
+        assertThat(audioProxy.codec).isEqualTo(audio.codec)
+        assertThat(audioProxy.mediaType).isEqualTo(audio.mediaType)
+        assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
+        assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
+        assertThat(audioProxy.channels).isEqualTo(audio.channels)
+        assertThat(audioProxy.profile).isEqualTo(audio.profile)
+    }
+
+    // TODO: removes after b/265613005 is fixed
+    private fun skipTestOnProblematicBuildsOfCuttlefishApi33() {
+        // Skip test for b/265613005
+        Assume.assumeFalse(
+            "Cuttlefish has null VideoProfile issue. Unable to test.",
+            Build.MODEL.contains("Cuttlefish") && Build.VERSION.SDK_INT == 33 &&
+                Build.ID.startsWith("TP1A")
+        )
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/EncoderProfilesProviderAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/EncoderProfilesProviderAdapter.kt
new file mode 100644
index 0000000..224eeab
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/EncoderProfilesProviderAdapter.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration.adapter
+
+import android.media.CamcorderProfile
+import android.media.EncoderProfiles
+import android.os.Build
+import androidx.annotation.DoNotInline
+import androidx.annotation.Nullable
+import androidx.annotation.RequiresApi
+import androidx.camera.core.Logger
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProxy
+import androidx.camera.core.impl.compat.EncoderProfilesProxyCompat
+
+/**
+ * Adapt the [EncoderProfilesProvider] interface to [CameraPipe].
+ */
+@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+class EncoderProfilesProviderAdapter(private val cameraIdString: String) : EncoderProfilesProvider {
+    private val hasValidCameraId: Boolean
+    private val cameraId: Int
+
+    init {
+        var hasValidCameraId = false
+        var intCameraId = -1
+        try {
+            intCameraId = cameraIdString.toInt()
+            hasValidCameraId = true
+        } catch (e: NumberFormatException) {
+            Logger.w(
+                TAG, "Camera id is not an integer:  $cameraIdString, unable to create" +
+                    " EncoderProfilesProviderAdapter."
+            )
+        }
+        this.hasValidCameraId = hasValidCameraId
+        cameraId = intCameraId
+
+        // TODO(b/241296464): CamcorderProfileResolutionQuirk
+    }
+
+    override fun hasProfile(quality: Int): Boolean {
+        if (!hasValidCameraId) {
+            return false
+        }
+        return CamcorderProfile.hasProfile(cameraId, quality)
+    }
+
+    override fun getAll(quality: Int): EncoderProfilesProxy? {
+        if (!hasValidCameraId) {
+            return null
+        }
+        if (!CamcorderProfile.hasProfile(cameraId, quality)) {
+             return null
+        }
+        return getProfilesInternal(quality)
+    }
+
+    @Nullable
+    @Suppress("DEPRECATION")
+    private fun getProfilesInternal(quality: Int): EncoderProfilesProxy? {
+        return if (Build.VERSION.SDK_INT >= 31) {
+            val profiles: EncoderProfiles? = Api31Impl.getAll(cameraIdString, quality)
+            if (profiles != null) EncoderProfilesProxyCompat.from(profiles) else null
+        } else {
+            var profile: CamcorderProfile? = null
+            try {
+                profile = CamcorderProfile.get(cameraId, quality)
+            } catch (e: RuntimeException) {
+                // CamcorderProfile.get() will throw
+                // - RuntimeException if not able to retrieve camcorder profile params.
+                // - IllegalArgumentException if quality is not valid.
+                Logger.w(TAG, "Unable to get CamcorderProfile by quality: $quality", e)
+            }
+            if (profile != null) EncoderProfilesProxyCompat.from(profile) else null
+        }
+    }
+
+    @RequiresApi(31)
+    internal object Api31Impl {
+        @DoNotInline
+        fun getAll(cameraId: String, quality: Int): EncoderProfiles? {
+            return CamcorderProfile.getAll(cameraId, quality)
+        }
+    }
+
+    companion object {
+        private const val TAG = "EncoderProfilesProviderAdapter"
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..8a87d6a
--- /dev/null
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal
+
+import android.media.CamcorderProfile
+import android.media.EncoderProfiles.VideoProfile.YUV_420
+import android.media.EncoderProfiles.VideoProfile.HDR_NONE
+import android.os.Build
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy.BIT_DEPTH_8
+import androidx.camera.testing.CameraUtil
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assume
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+@SmallTest
+@SdkSuppress(minSdkVersion = 21)
+class Camera2EncoderProfilesProviderTest(private val quality: Int) {
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters
+        fun data(): Array<Array<Int>> = arrayOf(
+            arrayOf(CamcorderProfile.QUALITY_LOW),
+            arrayOf(CamcorderProfile.QUALITY_HIGH),
+            arrayOf(CamcorderProfile.QUALITY_QCIF),
+            arrayOf(CamcorderProfile.QUALITY_CIF),
+            arrayOf(CamcorderProfile.QUALITY_480P),
+            arrayOf(CamcorderProfile.QUALITY_720P),
+            arrayOf(CamcorderProfile.QUALITY_1080P),
+            arrayOf(CamcorderProfile.QUALITY_QVGA),
+            arrayOf(CamcorderProfile.QUALITY_2160P),
+            arrayOf(CamcorderProfile.QUALITY_VGA),
+            arrayOf(CamcorderProfile.QUALITY_4KDCI),
+            arrayOf(CamcorderProfile.QUALITY_QHD),
+            arrayOf(CamcorderProfile.QUALITY_2K)
+        )
+    }
+
+    private lateinit var encoderProfilesProvider: Camera2EncoderProfilesProvider
+    private var cameraId = ""
+    private var intCameraId = -1
+
+    @get:Rule
+    val useCamera = CameraUtil.grantCameraPermissionAndPreTest()
+
+    @Before
+    fun setup() {
+        skipTestOnProblematicBuildsOfCuttlefishApi33()
+        assumeTrue(CameraUtil.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK))
+
+        cameraId = CameraUtil.getCameraIdWithLensFacing(CameraSelector.LENS_FACING_BACK)!!
+        intCameraId = cameraId.toInt()
+
+        encoderProfilesProvider = Camera2EncoderProfilesProvider(cameraId)
+    }
+
+    @Test
+    fun hasProfile_returnSameResult() {
+        assertThat(encoderProfilesProvider.hasProfile(quality))
+            .isEqualTo(CamcorderProfile.hasProfile(intCameraId, quality))
+    }
+
+    @Test
+    fun hasProfile_getReturnNonNull() {
+        assumeTrue(CamcorderProfile.hasProfile(intCameraId, quality))
+
+        assertThat(encoderProfilesProvider.getAll(quality)).isNotNull()
+    }
+
+    @Test
+    fun notHasProfile_getReturnNull() {
+        assumeTrue(!CamcorderProfile.hasProfile(intCameraId, quality))
+
+        assertThat(encoderProfilesProvider.getAll(quality)).isNull()
+    }
+
+    @Suppress("DEPRECATION")
+    @Test
+    fun hasSameContentAsCamcorderProfile() {
+        assumeTrue(CamcorderProfile.hasProfile(quality))
+
+        val profile = CamcorderProfile.get(quality)
+        val encoderProfiles = encoderProfilesProvider.getAll(quality)
+        val videoProfile = encoderProfiles!!.videoProfiles[0]
+        val audioProfile = encoderProfiles.audioProfiles[0]
+
+        assertThat(encoderProfiles.defaultDurationSeconds).isEqualTo(profile.duration)
+        assertThat(encoderProfiles.recommendedFileFormat).isEqualTo(profile.fileFormat)
+        assertThat(videoProfile.codec).isEqualTo(profile.videoCodec)
+        assertThat(videoProfile.bitrate).isEqualTo(profile.videoBitRate)
+        assertThat(videoProfile.frameRate).isEqualTo(profile.videoFrameRate)
+        assertThat(videoProfile.width).isEqualTo(profile.videoFrameWidth)
+        assertThat(videoProfile.height).isEqualTo(profile.videoFrameHeight)
+        assertThat(audioProfile.codec).isEqualTo(profile.audioCodec)
+        assertThat(audioProfile.bitrate).isEqualTo(profile.audioBitRate)
+        assertThat(audioProfile.sampleRate).isEqualTo(profile.audioSampleRate)
+        assertThat(audioProfile.channels).isEqualTo(profile.audioChannels)
+    }
+
+    @SdkSuppress(minSdkVersion = 31, maxSdkVersion = 32)
+    @Test
+    fun api31Api32_hasSameContentAsEncoderProfiles() {
+        assumeTrue(CamcorderProfile.hasProfile(quality))
+
+        val profiles = CamcorderProfile.getAll(cameraId, quality)
+        val video = profiles!!.videoProfiles[0]
+        val audio = profiles.audioProfiles[0]
+        val profilesProxy = encoderProfilesProvider.getAll(quality)
+        val videoProxy = profilesProxy!!.videoProfiles[0]
+        val audioProxy = profilesProxy.audioProfiles[0]
+
+        assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
+        assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
+        assertThat(videoProxy.codec).isEqualTo(video.codec)
+        assertThat(videoProxy.mediaType).isEqualTo(video.mediaType)
+        assertThat(videoProxy.bitrate).isEqualTo(video.bitrate)
+        assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
+        assertThat(videoProxy.width).isEqualTo(video.width)
+        assertThat(videoProxy.height).isEqualTo(video.height)
+        assertThat(videoProxy.profile).isEqualTo(video.profile)
+        assertThat(videoProxy.bitDepth).isEqualTo(BIT_DEPTH_8)
+        assertThat(videoProxy.chromaSubsampling).isEqualTo(YUV_420)
+        assertThat(videoProxy.hdrFormat).isEqualTo(HDR_NONE)
+        assertThat(audioProxy.codec).isEqualTo(audio.codec)
+        assertThat(audioProxy.mediaType).isEqualTo(audio.mediaType)
+        assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
+        assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
+        assertThat(audioProxy.channels).isEqualTo(audio.channels)
+        assertThat(audioProxy.profile).isEqualTo(audio.profile)
+    }
+
+    @SdkSuppress(minSdkVersion = 33)
+    @Test
+    fun afterApi33_hasSameContentAsEncoderProfiles() {
+        assumeTrue(CamcorderProfile.hasProfile(quality))
+
+        val profiles = CamcorderProfile.getAll(cameraId, quality)
+        val video = profiles!!.videoProfiles[0]
+        val audio = profiles.audioProfiles[0]
+        val profilesProxy = encoderProfilesProvider.getAll(quality)
+        val videoProxy = profilesProxy!!.videoProfiles[0]
+        val audioProxy = profilesProxy.audioProfiles[0]
+
+        assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
+        assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
+        assertThat(videoProxy.codec).isEqualTo(video.codec)
+        assertThat(videoProxy.mediaType).isEqualTo(video.mediaType)
+        assertThat(videoProxy.bitrate).isEqualTo(video.bitrate)
+        assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
+        assertThat(videoProxy.width).isEqualTo(video.width)
+        assertThat(videoProxy.height).isEqualTo(video.height)
+        assertThat(videoProxy.profile).isEqualTo(video.profile)
+        assertThat(videoProxy.bitDepth).isEqualTo(video.bitDepth)
+        assertThat(videoProxy.chromaSubsampling).isEqualTo(video.chromaSubsampling)
+        assertThat(videoProxy.hdrFormat).isEqualTo(video.hdrFormat)
+        assertThat(audioProxy.codec).isEqualTo(audio.codec)
+        assertThat(audioProxy.mediaType).isEqualTo(audio.mediaType)
+        assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
+        assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
+        assertThat(audioProxy.channels).isEqualTo(audio.channels)
+        assertThat(audioProxy.profile).isEqualTo(audio.profile)
+    }
+
+    // TODO: removes after b/265613005 is fixed
+    private fun skipTestOnProblematicBuildsOfCuttlefishApi33() {
+        // Skip test for b/265613005
+        Assume.assumeFalse(
+            "Cuttlefish has null VideoProfile issue. Unable to test.",
+            Build.MODEL.contains("Cuttlefish") && Build.VERSION.SDK_INT == 33 &&
+                Build.ID.startsWith("TP1A")
+        )
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProvider.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProvider.java
new file mode 100644
index 0000000..a35596c
--- /dev/null
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProvider.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal;
+
+import android.media.CamcorderProfile;
+import android.media.EncoderProfiles;
+import android.os.Build;
+
+import androidx.annotation.DoNotInline;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.Logger;
+import androidx.camera.core.impl.EncoderProfilesProvider;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.camera.core.impl.compat.EncoderProfilesProxyCompat;
+
+/** An implementation that provides the {@link EncoderProfilesProxy}. */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class Camera2EncoderProfilesProvider implements EncoderProfilesProvider {
+
+    private static final String TAG = "Camera2EncoderProfilesProvider";
+
+    private final boolean mHasValidCameraId;
+    private final String mCameraId;
+    private final int mIntCameraId;
+
+    public Camera2EncoderProfilesProvider(@NonNull String cameraId) {
+        mCameraId = cameraId;
+        boolean hasValidCameraId = false;
+        int intCameraId = -1;
+        try {
+            intCameraId = Integer.parseInt(cameraId);
+            hasValidCameraId = true;
+        } catch (NumberFormatException e) {
+            Logger.w(TAG, "Camera id is not an integer: " + cameraId
+                    + ", unable to create Camera2EncoderProfilesProvider");
+        }
+        mHasValidCameraId = hasValidCameraId;
+        mIntCameraId = intCameraId;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean hasProfile(int quality) {
+        if (!mHasValidCameraId) {
+            return false;
+        }
+
+        return CamcorderProfile.hasProfile(mIntCameraId, quality);
+    }
+
+    /** {@inheritDoc} */
+    @Nullable
+    @Override
+    public EncoderProfilesProxy getAll(int quality) {
+        if (!mHasValidCameraId) {
+            return null;
+        }
+
+        if (!CamcorderProfile.hasProfile(mIntCameraId, quality)) {
+            return null;
+        }
+
+        return getProfilesInternal(quality);
+    }
+
+    @Nullable
+    @SuppressWarnings("deprecation")
+    private EncoderProfilesProxy getProfilesInternal(int quality) {
+        if (Build.VERSION.SDK_INT >= 31) {
+            EncoderProfiles profiles = Api31Impl.getAll(mCameraId, quality);
+            return profiles != null ? EncoderProfilesProxyCompat.from(profiles) : null;
+        } else {
+            CamcorderProfile profile = null;
+            try {
+                profile = CamcorderProfile.get(mIntCameraId, quality);
+            } catch (RuntimeException e) {
+                // CamcorderProfile.get() will throw
+                // - RuntimeException if not able to retrieve camcorder profile params.
+                // - IllegalArgumentException if quality is not valid.
+                Logger.w(TAG, "Unable to get CamcorderProfile by quality: " + quality, e);
+            }
+            return profile != null ? EncoderProfilesProxyCompat.from(profile) : null;
+        }
+    }
+
+    @RequiresApi(31)
+    static class Api31Impl {
+        @DoNotInline
+        static EncoderProfiles getAll(String cameraId, int quality) {
+            return CamcorderProfile.getAll(cameraId, quality);
+        }
+
+        // This class is not instantiable.
+        private Api31Impl() {
+        }
+    }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/CamcorderProfileResolutionQuirk.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/CamcorderProfileResolutionQuirk.java
index fb2d013..86a0c16 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/CamcorderProfileResolutionQuirk.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/CamcorderProfileResolutionQuirk.java
@@ -28,7 +28,7 @@
 import androidx.camera.camera2.internal.compat.workaround.CamcorderProfileResolutionValidator;
 import androidx.camera.core.Logger;
 import androidx.camera.core.impl.ImageFormatConstants;
-import androidx.camera.core.impl.Quirk;
+import androidx.camera.core.impl.quirk.ProfileResolutionQuirk;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -55,7 +55,7 @@
  *     @see CamcorderProfileResolutionValidator
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-public class CamcorderProfileResolutionQuirk implements Quirk {
+public class CamcorderProfileResolutionQuirk implements ProfileResolutionQuirk {
     private static final String TAG = "CamcorderProfileResolutionQuirk";
 
     static boolean load(@NonNull CameraCharacteristicsCompat characteristicsCompat) {
@@ -86,6 +86,7 @@
     }
 
     /** Returns the supported video resolutions. */
+    @Override
     @NonNull
     public List<Size> getSupportedResolutions() {
         return new ArrayList<>(mSupportedResolutions);
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesProvider.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesProvider.java
new file mode 100644
index 0000000..1e041d6
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesProvider.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl;
+
+import android.media.CamcorderProfile;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+/**
+ * EncoderProfilesProvider is used to obtain the {@link EncoderProfilesProxy}.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public interface EncoderProfilesProvider {
+
+    /**
+     * Checks if the quality is supported on this device.
+     *
+     * <p>The quality should be one of quality constants defined in {@link CamcorderProfile}.
+     */
+    boolean hasProfile(int quality);
+
+    /**
+     * Gets the {@link EncoderProfilesProxy} if the quality is supported on the device.
+     *
+     * <p>The quality should be one of quality constants defined in {@link CamcorderProfile}.
+     *
+     * @see #hasProfile(int)
+     */
+    @Nullable
+    EncoderProfilesProxy getAll(int quality);
+
+    /** An implementation that contains no data. */
+    EncoderProfilesProvider EMPTY = new EncoderProfilesProvider() {
+        @Override
+        public boolean hasProfile(int quality) {
+            return false;
+        }
+
+        @Nullable
+        @Override
+        public EncoderProfilesProxy getAll(int quality) {
+            return null;
+        }
+    };
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesProxy.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesProxy.java
new file mode 100644
index 0000000..f37cb38
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesProxy.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl;
+
+import static android.media.MediaRecorder.AudioEncoder.AAC;
+import static android.media.MediaRecorder.AudioEncoder.AAC_ELD;
+import static android.media.MediaRecorder.AudioEncoder.AMR_NB;
+import static android.media.MediaRecorder.AudioEncoder.AMR_WB;
+import static android.media.MediaRecorder.AudioEncoder.HE_AAC;
+import static android.media.MediaRecorder.AudioEncoder.OPUS;
+import static android.media.MediaRecorder.AudioEncoder.VORBIS;
+import static android.media.MediaRecorder.VideoEncoder.AV1;
+import static android.media.MediaRecorder.VideoEncoder.DOLBY_VISION;
+import static android.media.MediaRecorder.VideoEncoder.H263;
+import static android.media.MediaRecorder.VideoEncoder.H264;
+import static android.media.MediaRecorder.VideoEncoder.HEVC;
+import static android.media.MediaRecorder.VideoEncoder.MPEG_4_SP;
+import static android.media.MediaRecorder.VideoEncoder.VP8;
+import static android.media.MediaRecorder.VideoEncoder.VP9;
+
+import static java.util.Collections.unmodifiableList;
+
+import android.media.EncoderProfiles;
+import android.media.MediaRecorder;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import com.google.auto.value.AutoValue;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * EncoderProfilesProxy defines the get methods that is mapping to the fields of
+ * {@link EncoderProfiles}.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public interface EncoderProfilesProxy {
+
+    /** Constant representing no codec profile. */
+    int CODEC_PROFILE_NONE = -1;
+
+    /** @see EncoderProfiles#getDefaultDurationSeconds() */
+    int getDefaultDurationSeconds();
+
+    /** @see EncoderProfiles#getRecommendedFileFormat() */
+    int getRecommendedFileFormat();
+
+    /** @see EncoderProfiles#getAudioProfiles() */
+    @NonNull
+    List<AudioProfileProxy> getAudioProfiles();
+
+    /** @see EncoderProfiles#getVideoProfiles() */
+    @NonNull
+    List<VideoProfileProxy> getVideoProfiles();
+
+    /**
+     * VideoProfileProxy defines the get methods that is mapping to the fields of
+     * {@link EncoderProfiles.VideoProfile}.
+     */
+    @AutoValue
+    abstract class VideoProfileProxy {
+
+        /** Constant representing no media type. */
+        public static final String MEDIA_TYPE_NONE = "video/none";
+
+        /** Constant representing bit depth 8. */
+        public static final int BIT_DEPTH_8 = 8;
+
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef({H263, H264, HEVC, VP8, MPEG_4_SP, VP9, DOLBY_VISION, AV1,
+                MediaRecorder.VideoEncoder.DEFAULT})
+        public @interface VideoEncoder {
+        }
+
+        /** Creates a VideoProfileProxy instance. */
+        @NonNull
+        public static VideoProfileProxy create(
+                @VideoEncoder int codec,
+                @NonNull String mediaType,
+                int bitrate,
+                int frameRate,
+                int width,
+                int height,
+                int profile,
+                int bitDepth,
+                int chromaSubsampling,
+                int hdrFormat) {
+            return new AutoValue_EncoderProfilesProxy_VideoProfileProxy(
+                    codec,
+                    mediaType,
+                    bitrate,
+                    frameRate,
+                    width,
+                    height,
+                    profile,
+                    bitDepth,
+                    chromaSubsampling,
+                    hdrFormat
+            );
+        }
+
+        /** @see EncoderProfiles.VideoProfile#getCodec() */
+        @VideoEncoder
+        public abstract int getCodec();
+
+        /** @see EncoderProfiles.VideoProfile#getMediaType() */
+        @NonNull
+        public abstract String getMediaType();
+
+        /** @see EncoderProfiles.VideoProfile#getBitrate() */
+        public abstract int getBitrate();
+
+        /** @see EncoderProfiles.VideoProfile#getFrameRate() */
+        public abstract int getFrameRate();
+
+        /** @see EncoderProfiles.VideoProfile#getWidth() */
+        public abstract int getWidth();
+
+        /** @see EncoderProfiles.VideoProfile#getHeight() */
+        public abstract int getHeight();
+
+        /** @see EncoderProfiles.VideoProfile#getProfile() */
+        public abstract int getProfile();
+
+        /** @see EncoderProfiles.VideoProfile#getBitDepth() */
+        public abstract int getBitDepth();
+
+        /** @see EncoderProfiles.VideoProfile#getChromaSubsampling() */
+        public abstract int getChromaSubsampling();
+
+        /** @see EncoderProfiles.VideoProfile#getHdrFormat() */
+        public abstract int getHdrFormat();
+    }
+
+    /**
+     * AudioProfileProxy defines the get methods that is mapping to the fields of
+     * {@link EncoderProfiles.AudioProfile}.
+     */
+    @AutoValue
+    abstract class AudioProfileProxy {
+
+        /** Constant representing no media type. */
+        public static final String MEDIA_TYPE_NONE = "audio/none";
+
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef({AAC, AAC_ELD, AMR_NB, AMR_WB, HE_AAC, OPUS, VORBIS,
+                MediaRecorder.AudioEncoder.DEFAULT})
+        public @interface AudioEncoder {
+        }
+
+        /** Creates an AudioProfileProxy instance. */
+        @NonNull
+        public static AudioProfileProxy create(
+                @AudioEncoder int codec,
+                @NonNull String mediaType,
+                int bitRate,
+                int sampleRate,
+                int channels,
+                int profile) {
+            return new AutoValue_EncoderProfilesProxy_AudioProfileProxy(
+                    codec,
+                    mediaType,
+                    bitRate,
+                    sampleRate,
+                    channels,
+                    profile
+            );
+        }
+
+        /** @see EncoderProfiles.AudioProfile#getCodec() */
+        @AudioEncoder
+        public abstract int getCodec();
+
+        /** @see EncoderProfiles.AudioProfile#getMediaType() */
+        @NonNull
+        public abstract String getMediaType();
+
+        /** @see EncoderProfiles.AudioProfile#getBitrate() */
+        public abstract int getBitrate();
+
+        /** @see EncoderProfiles.AudioProfile#getSampleRate() */
+        public abstract int getSampleRate();
+
+        /** @see EncoderProfiles.AudioProfile#getChannels() */
+        public abstract int getChannels();
+
+        /** @see EncoderProfiles.AudioProfile#getProfile() */
+        public abstract int getProfile();
+    }
+
+    /**
+     * An implementation of {@link EncoderProfilesProxy} that is immutable.
+     */
+    @AutoValue
+    abstract class ImmutableEncoderProfilesProxy implements EncoderProfilesProxy {
+
+        /** Creates an EncoderProfilesProxy instance. */
+        @NonNull
+        public static ImmutableEncoderProfilesProxy create(
+                int defaultDurationSeconds,
+                int recommendedFileFormat,
+                @NonNull List<AudioProfileProxy> audioProfiles,
+                @NonNull List<VideoProfileProxy> videoProfiles) {
+            return new AutoValue_EncoderProfilesProxy_ImmutableEncoderProfilesProxy(
+                    defaultDurationSeconds,
+                    recommendedFileFormat,
+                    unmodifiableList(new ArrayList<>(audioProfiles)),
+                    unmodifiableList(new ArrayList<>(videoProfiles))
+            );
+        }
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesResolutionValidator.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesResolutionValidator.java
new file mode 100644
index 0000000..1c54463
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/EncoderProfilesResolutionValidator.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl;
+
+import android.media.EncoderProfiles;
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy.ImmutableEncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy;
+import androidx.camera.core.impl.quirk.ProfileResolutionQuirk;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Validates the video resolution of {@link EncoderProfiles}.
+ *
+ * @see ProfileResolutionQuirk
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class EncoderProfilesResolutionValidator {
+
+    @NonNull
+    private final List<ProfileResolutionQuirk> mQuirks;
+    @NonNull
+    private final Set<Size> mSupportedResolutions;
+
+    public EncoderProfilesResolutionValidator(@Nullable List<ProfileResolutionQuirk> quirks) {
+        mQuirks = new ArrayList<>();
+        if (quirks != null) {
+            mQuirks.addAll(quirks);
+        }
+
+        mSupportedResolutions = generateSupportedResolutions(quirks);
+    }
+
+    @NonNull
+    private Set<Size> generateSupportedResolutions(@Nullable List<ProfileResolutionQuirk> quirks) {
+        if (quirks == null || quirks.isEmpty()) {
+            return Collections.emptySet();
+        }
+
+        Set<Size> supportedResolutions = new HashSet<>(quirks.get(0).getSupportedResolutions());
+        for (int i = 1; i < quirks.size(); i++) {
+            supportedResolutions.retainAll(quirks.get(i).getSupportedResolutions());
+        }
+
+        return supportedResolutions;
+    }
+
+    /** Checks if this validator contains quirk. */
+    public boolean hasQuirk() {
+        return !mQuirks.isEmpty();
+    }
+
+    /** Checks if any video resolution of EncoderProfiles is valid. */
+    public boolean hasValidVideoResolution(@Nullable EncoderProfilesProxy profiles) {
+        if (profiles == null) {
+            return false;
+        }
+
+        if (!hasQuirk()) {
+            return !profiles.getVideoProfiles().isEmpty();
+        }
+
+        boolean hasValidResolution = false;
+        for (VideoProfileProxy videoProfile : profiles.getVideoProfiles()) {
+            Size videoSize = new Size(videoProfile.getWidth(), videoProfile.getHeight());
+            if (mSupportedResolutions.contains(videoSize)) {
+                hasValidResolution = true;
+                break;
+            }
+        }
+
+        return hasValidResolution;
+    }
+
+    /** Returns an {@link EncoderProfilesProxy} that filters out invalid resolutions. */
+    @Nullable
+    public EncoderProfilesProxy filterInvalidVideoResolution(
+            @Nullable EncoderProfilesProxy profiles) {
+        if (profiles == null) {
+            return null;
+        }
+
+        if (!hasQuirk()) {
+            return profiles;
+        }
+
+        List<VideoProfileProxy> validVideoProfiles = new ArrayList<>();
+        for (VideoProfileProxy videoProfile : profiles.getVideoProfiles()) {
+            Size videoSize = new Size(videoProfile.getWidth(), videoProfile.getHeight());
+            if (mSupportedResolutions.contains(videoSize)) {
+                validVideoProfiles.add(videoProfile);
+            }
+        }
+
+        return validVideoProfiles.isEmpty() ? null : ImmutableEncoderProfilesProxy.create(
+                profiles.getDefaultDurationSeconds(),
+                profiles.getRecommendedFileFormat(),
+                profiles.getAudioProfiles(),
+                validVideoProfiles
+        );
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/ResolutionValidatedEncoderProfilesProvider.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/ResolutionValidatedEncoderProfilesProvider.java
new file mode 100644
index 0000000..df32c6d
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/ResolutionValidatedEncoderProfilesProvider.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.quirk.ProfileResolutionQuirk;
+
+import java.util.List;
+
+/**
+ * An implementation that provides the {@link EncoderProfilesProxy} whose video resolutions are
+ * validated.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class ResolutionValidatedEncoderProfilesProvider implements EncoderProfilesProvider {
+
+    private final EncoderProfilesProvider mProvider;
+    private final EncoderProfilesResolutionValidator mEncoderProfilesResolutionValidator;
+
+    public ResolutionValidatedEncoderProfilesProvider(@NonNull EncoderProfilesProvider provider,
+            @NonNull Quirks quirks) {
+        mProvider = provider;
+        List<ProfileResolutionQuirk> resolutionQuirks = quirks.getAll(ProfileResolutionQuirk.class);
+        mEncoderProfilesResolutionValidator = new EncoderProfilesResolutionValidator(
+                resolutionQuirks);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean hasProfile(int quality) {
+        if (!mProvider.hasProfile(quality)) {
+            return false;
+        }
+
+        if (mEncoderProfilesResolutionValidator.hasQuirk()) {
+            EncoderProfilesProxy profiles = mProvider.getAll(quality);
+            return mEncoderProfilesResolutionValidator.hasValidVideoResolution(profiles);
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Nullable
+    @Override
+    public EncoderProfilesProxy getAll(int quality) {
+        if (!mProvider.hasProfile(quality)) {
+            return null;
+        }
+
+        EncoderProfilesProxy profiles = mProvider.getAll(quality);
+        if (mEncoderProfilesResolutionValidator.hasQuirk()) {
+            profiles = mEncoderProfilesResolutionValidator.filterInvalidVideoResolution(profiles);
+        }
+
+        return profiles;
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompat.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompat.java
new file mode 100644
index 0000000..cd50106
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompat.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl.compat;
+
+import android.media.CamcorderProfile;
+import android.media.EncoderProfiles;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+
+/**
+ * Helper for accessing features of {@link EncoderProfiles} and {@link CamcorderProfile} in a
+ * backwards compatible fashion.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public final class EncoderProfilesProxyCompat {
+
+    /** Creates an EncoderProfilesProxy instance from {@link EncoderProfiles}. */
+    @RequiresApi(31)
+    @NonNull
+    public static EncoderProfilesProxy from(@NonNull EncoderProfiles encoderProfiles) {
+        if (Build.VERSION.SDK_INT >= 33) {
+            return EncoderProfilesProxyCompatApi33Impl.from(encoderProfiles);
+        } else if (Build.VERSION.SDK_INT >= 31) {
+            return EncoderProfilesProxyCompatApi31Impl.from(encoderProfiles);
+        } else {
+            throw new RuntimeException(
+                    "Unable to call from(EncoderProfiles) on API " + Build.VERSION.SDK_INT
+                            + ". Version 31 or higher required.");
+        }
+    }
+
+    /** Creates an EncoderProfilesProxy instance from {@link CamcorderProfile}. */
+    @NonNull
+    public static EncoderProfilesProxy from(@NonNull CamcorderProfile camcorderProfile) {
+        if (Build.VERSION.SDK_INT >= 31) {
+            throw new RuntimeException(
+                    "Should not use from(CamcorderProfile) on API " + Build.VERSION.SDK_INT
+                            + ". CamcorderProfile is deprecated on API 31, use "
+                            + "from(EncoderProfiles) instead.");
+        } else {
+            return EncoderProfilesProxyCompatBaseImpl.from(camcorderProfile);
+        }
+    }
+
+    // Class should not be instantiated.
+    private EncoderProfilesProxyCompat() {
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatApi31Impl.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatApi31Impl.java
new file mode 100644
index 0000000..57a5476
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatApi31Impl.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl.compat;
+
+import android.media.EncoderProfiles;
+import android.media.EncoderProfiles.AudioProfile;
+import android.media.EncoderProfiles.VideoProfile;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.AudioProfileProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.ImmutableEncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RequiresApi(31)
+class EncoderProfilesProxyCompatApi31Impl {
+
+    /** Creates an EncoderProfilesProxy instance from {@link EncoderProfiles}. */
+    @NonNull
+    public static EncoderProfilesProxy from(
+            @NonNull EncoderProfiles encoderProfiles) {
+        return ImmutableEncoderProfilesProxy.create(
+                encoderProfiles.getDefaultDurationSeconds(),
+                encoderProfiles.getRecommendedFileFormat(),
+                fromAudioProfiles(encoderProfiles.getAudioProfiles()),
+                fromVideoProfiles(encoderProfiles.getVideoProfiles())
+        );
+    }
+
+    /** Creates VideoProfileProxy instances from a list of {@link VideoProfile}. */
+    @NonNull
+    private static List<VideoProfileProxy> fromVideoProfiles(
+            @NonNull List<VideoProfile> profiles) {
+        List<VideoProfileProxy> proxies = new ArrayList<>();
+        for (VideoProfile profile : profiles) {
+            proxies.add(VideoProfileProxy.create(
+                    profile.getCodec(),
+                    profile.getMediaType(),
+                    profile.getBitrate(),
+                    profile.getFrameRate(),
+                    profile.getWidth(),
+                    profile.getHeight(),
+                    profile.getProfile(),
+                    VideoProfileProxy.BIT_DEPTH_8,
+                    VideoProfile.YUV_420,
+                    VideoProfile.HDR_NONE
+            ));
+        }
+        return proxies;
+    }
+
+    /** Creates AudioProfileProxy instances from a list of {@link AudioProfile}. */
+    @NonNull
+    private static List<AudioProfileProxy> fromAudioProfiles(
+            @NonNull List<AudioProfile> profiles) {
+        List<AudioProfileProxy> proxies = new ArrayList<>();
+        for (AudioProfile profile : profiles) {
+            proxies.add(AudioProfileProxy.create(
+                    profile.getCodec(),
+                    profile.getMediaType(),
+                    profile.getBitrate(),
+                    profile.getSampleRate(),
+                    profile.getChannels(),
+                    profile.getProfile()
+            ));
+        }
+        return proxies;
+    }
+
+    // Class should not be instantiated.
+    private EncoderProfilesProxyCompatApi31Impl() {
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatApi33Impl.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatApi33Impl.java
new file mode 100644
index 0000000..e69305b
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatApi33Impl.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl.compat;
+
+import android.media.EncoderProfiles;
+import android.media.EncoderProfiles.AudioProfile;
+import android.media.EncoderProfiles.VideoProfile;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.AudioProfileProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.ImmutableEncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RequiresApi(33)
+class EncoderProfilesProxyCompatApi33Impl {
+
+    /** Creates an EncoderProfilesProxy instance from {@link EncoderProfiles}. */
+    @NonNull
+    public static EncoderProfilesProxy from(
+            @NonNull EncoderProfiles encoderProfiles) {
+        return ImmutableEncoderProfilesProxy.create(
+                encoderProfiles.getDefaultDurationSeconds(),
+                encoderProfiles.getRecommendedFileFormat(),
+                fromAudioProfiles(encoderProfiles.getAudioProfiles()),
+                fromVideoProfiles(encoderProfiles.getVideoProfiles())
+        );
+    }
+
+    /** Creates VideoProfileProxy instances from a list of {@link VideoProfile}. */
+    @NonNull
+    private static List<VideoProfileProxy> fromVideoProfiles(
+            @NonNull List<VideoProfile> profiles) {
+        List<VideoProfileProxy> proxies = new ArrayList<>();
+        for (VideoProfile profile : profiles) {
+            proxies.add(VideoProfileProxy.create(
+                    profile.getCodec(),
+                    profile.getMediaType(),
+                    profile.getBitrate(),
+                    profile.getFrameRate(),
+                    profile.getWidth(),
+                    profile.getHeight(),
+                    profile.getProfile(),
+                    profile.getBitDepth(),
+                    profile.getChromaSubsampling(),
+                    profile.getHdrFormat()
+            ));
+        }
+        return proxies;
+    }
+
+    /** Creates AudioProfileProxy instances from a list of {@link AudioProfile}. */
+    @NonNull
+    private static List<AudioProfileProxy> fromAudioProfiles(
+            @NonNull List<AudioProfile> profiles) {
+        List<AudioProfileProxy> proxies = new ArrayList<>();
+        for (AudioProfile profile : profiles) {
+            proxies.add(AudioProfileProxy.create(
+                    profile.getCodec(),
+                    profile.getMediaType(),
+                    profile.getBitrate(),
+                    profile.getSampleRate(),
+                    profile.getChannels(),
+                    profile.getProfile()
+            ));
+        }
+        return proxies;
+    }
+
+    // Class should not be instantiated.
+    private EncoderProfilesProxyCompatApi33Impl() {
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatBaseImpl.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatBaseImpl.java
new file mode 100644
index 0000000..7e4b830
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/compat/EncoderProfilesProxyCompatBaseImpl.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl.compat;
+
+import static android.media.MediaRecorder.AudioEncoder.AAC;
+import static android.media.MediaRecorder.AudioEncoder.AAC_ELD;
+import static android.media.MediaRecorder.AudioEncoder.AMR_NB;
+import static android.media.MediaRecorder.AudioEncoder.AMR_WB;
+import static android.media.MediaRecorder.AudioEncoder.HE_AAC;
+import static android.media.MediaRecorder.AudioEncoder.OPUS;
+import static android.media.MediaRecorder.AudioEncoder.VORBIS;
+import static android.media.MediaRecorder.VideoEncoder.AV1;
+import static android.media.MediaRecorder.VideoEncoder.DOLBY_VISION;
+import static android.media.MediaRecorder.VideoEncoder.H263;
+import static android.media.MediaRecorder.VideoEncoder.H264;
+import static android.media.MediaRecorder.VideoEncoder.HEVC;
+import static android.media.MediaRecorder.VideoEncoder.MPEG_4_SP;
+import static android.media.MediaRecorder.VideoEncoder.VP8;
+import static android.media.MediaRecorder.VideoEncoder.VP9;
+
+import android.media.CamcorderProfile;
+import android.media.EncoderProfiles;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.media.MediaRecorder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.AudioProfileProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.ImmutableEncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+class EncoderProfilesProxyCompatBaseImpl {
+
+    /** Creates an EncoderProfilesProxy instance from {@link CamcorderProfile}. */
+    @NonNull
+    public static EncoderProfilesProxy from(
+            @NonNull CamcorderProfile camcorderProfile) {
+        return ImmutableEncoderProfilesProxy.create(
+                camcorderProfile.duration,
+                camcorderProfile.fileFormat,
+                toAudioProfiles(camcorderProfile),
+                toVideoProfiles(camcorderProfile)
+        );
+    }
+
+    /** Creates VideoProfileProxy instances from {@link CamcorderProfile}. */
+    @NonNull
+    private static List<VideoProfileProxy> toVideoProfiles(
+            @NonNull CamcorderProfile camcorderProfile) {
+        List<VideoProfileProxy> proxies = new ArrayList<>();
+        proxies.add(VideoProfileProxy.create(
+                camcorderProfile.videoCodec,
+                getVideoCodecMimeType(camcorderProfile.videoCodec),
+                camcorderProfile.videoBitRate,
+                camcorderProfile.videoFrameRate,
+                camcorderProfile.videoFrameWidth,
+                camcorderProfile.videoFrameHeight,
+                EncoderProfilesProxy.CODEC_PROFILE_NONE,
+                VideoProfileProxy.BIT_DEPTH_8,
+                EncoderProfiles.VideoProfile.YUV_420,
+                EncoderProfiles.VideoProfile.HDR_NONE
+        ));
+        return proxies;
+    }
+
+    /** Creates AudioProfileProxy instances from {@link CamcorderProfile}. */
+    @NonNull
+    private static List<AudioProfileProxy> toAudioProfiles(
+            @NonNull CamcorderProfile camcorderProfile) {
+        List<AudioProfileProxy> proxies = new ArrayList<>();
+        proxies.add(AudioProfileProxy.create(
+                camcorderProfile.audioCodec,
+                getAudioCodecMimeType(camcorderProfile.audioCodec),
+                camcorderProfile.audioBitRate,
+                camcorderProfile.audioSampleRate,
+                camcorderProfile.audioChannels,
+                getRequiredAudioProfile(camcorderProfile.audioCodec)
+        ));
+        return proxies;
+    }
+
+    /**
+     * Returns a mime-type string for the given video codec type.
+     *
+     * @return A mime-type string or {@link VideoProfileProxy#MEDIA_TYPE_NONE} if the codec type is
+     * {@link MediaRecorder.VideoEncoder#DEFAULT}, as this type is under-defined and cannot be
+     * resolved to a specific mime type without more information.
+     */
+    @NonNull
+    private static String getVideoCodecMimeType(
+            @VideoProfileProxy.VideoEncoder int codec) {
+        switch (codec) {
+            // Mime-type definitions taken from
+            // frameworks/av/media/libstagefright/foundation/MediaDefs.cpp
+            case H263:
+                return MediaFormat.MIMETYPE_VIDEO_H263;
+            case H264:
+                return MediaFormat.MIMETYPE_VIDEO_AVC;
+            case HEVC:
+                return MediaFormat.MIMETYPE_VIDEO_HEVC;
+            case VP8:
+                return MediaFormat.MIMETYPE_VIDEO_VP8;
+            case MPEG_4_SP:
+                return MediaFormat.MIMETYPE_VIDEO_MPEG4;
+            case VP9:
+                return MediaFormat.MIMETYPE_VIDEO_VP9;
+            case DOLBY_VISION:
+                return MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION;
+            case AV1:
+                return MediaFormat.MIMETYPE_VIDEO_AV1;
+            case MediaRecorder.VideoEncoder.DEFAULT:
+                break;
+        }
+
+        return VideoProfileProxy.MEDIA_TYPE_NONE;
+    }
+
+    /**
+     * Returns a mime-type string for the given audio codec type.
+     *
+     * @return A mime-type string or {@link AudioProfileProxy#MEDIA_TYPE_NONE} if the codec type is
+     * {@link android.media.MediaRecorder.AudioEncoder#DEFAULT}, as this type is under-defined
+     * and cannot be resolved to a specific mime type without more information.
+     */
+    @NonNull
+    private static String getAudioCodecMimeType(@AudioProfileProxy.AudioEncoder int codec) {
+        // Mime-type definitions taken from
+        // frameworks/av/media/libstagefright/foundation/MediaDefs.cpp
+        switch (codec) {
+            case AAC: // Should use aac-profile LC
+            case HE_AAC: // Should use aac-profile HE
+            case AAC_ELD: // Should use aac-profile ELD
+                return MediaFormat.MIMETYPE_AUDIO_AAC;
+            case AMR_NB:
+                return MediaFormat.MIMETYPE_AUDIO_AMR_NB;
+            case AMR_WB:
+                return MediaFormat.MIMETYPE_AUDIO_AMR_WB;
+            case OPUS:
+                return MediaFormat.MIMETYPE_AUDIO_OPUS;
+            case VORBIS:
+                return MediaFormat.MIMETYPE_AUDIO_VORBIS;
+            case MediaRecorder.AudioEncoder.DEFAULT:
+                break;
+        }
+
+        return AudioProfileProxy.MEDIA_TYPE_NONE;
+    }
+
+    /**
+     * Returns the required audio profile for the given audio encoder.
+     *
+     * <p>For example, this can be used to differentiate between AAC encoders
+     * {@link android.media.MediaRecorder.AudioEncoder#AAC},
+     * {@link android.media.MediaRecorder.AudioEncoder#AAC_ELD},
+     * and {@link android.media.MediaRecorder.AudioEncoder#HE_AAC}.
+     * Should be used with the {@link MediaCodecInfo.CodecProfileLevel#profile} field.
+     *
+     * @return The profile required by the audio codec. If no profile is required, returns
+     * {@link EncoderProfilesProxy#CODEC_PROFILE_NONE}.
+     */
+    private static int getRequiredAudioProfile(@AudioProfileProxy.AudioEncoder int codec) {
+        switch (codec) {
+            case AAC:
+                return MediaCodecInfo.CodecProfileLevel.AACObjectLC;
+            case AAC_ELD:
+                return MediaCodecInfo.CodecProfileLevel.AACObjectELD;
+            case HE_AAC:
+                return MediaCodecInfo.CodecProfileLevel.AACObjectHE;
+            default:
+                return EncoderProfilesProxy.CODEC_PROFILE_NONE;
+        }
+    }
+
+    // Class should not be instantiated.
+    private EncoderProfilesProxyCompatBaseImpl() {
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/quirk/ProfileResolutionQuirk.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/quirk/ProfileResolutionQuirk.java
new file mode 100644
index 0000000..cdb5a87
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/quirk/ProfileResolutionQuirk.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl.quirk;
+
+import android.media.EncoderProfiles;
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.Quirk;
+
+import java.util.List;
+
+/**
+ * A Quirk interface which denotes that CameraX should validate video resolutions returned from
+ * {@link EncoderProfiles} instead of using them directly.
+ *
+ * <p>Subclasses of this quirk should provide a list of supported resolutions for CameraX to
+ * verify.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public interface ProfileResolutionQuirk extends Quirk {
+
+    /** Returns a list of supported resolutions. */
+    @NonNull
+    List<Size> getSupportedResolutions();
+}
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/impl/EncoderProfilesResolutionValidatorTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/impl/EncoderProfilesResolutionValidatorTest.kt
new file mode 100644
index 0000000..57b9338
--- /dev/null
+++ b/camera/camera-core/src/test/java/androidx/camera/core/impl/EncoderProfilesResolutionValidatorTest.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl
+
+import android.os.Build
+import android.util.Size
+import androidx.camera.core.impl.quirk.ProfileResolutionQuirk
+import androidx.camera.testing.EncoderProfilesUtil
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class EncoderProfilesResolutionValidatorTest {
+
+    @Test
+    fun noQuirk_alwaysValid() {
+        val validator = EncoderProfilesResolutionValidator(null)
+
+        assertThat(validator.hasValidVideoResolution(EncoderProfilesUtil.PROFILES_2160P)).isTrue()
+        assertThat(validator.hasValidVideoResolution(EncoderProfilesUtil.PROFILES_720P)).isTrue()
+    }
+
+    @Test
+    fun hasQuirk_shouldCheckSupportedResolutions() {
+        val quirk = createFakeProfileResolutionQuirk(
+            supportedResolution = arrayOf(EncoderProfilesUtil.RESOLUTION_2160P)
+        )
+        val validator = EncoderProfilesResolutionValidator(listOf(quirk))
+
+        assertThat(validator.hasValidVideoResolution(EncoderProfilesUtil.PROFILES_2160P)).isTrue()
+        assertThat(validator.hasValidVideoResolution(EncoderProfilesUtil.PROFILES_720P)).isFalse()
+    }
+
+    @Test
+    fun nullProfile_notValid() {
+        val quirk = createFakeProfileResolutionQuirk(
+            supportedResolution = arrayOf(EncoderProfilesUtil.RESOLUTION_2160P)
+        )
+        val validator = EncoderProfilesResolutionValidator(listOf(quirk))
+
+        assertThat(validator.hasValidVideoResolution(null)).isFalse()
+    }
+
+    private fun createFakeProfileResolutionQuirk(
+        supportedResolution: Array<Size> = emptyArray()
+    ): ProfileResolutionQuirk {
+        return FakeQuirk(supportedResolution)
+    }
+
+    class FakeQuirk(private val supportedResolutions: Array<Size>) : ProfileResolutionQuirk {
+
+        override fun getSupportedResolutions(): MutableList<Size> {
+            return supportedResolutions.toMutableList()
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/impl/ResolutionValidatedEncoderProfilesProviderTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/impl/ResolutionValidatedEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..198bc4c
--- /dev/null
+++ b/camera/camera-core/src/test/java/androidx/camera/core/impl/ResolutionValidatedEncoderProfilesProviderTest.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.os.Build
+import android.util.Size
+import androidx.camera.core.impl.quirk.ProfileResolutionQuirk
+import androidx.camera.testing.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+private val PAIR_2160 = Pair(QUALITY_2160P, PROFILES_2160P)
+private val PAIR_1080 = Pair(QUALITY_1080P, PROFILES_1080P)
+private val PAIR_720 = Pair(QUALITY_720P, PROFILES_720P)
+private val PAIR_480 = Pair(QUALITY_480P, PROFILES_480P)
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class ResolutionValidatedEncoderProfilesProviderTest {
+
+    private val defaultProvider = createFakeEncoderProfilesProvider(
+        arrayOf(PAIR_2160, PAIR_1080, PAIR_720, PAIR_480)
+    )
+
+    @Test
+    fun hasNoProfile_canNotGetProfiles() {
+        val quirks = createQuirksWithProfileResolutionQuirk(
+            supportedResolution = arrayOf(RESOLUTION_1080P)
+        )
+        val emptyProvider = createFakeEncoderProfilesProvider()
+        val provider = ResolutionValidatedEncoderProfilesProvider(emptyProvider, quirks)
+
+        assertThat(provider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(provider.hasProfile(QUALITY_1080P)).isFalse()
+        assertThat(provider.hasProfile(QUALITY_720P)).isFalse()
+        assertThat(provider.hasProfile(QUALITY_480P)).isFalse()
+        assertThat(provider.getAll(QUALITY_2160P)).isNull()
+        assertThat(provider.getAll(QUALITY_1080P)).isNull()
+        assertThat(provider.getAll(QUALITY_720P)).isNull()
+        assertThat(provider.getAll(QUALITY_480P)).isNull()
+    }
+
+    @Test
+    fun hasQuirk_canOnlyGetSupportedProfiles() {
+        val quirks = createQuirksWithProfileResolutionQuirk(
+            supportedResolution = arrayOf(RESOLUTION_1080P)
+        )
+        val provider = ResolutionValidatedEncoderProfilesProvider(defaultProvider, quirks)
+
+        assertThat(provider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(provider.hasProfile(QUALITY_1080P)).isTrue()
+        assertThat(provider.hasProfile(QUALITY_720P)).isFalse()
+        assertThat(provider.hasProfile(QUALITY_480P)).isFalse()
+        assertThat(provider.getAll(QUALITY_2160P)).isNull()
+        assertThat(provider.getAll(QUALITY_1080P)).isNotNull()
+        assertThat(provider.getAll(QUALITY_720P)).isNull()
+        assertThat(provider.getAll(QUALITY_480P)).isNull()
+    }
+
+    @Test
+    fun hasNoQuirk_canGetProfiles() {
+        val quirks = Quirks(emptyList())
+        val provider = ResolutionValidatedEncoderProfilesProvider(defaultProvider, quirks)
+
+        assertThat(provider.hasProfile(QUALITY_2160P)).isTrue()
+        assertThat(provider.hasProfile(QUALITY_1080P)).isTrue()
+        assertThat(provider.hasProfile(QUALITY_720P)).isTrue()
+        assertThat(provider.hasProfile(QUALITY_480P)).isTrue()
+        assertThat(provider.getAll(QUALITY_2160P)).isNotNull()
+        assertThat(provider.getAll(QUALITY_1080P)).isNotNull()
+        assertThat(provider.getAll(QUALITY_720P)).isNotNull()
+        assertThat(provider.getAll(QUALITY_480P)).isNotNull()
+    }
+
+    private fun createFakeEncoderProfilesProvider(
+        qualityToProfilesPairs: Array<Pair<Int, EncoderProfilesProxy>> = emptyArray()
+    ): EncoderProfilesProvider {
+        return FakeEncoderProfilesProvider.Builder().also { builder ->
+            for (pair in qualityToProfilesPairs) {
+                builder.add(pair.first, pair.second)
+            }
+        }.build()
+    }
+
+    private fun createQuirksWithProfileResolutionQuirk(
+        supportedResolution: Array<Size> = emptyArray()
+    ): Quirks {
+        return Quirks(listOf(FakeQuirk(supportedResolution)))
+    }
+
+    class FakeQuirk(private val supportedResolutions: Array<Size>) : ProfileResolutionQuirk {
+
+        override fun getSupportedResolutions(): MutableList<Size> {
+            return supportedResolutions.toMutableList()
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/EncoderProfilesUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/EncoderProfilesUtil.java
new file mode 100644
index 0000000..4c84b93
--- /dev/null
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/EncoderProfilesUtil.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.testing;
+
+import android.media.EncoderProfiles;
+import android.media.MediaFormat;
+import android.media.MediaRecorder;
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.AudioProfileProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.ImmutableEncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy;
+
+import java.util.Collections;
+
+/**
+ * Utility methods for testing {@link EncoderProfiles} related classes, including predefined
+ * resolutions, attributes and {@link EncoderProfilesProxy}, which can be used directly on the
+ * unit tests.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public final class EncoderProfilesUtil {
+
+    /** Resolution for QCIF. */
+    public static final Size RESOLUTION_QCIF = new Size(176, 144);
+    /** Resolution for QVGA. */
+    public static final Size RESOLUTION_QVGA = new Size(320, 240);
+    /** Resolution for CIF. */
+    public static final Size RESOLUTION_CIF = new Size(352, 288);
+    /** Resolution for VGA. */
+    public static final Size RESOLUTION_VGA = new Size(640, 480);
+    /** Resolution for 480P. */
+    public static final Size RESOLUTION_480P = new Size(720, 480); /* 640, 704 or 720 x 480 */
+    /** Resolution for 720P. */
+    public static final Size RESOLUTION_720P = new Size(1280, 720);
+    /** Resolution for 1080P. */
+    public static final Size RESOLUTION_1080P = new Size(1920, 1080); /* 1920 x 1080 or 1088 */
+    /** Resolution for 2K. */
+    public static final Size RESOLUTION_2K = new Size(2048, 1080);
+    /** Resolution for QHD. */
+    public static final Size RESOLUTION_QHD = new Size(2560, 1440);
+    /** Resolution for 2160P. */
+    public static final Size RESOLUTION_2160P = new Size(3840, 2160);
+    /** Resolution for 4KDCI. */
+    public static final Size RESOLUTION_4KDCI = new Size(4096, 2160);
+
+    /** Default duration. */
+    public static final int DEFAULT_DURATION = 30;
+    /** Default output format. */
+    public static final int DEFAULT_OUTPUT_FORMAT = MediaRecorder.OutputFormat.MPEG_4;
+    /** Default video codec. */
+    public static final int DEFAULT_VIDEO_CODEC = MediaRecorder.VideoEncoder.H264;
+    /** Default media type. */
+    public static final String DEFAULT_VIDEO_MEDIA_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC;
+    /** Default video bitrate. */
+    public static final int DEFAULT_VIDEO_BITRATE = 8 * 1024 * 1024;
+    /** Default video frame rate. */
+    public static final int DEFAULT_VIDEO_FRAME_RATE = 30;
+    /** Default video code profile. */
+    public static final int DEFAULT_VIDEO_PROFILE = EncoderProfilesProxy.CODEC_PROFILE_NONE;
+    /** Default bit depth. */
+    public static final int DEFAULT_VIDEO_BIT_DEPTH = VideoProfileProxy.BIT_DEPTH_8;
+    /** Default chroma subsampling. */
+    public static final int DEFAULT_VIDEO_CHROMA_SUBSAMPLING = EncoderProfiles.VideoProfile.YUV_420;
+    /** Default hdr format. */
+    public static final int DEFAULT_VIDEO_HDR_FORMAT = EncoderProfiles.VideoProfile.HDR_NONE;
+    /** Default audio codec. */
+    public static final int DEFAULT_AUDIO_CODEC = MediaRecorder.AudioEncoder.AAC;
+    /** Default media type. */
+    public static final String DEFAULT_AUDIO_MEDIA_TYPE = MediaFormat.MIMETYPE_AUDIO_AAC;
+    /** Default audio bitrate. */
+    public static final int DEFAULT_AUDIO_BITRATE = 192_000;
+    /** Default audio sample rate. */
+    public static final int DEFAULT_AUDIO_SAMPLE_RATE = 48_000;
+    /** Default channel count. */
+    public static final int DEFAULT_AUDIO_CHANNELS = 1;
+    /** Default audio code profile. */
+    public static final int DEFAULT_AUDIO_PROFILE = EncoderProfilesProxy.CODEC_PROFILE_NONE;
+
+    public static final EncoderProfilesProxy PROFILES_QCIF = createFakeEncoderProfilesProxy(
+            RESOLUTION_QCIF.getWidth(),
+            RESOLUTION_QCIF.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_QVGA = createFakeEncoderProfilesProxy(
+            RESOLUTION_QVGA.getWidth(),
+            RESOLUTION_QVGA.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_CIF = createFakeEncoderProfilesProxy(
+            RESOLUTION_CIF.getWidth(),
+            RESOLUTION_CIF.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_VGA = createFakeEncoderProfilesProxy(
+            RESOLUTION_VGA.getWidth(),
+            RESOLUTION_VGA.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_480P = createFakeEncoderProfilesProxy(
+            RESOLUTION_480P.getWidth(),
+            RESOLUTION_480P.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_720P = createFakeEncoderProfilesProxy(
+            RESOLUTION_720P.getWidth(),
+            RESOLUTION_720P.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_1080P = createFakeEncoderProfilesProxy(
+            RESOLUTION_1080P.getWidth(),
+            RESOLUTION_1080P.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_2K = createFakeEncoderProfilesProxy(
+            RESOLUTION_2K.getWidth(),
+            RESOLUTION_2K.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_QHD = createFakeEncoderProfilesProxy(
+            RESOLUTION_QHD.getWidth(),
+            RESOLUTION_QHD.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_2160P = createFakeEncoderProfilesProxy(
+            RESOLUTION_2160P.getWidth(),
+            RESOLUTION_2160P.getHeight()
+    );
+
+    public static final EncoderProfilesProxy PROFILES_4KDCI = createFakeEncoderProfilesProxy(
+            RESOLUTION_4KDCI.getWidth(),
+            RESOLUTION_4KDCI.getHeight()
+    );
+
+    /** A utility method to create an EncoderProfilesProxy with some default values. */
+    @NonNull
+    public static EncoderProfilesProxy createFakeEncoderProfilesProxy(
+            int videoFrameWidth,
+            int videoFrameHeight
+    ) {
+        VideoProfileProxy videoProfile = VideoProfileProxy.create(
+                DEFAULT_VIDEO_CODEC,
+                DEFAULT_VIDEO_MEDIA_TYPE,
+                DEFAULT_VIDEO_BITRATE,
+                DEFAULT_VIDEO_FRAME_RATE,
+                videoFrameWidth,
+                videoFrameHeight,
+                DEFAULT_VIDEO_PROFILE,
+                DEFAULT_VIDEO_BIT_DEPTH,
+                DEFAULT_VIDEO_CHROMA_SUBSAMPLING,
+                DEFAULT_VIDEO_HDR_FORMAT
+        );
+        AudioProfileProxy audioProfile = AudioProfileProxy.create(
+                DEFAULT_AUDIO_CODEC,
+                DEFAULT_AUDIO_MEDIA_TYPE,
+                DEFAULT_AUDIO_BITRATE,
+                DEFAULT_AUDIO_SAMPLE_RATE,
+                DEFAULT_AUDIO_CHANNELS,
+                DEFAULT_AUDIO_PROFILE
+        );
+
+        return ImmutableEncoderProfilesProxy.create(
+                DEFAULT_DURATION,
+                DEFAULT_OUTPUT_FORMAT,
+                Collections.singletonList(audioProfile),
+                Collections.singletonList(videoProfile)
+        );
+    }
+
+    // This class is not instantiable.
+    private EncoderProfilesUtil() {
+    }
+}
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeEncoderProfilesProvider.java b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeEncoderProfilesProvider.java
new file mode 100644
index 0000000..a4d1e46
--- /dev/null
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeEncoderProfilesProvider.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.testing.fakes;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProvider;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A fake implementation of the {@link EncoderProfilesProvider} and used for test.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class FakeEncoderProfilesProvider implements EncoderProfilesProvider {
+
+    private final Map<Integer, EncoderProfilesProxy> mQualityToProfileMap;
+
+    FakeEncoderProfilesProvider(@NonNull Map<Integer, EncoderProfilesProxy> qualityToProfileMap) {
+        mQualityToProfileMap = qualityToProfileMap;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean hasProfile(int quality) {
+        return mQualityToProfileMap.get(quality) != null;
+    }
+
+    /** {@inheritDoc} */
+    @Nullable
+    @Override
+    public EncoderProfilesProxy getAll(int quality) {
+        return mQualityToProfileMap.get(quality);
+    }
+
+    /**
+     * The builder to create a FakeEncoderProfilesProvider instance.
+     */
+    @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+    public static class Builder {
+
+        private final Map<Integer, EncoderProfilesProxy> mQualityToProfileMap = new HashMap<>();
+
+        /**
+         * Adds a quality and its corresponding profiles.
+         */
+        @NonNull
+        public Builder add(int quality, @NonNull EncoderProfilesProxy profiles) {
+            mQualityToProfileMap.put(quality, profiles);
+            return this;
+        }
+
+        /**
+         * Adds qualities and their corresponding profiles.
+         */
+        @NonNull
+        public Builder addAll(@NonNull Map<Integer, EncoderProfilesProxy> qualityToProfileMap) {
+            mQualityToProfileMap.putAll(qualityToProfileMap);
+            return this;
+        }
+
+        /** Builds the FakeEncoderProfilesProvider instance. */
+        @NonNull
+        public FakeEncoderProfilesProvider build() {
+            return new FakeEncoderProfilesProvider(mQualityToProfileMap);
+        }
+    }
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/FallbackStrategy.java b/camera/camera-video/src/main/java/androidx/camera/video/FallbackStrategy.java
index e45ba08..ad3e94f 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/FallbackStrategy.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/FallbackStrategy.java
@@ -73,7 +73,7 @@
     }
 
     /**
-     * Returns a fallback strategy that will choose the quality that is closest to and higher
+     * Returns a fallback strategy that will choose the quality that is closest to and lower
      * than the input quality.
      */
     @NonNull
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/VideoValidatedEncoderProfilesProxy.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/VideoValidatedEncoderProfilesProxy.java
new file mode 100644
index 0000000..025c0c2
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/VideoValidatedEncoderProfilesProxy.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal;
+
+import static java.util.Collections.unmodifiableList;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.core.util.Preconditions;
+
+import com.google.auto.value.AutoValue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * VideoValidatedEncoderProfilesProxy is an implementation of {@link EncoderProfilesProxy} that
+ * guarantees to provide video information.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+@AutoValue
+public abstract class VideoValidatedEncoderProfilesProxy implements EncoderProfilesProxy {
+
+    /** Creates a VideoValidatedEncoderProfilesProxy instance from {@link EncoderProfilesProxy}. */
+    @NonNull
+    public static VideoValidatedEncoderProfilesProxy from(@NonNull EncoderProfilesProxy profiles) {
+        return create(
+                profiles.getDefaultDurationSeconds(),
+                profiles.getRecommendedFileFormat(),
+                profiles.getAudioProfiles(),
+                profiles.getVideoProfiles()
+        );
+    }
+
+    /** Creates a VideoValidatedEncoderProfilesProxy instance. */
+    @NonNull
+    public static VideoValidatedEncoderProfilesProxy create(
+            int defaultDurationSeconds,
+            int recommendedFileFormat,
+            @NonNull List<AudioProfileProxy> audioProfiles,
+            @NonNull List<VideoProfileProxy> videoProfiles) {
+        Preconditions.checkArgument(!videoProfiles.isEmpty(),
+                "Should contain at least one VideoProfile.");
+        VideoProfileProxy defaultVideoProfile = videoProfiles.get(0);
+
+        AudioProfileProxy defaultAudioProfile = null;
+        if (!audioProfiles.isEmpty()) {
+            defaultAudioProfile = audioProfiles.get(0);
+        }
+
+        return new AutoValue_VideoValidatedEncoderProfilesProxy(
+                defaultDurationSeconds,
+                recommendedFileFormat,
+                unmodifiableList(new ArrayList<>(audioProfiles)),
+                unmodifiableList(new ArrayList<>(videoProfiles)),
+                defaultAudioProfile,
+                defaultVideoProfile
+        );
+    }
+
+    /** Returns the default {@link AudioProfileProxy} or null if not existed. */
+    @Nullable
+    public abstract AudioProfileProxy getDefaultAudioProfile();
+
+    /** Returns the default {@link VideoProfileProxy}. */
+    @NonNull
+    public abstract VideoProfileProxy getDefaultVideoProfile();
+}
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/VideoValidatedEncoderProfilesProxyTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/VideoValidatedEncoderProfilesProxyTest.kt
new file mode 100644
index 0000000..b853ba7
--- /dev/null
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/VideoValidatedEncoderProfilesProxyTest.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal
+
+import android.os.Build
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy
+import androidx.camera.testing.EncoderProfilesUtil
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+private const val DEFAULT_WIDTH = 1920
+private const val DEFAULT_HEIGHT = 1080
+private val DEFAULT_VIDEO_PROFILE = VideoProfileProxy.create(
+    EncoderProfilesUtil.DEFAULT_VIDEO_CODEC,
+    EncoderProfilesUtil.DEFAULT_VIDEO_MEDIA_TYPE,
+    EncoderProfilesUtil.DEFAULT_VIDEO_BITRATE,
+    EncoderProfilesUtil.DEFAULT_VIDEO_FRAME_RATE,
+    DEFAULT_WIDTH,
+    DEFAULT_HEIGHT,
+    EncoderProfilesUtil.DEFAULT_VIDEO_PROFILE,
+    EncoderProfilesUtil.DEFAULT_VIDEO_BIT_DEPTH,
+    EncoderProfilesUtil.DEFAULT_VIDEO_CHROMA_SUBSAMPLING,
+    EncoderProfilesUtil.DEFAULT_VIDEO_HDR_FORMAT
+)
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class VideoValidatedEncoderProfilesProxyTest {
+
+    @Test
+    fun createFromEncoderProfilesProxy() {
+        val profiles = EncoderProfilesUtil.PROFILES_1080P
+        val validatedProfiles = VideoValidatedEncoderProfilesProxy.from(profiles)
+
+        assertThat(validatedProfiles.recommendedFileFormat)
+            .isEqualTo(profiles.recommendedFileFormat)
+        assertThat(validatedProfiles.defaultDurationSeconds)
+            .isEqualTo(profiles.defaultDurationSeconds)
+        assertThat(validatedProfiles.audioProfiles.size).isEqualTo(profiles.audioProfiles.size)
+        assertThat(validatedProfiles.videoProfiles.size).isEqualTo(profiles.videoProfiles.size)
+        assertThat(validatedProfiles.defaultAudioProfile).isNotNull()
+        assertThat(validatedProfiles.defaultVideoProfile).isNotNull()
+        assertThat(validatedProfiles.audioProfiles[0].codec)
+            .isEqualTo(profiles.audioProfiles[0].codec)
+        assertThat(validatedProfiles.audioProfiles[0].mediaType)
+            .isEqualTo(profiles.audioProfiles[0].mediaType)
+        assertThat(validatedProfiles.audioProfiles[0].bitrate)
+            .isEqualTo(profiles.audioProfiles[0].bitrate)
+        assertThat(validatedProfiles.audioProfiles[0].sampleRate)
+            .isEqualTo(profiles.audioProfiles[0].sampleRate)
+        assertThat(validatedProfiles.audioProfiles[0].channels)
+            .isEqualTo(profiles.audioProfiles[0].channels)
+        assertThat(validatedProfiles.audioProfiles[0].profile)
+            .isEqualTo(profiles.audioProfiles[0].profile)
+        assertThat(validatedProfiles.videoProfiles[0].codec)
+            .isEqualTo(profiles.videoProfiles[0].codec)
+        assertThat(validatedProfiles.videoProfiles[0].mediaType)
+            .isEqualTo(profiles.videoProfiles[0].mediaType)
+        assertThat(validatedProfiles.videoProfiles[0].bitrate)
+            .isEqualTo(profiles.videoProfiles[0].bitrate)
+        assertThat(validatedProfiles.videoProfiles[0].frameRate)
+            .isEqualTo(profiles.videoProfiles[0].frameRate)
+        assertThat(validatedProfiles.videoProfiles[0].width)
+            .isEqualTo(profiles.videoProfiles[0].width)
+        assertThat(validatedProfiles.videoProfiles[0].height)
+            .isEqualTo(profiles.videoProfiles[0].height)
+        assertThat(validatedProfiles.videoProfiles[0].profile)
+            .isEqualTo(profiles.videoProfiles[0].profile)
+        assertThat(validatedProfiles.videoProfiles[0].bitDepth)
+            .isEqualTo(profiles.videoProfiles[0].bitDepth)
+        assertThat(validatedProfiles.videoProfiles[0].chromaSubsampling)
+            .isEqualTo(profiles.videoProfiles[0].chromaSubsampling)
+        assertThat(validatedProfiles.videoProfiles[0].hdrFormat)
+            .isEqualTo(profiles.videoProfiles[0].hdrFormat)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun create_throwsException_whenVideoProfilesIsEmpty() {
+        VideoValidatedEncoderProfilesProxy.create(
+            EncoderProfilesUtil.DEFAULT_DURATION,
+            EncoderProfilesUtil.DEFAULT_OUTPUT_FORMAT,
+            emptyList(),
+            emptyList()
+        )
+    }
+
+    @Test
+    fun create_withEmptyAudioProfiles() {
+        val validatedProfiles = VideoValidatedEncoderProfilesProxy.create(
+            EncoderProfilesUtil.DEFAULT_DURATION,
+            EncoderProfilesUtil.DEFAULT_OUTPUT_FORMAT,
+            emptyList(),
+            listOf(DEFAULT_VIDEO_PROFILE)
+        )
+        assertThat(validatedProfiles.defaultAudioProfile).isNull()
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java b/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java
index 2ed53fc..3602e67 100644
--- a/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java
+++ b/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java
@@ -1435,6 +1435,7 @@
             return;
         }
         mVideoCaptureQuality = targetQuality;
+        unbindVideoAndRecreate();
         startCameraAndTrackStates();
     }
 
@@ -1450,6 +1451,16 @@
         return mVideoCaptureQuality;
     }
 
+    /**
+     * Unbinds VideoCapture and recreate with the latest parameters.
+     */
+    private void unbindVideoAndRecreate() {
+        if (isCameraInitialized()) {
+            mCameraProvider.unbind(mVideoCapture);
+        }
+        mVideoCapture = createNewVideoCapture();
+    }
+
     private VideoCapture<Recorder> createNewVideoCapture() {
         return VideoCapture.withOutput(generateVideoCaptureRecorder(mVideoCaptureQuality));
     }
@@ -2002,11 +2013,10 @@
             mCameraProvider.unbind(mImageAnalysis);
         }
 
-        // TODO: revert aosp/2280599 to reuse VideoCapture when VideoCapture supports reuse.
-        mCameraProvider.unbind(mVideoCapture);
         if (isVideoCaptureEnabled()) {
-            mVideoCapture = createNewVideoCapture();
             builder.addUseCase(mVideoCapture);
+        } else {
+            mCameraProvider.unbind(mVideoCapture);
         }
 
         builder.setViewPort(mViewPort);
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CardTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CardTest.kt
index 4d6e879..e75252f 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CardTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/CardTest.kt
@@ -117,7 +117,7 @@
         }
         rule.onNodeWithTag("card")
             .assertHasClickAction()
-            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+            .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
             .assertIsEnabled()
             // since we merge descendants we should have text on the same node
             .assertTextEquals("0")
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonTest.kt
index b9bdd17..bfb6224 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonTest.kt
@@ -34,6 +34,10 @@
 import androidx.compose.ui.layout.boundsInRoot
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
 import androidx.compose.ui.test.assertHeightIsEqualTo
 import androidx.compose.ui.test.assertIsEnabled
 import androidx.compose.ui.test.assertWidthIsAtLeast
@@ -71,6 +75,7 @@
         }
 
         rule.onNodeWithTag("myButton")
+            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
             .assertIsEnabled()
     }
 
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SurfaceTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SurfaceTest.kt
index 99ffbe5..f288a88 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SurfaceTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SurfaceTest.kt
@@ -218,7 +218,7 @@
         }
         rule.onNodeWithTag("surface")
             .assertHasClickAction()
-            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+            .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
             .assertIsEnabled()
             // since we merge descendants we should have text on the same node
             .assertTextEquals("0")
@@ -364,7 +364,7 @@
         }
         rule.onNodeWithTag("surface")
             .assertHasClickAction()
-            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab))
+            .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
             .assertIsEnabled()
             // since we merge descendants we should have text on the same node
             .assertTextEquals("false")
@@ -468,7 +468,7 @@
         }
         rule.onNodeWithTag("surface")
             .assertHasClickAction()
-            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Switch))
+            .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
             .assertIsEnabled()
             // since we merge descendants we should have text on the same node
             .assertTextEquals("false")
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt
index 496ed96..b27bb71 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Button.kt
@@ -48,6 +48,9 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import kotlinx.coroutines.flow.collect
@@ -103,7 +106,7 @@
     val contentColor by colors.contentColor(enabled)
     Surface(
         onClick = onClick,
-        modifier = modifier,
+        modifier = modifier.semantics { role = Role.Button },
         enabled = enabled,
         shape = shape,
         color = colors.backgroundColor(enabled).value,
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Chip.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Chip.kt
index 437d88d..d151712 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Chip.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Chip.kt
@@ -99,7 +99,7 @@
     val contentColor by colors.contentColor(enabled)
     Surface(
         onClick = onClick,
-        modifier = modifier,
+        modifier = modifier.semantics { role = Role.Button },
         enabled = enabled,
         shape = shape,
         color = colors.backgroundColor(enabled).value,
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
index 6225e3a..8995a68 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
@@ -44,6 +44,9 @@
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import kotlinx.coroutines.flow.collect
@@ -88,7 +91,7 @@
 ) {
     Surface(
         onClick = onClick,
-        modifier = modifier,
+        modifier = modifier.semantics { role = Role.Button },
         shape = shape,
         color = backgroundColor,
         contentColor = contentColor,
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Surface.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Surface.kt
index 9aef3d8..895c713 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Surface.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Surface.kt
@@ -178,9 +178,8 @@
  * that doesn't require [onClick] param.
  *
  * 7) Semantics for clicks. Just like with [Modifier.clickable], clickable version of Surface will
- * produce semantics to indicate that it is clicked. Also, by default, accessibility services will
- * describe the element as [Role.Button]. You may change this by passing a desired [Role] with a
- * [Modifier.semantics].
+ * produce semantics to indicate that it is clicked. No semantic role is set by default, you
+ * may specify one by passing a desired [Role] with a [Modifier.semantics].
  *
  * @sample androidx.compose.material.samples.ClickableSurfaceSample
  *
@@ -243,7 +242,6 @@
                     interactionSource = interactionSource,
                     indication = rememberRipple(),
                     enabled = enabled,
-                    role = Role.Button,
                     onClick = onClick
                 ),
             propagateMinConstraints = true
@@ -292,9 +290,8 @@
  * that doesn't require [onClick] param.
  *
  * 7) Semantics for selection. Just like with [Modifier.selectable], selectable version of Surface
- * will produce semantics to indicate that it is selected. Also, by default, accessibility services
- * will describe the element as [Role.Tab]. You may change this by passing a desired [Role] with a
- * [Modifier.semantics].
+ * will produce semantics to indicate that it is selected. No semantic role is set by default, you
+ * may specify one by passing a desired [Role] with a [Modifier.semantics].
  *
  * @sample androidx.compose.material.samples.SelectableSurfaceSample
  *
@@ -360,7 +357,6 @@
                     interactionSource = interactionSource,
                     indication = rememberRipple(),
                     enabled = enabled,
-                    role = Role.Tab,
                     onClick = onClick
                 ),
             propagateMinConstraints = true
@@ -409,9 +405,8 @@
  * handling, consider using a Surface function that doesn't require [onCheckedChange] param.
  *
  * 7) Semantics for toggle. Just like with [Modifier.toggleable], toggleable version of Surface
- * will produce semantics to indicate that it is checked.  Also, by default, accessibility services
- * will describe the element as [Role.Switch]. You may change this by passing a desired [Role] with
- * a [Modifier.semantics].
+ * will produce semantics to indicate that it is checked.  No semantic role is set by default, you
+ * may specify one by passing a desired [Role] with a [Modifier.semantics].
  *
  * @sample androidx.compose.material.samples.ToggleableSurfaceSample
  *
@@ -477,7 +472,6 @@
                     interactionSource = interactionSource,
                     indication = rememberRipple(),
                     enabled = enabled,
-                    role = Role.Switch,
                     onValueChange = onCheckedChange
                 ),
             propagateMinConstraints = true
diff --git a/compose/material3/material3/build.gradle b/compose/material3/material3/build.gradle
index 4b043b1..8ba8b89 100644
--- a/compose/material3/material3/build.gradle
+++ b/compose/material3/material3/build.gradle
@@ -16,13 +16,13 @@
 
 import androidx.build.AndroidXComposePlugin
 import androidx.build.LibraryType
-import androidx.build.Publish
 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
 
 plugins {
     id("AndroidXPlugin")
     id("com.android.library")
     id("AndroidXComposePlugin")
+    id("AndroidXPaparazziPlugin")
 }
 
 AndroidXComposePlugin.applyAndConfigureKotlinPlugin(project)
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TabTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TabTest.kt
index ed385e3..47dd16b 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TabTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TabTest.kt
@@ -17,8 +17,10 @@
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Favorite
 import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
@@ -630,7 +632,7 @@
     }
 
     @Test
-    fun LeadingIconTabRow_selectNewTab() {
+    fun leadingIconTabRow_selectNewTab() {
         rule
             .setMaterialContent(lightColorScheme()) {
                 LeadingIconTabs()
@@ -745,7 +747,8 @@
 
             val indicator = @Composable { tabPositions: List<TabPosition> ->
                 TabRowDefaults.Indicator(
-                    Modifier.tabIndicatorOffset(tabPositions[state])
+                    Modifier
+                        .tabIndicatorOffset(tabPositions[state])
                         .testTag("indicator")
                 )
             }
@@ -866,4 +869,68 @@
             }
             .assertHeightIsAtLeast(100.dp)
     }
+
+    @Test
+    fun tabRow_layoutHeightRespected() {
+        var height by mutableStateOf(0.dp)
+        rule
+            .setMaterialContent(lightColorScheme()) {
+                var state by remember { mutableStateOf(0) }
+                val titles = listOf("Tab 1", "Tab 2", "Tab 3")
+                Column(
+                    Modifier
+                        .heightIn(max = height)
+                        .testTag("Tabs")
+                ) {
+                    TabRow(selectedTabIndex = state) {
+                        titles.forEachIndexed { index, title ->
+                            Tab(
+                                selected = state == index,
+                                onClick = { state = index },
+                                text = { Text(text = title) }
+                            )
+                        }
+                    }
+                }
+            }
+
+        rule.onNodeWithTag("Tabs").assertHeightIsEqualTo(height)
+
+        height = 40.dp
+        rule.waitForIdle()
+
+        rule.onNodeWithTag("Tabs").assertHeightIsEqualTo(height)
+    }
+
+    @Test
+    fun scrollableTabRow_layoutHeightRespected() {
+        var height by mutableStateOf(0.dp)
+        rule
+            .setMaterialContent(lightColorScheme()) {
+                var state by remember { mutableStateOf(0) }
+                val titles = listOf("Tab 1", "Tab 2", "Tab 3")
+                Column(
+                    Modifier
+                        .heightIn(max = height)
+                        .testTag("Tabs")
+                ) {
+                    ScrollableTabRow(selectedTabIndex = state) {
+                        titles.forEachIndexed { index, title ->
+                            Tab(
+                                selected = state == index,
+                                onClick = { state = index },
+                                text = { Text(text = title) }
+                            )
+                        }
+                    }
+                }
+            }
+
+        rule.onNodeWithTag("Tabs").assertHeightIsEqualTo(height)
+
+        height = 40.dp
+        rule.waitForIdle()
+
+        rule.onNodeWithTag("Tabs").assertHeightIsEqualTo(height)
+    }
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt
index 77881a5..043b2aa 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt
@@ -157,7 +157,8 @@
                     constraints.copy(
                         minWidth = tabWidth,
                         maxWidth = tabWidth,
-                        minHeight = tabRowHeight
+                        minHeight = tabRowHeight,
+                        maxHeight = tabRowHeight,
                     )
                 )
             }
@@ -265,7 +266,11 @@
                 maxOf(curr, measurable.maxIntrinsicHeight(Constraints.Infinity))
             }
 
-            val tabConstraints = constraints.copy(minWidth = minTabWidth, minHeight = layoutHeight)
+            val tabConstraints = constraints.copy(
+                minWidth = minTabWidth,
+                minHeight = layoutHeight,
+                maxHeight = layoutHeight,
+            )
             val tabPlaceables = tabMeasurables
                 .map { it.measure(tabConstraints) }
 
diff --git a/compose/material3/material3/src/test/kotlin/androidx/compose/material3/ButtonPaparazziTest.kt b/compose/material3/material3/src/test/kotlin/androidx/compose/material3/ButtonPaparazziTest.kt
new file mode 100644
index 0000000..e4737d5
--- /dev/null
+++ b/compose/material3/material3/src/test/kotlin/androidx/compose/material3/ButtonPaparazziTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+import androidx.testutils.paparazzi.androidxPaparazzi
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class ButtonPaparazziTest {
+    @get:Rule
+    val paparazzi = androidxPaparazzi()
+
+    @Test
+    fun default_button_light_theme() {
+        paparazzi.snapshot {
+            MaterialTheme(lightColorScheme()) {
+                Surface {
+                    Button(onClick = { }) {
+                        Text("Button")
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    fun default_button_dark_theme() {
+        paparazzi.snapshot {
+            MaterialTheme(darkColorScheme()) {
+                Surface {
+                    Button(onClick = { }) {
+                        Text("Button")
+                    }
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 5868acd..c10ff1a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,6 +6,8 @@
 org.gradle.welcome=never
 # Disabled due to https://github.com/gradle/gradle/issues/18626
 # org.gradle.vfs.watch=true
+# Reenabled in gradlew, but disabled in Studio until these errors become shown (b/268380971) or computed more quickly (https://github.com/gradle/gradle/issues/23272)
+org.gradle.dependency.verification=off
 org.gradle.dependency.verification.console=verbose
 org.gradle.unsafe.configuration-cache=true
 org.gradle.unsafe.configuration-cache-problems=warn
diff --git a/gradlew b/gradlew
index 9b54b57..11139b9 100755
--- a/gradlew
+++ b/gradlew
@@ -396,7 +396,13 @@
 
   RETURN_VALUE=0
   PROJECT_CACHE_DIR_ARGUMENT="--project-cache-dir $OUT_DIR/gradle-project-cache"
-  if $wrapper "$JAVACMD" "${JVM_OPTS[@]}" $TMPDIR_ARG -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain $HOME_SYSTEM_PROPERTY_ARGUMENT $TMPDIR_ARG $PROJECT_CACHE_DIR_ARGUMENT "$ORG_GRADLE_JVMARGS" "$@"; then
+  # Disabled in Studio until these errors become shown (b/268380971) or computed more quickly (https://github.com/gradle/gradle/issues/23272)
+  if [[ " ${@} " =~ " --dependency-verification=" ]]; then
+    VERIFICATION_ARGUMENT="" # already specified by caller
+  else
+    VERIFICATION_ARGUMENT=--dependency-verification=strict
+  fi
+  if $wrapper "$JAVACMD" "${JVM_OPTS[@]}" $TMPDIR_ARG -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain $HOME_SYSTEM_PROPERTY_ARGUMENT $TMPDIR_ARG $PROJECT_CACHE_DIR_ARGUMENT $VERIFICATION_ARGUMENT "$ORG_GRADLE_JVMARGS" "$@"; then
     RETURN_VALUE=0
   else
     # Print AndroidX-specific help message if build fails
diff --git a/testutils/testutils-paparazzi/build.gradle b/testutils/testutils-paparazzi/build.gradle
index 4128c88..aeb9d21 100644
--- a/testutils/testutils-paparazzi/build.gradle
+++ b/testutils/testutils-paparazzi/build.gradle
@@ -25,7 +25,12 @@
 BundleInsideHelper.forInsideJar(
         project,
         "com.google.protobuf",
-        "androidx.testutils.paparazzi.protobuf"
+        // b/268288856 untangle this mess.
+        // Currently this has to match test/screenshot/screenshot/build.gradle because sometimes
+        // both :internal-testutils-paparazzi and :test:screenshot:screenshot are added to the
+        // classpath and picking a different package will cause missing class exceptions due to
+        // class shadowing
+        "androidx.test.screenshot.protobuf"
 )
 
 dependencies {
diff --git a/testutils/testutils-paparazzi/src/main/kotlin/androidx/testutils/paparazzi/GoldenVerifier.kt b/testutils/testutils-paparazzi/src/main/kotlin/androidx/testutils/paparazzi/GoldenVerifier.kt
index bfca2c7..71be049 100644
--- a/testutils/testutils-paparazzi/src/main/kotlin/androidx/testutils/paparazzi/GoldenVerifier.kt
+++ b/testutils/testutils-paparazzi/src/main/kotlin/androidx/testutils/paparazzi/GoldenVerifier.kt
@@ -110,8 +110,8 @@
             )
             is AnalysisResult.MissingGolden -> throw AssertionError(
                 "Expected golden image for test \"${snapshot.testName.methodName}\" does not " +
-                    "exist. Run ${updateGoldenGradleTask()} to create it and update all golden " +
-                    "images for this test module."
+                    "exist. Run ${updateGoldenGradleTask()} -Pandroidx.ignoreTestFailures=true " +
+                    "to create it and update all golden images for this test module."
             )
         }
     }
diff --git a/testutils/testutils-paparazzi/src/test/kotlin/androidx/testutils/paparazzi/GoldenVerifierTest.kt b/testutils/testutils-paparazzi/src/test/kotlin/androidx/testutils/paparazzi/GoldenVerifierTest.kt
index 6a460df..0775bf6 100644
--- a/testutils/testutils-paparazzi/src/test/kotlin/androidx/testutils/paparazzi/GoldenVerifierTest.kt
+++ b/testutils/testutils-paparazzi/src/test/kotlin/androidx/testutils/paparazzi/GoldenVerifierTest.kt
@@ -219,8 +219,8 @@
     @Test
     fun `asserts on missing golden`() {
         val message = "Expected golden image for test \"asserts on missing golden\" does not " +
-            "exist. Run :updateGolden to create it and update all golden images for this test " +
-            "module."
+            "exist. Run :updateGolden -Pandroidx.ignoreTestFailures=true to create it and update " +
+            "all golden images for this test module."
 
         assertFailsWithMessage(message) {
             goldenVerifier().assertMatchesGolden(snapshot(), loadTestImage("circle"))
diff --git a/wear/compose/compose-navigation/build.gradle b/wear/compose/compose-navigation/build.gradle
index 0cfac0c..0f056c9 100644
--- a/wear/compose/compose-navigation/build.gradle
+++ b/wear/compose/compose-navigation/build.gradle
@@ -45,6 +45,7 @@
     androidTestImplementation(project(":wear:compose:compose-navigation-samples"))
     androidTestImplementation(libs.truth)
     androidTestImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.5.1")
+    androidTestImplementation("androidx.navigation:navigation-testing:2.5.3")
 
     samples(project(":wear:compose:compose-navigation-samples"))
 }
diff --git a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
index 4ad9e64..68f68b5 100644
--- a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
+++ b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
@@ -34,6 +34,7 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.ExperimentalTestApi
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
@@ -49,6 +50,7 @@
 import androidx.lifecycle.testing.TestLifecycleOwner
 import androidx.navigation.NavBackStackEntry
 import androidx.navigation.NavHostController
+import androidx.navigation.testing.TestNavHostController
 import androidx.wear.compose.material.Button
 import androidx.wear.compose.material.CompactChip
 import androidx.wear.compose.material.MaterialTheme
@@ -390,6 +392,35 @@
         }
     }
 
+    @Test
+    fun testNavHostController_starts_at_default_destination() {
+        lateinit var navController: TestNavHostController
+
+        rule.setContentWithTheme {
+            navController = TestNavHostController(LocalContext.current)
+            navController.navigatorProvider.addNavigator(WearNavigator())
+
+            SwipeDismissWithNavigation(navController)
+        }
+
+        rule.onNodeWithText(START).assertExists()
+    }
+
+    @Test
+    fun testNavHostController_sets_current_destination() {
+        lateinit var navController: TestNavHostController
+
+        rule.setContentWithTheme {
+            navController = TestNavHostController(LocalContext.current)
+            navController.navigatorProvider.addNavigator(WearNavigator())
+
+            SwipeDismissWithNavigation(navController)
+            navController.setCurrentDestination(NEXT)
+        }
+
+        rule.onNodeWithText(NEXT).assertExists()
+    }
+
     @Composable
     fun SwipeDismissWithNavigation(
         navController: NavHostController = rememberSwipeDismissableNavController()
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java
index 32cca2b..2468ae7 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java
@@ -43,7 +43,9 @@
 import androidx.wear.protolayout.expression.pipeline.FloatNodes.FixedFloatNode;
 import androidx.wear.protolayout.expression.pipeline.FloatNodes.Int32ToFloatNode;
 import androidx.wear.protolayout.expression.pipeline.FloatNodes.StateFloatNode;
+import androidx.wear.protolayout.expression.pipeline.Int32Nodes.AnimatableFixedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.ArithmeticInt32Node;
+import androidx.wear.protolayout.expression.pipeline.Int32Nodes.DynamicAnimatedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.FixedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.FloatToInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.PlatformInt32SourceNode;
@@ -58,6 +60,7 @@
 import androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway;
 import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableDynamicColor;
 import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableDynamicFloat;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableDynamicInt32;
 import androidx.wear.protolayout.expression.proto.DynamicProto.ConditionalFloatOp;
 import androidx.wear.protolayout.expression.proto.DynamicProto.ConditionalInt32Op;
 import androidx.wear.protolayout.expression.proto.DynamicProto.ConditionalStringOp;
@@ -68,6 +71,7 @@
 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicString;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedColor;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedFloat;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedInt32;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -340,7 +344,8 @@
             @NonNull DynamicBuilders.DynamicInt32 int32Source,
             @NonNull DynamicTypeValueReceiver<Integer> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(int32Source.toDynamicInt32Proto(), consumer, resultBuilder);
+        bindRecursively(
+                int32Source.toDynamicInt32Proto(), consumer, resultBuilder, Optional.empty());
         mUiHandler.post(() -> processBindings(resultBuilder));
         return new BoundDynamicTypeImpl(resultBuilder);
     }
@@ -365,7 +370,33 @@
             @NonNull DynamicInt32 int32Source,
             @NonNull DynamicTypeValueReceiver<Integer> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(int32Source, consumer, resultBuilder);
+        bindRecursively(int32Source, consumer, resultBuilder, Optional.empty());
+        mDynamicTypeNodes.addAll(resultBuilder);
+        return new BoundDynamicTypeImpl(resultBuilder);
+    }
+
+    /**
+     * Adds pending expression from the given {@link DynamicInt32} for future evaluation.
+     *
+     * <p>While the {@link BoundDynamicType} is not destroyed with{@link BoundDynamicType#close()}
+     * by caller, results of evaluation will be sent through the given {@link
+     * DynamicTypeValueReceiver}.
+     *
+     * @param int32Source The given integer dynamic type that should be evaluated.
+     * @param consumer The registered consumer for results of the evaluation. It will be called from
+     *     UI thread.
+     * @param animationFallbackValue The value used if the given {@link DynamicInt32} is animatable
+     *     and animations are disabled.
+     * @hide
+     */
+    @NonNull
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public BoundDynamicType bind(
+            @NonNull DynamicInt32 int32Source,
+            @NonNull DynamicTypeValueReceiver<Integer> consumer,
+            int animationFallbackValue) {
+        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
+        bindRecursively(int32Source, consumer, resultBuilder, Optional.of(animationFallbackValue));
         mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
@@ -598,7 +629,8 @@
                     bindRecursively(
                             stringSource.getInt32FormatOp().getInput(),
                             int32FormatNode.getIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
                     break;
                 }
             case FLOAT_FORMAT_OP:
@@ -677,7 +709,8 @@
     private void bindRecursively(
             @NonNull DynamicInt32 int32Source,
             @NonNull DynamicTypeValueReceiver<Integer> consumer,
-            @NonNull List<DynamicDataNode<?>> resultBuilder) {
+            @NonNull List<DynamicDataNode<?>> resultBuilder,
+            @NonNull Optional<Integer> animationFallbackValue) {
         DynamicDataNode<Integer> node;
 
         switch (int32Source.getInnerCase()) {
@@ -685,8 +718,7 @@
                 node = new FixedInt32Node(int32Source.getFixed(), consumer);
                 break;
             case PLATFORM_SOURCE:
-                node =
-                        new PlatformInt32SourceNode(
+                node = new PlatformInt32SourceNode(
                                 int32Source.getPlatformSource(),
                                 mTimeDataSource,
                                 mSensorGatewayDataSource,
@@ -701,11 +733,13 @@
                     bindRecursively(
                             int32Source.getArithmeticOperation().getInputLhs(),
                             arithmeticNode.getLhsIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
                     bindRecursively(
                             int32Source.getArithmeticOperation().getInputRhs(),
                             arithmeticNode.getRhsIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
 
                     break;
                 }
@@ -728,11 +762,13 @@
                     bindRecursively(
                             op.getValueIfTrue(),
                             conditionalNode.getTrueValueIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
                     bindRecursively(
                             op.getValueIfFalse(),
                             conditionalNode.getFalseValueIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
 
                     node = conditionalNode;
                     break;
@@ -750,6 +786,53 @@
                             Optional.empty());
                     break;
                 }
+            case ANIMATABLE_FIXED:
+                if (!mEnableAnimations && animationFallbackValue.isPresent()) {
+                    // Just assign static value if animations are disabled.
+                    node =
+                            new FixedInt32Node(
+                                    FixedInt32.newBuilder().setValue(
+                                            animationFallbackValue.get()).build(), consumer);
+
+                } else {
+                    // We don't have to check if enableAnimations is true, because if it's false
+                    // and we didn't
+                    // have static value set, constructor has put QuotaManager that don't have
+                    // any quota, so
+                    // animations won't be played and they would jump to the end value.
+                    node =
+                            new AnimatableFixedInt32Node(
+                                    int32Source.getAnimatableFixed(), consumer,
+                                    mAnimationQuotaManager);
+                }
+                break;
+            case ANIMATABLE_DYNAMIC:
+                if (!mEnableAnimations && animationFallbackValue.isPresent()) {
+                    // Just assign static value if animations are disabled.
+                    node =
+                            new FixedInt32Node(
+                                    FixedInt32.newBuilder().setValue(
+                                            animationFallbackValue.get()).build(), consumer);
+
+                } else {
+                    // We don't have to check if enableAnimations is true, because if it's false
+                    // and we didn't
+                    // have static value set, constructor has put QuotaManager that don't have
+                    // any quota, so
+                    // animations won't be played and they would jump to the end value.
+                    AnimatableDynamicInt32 dynamicNode = int32Source.getAnimatableDynamic();
+                    DynamicAnimatedInt32Node animationNode =
+                            new DynamicAnimatedInt32Node(consumer, dynamicNode.getSpec(),
+                                    mAnimationQuotaManager);
+                    node = animationNode;
+
+                    bindRecursively(
+                            dynamicNode.getInput(),
+                            animationNode.getInputCallback(),
+                            resultBuilder,
+                            animationFallbackValue);
+                }
+                break;
             case INNER_NOT_SET:
                 throw new IllegalArgumentException("DynamicInt32 has no inner source set");
             default:
@@ -807,7 +890,8 @@
                     bindRecursively(
                             floatSource.getInt32ToFloatOperation().getInput(),
                             toFloatNode.getIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
                     break;
                 }
             case CONDITIONAL_OP:
@@ -999,11 +1083,13 @@
                     bindRecursively(
                             boolSource.getInt32Comparison().getInputLhs(),
                             compNode.getLhsIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
                     bindRecursively(
                             boolSource.getInt32Comparison().getInputRhs(),
                             compNode.getRhsIncomingCallback(),
-                            resultBuilder);
+                            resultBuilder,
+                            Optional.empty());
 
                     break;
                 }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java
index 53a7968..4fc75c6 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java
@@ -16,13 +16,19 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
+import static androidx.wear.protolayout.expression.pipeline.AnimationsHelper.applyAnimationSpecToAnimator;
+
+import android.animation.ValueAnimator;
 import android.util.Log;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.wear.protolayout.expression.pipeline.PlatformDataSources.EpochTimePlatformDataSource;
 import androidx.wear.protolayout.expression.pipeline.PlatformDataSources.PlatformDataSource;
 import androidx.wear.protolayout.expression.pipeline.PlatformDataSources.SensorGatewayPlatformDataSource;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableFixedInt32;
 import androidx.wear.protolayout.expression.proto.DynamicProto.ArithmeticInt32Op;
 import androidx.wear.protolayout.expression.proto.DynamicProto.FloatToInt32Op;
 import androidx.wear.protolayout.expression.proto.DynamicProto.PlatformInt32Source;
@@ -203,4 +209,125 @@
                     });
         }
     }
+
+    /** Dynamic int32 node that gets animatable value from fixed source. */
+    static class AnimatableFixedInt32Node extends AnimatableNode
+            implements DynamicDataSourceNode<Integer> {
+
+        private final AnimatableFixedInt32 mProtoNode;
+        private final DynamicTypeValueReceiver<Integer> mDownstream;
+
+        AnimatableFixedInt32Node(
+                AnimatableFixedInt32 protoNode,
+                DynamicTypeValueReceiver<Integer> downstream,
+                QuotaManager quotaManager) {
+            super(quotaManager);
+            this.mProtoNode = protoNode;
+            this.mDownstream = downstream;
+        }
+
+        @Override
+        @UiThread
+        public void preInit() {
+            mDownstream.onPreUpdate();
+        }
+
+        @Override
+        @UiThread
+        public void init() {
+            ValueAnimator animator =
+                    ValueAnimator.ofInt(mProtoNode.getFromValue(), mProtoNode.getToValue());
+            applyAnimationSpecToAnimator(animator, mProtoNode.getSpec());
+            animator.addUpdateListener(a -> mDownstream.onData((Integer) a.getAnimatedValue()));
+            mQuotaAwareAnimator.updateAnimator(animator);
+            startOrSkipAnimator();
+        }
+
+        @Override
+        @UiThread
+        public void destroy() {
+            mQuotaAwareAnimator.stopAnimator();
+        }
+    }
+
+    /** Dynamic int32 node that gets animatable value from dynamic source. */
+    static class DynamicAnimatedInt32Node extends AnimatableNode implements
+            DynamicDataNode<Integer> {
+
+        final DynamicTypeValueReceiver<Integer> mDownstream;
+        private final DynamicTypeValueReceiver<Integer> mInputCallback;
+
+        @Nullable
+        Integer mCurrentValue = null;
+        int mPendingCalls = 0;
+
+        // Static analysis complains about calling methods of parent class AnimatableNode under
+        // initialization but mInputCallback is only used after the constructor is finished.
+        @SuppressWarnings("method.invocation.invalid")
+        DynamicAnimatedInt32Node(
+                DynamicTypeValueReceiver<Integer> downstream,
+                @NonNull AnimationSpec spec,
+                QuotaManager quotaManager) {
+            super(quotaManager);
+            this.mDownstream = downstream;
+            this.mInputCallback =
+                    new DynamicTypeValueReceiver<Integer>() {
+                        @Override
+                        public void onPreUpdate() {
+                            mPendingCalls++;
+
+                            if (mPendingCalls == 1) {
+                                mDownstream.onPreUpdate();
+
+                                mQuotaAwareAnimator.resetAnimator();
+                            }
+                        }
+
+                        @Override
+                        public void onData(Integer newData) {
+                            if (mPendingCalls > 0) {
+                                mPendingCalls--;
+                            }
+
+                            if (mPendingCalls == 0) {
+                                if (mCurrentValue == null) {
+                                    mCurrentValue = newData;
+                                    mDownstream.onData(mCurrentValue);
+                                } else {
+                                    ValueAnimator animator = ValueAnimator.ofInt(mCurrentValue,
+                                            newData);
+
+                                    applyAnimationSpecToAnimator(animator, spec);
+                                    animator.addUpdateListener(
+                                            a -> {
+                                                if (mPendingCalls == 0) {
+                                                    mCurrentValue = (Integer) a.getAnimatedValue();
+                                                    mDownstream.onData(mCurrentValue);
+                                                }
+                                            });
+
+                                    mQuotaAwareAnimator.updateAnimator(animator);
+                                    startOrSkipAnimator();
+                                }
+                            }
+                        }
+
+                        @Override
+                        public void onInvalidated() {
+                            if (mPendingCalls > 0) {
+                                mPendingCalls--;
+                            }
+
+                            if (mPendingCalls == 0) {
+                                mCurrentValue = null;
+                                mDownstream.onInvalidated();
+                            }
+                        }
+                    };
+        }
+
+        public DynamicTypeValueReceiver<Integer> getInputCallback() {
+            return mInputCallback;
+        }
+    }
 }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java
index dc0d8d4..ddb9ec7 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java
@@ -18,14 +18,23 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.robolectric.Shadows.shadowOf;
+
+import android.os.Looper;
+
 import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.expression.pipeline.Int32Nodes.AnimatableFixedInt32Node;
+import androidx.wear.protolayout.expression.pipeline.Int32Nodes.DynamicAnimatedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.FixedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.StateInt32SourceNode;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableFixedInt32;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateInt32Source;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedInt32;
 import androidx.wear.protolayout.expression.proto.StateEntryProto.StateEntryValue;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -97,4 +106,127 @@
 
         assertThat(results).containsExactly(12);
     }
+
+    @Test
+    public void animatableFixedInt32_animates() {
+        int startValue = 3;
+        int endValue = 33;
+        List<Integer> results = new ArrayList<>();
+        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        AnimatableFixedInt32 protoNode =
+                AnimatableFixedInt32.newBuilder().setFromValue(startValue).setToValue(
+                        endValue).build();
+        AnimatableFixedInt32Node node =
+                new AnimatableFixedInt32Node(protoNode, new AddToListCallback<>(results),
+                        quotaManager);
+        node.setVisibility(true);
+
+        node.init();
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertThat(results.size()).isGreaterThan(2);
+        assertThat(results.get(0)).isEqualTo(startValue);
+        assertThat(Iterables.getLast(results)).isEqualTo(endValue);
+    }
+
+    @Test
+    public void animatableFixedInt32_whenInvisible_skipToEnd() {
+        int startValue = 3;
+        int endValue = 33;
+        List<Integer> results = new ArrayList<>();
+        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        AnimatableFixedInt32 protoNode =
+                AnimatableFixedInt32.newBuilder().setFromValue(startValue).setToValue(
+                        endValue).build();
+        AnimatableFixedInt32Node node =
+                new AnimatableFixedInt32Node(protoNode, new AddToListCallback<>(results),
+                        quotaManager);
+        node.setVisibility(false);
+
+        node.init();
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertThat(results).hasSize(1);
+        assertThat(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void animatableFixedInt32_whenNoQuota_skip() {
+        int startValue = 3;
+        int endValue = 33;
+        List<Integer> results = new ArrayList<>();
+        QuotaManager quotaManager = new TestNoQuotaManagerImpl();
+        AnimatableFixedInt32 protoNode =
+                AnimatableFixedInt32.newBuilder().setFromValue(startValue).setToValue(
+                        endValue).build();
+        AnimatableFixedInt32Node node =
+                new AnimatableFixedInt32Node(protoNode, new AddToListCallback<>(results),
+                        quotaManager);
+        node.setVisibility(true);
+
+        node.init();
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertThat(results).hasSize(1);
+        assertThat(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void dynamicAnimatedInt32_onlyAnimateWhenVisible() {
+        int value1 = 3;
+        int value2 = 11;
+        int value3 = 17;
+        List<Integer> results = new ArrayList<>();
+        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        ObservableStateStore oss =
+                new ObservableStateStore(
+                        ImmutableMap.of(
+                                "foo",
+                                StateEntryValue.newBuilder()
+                                        .setInt32Val(
+                                                FixedInt32.newBuilder().setValue(value1).build())
+                                        .build()));
+        DynamicAnimatedInt32Node int32Node =
+                new DynamicAnimatedInt32Node(
+                        new AddToListCallback<>(results), AnimationSpec.getDefaultInstance(),
+                        quotaManager);
+        int32Node.setVisibility(false);
+        StateInt32SourceNode stateNode =
+                new StateInt32SourceNode(
+                        oss,
+                        StateInt32Source.newBuilder().setSourceKey("foo").build(),
+                        int32Node.getInputCallback());
+
+        stateNode.preInit();
+        stateNode.init();
+
+        results.clear();
+        oss.setStateEntryValuesProto(
+                ImmutableMap.of(
+                        "foo",
+                        StateEntryValue.newBuilder()
+                                .setInt32Val(FixedInt32.newBuilder().setValue(value2))
+                                .build()));
+        shadowOf(Looper.getMainLooper()).idle();
+
+        // Only contains last value.
+        assertThat(results).hasSize(1);
+        assertThat(results).containsExactly(value2);
+
+        int32Node.setVisibility(true);
+        results.clear();
+        oss.setStateEntryValuesProto(
+                ImmutableMap.of(
+                        "foo",
+                        StateEntryValue.newBuilder()
+                                .setInt32Val(FixedInt32.newBuilder().setValue(value3))
+                                .build()));
+        shadowOf(Looper.getMainLooper()).idle();
+
+        // Contains intermediate values besides the initial and last.
+        assertThat(results.size()).isGreaterThan(2);
+        assertThat(results.get(0)).isEqualTo(value2);
+        assertThat(Iterables.getLast(results)).isEqualTo(value3);
+        assertThat(results).isInOrder();
+    }
 }
diff --git a/wear/protolayout/protolayout-expression/api/current.txt b/wear/protolayout/protolayout-expression/api/current.txt
index d9949ab..49d68e38 100644
--- a/wear/protolayout/protolayout-expression/api/current.txt
+++ b/wear/protolayout/protolayout-expression/api/current.txt
@@ -157,6 +157,12 @@
   }
 
   public static interface DynamicBuilders.DynamicInt32 extends androidx.wear.protolayout.expression.DynamicBuilders.DynamicType {
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(int, int);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(int, int, androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(String);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(String, androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate();
     method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat asFloat();
     method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 constant(int);
     method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 div(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32);
@@ -228,5 +234,20 @@
     method public static androidx.wear.protolayout.expression.StateEntryBuilders.StateEntryValue fromString(String);
   }
 
+  public final class VersionBuilders {
+  }
+
+  public static final class VersionBuilders.VersionInfo {
+    method public int getMajor();
+    method public int getMinor();
+  }
+
+  public static final class VersionBuilders.VersionInfo.Builder {
+    ctor public VersionBuilders.VersionInfo.Builder();
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo build();
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo.Builder setMajor(int);
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo.Builder setMinor(int);
+  }
+
 }
 
diff --git a/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt b/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
index be278c8..5ab6ba0 100644
--- a/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
+++ b/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
@@ -157,6 +157,12 @@
   }
 
   public static interface DynamicBuilders.DynamicInt32 extends androidx.wear.protolayout.expression.DynamicBuilders.DynamicType {
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(int, int);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(int, int, androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(String);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(String, androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate();
     method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat asFloat();
     method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 constant(int);
     method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 div(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32);
@@ -231,5 +237,20 @@
     method public static androidx.wear.protolayout.expression.StateEntryBuilders.StateEntryValue fromString(String);
   }
 
+  public final class VersionBuilders {
+  }
+
+  public static final class VersionBuilders.VersionInfo {
+    method public int getMajor();
+    method public int getMinor();
+  }
+
+  public static final class VersionBuilders.VersionInfo.Builder {
+    ctor public VersionBuilders.VersionInfo.Builder();
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo build();
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo.Builder setMajor(int);
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo.Builder setMinor(int);
+  }
+
 }
 
diff --git a/wear/protolayout/protolayout-expression/api/restricted_current.txt b/wear/protolayout/protolayout-expression/api/restricted_current.txt
index d9949ab..49d68e38 100644
--- a/wear/protolayout/protolayout-expression/api/restricted_current.txt
+++ b/wear/protolayout/protolayout-expression/api/restricted_current.txt
@@ -157,6 +157,12 @@
   }
 
   public static interface DynamicBuilders.DynamicInt32 extends androidx.wear.protolayout.expression.DynamicBuilders.DynamicType {
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(int, int);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(int, int, androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(String);
+    method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(String, androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 animate();
     method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat asFloat();
     method public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 constant(int);
     method public default androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 div(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32);
@@ -228,5 +234,20 @@
     method public static androidx.wear.protolayout.expression.StateEntryBuilders.StateEntryValue fromString(String);
   }
 
+  public final class VersionBuilders {
+  }
+
+  public static final class VersionBuilders.VersionInfo {
+    method public int getMajor();
+    method public int getMinor();
+  }
+
+  public static final class VersionBuilders.VersionInfo.Builder {
+    ctor public VersionBuilders.VersionInfo.Builder();
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo build();
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo.Builder setMajor(int);
+    method public androidx.wear.protolayout.expression.VersionBuilders.VersionInfo.Builder setMinor(int);
+  }
+
 }
 
diff --git a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/DynamicBuilders.java b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/DynamicBuilders.java
index dfecd528..110f68a 100644
--- a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/DynamicBuilders.java
+++ b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/DynamicBuilders.java
@@ -884,6 +884,302 @@
   }
 
   /**
+   * A static interpolation node, between two fixed int32 values.
+   *
+   * @since 1.2
+   */
+  static final class AnimatableFixedInt32 implements DynamicInt32 {
+    private final DynamicProto.AnimatableFixedInt32 mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    AnimatableFixedInt32(
+            DynamicProto.AnimatableFixedInt32 impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the value to start animating from.
+     *
+     * @since 1.2
+     */
+    public int getFromValue() {
+      return mImpl.getFromValue();
+    }
+
+    /**
+     * Gets the value to animate to.
+     *
+     * @since 1.2
+     */
+    public int getToValue() {
+      return mImpl.getToValue();
+    }
+
+    /**
+     * Gets the animation parameters for duration, delay, etc.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public AnimationSpec getSpec() {
+      if (mImpl.hasSpec()) {
+        return AnimationSpec.fromProto(mImpl.getSpec());
+      } else {
+        return null;
+      }
+    }
+
+    /** @hide */
+    @Override
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static AnimatableFixedInt32 fromProto(
+            @NonNull DynamicProto.AnimatableFixedInt32 proto, @Nullable Fingerprint fingerprint) {
+      return new AnimatableFixedInt32(proto, fingerprint);
+    }
+
+    @NonNull
+    static AnimatableFixedInt32 fromProto(@NonNull DynamicProto.AnimatableFixedInt32 proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    DynamicProto.AnimatableFixedInt32 toProto() {
+      return mImpl;
+    }
+
+    /** @hide */
+    @Override
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public DynamicProto.DynamicInt32 toDynamicInt32Proto() {
+      return DynamicProto.DynamicInt32.newBuilder().setAnimatableFixed(mImpl).build();
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "AnimatableFixedInt32{"
+              + "fromValue="
+              + getFromValue()
+              + ", toValue="
+              + getToValue()
+              + ", spec="
+              + getSpec()
+              + "}";
+    }
+
+    /** Builder for {@link AnimatableFixedInt32}. */
+    public static final class Builder implements DynamicInt32.Builder {
+      private final DynamicProto.AnimatableFixedInt32.Builder mImpl =
+              DynamicProto.AnimatableFixedInt32.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-1831435966);
+
+      public Builder() {}
+
+      /**
+       * Sets the value to start animating from.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public AnimatableFixedInt32.Builder setFromValue(int fromValue) {
+        mImpl.setFromValue(fromValue);
+        mFingerprint.recordPropertyUpdate(1, fromValue);
+        return this;
+      }
+
+      /**
+       * Sets the value to animate to.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public AnimatableFixedInt32.Builder setToValue(int toValue) {
+        mImpl.setToValue(toValue);
+        mFingerprint.recordPropertyUpdate(2, toValue);
+        return this;
+      }
+
+      /**
+       * Sets the animation parameters for duration, delay, etc.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public AnimatableFixedInt32.Builder setSpec(@NonNull AnimationSpec spec) {
+        mImpl.setSpec(spec.toProto());
+        mFingerprint.recordPropertyUpdate(
+                3, checkNotNull(spec.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      @Override
+      @NonNull
+      public AnimatableFixedInt32 build() {
+        return new AnimatableFixedInt32(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * A dynamic interpolation node. This will watch the value of its input and, when the first update
+   * arrives, immediately emit that value. On subsequent updates, it will animate between the old
+   * and new values.
+   *
+   * <p>If this node receives an invalid value (e.g. as a result of an upstream node having no
+   * value), then it will emit a single invalid value, and forget its "stored" value. The next valid
+   * value that arrives is then used as the "first" value again.
+   *
+   * @since 1.2
+   */
+  static final class AnimatableDynamicInt32 implements DynamicInt32 {
+    private final DynamicProto.AnimatableDynamicInt32 mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    AnimatableDynamicInt32(
+            DynamicProto.AnimatableDynamicInt32 impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the value to watch, and animate when it changes.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public DynamicInt32 getInput() {
+      if (mImpl.hasInput()) {
+        return dynamicInt32FromProto(mImpl.getInput());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Gets the animation parameters for duration, delay, etc.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public AnimationSpec getSpec() {
+      if (mImpl.hasSpec()) {
+        return AnimationSpec.fromProto(mImpl.getSpec());
+      } else {
+        return null;
+      }
+    }
+
+    /** @hide */
+    @Override
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static AnimatableDynamicInt32 fromProto(
+            @NonNull DynamicProto.AnimatableDynamicInt32 proto, @Nullable Fingerprint fingerprint) {
+      return new AnimatableDynamicInt32(proto, fingerprint);
+    }
+
+    @NonNull
+    static AnimatableDynamicInt32 fromProto(@NonNull DynamicProto.AnimatableDynamicInt32 proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    DynamicProto.AnimatableDynamicInt32 toProto() {
+      return mImpl;
+    }
+
+    /** @hide */
+    @Override
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public DynamicProto.DynamicInt32 toDynamicInt32Proto() {
+      return DynamicProto.DynamicInt32.newBuilder().setAnimatableDynamic(mImpl).build();
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "AnimatableDynamicInt32{" + "input=" + getInput() + ", spec=" + getSpec() + "}";
+    }
+
+    /** Builder for {@link AnimatableDynamicInt32}. */
+    public static final class Builder implements DynamicInt32.Builder {
+      private final DynamicProto.AnimatableDynamicInt32.Builder mImpl =
+              DynamicProto.AnimatableDynamicInt32.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-1554674954);
+
+      public Builder() {}
+
+      /**
+       * Sets the value to watch, and animate when it changes.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public AnimatableDynamicInt32.Builder setInput(@NonNull DynamicInt32 input) {
+        mImpl.setInput(input.toDynamicInt32Proto());
+        mFingerprint.recordPropertyUpdate(
+                1, checkNotNull(input.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /**
+       * Sets the animation parameters for duration, delay, etc.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public AnimatableDynamicInt32.Builder setSpec(@NonNull AnimationSpec spec) {
+        mImpl.setSpec(spec.toProto());
+        mFingerprint.recordPropertyUpdate(
+                2, checkNotNull(spec.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      @Override
+      @NonNull
+      public AnimatableDynamicInt32 build() {
+        return new AnimatableDynamicInt32(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
    * Interface defining a dynamic int32 type.
    *
    * <p>It offers a set of helper methods for creating arithmetic and logical expressions, e.g.
@@ -956,6 +1252,84 @@
       return new StateInt32Source.Builder().setSourceKey(stateKey).build();
     }
 
+    /**
+     * Creates a {@link DynamicInt32} which will animate from {@code start} to {@code end}.
+     *
+     * @param start The start value of the range.
+     * @param end The end value of the range.
+     */
+    @NonNull
+    static DynamicInt32 animate(int start, int end) {
+      return new AnimatableFixedInt32.Builder().setFromValue(start).setToValue(end).build();
+    }
+
+    /**
+     * Creates a {@link DynamicInt32} which will animate from {@code start} to {@code end} with the
+     * given animation parameters.
+     *
+     * @param start The start value of the range.
+     * @param end The end value of the range.
+     * @param spec The animation parameters.
+     */
+    @NonNull
+    static DynamicInt32 animate(int start, int end, @NonNull AnimationSpec spec) {
+      return new AnimatableFixedInt32.Builder()
+              .setFromValue(start)
+              .setToValue(end)
+              .setSpec(spec)
+              .build();
+    }
+
+    /**
+     * Creates a {@link DynamicInt32} that is bound to the value of an item of the State. Every time
+     * the state value changes, this {@link DynamicInt32} will animate from its current value to the
+     * new value (from the state).
+     *
+     * @param stateKey The key to a {@link StateEntryValue} with an int value from the provider's
+     *     state.
+     */
+    @NonNull
+    static DynamicInt32 animate(@NonNull String stateKey) {
+      return new AnimatableDynamicInt32.Builder().setInput(fromState(stateKey)).build();
+    }
+
+    /**
+     * Creates a {@link DynamicInt32} that is bound to the value of an item of the State. Every time
+     * the state value changes, this {@link DynamicInt32} will animate from its current value to the
+     * new value (from the state).
+     *
+     * @param stateKey The key to a {@link StateEntryValue} with an int value from the provider's
+     *     state.
+     * @param spec The animation parameters.
+     */
+    @NonNull
+    static DynamicInt32 animate(@NonNull String stateKey, @NonNull AnimationSpec spec) {
+      return new AnimatableDynamicInt32.Builder()
+              .setInput(fromState(stateKey))
+              .setSpec(spec)
+              .build();
+    }
+
+    /**
+     * Returns a {@link DynamicInt32} that is bound to the value of this {@link DynamicInt32} and
+     * every time its value is changing, it animates from its current value to the new value.
+     *
+     * @param spec The animation parameters.
+     */
+    @NonNull
+    default DynamicInt32 animate(@NonNull AnimationSpec spec) {
+      return new AnimatableDynamicInt32.Builder().setInput(this).setSpec(spec).build();
+    }
+
+    /**
+     * Returns a {@link DynamicInt32} that is bound to the value of this {@link DynamicInt32} and
+     * every time its value is changing, it animates from its current value to the new value.
+     */
+    @NonNull
+    default DynamicInt32 animate() {
+      return new AnimatableDynamicInt32.Builder().setInput(this).build();
+    }
+
     /** Convert the value represented by this {@link DynamicInt32} into a {@link DynamicFloat}. */
     @NonNull
     default DynamicFloat asFloat() {
@@ -1741,6 +2115,12 @@
     if (proto.hasFloatToInt()) {
       return FloatToInt32Op.fromProto(proto.getFloatToInt());
     }
+    if (proto.hasAnimatableFixed()) {
+      return AnimatableFixedInt32.fromProto(proto.getAnimatableFixed());
+    }
+    if (proto.hasAnimatableDynamic()) {
+      return AnimatableDynamicInt32.fromProto(proto.getAnimatableDynamic());
+    }
     throw new IllegalStateException("Proto was not a recognised instance of DynamicInt32");
   }
 
@@ -2818,7 +3198,7 @@
   }
 
   /**
-   * An operation to convert a Int32 value in the dynamic data pipeline to a Float value.
+   * An operation to convert an Int32 value in the dynamic data pipeline to a Float value.
    *
    * @since 1.2
    */
@@ -2907,7 +3287,7 @@
   }
 
   /**
-   * A static interpolation, between two fixed floating point values.
+   * A static interpolation node, between two fixed floating point values.
    *
    * @since 1.2
    */
@@ -4866,7 +5246,7 @@
   }
 
   /**
-   * A static interpolation, between two fixed color values.
+   * A static interpolation node, between two fixed color values.
    *
    * @since 1.2
    */
diff --git a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/VersionBuilders.java b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/VersionBuilders.java
new file mode 100644
index 0000000..366e97c
--- /dev/null
+++ b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/VersionBuilders.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.expression;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+import androidx.wear.protolayout.expression.proto.VersionProto;
+
+/** Builders for the schema version information of a layout (or an expression). */
+public final class VersionBuilders {
+  private VersionBuilders() {}
+
+  /**
+   * Version information. This is used to encode the schema version of a payload (e.g. inside of a
+   * layout).
+   *
+   * @since 1.0
+   */
+  public static final class VersionInfo {
+    private final VersionProto.VersionInfo mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    VersionInfo(VersionProto.VersionInfo impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets major version. Incremented on breaking changes (i.e. compatibility is not guaranteed
+     * across major versions).
+     *
+     * @since 1.0
+     */
+    public int getMajor() {
+      return mImpl.getMajor();
+    }
+
+    /**
+     * Gets minor version. Incremented on non-breaking changes (e.g. schema additions). Anything
+     * consuming a payload can safely consume anything with a lower minor version.
+     *
+     * @since 1.0
+     */
+    public int getMinor() {
+      return mImpl.getMinor();
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static VersionInfo fromProto(
+        @NonNull VersionProto.VersionInfo proto, @Nullable Fingerprint fingerprint) {
+      return new VersionInfo(proto, fingerprint);
+    }
+
+    /**
+     * Creates a new wrapper instance from the proto. Intended for testing purposes only. An object
+     * created using this method can't be added to any other wrapper.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static VersionInfo fromProto(@NonNull VersionProto.VersionInfo proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public VersionProto.VersionInfo toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "VersionInfo{" + "major=" + getMajor() + ", minor=" + getMinor() + "}";
+    }
+
+    /** Builder for {@link VersionInfo} */
+    public static final class Builder {
+      private final VersionProto.VersionInfo.Builder mImpl = VersionProto.VersionInfo.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(77091996);
+
+      public Builder() {}
+
+      /**
+       * Sets major version. Incremented on breaking changes (i.e. compatibility is not guaranteed
+       * across major versions).
+       *
+       * @since 1.0
+       */
+      @NonNull
+      public Builder setMajor(int major) {
+        mImpl.setMajor(major);
+        mFingerprint.recordPropertyUpdate(1, major);
+        return this;
+      }
+
+      /**
+       * Sets minor version. Incremented on non-breaking changes (e.g. schema additions). Anything
+       * consuming a payload can safely consume anything with a lower minor version.
+       *
+       * @since 1.0
+       */
+      @NonNull
+      public Builder setMinor(int minor) {
+        mImpl.setMinor(minor);
+        mFingerprint.recordPropertyUpdate(2, minor);
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public VersionInfo build() {
+        return new VersionInfo(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+}
diff --git a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/VersionInfoTest.java b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/VersionInfoTest.java
new file mode 100644
index 0000000..f8e4c67
--- /dev/null
+++ b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/VersionInfoTest.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.expression;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class VersionInfoTest {
+    @Test
+    public void versionInfo() {
+        int major = 10;
+        int minor = 20;
+
+        VersionBuilders.VersionInfo versionInfo =
+                new VersionBuilders.VersionInfo.Builder().setMajor(major).setMinor(minor).build();
+
+
+        assertThat(versionInfo.toProto().getMajor()).isEqualTo(major);
+        assertThat(versionInfo.toProto().getMinor()).isEqualTo(minor);
+    }
+
+}
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/device_parameters.proto b/wear/protolayout/protolayout-proto/src/main/proto/device_parameters.proto
index 25c9e37..bcd3063 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/device_parameters.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/device_parameters.proto
@@ -54,7 +54,7 @@
   ScreenShape screen_shape = 5;
 
   // The maximum schema version supported by the current renderer.
-  VersionInfo renderer_schema_version = 6;
+  androidx.wear.protolayout.expression.proto.VersionInfo renderer_schema_version = 6;
 
   // Renderer supported capabilities
   Capabilities capabilities = 8;
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/dynamic.proto b/wear/protolayout/protolayout-proto/src/main/proto/dynamic.proto
index adc7d78..52dfa35 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/dynamic.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/dynamic.proto
@@ -132,7 +132,55 @@
   FloatToInt32RoundMode round_mode = 2;
 }
 
+// A static interpolation node, between two fixed int32 values.
+message AnimatableFixedInt32 {
+  // The value to start animating from.
+  int32 from_value = 1;
+
+  // The value to animate to.
+  int32 to_value = 2;
+
+  // The animation parameters for duration, delay, etc.
+  AnimationSpec spec = 3;
+}
+
+// A dynamic interpolation node. This will watch the value of its input and,
+// when the first update arrives, immediately emit that value. On subsequent
+// updates, it will animate between the old and new values.
+//
+// If this node receives an invalid value (e.g. as a result of an upstream node
+// having no value), then it will emit a single invalid value, and forget its
+// "stored" value. The next valid value that arrives is then used as the
+// "first" value again.
+message AnimatableDynamicInt32 {
+  // The value to watch, and animate when it changes.
+  DynamicInt32 input = 1;
+
+  // The animation parameters for duration, delay, etc.
+  AnimationSpec spec = 2;
+}
+
 // A dynamic int32 type.
+//
+// It offers a set of helper methods for creating arithmetic and logical
+// expressions, e.g. {@link #plus(int)}, {@link #times(int)}, {@link #eq(int)},
+// etc. These helper methods produce expression trees based on the order in
+// which they were called in an expression. Thus, no operator precedence rules
+// are applied.
+//
+// <p>For example the following expression is equivalent to {@code result = ((a
+// + b)*c)/d }:
+//
+// ```
+//    a.plus(b).times(c).div(d);
+// ```
+//
+// More complex expressions can be created by nesting expressions. For example
+// the following expression is equivalent to {@code result = (a + b)*(c - d) }:
+//
+// ```
+//    (a.plus(b)).times(c.minus(d));
+// ```
 message DynamicInt32 {
   oneof inner {
     FixedInt32 fixed = 1;
@@ -141,6 +189,8 @@
     StateInt32Source state_source = 4;
     ConditionalInt32Op conditional_op = 5;
     FloatToInt32Op float_to_int = 6;
+    AnimatableFixedInt32 animatable_fixed = 8;
+    AnimatableDynamicInt32 animatable_dynamic = 9;
   }
 }
 
@@ -260,7 +310,7 @@
   DynamicInt32 input = 1;
 }
 
-// A static interpolation, between two fixed floating point values.
+// A static interpolation node, between two fixed floating point values.
 message AnimatableFixedFloat {
   // The number to start animating from.
   float from_value = 1;
@@ -289,6 +339,26 @@
 }
 
 // A dynamic float type.
+//
+// It offers a set of helper methods for creating arithmetic and logical
+// expressions, e.g. {@link #plus(float)}, {@link #times(float)}, {@link
+// #eq(float)}, etc. These helper methods produce expression trees based on the
+// order in which they were called in an expression. Thus, no operator
+// precedence rules are applied.
+//
+// <p>For example the following expression is equivalent to {@code result = ((a
+// + b)*c)/d }:
+//
+// ```
+//    a.plus(b).times(c).div(d);
+// ```
+//
+// More complex expressions can be created by nesting expressions. For example
+// the following expression is equivalent to {@code result = (a + b)*(c - d) }:
+//
+// ```
+//    (a.plus(b)).times(c.minus(d));
+// ```
 message DynamicFloat {
   oneof inner {
     FixedFloat fixed = 1;
@@ -411,7 +481,7 @@
   string source_key = 1;
 }
 
-// A static interpolation, between two fixed color values.
+// A static interpolation node, between two fixed color values.
 message AnimatableFixedColor {
   // The color value (in ARGB format) to start animating from.
   uint32 from_argb = 1;
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/version.proto b/wear/protolayout/protolayout-proto/src/main/proto/version.proto
index 7c581e3..636de02 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/version.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/version.proto
@@ -1,9 +1,9 @@
 // The schema version information of a layout.
 syntax = "proto3";
 
-package androidx.wear.protolayout.proto;
+package androidx.wear.protolayout.expression.proto;
 
-option java_package = "androidx.wear.protolayout.proto";
+option java_package = "androidx.wear.protolayout.expression.proto";
 option java_outer_classname = "VersionProto";
 
 // Version information. This is used to encode the schema version of a payload
diff --git a/wear/tiles/tiles-proto/src/main/proto/tile.proto b/wear/tiles/tiles-proto/src/main/proto/tile.proto
index fc3d294..4f2b1b6 100644
--- a/wear/tiles/tiles-proto/src/main/proto/tile.proto
+++ b/wear/tiles/tiles-proto/src/main/proto/tile.proto
@@ -22,7 +22,7 @@
   androidx.wear.protolayout.proto.Timeline timeline = 2;
 
   // The schema version that this tile was built with.
-  androidx.wear.protolayout.proto.VersionInfo schema_version = 3;
+  androidx.wear.protolayout.expression.proto.VersionInfo schema_version = 3;
 
   // How many milliseconds of elapsed time (**not** wall clock time) this tile
   // can be considered to be "fresh". The platform will attempt to refresh
diff --git a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileBuilders.java b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileBuilders.java
index dc56dd4..1ad1937 100644
--- a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileBuilders.java
+++ b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileBuilders.java
@@ -22,7 +22,7 @@
 import androidx.annotation.RestrictTo.Scope;
 import androidx.wear.tiles.TimelineBuilders.Timeline;
 import androidx.wear.tiles.proto.TileProto;
-import androidx.wear.protolayout.proto.VersionProto.VersionInfo;
+import androidx.wear.protolayout.expression.proto.VersionProto.VersionInfo;
 
 /** Builders for the components of a tile that can be rendered by a tile renderer. */
 public final class TileBuilders {
diff --git a/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt b/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
index 44aa387..ae7dd7e 100644
--- a/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
+++ b/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
@@ -50,20 +50,25 @@
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 class ComplicationDataExpressionEvaluator(
     val unevaluatedData: WireComplicationData,
-    private val sensorGateway: SensorGateway? = null,
     private val stateStore: ObservableStateStore = ObservableStateStore(emptyMap()),
+    private val sensorGateway: SensorGateway? = null,
 ) : AutoCloseable {
     /**
      * Java compatibility class for [ComplicationDataExpressionEvaluator].
      *
      * Unlike [data], [listener] is not invoked until there is a value (until [data] is non-null).
      */
-    class Compat(
+    class Compat
+    @JvmOverloads
+    constructor(
         val unevaluatedData: WireComplicationData,
         private val executor: Executor,
         private val listener: Consumer<WireComplicationData>,
+        stateStore: ObservableStateStore = ObservableStateStore(emptyMap()),
+        sensorGateway: SensorGateway? = null,
     ) : AutoCloseable {
-        private val evaluator = ComplicationDataExpressionEvaluator(unevaluatedData)
+        private val evaluator =
+            ComplicationDataExpressionEvaluator(unevaluatedData, stateStore, sensorGateway)
 
         /** @see ComplicationDataExpressionEvaluator.init */
         fun init() {
diff --git a/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt b/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt
index 37cae45..47e9331 100644
--- a/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt
+++ b/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt
@@ -262,32 +262,28 @@
             // Defensive copy due to in-place evaluation.
             val expressed = WireComplicationData.Builder(scenario.expressed).build()
             val stateStore = ObservableStateStore(mapOf())
-            ComplicationDataExpressionEvaluator(
-                    expressed,
-                    stateStore = stateStore,
-                )
-                .use { evaluator ->
-                    val allEvaluations =
-                        evaluator.data
-                            .filterNotNull()
-                            .shareIn(
-                                CoroutineScope(Dispatchers.Main),
-                                SharingStarted.Eagerly,
-                                replay = 10,
-                            )
-                    evaluator.init()
-                    runUiThreadTasks() // Ensures data sharing started.
+            ComplicationDataExpressionEvaluator(expressed, stateStore).use { evaluator ->
+                val allEvaluations =
+                    evaluator.data
+                        .filterNotNull()
+                        .shareIn(
+                            CoroutineScope(Dispatchers.Main),
+                            SharingStarted.Eagerly,
+                            replay = 10,
+                        )
+                evaluator.init()
+                runUiThreadTasks() // Ensures data sharing started.
 
-                    for (state in scenario.states) {
-                        stateStore.setStateEntryValues(state)
-                        runUiThreadTasks() // Ensures data sharing ended.
-                    }
-
-                    expect
-                        .withMessage(scenario.name)
-                        .that(allEvaluations.replayCache)
-                        .isEqualTo(scenario.evaluated)
+                for (state in scenario.states) {
+                    stateStore.setStateEntryValues(state)
+                    runUiThreadTasks() // Ensures data sharing ended.
                 }
+
+                expect
+                    .withMessage(scenario.name)
+                    .that(allEvaluations.replayCache)
+                    .isEqualTo(scenario.evaluated)
+            }
         }
     }
 
diff --git a/webkit/integration-tests/testapp/build.gradle b/webkit/integration-tests/testapp/build.gradle
index c7f39ec..2d2e298 100644
--- a/webkit/integration-tests/testapp/build.gradle
+++ b/webkit/integration-tests/testapp/build.gradle
@@ -31,7 +31,6 @@
     implementation(libs.espressoIdlingNet)
     implementation(libs.espressoIdlingResource)
 
-    androidTestImplementation(libs.espressoRemote)
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
diff --git a/webkit/integration-tests/testapp/src/androidTest/AndroidManifest.xml b/webkit/integration-tests/testapp/src/androidTest/AndroidManifest.xml
index 5ec74fb..99cbcac 100644
--- a/webkit/integration-tests/testapp/src/androidTest/AndroidManifest.xml
+++ b/webkit/integration-tests/testapp/src/androidTest/AndroidManifest.xml
@@ -15,13 +15,4 @@
   -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools">
-    <instrumentation
-        android:name="androidx.test.runner.AndroidJUnitRunner"
-        android:targetPackage="com.example.androidx.webkit"
-        android:targetProcesses="*">
-        <!-- This enables Multiprocess Espresso. -->
-        <meta-data
-            android:name="remoteMethod"
-            android:value="androidx.test.espresso.remote.EspressoRemote#remoteInit" />
-    </instrumentation>
 </manifest>
\ No newline at end of file
diff --git a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProcessGlobalConfigActivityTestAppTest.java b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProcessGlobalConfigActivityTestAppTest.java
index 43e3a0d..8babd32 100644
--- a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProcessGlobalConfigActivityTestAppTest.java
+++ b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProcessGlobalConfigActivityTestAppTest.java
@@ -16,11 +16,6 @@
 
 package com.example.androidx.webkit;
 
-import static androidx.test.espresso.Espresso.onView;
-import static androidx.test.espresso.assertion.ViewAssertions.matches;
-import static androidx.test.espresso.matcher.ViewMatchers.withId;
-import static androidx.test.espresso.matcher.ViewMatchers.withText;
-
 import static org.junit.Assert.assertTrue;
 
 import androidx.core.content.ContextCompat;
@@ -44,8 +39,8 @@
 @LargeTest
 public class ProcessGlobalConfigActivityTestAppTest {
     @Rule
-    public ActivityScenarioRule<MainActivity> mRule =
-            new ActivityScenarioRule<>(MainActivity.class);
+    public ActivityScenarioRule<ProcessGlobalConfigActivity> mRule =
+            new ActivityScenarioRule<>(ProcessGlobalConfigActivity.class);
 
     @Before
     public void setUp() {
@@ -70,8 +65,9 @@
                     + "delete it");
         }
         WebkitTestHelpers.clickMenuListItemWithString(
-                R.string.process_global_config_activity_title);
-        onView(withId(R.id.process_global_textview)).check(matches(withText("WebView Loaded!")));
+                R.string.data_directory_suffix_activity_title);
+        // We need to wait for the WebView to finish loading on a different process.
+        Thread.sleep(5000);
 
         assertTrue(file.exists());
     }
diff --git a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProxyOverrideTestAppTest.java b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProxyOverrideTestAppTest.java
index 18a126c..45deec8 100644
--- a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProxyOverrideTestAppTest.java
+++ b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/ProxyOverrideTestAppTest.java
@@ -22,7 +22,6 @@
 import androidx.webkit.WebViewFeature;
 
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -42,7 +41,6 @@
         WebkitTestHelpers.assumeFeature(WebViewFeature.PROXY_OVERRIDE);
     }
 
-    @Ignore // b/267381688
     @Test
     public void testProxyOverride() {
         WebkitTestHelpers.clickMenuListItemWithString(R.string.proxy_override_activity_title);
@@ -58,7 +56,6 @@
                                 R.string.proxy_override_requests_served, 1);
     }
 
-    @Ignore // b/267381688
     @Test
     public void testReverseBypass() {
         WebkitTestHelpers.assumeFeature(WebViewFeature.PROXY_OVERRIDE_REVERSE_BYPASS);
diff --git a/webkit/integration-tests/testapp/src/main/AndroidManifest.xml b/webkit/integration-tests/testapp/src/main/AndroidManifest.xml
index 0428b05..fd36cc4 100644
--- a/webkit/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/webkit/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -125,7 +125,10 @@
             android:exported="true" />
         <activity
             android:name=".ProcessGlobalConfigActivity"
-            android:process=":processGlobalConfigTest"
+            android:exported="true" />
+        <activity
+            android:name=".DataDirectorySuffixActivity"
+            android:process=":dataDirectorySuffixActivity"
             android:exported="true" />
         <activity
             android:name=".RequestedWithHeaderActivity"
diff --git a/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/DataDirectorySuffixActivity.java b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/DataDirectorySuffixActivity.java
new file mode 100644
index 0000000..1c92b41
--- /dev/null
+++ b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/DataDirectorySuffixActivity.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.androidx.webkit;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.webkit.ProcessGlobalConfig;
+import androidx.webkit.WebViewFeature;
+
+
+/**
+ * An {@link Activity} which makes use of
+ * {@link androidx.webkit.ProcessGlobalConfig#setDataDirectorySuffix(Context, String)}.
+ */
+public class DataDirectorySuffixActivity extends AppCompatActivity {
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setTitle(R.string.data_directory_suffix_activity_title);
+        WebkitHelpers.appendWebViewVersionToTitle(this);
+
+        if (!WebViewFeature.isStartupFeatureSupported(this,
+                WebViewFeature.STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX)) {
+            WebkitHelpers.showMessageInActivity(this, R.string.webkit_api_not_available);
+            return;
+        }
+        ProcessGlobalConfig config = new ProcessGlobalConfig();
+        config.setDataDirectorySuffix(this,
+                "per_process_webview_data_test");
+        ProcessGlobalConfig.apply(config);
+        setContentView(R.layout.activity_data_directory_config);
+        WebView wv = findViewById(R.id.data_directory_config_webview);
+        wv.getSettings().setJavaScriptEnabled(true);
+        wv.setWebViewClient(new WebViewClient());
+        wv.loadUrl("www.google.com");
+        TextView tv = findViewById(R.id.data_directory_config_textview);
+        tv.setText("WebView Loaded!");
+    }
+}
diff --git a/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/ProcessGlobalConfigActivity.java b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/ProcessGlobalConfigActivity.java
index b6fb511..0bd1d67 100644
--- a/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/ProcessGlobalConfigActivity.java
+++ b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/ProcessGlobalConfigActivity.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * Copyright 2018 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.
@@ -17,43 +17,33 @@
 package com.example.androidx.webkit;
 
 import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
 import android.os.Bundle;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.widget.TextView;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
-import androidx.webkit.ProcessGlobalConfig;
-import androidx.webkit.WebViewFeature;
-
 
 /**
- * An {@link Activity} which makes use of {@link androidx.webkit.ProcessGlobalConfig} to set up
- * process global configuration prior to loading WebView.
+ * An {@link Activity} which lists features that make use of
+ * {@link androidx.webkit.ProcessGlobalConfig} to set up process global configuration prior to
+ * loading WebView.
  */
 public class ProcessGlobalConfigActivity extends AppCompatActivity {
+
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
         setTitle(R.string.process_global_config_activity_title);
         WebkitHelpers.appendWebViewVersionToTitle(this);
-
-        if (!WebViewFeature.isStartupFeatureSupported(this,
-                WebViewFeature.STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX)) {
-            WebkitHelpers.showMessageInActivity(this, R.string.webkit_api_not_available);
-            return;
-        }
-        ProcessGlobalConfig config = new ProcessGlobalConfig();
-        config.setDataDirectorySuffix(this,
-                "per_process_webview_data_test");
-        ProcessGlobalConfig.apply(config);
-        setContentView(R.layout.activity_process_global_config);
-        WebView wv = findViewById(R.id.process_global_config_webview);
-        wv.getSettings().setJavaScriptEnabled(true);
-        wv.setWebViewClient(new WebViewClient());
-        wv.loadUrl("www.google.com");
-        TextView tv = findViewById(R.id.process_global_textview);
-        tv.setText("WebView Loaded!");
+        final Context activityContext = this;
+        MenuListView listView = findViewById(R.id.top_level_list);
+        MenuListView.MenuItem[] menuItems = new MenuListView.MenuItem[] {
+                new MenuListView.MenuItem(
+                        getResources().getString(R.string.data_directory_suffix_activity_title),
+                        new Intent(activityContext, DataDirectorySuffixActivity.class)),
+        };
+        listView.setItems(menuItems);
     }
 }
diff --git a/webkit/integration-tests/testapp/src/main/res/layout/activity_process_global_config.xml b/webkit/integration-tests/testapp/src/main/res/layout/activity_data_directory_config.xml
similarity index 91%
rename from webkit/integration-tests/testapp/src/main/res/layout/activity_process_global_config.xml
rename to webkit/integration-tests/testapp/src/main/res/layout/activity_data_directory_config.xml
index e898b1d..3e41dab 100644
--- a/webkit/integration-tests/testapp/src/main/res/layout/activity_process_global_config.xml
+++ b/webkit/integration-tests/testapp/src/main/res/layout/activity_data_directory_config.xml
@@ -24,13 +24,13 @@
     tools:ignore="Orientation">
 
     <TextView
-        android:id="@+id/process_global_textview"
+        android:id="@+id/data_directory_config_textview"
         android:layout_width="wrap_content"
         android:layout_height="105dp"
         android:text="TextView" />
 
     <WebView
-        android:id="@+id/process_global_config_webview"
+        android:id="@+id/data_directory_config_webview"
         android:layout_width="match_parent"
         android:layout_height="604dp"></WebView>
 </LinearLayout>
diff --git a/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml b/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
index fd3734d..7108765 100644
--- a/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
+++ b/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
@@ -100,6 +100,7 @@
 
     <!-- Process Global Config -->
     <string name="process_global_config_activity_title">Process Global Config</string>
+    <string name="data_directory_suffix_activity_title">Data Directory Suffix</string>
 
     <!-- Requested With Header -->
     <string name="requested_with_activity_title">X-Requested-With header allow-list</string>