Merge changes I5166dc89,Ifbdf5a3a into androidx-main

* changes:
  Add a Kotlin codegen flavor to Room's Kotlin test app.
  Generate AutoMigration in Kotlin
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
index 92c1f32..c54e415 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
@@ -46,6 +46,9 @@
     /**
      * Set to true to enable androidx.tracing.perfetto tracepoints (such as composition tracing)
      *
+     * Note this only affects Macrobenchmarks currently, and only when StartupMode.COLD is not used,
+     * since enabling the tracepoints wakes the target process
+     *
      * Currently internal/experimental
      */
     val fullTracingEnable: Boolean
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt
index 4e88608..166b3e9 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoCaptureWrapper.kt
@@ -20,7 +20,6 @@
 import android.util.Log
 import androidx.annotation.RequiresApi
 import androidx.annotation.RestrictTo
-import androidx.benchmark.Arguments
 import androidx.benchmark.Outputs
 import androidx.benchmark.Outputs.dateToFileName
 import androidx.benchmark.PropOverride
@@ -41,20 +40,22 @@
     }
 
     @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
-    private fun start(packages: List<String>): Boolean {
+    private fun start(
+        appTagPackages: List<String>,
+        userspaceTracingPackage: String?
+    ): Boolean {
         capture?.apply {
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                 Log.d(PerfettoHelper.LOG_TAG, "Recording perfetto trace")
-                if (Arguments.fullTracingEnable &&
-                    packages.isNotEmpty() &&
+                if (userspaceTracingPackage != null &&
                     Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
                 ) {
                     enableAndroidxTracingPerfetto(
-                        targetPackage = packages.first(),
+                        targetPackage = userspaceTracingPackage,
                         provideBinariesIfMissing = true
                     )
                 }
-                start(packages)
+                start(appTagPackages)
             }
         }
 
@@ -80,7 +81,8 @@
 
     fun record(
         benchmarkName: String,
-        packages: List<String>,
+        appTagPackages: List<String>,
+        userspaceTracingPackage: String?,
         iteration: Int? = null,
         block: () -> Unit
     ): String? {
@@ -97,7 +99,7 @@
         } else null
         try {
             propOverride?.forceValue()
-            start(packages)
+            start(appTagPackages, userspaceTracingPackage)
             val path: String
             try {
                 block()
diff --git a/benchmark/benchmark-darwin-samples-xcode/.gitignore b/benchmark/benchmark-darwin-samples-xcode/.gitignore
new file mode 100644
index 0000000..78f146f
--- /dev/null
+++ b/benchmark/benchmark-darwin-samples-xcode/.gitignore
@@ -0,0 +1,7 @@
+# XCode Projects
+*.xcodeproj
+Info.plist
+
+# XCode results
+*.xcresult
+
diff --git a/benchmark/benchmark-darwin-samples-xcode/run-benchmarks.sh b/benchmark/benchmark-darwin-samples-xcode/run-benchmarks.sh
index 0a3f119..a7062d0 100755
--- a/benchmark/benchmark-darwin-samples-xcode/run-benchmarks.sh
+++ b/benchmark/benchmark-darwin-samples-xcode/run-benchmarks.sh
@@ -2,11 +2,9 @@
 
 # Runs benchmark tests.
 
-rm -rf benchmark-darwin-sample-xcode.xcodeproj
-xcodegen --spec xcodegen-project.yml
-
 xcodebuild \
  test \
  -project benchmark-darwin-sample-xcode.xcodeproj \
  -scheme testapp-ios \
- -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.2'
+ -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.2' \
+ -resultBundlePath 'benchmark-darwin-sample-xcode.xcresult'
diff --git a/benchmark/benchmark-darwin-samples-xcode/xcodegen-project.yml b/benchmark/benchmark-darwin-samples-xcode/xcodegen-project.yml
index 27052a5..bece70f 100644
--- a/benchmark/benchmark-darwin-samples-xcode/xcodegen-project.yml
+++ b/benchmark/benchmark-darwin-samples-xcode/xcodegen-project.yml
@@ -13,7 +13,19 @@
     scheme:
       testTargets:
         - testapp-ios-benchmarks
+      preActions:
+        - name: build AndroidXDarwinSampleBenchmarks.xcframework
+          basedOnDependencyAnalysis: false
+          settingsTarget: testapp-ios
+          script: |
+            cd ${PROJECT_DIR}/../..
+            ANDROIDX_PROJECTS=KMP ./gradlew :benchmark:benchmark-darwin-samples:assembleAndroidXDarwinSampleBenchmarksReleaseXCFramework \
+                --no-configuration-cache < /dev/null
+          outputFiles:
+            - "${PROJECT_DIR}/../../../../out/androidx/benchmark/benchmark-darwin-samples/build/XCFrameworks/release/AndroidXDarwinSampleBenchmarks.xcframework"
       gatherCoverageData: false
+    dependencies:
+      - framework: "${PROJECT_DIR}/../../../../out/androidx/benchmark/benchmark-darwin-samples/build/XCFrameworks/release/AndroidXDarwinSampleBenchmarks.xcframework"
     settings:
       PRODUCT_NAME: testapp-ios
 
@@ -25,19 +37,7 @@
     sources:
       - path: 'iosAppUnitTests/main'
     scheme:
-      preActions:
-        - name: build AndroidXDarwinSampleBenchmarks.xcframework
-          basedOnDependencyAnalysis: false
-          settingsTarget: testapp-ios
-          script: |
-            cd ${PROJECT_DIR}/../..
-            ./gradlew :benchmark:benchmark-darwin-samples:assembleAndroidXDarwinSampleBenchmarksDebugXCFramework \
-                --no-configuration-cache                         \
-                -Pandroidx.enabled.kmp.target.platforms="+MAC"
-          outputFiles:
-            - "${PROJECT_DIR}/../../../../out/androidx/benchmark/benchmark-darwin-samples/build/XCFrameworks/debug/AndroidXDarwinSampleBenchmarks.xcframework"
-    dependencies:
-      - framework: "${PROJECT_DIR}/../../../../out/androidx/benchmark/benchmark-darwin-samples/build/XCFrameworks/debug/AndroidXDarwinSampleBenchmarks.xcframework"
+      gatherCoverageData: false
     settings:
       PRODUCT_NAME: testapp-ios-benchmarks
 
diff --git a/benchmark/benchmark-darwin-samples-xcode/xcodegenw.sh b/benchmark/benchmark-darwin-samples-xcode/xcodegenw.sh
index fdd0cf3..8010710 100755
--- a/benchmark/benchmark-darwin-samples-xcode/xcodegenw.sh
+++ b/benchmark/benchmark-darwin-samples-xcode/xcodegenw.sh
@@ -1,5 +1,6 @@
 #!/bin/bash
 # Generates the XCode project to run / debug benchmark tests.
 
+rm -rf benchmark-darwin-sample-xcode.xcodeproj
 xcodegen --spec xcodegen-project.yml
 open benchmark-darwin-sample-xcode.xcodeproj
diff --git a/benchmark/benchmark-junit4/api/restricted_current.ignore b/benchmark/benchmark-junit4/api/restricted_current.ignore
new file mode 100644
index 0000000..7b0440e
--- /dev/null
+++ b/benchmark/benchmark-junit4/api/restricted_current.ignore
@@ -0,0 +1,3 @@
+// Baseline format: 1.0
+RemovedMethod: androidx.benchmark.junit4.PerfettoRule#PerfettoRule():
+    Removed constructor androidx.benchmark.junit4.PerfettoRule()
diff --git a/benchmark/benchmark-junit4/api/restricted_current.txt b/benchmark/benchmark-junit4/api/restricted_current.txt
index d3bf38c..e6624c1 100644
--- a/benchmark/benchmark-junit4/api/restricted_current.txt
+++ b/benchmark/benchmark-junit4/api/restricted_current.txt
@@ -21,8 +21,12 @@
   }
 
   @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PerfettoRule implements org.junit.rules.TestRule {
-    ctor public PerfettoRule();
+    ctor public PerfettoRule(optional boolean enableAppTagTracing, optional boolean enableUserspaceTracing);
     method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description);
+    method public boolean getEnableAppTagTracing();
+    method public boolean getEnableUserspaceTracing();
+    property public final boolean enableAppTagTracing;
+    property public final boolean enableUserspaceTracing;
   }
 
 }
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
index cd65676..9a33fe9 100644
--- a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
@@ -213,7 +213,8 @@
 
             val tracePath = PerfettoCaptureWrapper().record(
                 benchmarkName = uniqueName,
-                packages = packages,
+                appTagPackages = packages,
+                userspaceTracingPackage = null
             ) {
                 UserspaceTracing.commitToTrace() // clear buffer
 
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoRule.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoRule.kt
index 0b8c6a6..97fd9d6 100644
--- a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoRule.kt
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoRule.kt
@@ -46,15 +46,30 @@
  * ```
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
-public class PerfettoRule : TestRule {
+public class PerfettoRule(
+    /**
+     * Pass false to disable android.os.Trace API tracing in this process
+     *
+     * Defaults to true.
+     */
+    val enableAppTagTracing: Boolean = true,
+    /**
+     * Pass true to enable userspace tracing (androidx.tracing.tracing-perfetto APIs)
+     *
+     * Defaults to false.
+     */
+    val enableUserspaceTracing: Boolean = false
+) : TestRule {
     override fun apply(
         base: Statement,
         description: Description
     ): Statement = object : Statement() {
         override fun evaluate() {
+            val thisPackage = InstrumentationRegistry.getInstrumentation().context.packageName
             PerfettoCaptureWrapper().record(
                 benchmarkName = "${description.className}_${description.methodName}",
-                packages = listOf(InstrumentationRegistry.getInstrumentation().context.packageName)
+                appTagPackages = if (enableAppTagTracing) listOf(thisPackage) else emptyList(),
+                userspaceTracingPackage = if (enableUserspaceTracing) thisPackage else null
             ) {
                 base.evaluate()
             }
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt
index f206893..c3fbdc2 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt
@@ -278,11 +278,12 @@
         benchmarkName = packageName,
         // note - packageName may be this package, so we convert to set then list to make unique
         // and on API 23 and below, we use reflection to trace instead within this process
-        packages = if (Build.VERSION.SDK_INT >= 24 && packageName != Packages.TEST) {
+        appTagPackages = if (Build.VERSION.SDK_INT >= 24 && packageName != Packages.TEST) {
             listOf(packageName, Packages.TEST)
         } else {
             listOf(packageName)
         },
+        userspaceTracingPackage = packageName,
         block = measureBlock
     )!!
 
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
index 16a6787..e63fb5f 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
@@ -111,6 +111,7 @@
     iterations: Int,
     launchWithClearTask: Boolean,
     startupModeMetricHint: StartupMode?,
+    userspaceTracingPackage: String?,
     setupBlock: MacrobenchmarkScope.() -> Unit,
     measureBlock: MacrobenchmarkScope.() -> Unit
 ) {
@@ -180,11 +181,12 @@
                      *
                      * @see androidx.benchmark.macro.perfetto.ForceTracing
                      */
-                    packages = if (Build.VERSION.SDK_INT >= 24) {
+                    appTagPackages = if (Build.VERSION.SDK_INT >= 24) {
                         listOf(packageName, macrobenchPackageName)
                     } else {
                         listOf(packageName)
-                    }
+                    },
+                    userspaceTracingPackage = userspaceTracingPackage
                 ) {
                     try {
                         trace("start metrics") {
@@ -312,6 +314,13 @@
     setupBlock: MacrobenchmarkScope.() -> Unit,
     measureBlock: MacrobenchmarkScope.() -> Unit
 ) {
+    val userspaceTracingPackage = if (Arguments.fullTracingEnable &&
+        startupMode != StartupMode.COLD // can't use with COLD, since the broadcast wakes up target
+    ) {
+        packageName
+    } else {
+        null
+    }
     macrobenchmark(
         uniqueName = uniqueName,
         className = className,
@@ -321,6 +330,7 @@
         compilationMode = compilationMode,
         iterations = iterations,
         startupModeMetricHint = startupMode,
+        userspaceTracingPackage = userspaceTracingPackage,
         setupBlock = {
             if (startupMode == StartupMode.COLD) {
                 killProcess()
diff --git a/buildSrc-tests/src/test/kotlin/androidx/build/AndroidXRootPluginTest.kt b/buildSrc-tests/src/test/kotlin/androidx/build/AndroidXRootPluginTest.kt
index 851aba2..0d8d6ff 100644
--- a/buildSrc-tests/src/test/kotlin/androidx/build/AndroidXRootPluginTest.kt
+++ b/buildSrc-tests/src/test/kotlin/androidx/build/AndroidXRootPluginTest.kt
@@ -115,11 +115,9 @@
         val docsPublicBuildGradle =
             """|plugins {
                |  id("com.android.library")
+               |  id("AndroidXDocsImplPlugin")
                |}
                |
-               |// b/233089408: would prefer to use plugins { id } syntax, but work around.
-               |apply plugin: androidx.build.docs.AndroidXDocsImplPlugin
-               |
                |repositories {
                |  mavenLocal()
                |}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
index 9237885..81334be 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
@@ -289,8 +289,6 @@
             // If no one else is going to register a source jar, then we should.
             // This cross-plugin hands-off logic shouldn't be necessary once we clean up sourceSet
             // logic (b/235828421)
-            // If this is done outside of afterEvaluate, then we don't always get the right answer,
-            // but due to b/233089408 we don't currently have a way to test that.
             if (!project.plugins.hasPlugin(LibraryPlugin::class.java) &&
                 !project.plugins.hasPlugin(JavaPlugin::class.java)) {
                 project.configureSourceJarForJava()
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt
index 6343b01..3675e62 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt
@@ -139,8 +139,6 @@
             project.plugins.withType(JavaPlugin::class.java) {
                 buildOnServerTask.dependsOn("${project.path}:jar")
             }
-
-            project.tasks.register("validateProperties", ValidatePropertiesTask::class.java)
         }
         project.configureRootProjectForLint()
 
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/ValidatePropertiesTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/ValidatePropertiesTask.kt
deleted file mode 100644
index 478d443..0000000
--- a/buildSrc/private/src/main/kotlin/androidx/build/ValidatePropertiesTask.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright 2021 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
-
-import org.gradle.api.DefaultTask
-import org.gradle.api.tasks.TaskAction
-import org.gradle.work.DisableCachingByDefault
-
-/**
- * Validates that all properties of the project can be computed successfully.
- * This is similar to the built-in ":properties" task of type PropertyReportTask within
- * Gradle itself, but does not output them to stdout.
- * In practice, this should ensure that Android Studio sync is able to succeed
- */
-@DisableCachingByDefault(because = "Too many inputs to be feasible to cache, and also runs quickly")
-abstract class ValidatePropertiesTask : DefaultTask() {
-    @TaskAction
-    fun exec() {
-        for (entry in project.properties.toMap()) {
-            if (entry.key != "properties") {
-                // query the entry's value and discard it
-                entry.value.toString()
-            }
-        }
-    }
-}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt b/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt
index a0ac369..0d1f592 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt
@@ -131,7 +131,6 @@
 val DONT_TRY_RERUNNING_TASKS = setOf(
     ":buildSrc-tests:project-subsets:test",
     "listTaskOutputs",
-    "validateProperties",
     "tasks",
 
     // More information about the fact that these dokka tasks rerun can be found at b/167569304
diff --git a/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt b/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt
index 703907f..8619ce0 100644
--- a/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt
+++ b/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt
@@ -81,14 +81,14 @@
  * configs cause all the tests to be run, except in cases where buildSrc changes.
  */
 fun Project.getTestConfigDirectory(): File =
-    File(project.buildDir, "test-xml-configs")
+    File(getDistributionDirectory(), "test-xml-configs")
 
 /**
  * Directory for android test configuration files that get consumed by Tradefed in CI. These
  * "constrained" configs cause only small and medium tests to be run for dependent projects.
  */
 fun Project.getConstrainedTestConfigDirectory(): File =
-    File(project.buildDir, "constrained-test-xml-configs")
+    File(getDistributionDirectory(), "constrained-test-xml-configs")
 
 /**
  * Directory to put release note files for generate release note tasks.
diff --git a/busytown/androidx.sh b/busytown/androidx.sh
index 7813bc8..52fb12e 100755
--- a/busytown/androidx.sh
+++ b/busytown/androidx.sh
@@ -18,7 +18,7 @@
   EXIT_VALUE=1
 else
   # Run Gradle
-  if ! impl/build.sh buildOnServer checkExternalLicenses listTaskOutputs validateProperties \
+  if ! impl/build.sh buildOnServer checkExternalLicenses listTaskOutputs \
       -Pandroidx.enableComposeCompilerMetrics=true \
       -Pandroidx.enableComposeCompilerReports=true \
       --no-daemon \
diff --git a/busytown/androidx_incremental.sh b/busytown/androidx_incremental.sh
index b7a077e..4b17487 100755
--- a/busytown/androidx_incremental.sh
+++ b/busytown/androidx_incremental.sh
@@ -56,7 +56,7 @@
   EXIT_VALUE=1
 else
     # Run Gradle
-    if impl/build.sh $DIAGNOSE_ARG buildOnServer checkExternalLicenses listTaskOutputs validateProperties \
+    if impl/build.sh $DIAGNOSE_ARG buildOnServer checkExternalLicenses listTaskOutputs \
         --profile "$@"; then
     echo build succeeded
     EXIT_VALUE=0
diff --git a/camera/camera-camera2-pipe-integration/build.gradle b/camera/camera-camera2-pipe-integration/build.gradle
index 981353e..7806882 100644
--- a/camera/camera-camera2-pipe-integration/build.gradle
+++ b/camera/camera-camera2-pipe-integration/build.gradle
@@ -59,7 +59,7 @@
     testImplementation(libs.testRunner)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(libs.robolectric)
     testImplementation(libs.kotlinCoroutinesTest)
     testImplementation(project(":camera:camera-camera2-pipe-testing"))
@@ -67,6 +67,7 @@
     testImplementation(project(":internal-testutils-ktx"))
     testImplementation(project(":internal-testutils-truth"))
     testImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.3.1")
+    testImplementation(project(":camera:camera-video"))
 
     androidTestImplementation(libs.multidex)
     androidTestImplementation(libs.testExtJunit)
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
index 38dc5a0..c0cb37f 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
@@ -106,14 +106,14 @@
     }
 
     @After
-    fun tearDown() {
-        if (this::testSessionParameters.isInitialized) {
+    fun tearDown() = runBlocking {
+        if (::testSessionParameters.isInitialized) {
             testSessionParameters.cleanup()
         }
-        if (this::testUseCaseCamera.isInitialized) {
-            testUseCaseCamera.close()
+        if (::testUseCaseCamera.isInitialized) {
+            testUseCaseCamera.close().join()
         }
-        if (this::cameraHolder.isInitialized) {
+        if (::cameraHolder.isInitialized) {
             CameraUtil.releaseCameraDevice(cameraHolder)
             cameraHolder.closedFuture.get(3, TimeUnit.SECONDS)
         }
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt
index 1e81e83..8f93913 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt
@@ -45,6 +45,8 @@
 import androidx.camera.core.UseCase
 import androidx.camera.core.impl.Config
 import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
 
 /**
  * Open a [CameraGraph] for the desired [cameraId] and [useCases]
@@ -122,8 +124,10 @@
         throw NotImplementedError("Not implemented")
     }
 
-    override fun close() {
-        useCaseSurfaceManager.stopAsync()
-        useCaseCameraGraphConfig.graph.close()
+    override fun close(): Job {
+        return threads.scope.launch {
+            useCaseCameraGraphConfig.graph.close()
+            useCaseSurfaceManager.stopAsync().await()
+        }
     }
 }
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/CameraPipeConfig.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/CameraPipeConfig.kt
index d6e3c92..0064af2 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/CameraPipeConfig.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/CameraPipeConfig.kt
@@ -29,6 +29,7 @@
     /**
      * Creates a [CameraXConfig] containing a default CameraPipe implementation for CameraX.
      */
+    @JvmStatic
     fun defaultConfig(): CameraXConfig {
         return CameraXConfig.Builder()
             .setCameraFactoryProvider(::CameraFactoryAdapter)
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInternalAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInternalAdapter.kt
index 5a44bc0..1286de1 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInternalAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInternalAdapter.kt
@@ -21,20 +21,20 @@
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.CameraPipe
 import androidx.camera.camera2.pipe.core.Log.debug
-import androidx.camera.camera2.pipe.core.Log.warn
 import androidx.camera.camera2.pipe.integration.config.CameraConfig
 import androidx.camera.camera2.pipe.integration.config.CameraScope
 import androidx.camera.camera2.pipe.integration.impl.UseCaseManager
+import androidx.camera.camera2.pipe.integration.impl.UseCaseThreads
 import androidx.camera.core.UseCase
 import androidx.camera.core.impl.CameraControlInternal
 import androidx.camera.core.impl.CameraInfoInternal
 import androidx.camera.core.impl.CameraInternal
 import androidx.camera.core.impl.LiveDataObservable
 import androidx.camera.core.impl.Observable
-import androidx.camera.core.impl.utils.futures.Futures
 import com.google.common.util.concurrent.ListenableFuture
 import javax.inject.Inject
 import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.launch
 
 internal val cameraAdapterIds = atomic(0)
 
@@ -46,7 +46,8 @@
     config: CameraConfig,
     private val useCaseManager: UseCaseManager,
     private val cameraInfo: CameraInfoInternal,
-    private val cameraController: CameraControlInternal
+    private val cameraController: CameraControlInternal,
+    private val threads: UseCaseThreads,
 ) : CameraInternal {
     private val cameraId = config.cameraId
     private val debugId = cameraAdapterIds.incrementAndGet()
@@ -69,9 +70,8 @@
     }
 
     override fun release(): ListenableFuture<Void> {
-        warn { "$this#release is not yet implemented." }
-        // TODO: Determine what the correct way to invoke release is.
-        return Futures.immediateFuture(null)
+        // TODO(b/185207100): Implement when CameraState is ready.
+        return threads.scope.launch { useCaseManager.clear() }.asListenableFuture()
     }
 
     override fun getCameraInfoInternal(): CameraInfoInternal = cameraInfo
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraSurfaceAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraSurfaceAdapter.kt
index ee07f3c..9c7b54f 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraSurfaceAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraSurfaceAdapter.kt
@@ -49,7 +49,7 @@
     init {
         debug { "AvailableCameraIds = $availableCameraIds" }
         debug { "Created StreamConfigurationMap from $context" }
-        initSupportedSurfaceCombinationMap(context, component.getCameraPipe(), availableCameraIds)
+        initSupportedSurfaceCombinationMap(context, availableCameraIds)
     }
 
     /**
@@ -57,7 +57,6 @@
      */
     private fun initSupportedSurfaceCombinationMap(
         context: Context,
-        cameraPipe: CameraPipe,
         availableCameraIds: Set<String>
     ) {
         Preconditions.checkNotNull(context)
@@ -65,7 +64,7 @@
             supportedSurfaceCombinationMap[cameraId] =
                 SupportedSurfaceCombination(
                     context,
-                    runBlocking { cameraPipe.cameras().awaitMetadata(CameraId(cameraId)) },
+                    runBlocking { component.getCameraDevices().awaitMetadata(CameraId(cameraId)) },
                     cameraId,
                     CamcorderProfileProviderAdapter(cameraId)
                 )
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt
index 89e5b2c..fc355fe 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt
@@ -82,7 +82,7 @@
     private val outputSizesCache: MutableMap<Int, Array<Size>> = HashMap()
     private var isRawSupported = false
     private var isBurstCaptureSupported = false
-    private lateinit var surfaceSizeDefinition: SurfaceSizeDefinition
+    internal lateinit var surfaceSizeDefinition: SurfaceSizeDefinition
     private val displayManager: DisplayManager =
         (context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager)
 
@@ -459,7 +459,7 @@
      * @param imageFormat the image format info
      * @return the max supported output size for the image format
      */
-    private fun getMaxOutputSizeByFormat(imageFormat: Int): Size {
+    internal fun getMaxOutputSizeByFormat(imageFormat: Int): Size {
         val outputSizes = getAllOutputSizesByFormat(imageFormat)
         return Collections.max(listOf(*outputSizes), CompareSizesByArea())
     }
@@ -690,7 +690,7 @@
     /**
      * Obtains the supported sizes for a given user case.
      */
-    private fun getSupportedOutputSizes(config: UseCaseConfig<*>): List<Size> {
+    internal fun getSupportedOutputSizes(config: UseCaseConfig<*>): List<Size> {
         val imageFormat = config.inputFormat
         val imageOutputConfig = config as ImageOutputConfig
         var outputSizes: Array<Size>? =
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraAppConfig.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraAppConfig.kt
index 5c014ea..9ce1084 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraAppConfig.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraAppConfig.kt
@@ -20,6 +20,7 @@
 
 import android.content.Context
 import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CameraDevices
 import androidx.camera.camera2.pipe.CameraPipe
 import androidx.camera.core.impl.CameraFactory
 import androidx.camera.core.impl.CameraThreadConfig
@@ -39,6 +40,11 @@
         fun provideCameraPipe(context: Context): CameraPipe {
             return CameraPipe(CameraPipe.Config(appContext = context.applicationContext))
         }
+
+        @Provides
+        fun provideCameraDevices(cameraPipe: CameraPipe): CameraDevices {
+            return cameraPipe.cameras()
+        }
     }
 }
 
@@ -66,6 +72,7 @@
 interface CameraAppComponent {
     fun cameraBuilder(): CameraComponent.Builder
     fun getCameraPipe(): CameraPipe
+    fun getCameraDevices(): CameraDevices
 
     @Component.Builder
     interface Builder {
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt
index dace968..561805c 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt
@@ -36,6 +36,8 @@
 import javax.inject.Inject
 import kotlinx.atomicfu.atomic
 import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
 
 internal val useCaseCameraIds = atomic(0)
 internal val defaultOptionPriority = Config.OptionPriority.OPTIONAL
@@ -68,7 +70,7 @@
     ): Deferred<Result3A>
 
     // Lifecycle
-    fun close()
+    fun close(): Job
 }
 
 /**
@@ -79,6 +81,7 @@
     private val useCaseGraphConfig: UseCaseGraphConfig,
     private val useCases: java.util.ArrayList<UseCase>,
     private val useCaseSurfaceManager: UseCaseSurfaceManager,
+    private val threads: UseCaseThreads,
     override val requestControl: UseCaseCameraRequestControl,
 ) : UseCaseCamera {
     private val debugId = useCaseCameraIds.incrementAndGet()
@@ -104,10 +107,12 @@
         debug { "Configured $this for $useCases" }
     }
 
-    override fun close() {
-        debug { "Closing $this" }
-        useCaseSurfaceManager.stopAsync()
-        useCaseGraphConfig.graph.close()
+    override fun close(): Job {
+        return threads.scope.launch {
+            debug { "Closing $this" }
+            useCaseGraphConfig.graph.close()
+            useCaseSurfaceManager.stopAsync().await()
+        }
     }
 
     override suspend fun startFocusAndMeteringAsync(
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
index 956ff2f..9314817 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
@@ -25,6 +25,8 @@
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.UseCase
 import javax.inject.Inject
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.joinAll
 
 /**
  * This class keeps track of the currently attached and active [UseCase]'s for a specific camera.
@@ -71,6 +73,8 @@
     val camera: UseCaseCamera?
         get() = _activeComponent?.getUseCaseCamera()
 
+    private val closingCameraJob = mutableListOf<Job>()
+
     /**
      * This attaches the specified [useCases] to the current set of attached use cases. When any
      * changes are identified (i.e., a new use case is added), the subsequent actions would trigger
@@ -162,6 +166,11 @@
         }
     }
 
+    suspend fun clear() {
+        detach(attachedUseCases.toList())
+        closingCameraJob.toList().joinAll()
+    }
+
     override fun toString(): String = "UseCaseManager<${cameraConfig.cameraId}>"
 
     private fun refreshRunningUseCases() {
@@ -179,7 +188,12 @@
         // Close prior camera graph
         camera.let {
             _activeComponent = null
-            it?.close()
+            it?.close()?.let { closingJob ->
+                closingCameraJob.add(closingJob)
+                closingJob.invokeOnCompletion {
+                    closingCameraJob.remove(closingJob)
+                }
+            }
         }
 
         // Update list of active useCases
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombinationTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombinationTest.kt
new file mode 100644
index 0000000..b27be3a
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombinationTest.kt
@@ -0,0 +1,2713 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.camera.camera2.pipe.integration.adapter
+
+import android.content.Context
+import android.graphics.ImageFormat
+import android.graphics.SurfaceTexture
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CameraManager
+import android.hardware.camera2.params.StreamConfigurationMap
+import android.media.CamcorderProfile
+import android.media.MediaRecorder
+import android.os.Build
+import android.util.Pair
+import android.util.Rational
+import android.util.Size
+import android.view.Surface
+import android.view.WindowManager
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.CameraMetadata
+import androidx.camera.camera2.pipe.integration.CameraPipeConfig.defaultConfig
+import androidx.camera.camera2.pipe.integration.adapter.GuaranteedConfigurationsUtil.getFullSupportedCombinationList
+import androidx.camera.camera2.pipe.integration.adapter.GuaranteedConfigurationsUtil.getLegacySupportedCombinationList
+import androidx.camera.camera2.pipe.integration.adapter.GuaranteedConfigurationsUtil.getLevel3SupportedCombinationList
+import androidx.camera.camera2.pipe.integration.adapter.GuaranteedConfigurationsUtil.getLimitedSupportedCombinationList
+import androidx.camera.camera2.pipe.integration.adapter.GuaranteedConfigurationsUtil.getRAWSupportedCombinationList
+import androidx.camera.camera2.pipe.integration.config.CameraAppComponent
+import androidx.camera.camera2.pipe.integration.testing.FakeCameraDevicesWithCameraMetaData
+import androidx.camera.core.AspectRatio
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraSelector.LensFacing
+import androidx.camera.core.CameraX
+import androidx.camera.core.CameraXConfig
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.core.UseCase
+import androidx.camera.core.impl.CamcorderProfileProxy
+import androidx.camera.core.impl.CameraThreadConfig
+import androidx.camera.core.impl.MutableStateObservable
+import androidx.camera.core.impl.Observable
+import androidx.camera.core.impl.SurfaceCombination
+import androidx.camera.core.impl.SurfaceConfig
+import androidx.camera.core.impl.UseCaseConfig
+import androidx.camera.core.impl.UseCaseConfigFactory
+import androidx.camera.core.impl.utils.CompareSizesByArea
+import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import androidx.camera.testing.CamcorderProfileUtil
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.CameraXUtil
+import androidx.camera.testing.Configs
+import androidx.camera.testing.SurfaceTextureProvider
+import androidx.camera.testing.SurfaceTextureProvider.SurfaceTextureCallback
+import androidx.camera.testing.fakes.FakeCamcorderProfileProvider
+import androidx.camera.testing.fakes.FakeCamera
+import androidx.camera.testing.fakes.FakeCameraFactory
+import androidx.camera.testing.fakes.FakeCameraInfoInternal
+import androidx.camera.testing.fakes.FakeUseCaseConfig
+import androidx.camera.video.FallbackStrategy
+import androidx.camera.video.MediaSpec
+import androidx.camera.video.Quality
+import androidx.camera.video.QualitySelector
+import androidx.camera.video.VideoCapture
+import androidx.camera.video.VideoOutput
+import androidx.camera.video.VideoOutput.SourceState
+import androidx.camera.video.VideoSpec
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.testutils.assertThrows
+import com.google.common.truth.Truth
+import java.util.Arrays
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowCameraCharacteristics
+import org.robolectric.shadows.ShadowCameraManager
+
+@Suppress("DEPRECATION")
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SupportedSurfaceCombinationTest {
+    private val cameraId = "0"
+    private val cameraIdExternal = "0-external"
+    private val sensorOrientation0 = 0
+    private val sensorOrientation90 = 90
+    private val aspectRatio43 = Rational(4, 3)
+    private val aspectRatio169 = Rational(16, 9)
+    private val landscapePixelArraySize = Size(4032, 3024)
+    private val portraitPixelArraySize = Size(3024, 4032)
+    private val displaySize = Size(720, 1280)
+    private val vgaSize = Size(640, 480)
+    private val previewSize = Size(1280, 720)
+    private val recordSize = Size(3840, 2160)
+    private val maximumSize = Size(4032, 3024)
+    private val legacyVideoMaximumVideoSize = Size(1920, 1080)
+    private val mod16Size = Size(960, 544)
+    private val profileUhd = CamcorderProfileUtil.createCamcorderProfileProxy(
+        CamcorderProfile.QUALITY_2160P, recordSize.width, recordSize
+            .height
+    )
+    private val profileFhd = CamcorderProfileUtil.createCamcorderProfileProxy(
+        CamcorderProfile.QUALITY_1080P, 1920, 1080
+    )
+    private val profileHd = CamcorderProfileUtil.createCamcorderProfileProxy(
+        CamcorderProfile.QUALITY_720P, previewSize.width, previewSize
+            .height
+    )
+    private val profileSd = CamcorderProfileUtil.createCamcorderProfileProxy(
+        CamcorderProfile.QUALITY_480P, vgaSize.width,
+        vgaSize.height
+    )
+    private val supportedSizes = arrayOf(
+        Size(4032, 3024), // 4:3
+        Size(3840, 2160), // 16:9
+        Size(1920, 1440), // 4:3
+        Size(1920, 1080), // 16:9
+        Size(1280, 960), // 4:3
+        Size(1280, 720), // 16:9
+        Size(1280, 720), // duplicate the size since Nexus 5X emulator has the
+        Size(960, 544), // a mod16 version of resolution with 16:9 aspect ratio.
+        Size(800, 450), // 16:9
+        Size(640, 480), // 4:3
+        Size(320, 240), // 4:3
+        Size(320, 180), // 16:9
+        Size(256, 144) // 16:9 For checkSmallSizesAreFilteredOut test.
+    )
+    private val context = InstrumentationRegistry.getInstrumentation().context
+    private var cameraFactory: FakeCameraFactory? = null
+    private var useCaseConfigFactory = Mockito.mock(
+        UseCaseConfigFactory::class.java
+    )
+    private val mockCameraMetadata = Mockito.mock(
+        CameraMetadata::class.java
+    )
+    private val mockCameraAppComponent = Mockito.mock(
+        CameraAppComponent::class.java
+    )
+    private val mockCamcorderProfileAdapter = Mockito.mock(
+        CamcorderProfileProviderAdapter::class.java
+    )
+    private val mockCamcorderProxy = Mockito.mock(
+        CamcorderProfileProxy::class.java
+    )
+
+    @Before
+    fun setUp() {
+        val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+        Shadows.shadowOf(windowManager.defaultDisplay).setRealWidth(displaySize.width)
+        Shadows.shadowOf(windowManager.defaultDisplay).setRealHeight(
+            displaySize
+                .height
+        )
+        Mockito.`when`(mockCamcorderProfileAdapter.hasProfile(ArgumentMatchers.anyInt()))
+            .thenReturn(true)
+        Mockito.`when`(mockCamcorderProxy.videoFrameWidth).thenReturn(3840)
+        Mockito.`when`(mockCamcorderProxy.videoFrameHeight).thenReturn(2160)
+        Mockito.`when`(mockCamcorderProfileAdapter[ArgumentMatchers.anyInt()])
+            .thenReturn(mockCamcorderProxy)
+    }
+
+    @After
+    fun tearDown() {
+        CameraXUtil.shutdown()[10000, TimeUnit.MILLISECONDS]
+    }
+
+    @Test
+    fun checkLegacySurfaceCombinationSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLegacySupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkLegacySurfaceCombinationSubListSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLegacySupportedCombinationList()
+        val isSupported = isAllSubConfigListSupported(supportedSurfaceCombination, combinationList)
+        Truth.assertThat(isSupported).isTrue()
+    }
+
+    @Test
+    fun checkLimitedSurfaceCombinationNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLimitedSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isFalse()
+        }
+    }
+
+    @Test
+    fun checkFullSurfaceCombinationNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getFullSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isFalse()
+        }
+    }
+
+    @Test
+    fun checkLevel3SurfaceCombinationNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLevel3SupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isFalse()
+        }
+    }
+
+    @Test
+    fun checkLimitedSurfaceCombinationSupportedInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLimitedSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkLimitedSurfaceCombinationSubListSupportedInLimited3Device() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLimitedSupportedCombinationList()
+        val isSupported = isAllSubConfigListSupported(supportedSurfaceCombination, combinationList)
+        Truth.assertThat(isSupported).isTrue()
+    }
+
+    @Test
+    fun checkFullSurfaceCombinationNotSupportedInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getFullSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isFalse()
+        }
+    }
+
+    @Test
+    fun checkLevel3SurfaceCombinationNotSupportedInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLevel3SupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isFalse()
+        }
+    }
+
+    @Test
+    fun checkFullSurfaceCombinationSupportedInFullDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getFullSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkFullSurfaceCombinationSubListSupportedInFullDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getFullSupportedCombinationList()
+        val isSupported = isAllSubConfigListSupported(supportedSurfaceCombination, combinationList)
+        Truth.assertThat(isSupported).isTrue()
+    }
+
+    @Test
+    fun checkLevel3SurfaceCombinationNotSupportedInFullDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLevel3SupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isFalse()
+        }
+    }
+
+    @Test
+    fun checkLimitedSurfaceCombinationSupportedInRawDevice() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, intArrayOf(
+                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLimitedSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkLegacySurfaceCombinationSupportedInRawDevice() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, intArrayOf(
+                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLegacySupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkFullSurfaceCombinationSupportedInRawDevice() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, intArrayOf(
+                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getFullSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkRawSurfaceCombinationSupportedInRawDevice() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, intArrayOf(
+                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getRAWSupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkLevel3SurfaceCombinationSupportedInLevel3Device() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_3)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLevel3SupportedCombinationList()
+        for (combination in combinationList) {
+            val isSupported =
+                supportedSurfaceCombination.checkSupported(combination.surfaceConfigList)
+            Truth.assertThat(isSupported).isTrue()
+        }
+    }
+
+    @Test
+    fun checkLevel3SurfaceCombinationSubListSupportedInLevel3Device() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_3)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val combinationList = getLevel3SupportedCombinationList()
+        val isSupported = isAllSubConfigListSupported(supportedSurfaceCombination, combinationList)
+        Truth.assertThat(isSupported).isTrue()
+    }
+
+    @Test
+    fun checkTargetAspectRatio() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val fakeUseCase = FakeUseCaseConfig.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(fakeUseCase)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            ArrayList(useCaseToConfigMap.values)
+        )
+        val selectedSize = suggestedResolutionMap[useCaseToConfigMap[fakeUseCase]]!!
+        val resultAspectRatio = Rational(
+            selectedSize.width,
+            selectedSize.height
+        )
+        Truth.assertThat(resultAspectRatio).isEqualTo(aspectRatio169)
+    }
+
+    @Test
+    fun checkResolutionForMixedUseCase_AfterBindToLifecycle() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+
+        // The test case make sure the selected result is expected after the regular flow.
+        val targetAspectRatio = aspectRatio169
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        preview.setSurfaceProvider(
+            CameraXExecutors.directExecutor(),
+            SurfaceTextureProvider.createSurfaceTextureProvider(
+                Mockito.mock(
+                    SurfaceTextureCallback::class.java
+                )
+            )
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val imageAnalysis = ImageAnalysis.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val cameraUseCaseAdapter = CameraUtil
+            .createCameraUseCaseAdapter(
+                context,
+                CameraSelector.DEFAULT_BACK_CAMERA
+            )
+        cameraUseCaseAdapter.addUseCases(listOf(preview, imageCapture, imageAnalysis))
+        val previewResolution = preview.attachedSurfaceResolution!!
+        val previewRatio = Rational(
+            previewResolution.width,
+            previewResolution.height
+        )
+        val imageCaptureResolution = preview.attachedSurfaceResolution
+        val imageCaptureRatio = Rational(
+            imageCaptureResolution!!.width,
+            imageCaptureResolution.height
+        )
+        val imageAnalysisResolution = preview.attachedSurfaceResolution
+        val imageAnalysisRatio = Rational(
+            imageAnalysisResolution!!.width,
+            imageAnalysisResolution.height
+        )
+
+        // Checks no correction is needed.
+        Truth.assertThat(previewRatio).isEqualTo(targetAspectRatio)
+        Truth.assertThat(imageCaptureRatio).isEqualTo(targetAspectRatio)
+        Truth.assertThat(imageAnalysisRatio).isEqualTo(targetAspectRatio)
+    }
+
+    @Test
+    fun checkDefaultAspectRatioAndResolutionForMixedUseCase() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val preview = Preview.Builder().build()
+        preview.setSurfaceProvider(
+            CameraXExecutors.directExecutor(),
+            SurfaceTextureProvider.createSurfaceTextureProvider(
+                Mockito.mock(
+                    SurfaceTextureCallback::class.java
+                )
+            )
+        )
+        val imageCapture = ImageCapture.Builder().build()
+        val imageAnalysis = ImageAnalysis.Builder().build()
+
+        // Preview/ImageCapture/ImageAnalysis' default config settings that will be applied after
+        // bound to lifecycle. Calling bindToLifecycle here to make sure sizes matching to
+        // default aspect ratio will be selected.
+        val cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(
+            context,
+            CameraSelector.DEFAULT_BACK_CAMERA
+        )
+        cameraUseCaseAdapter.addUseCases(
+            listOf(
+                preview,
+                imageCapture, imageAnalysis
+            )
+        )
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(preview)
+        useCases.add(imageCapture)
+        useCases.add(imageAnalysis)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            ArrayList(useCaseToConfigMap.values)
+        )
+        val previewSize = suggestedResolutionMap[useCaseToConfigMap[preview]]
+        val imageCaptureSize = suggestedResolutionMap[useCaseToConfigMap[imageCapture]]
+        val imageAnalysisSize = suggestedResolutionMap[useCaseToConfigMap[imageAnalysis]]
+        assert(previewSize != null)
+        val previewAspectRatio = Rational(
+            previewSize!!.width,
+            previewSize.height
+        )
+        assert(imageCaptureSize != null)
+        val imageCaptureAspectRatio = Rational(
+            imageCaptureSize!!.width,
+            imageCaptureSize.height
+        )
+        assert(imageAnalysisSize != null)
+        val imageAnalysisAspectRatio = Rational(
+            imageAnalysisSize!!.width,
+            imageAnalysisSize.height
+        )
+
+        // Checks the default aspect ratio.
+        Truth.assertThat(previewAspectRatio).isEqualTo(aspectRatio43)
+        Truth.assertThat(imageCaptureAspectRatio).isEqualTo(aspectRatio43)
+        Truth.assertThat(imageAnalysisAspectRatio).isEqualTo(aspectRatio43)
+
+        // Checks the default resolution.
+        Truth.assertThat(imageAnalysisSize).isEqualTo(vgaSize)
+    }
+
+    @Test
+    fun checkSmallSizesAreFilteredOutByDefaultSize480p() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        /* This test case is for b/139018208 that get small resolution 144x256 with below
+        conditions:
+        1. The target aspect ratio is set to the screen size 1080 x 2220 (9:18.5).
+        2. The camera doesn't provide any 9:18.5 resolution and the size 144x256(9:16)
+         is considered the 9:18.5 mod16 version.
+        3. There is no other bigger resolution matched the target aspect ratio.
+        */
+        val displayWidth = 1080
+        val displayHeight = 2220
+        val preview = Preview.Builder()
+            .setTargetResolution(Size(displayHeight, displayWidth))
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            ArrayList(useCaseToConfigMap.values)
+        )
+
+        // Checks the preconditions.
+        val preconditionSize = Size(256, 144)
+        val targetRatio = Rational(displayHeight, displayWidth)
+        val sizeList = ArrayList(listOf(*supportedSizes))
+        Truth.assertThat(sizeList).contains(preconditionSize)
+        for (s in supportedSizes) {
+            val supportedRational = Rational(s.width, s.height)
+            Truth.assertThat(supportedRational).isNotEqualTo(targetRatio)
+        }
+
+        // Checks the mechanism has filtered out the sizes which are smaller than default size
+        // 480p.
+        val previewSize = suggestedResolutionMap[useCaseToConfigMap[preview]]
+        Truth.assertThat(previewSize).isNotEqualTo(preconditionSize)
+    }
+
+    @Test
+    fun checkAspectRatioMatchedSizeCanBeSelected() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        // Sets each of mSupportedSizes as target resolution and also sets target rotation as
+        // Surface.ROTATION to make it aligns the sensor direction and then exactly the same size
+        // will be selected as the result. This test can also verify that size smaller than
+        // 640x480 can be selected after set as target resolution.
+        for (targetResolution in supportedSizes) {
+            val imageCapture = ImageCapture.Builder().setTargetResolution(
+                targetResolution
+            ).setTargetRotation(Surface.ROTATION_90).build()
+            val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                listOf(imageCapture.currentConfig)
+            )
+            Truth.assertThat(targetResolution).isEqualTo(
+                suggestedResolutionMap[imageCapture.currentConfig]
+            )
+        }
+    }
+
+    @Test
+    fun checkCorrectAspectRatioNotMatchedSizeCanBeSelected() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        // Sets target resolution as 1200x720, all supported resolutions will be put into aspect
+        // ratio not matched list. Then, 1280x720 will be the nearest matched one. Finally,
+        // checks whether 1280x720 is selected or not.
+        val targetResolution = Size(1200, 720)
+        val imageCapture = ImageCapture.Builder().setTargetResolution(
+            targetResolution
+        ).setTargetRotation(Surface.ROTATION_90).build()
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            listOf(imageCapture.currentConfig)
+        )
+        Truth.assertThat(Size(1280, 720)).isEqualTo(
+            suggestedResolutionMap[imageCapture.currentConfig]
+        )
+    }
+
+    @Test
+    fun legacyVideo_suggestedResolutionsForMixedUseCaseNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val videoCapture = androidx.camera.core.VideoCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        assertThrows(IllegalArgumentException::class.java) {
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+        }
+    }
+
+    @Test
+    fun suggestedResolutionsForMixedUseCaseNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val videoCapture = createVideoCapture()
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        assertThrows(IllegalArgumentException::class.java) {
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+        }
+    }
+
+    @Test
+    fun legacyVideo_suggestedResForCustomizeResolutionsNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        // Legacy camera only support (PRIV, PREVIEW) + (PRIV, PREVIEW)
+        val videoResolutionsPairs = listOf(
+            Pair.create(ImageFormat.PRIVATE, arrayOf(recordSize))
+        )
+        val previewResolutionsPairs = listOf(
+            Pair.create(ImageFormat.PRIVATE, arrayOf(previewSize))
+        )
+        // Override the default max resolution in VideoCapture
+        val videoCapture =
+            androidx.camera.core.VideoCapture.Builder()
+                .setMaxResolution(recordSize)
+                .setSupportedResolutions(videoResolutionsPairs)
+                .build()
+        val preview = Preview.Builder()
+            .setSupportedResolutions(previewResolutionsPairs)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        assertThrows(IllegalArgumentException::class.java) {
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+        }
+    }
+
+    @Test
+    fun suggestedResolutionsForCustomizeResolutionsNotSupportedInLegacyDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        // Legacy camera only support (PRIV, PREVIEW) + (PRIV, PREVIEW)
+        val quality = Quality.UHD
+        val previewResolutionsPairs = listOf(
+            Pair.create(ImageFormat.PRIVATE, arrayOf(previewSize))
+        )
+        val videoCapture = createVideoCapture(quality)
+        val preview = Preview.Builder()
+            .setSupportedResolutions(previewResolutionsPairs)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        assertThrows(IllegalArgumentException::class.java) {
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+        }
+    }
+
+    @Test
+    fun legacyVideo_getSuggestedResolutionsForMixedUseCaseInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val videoCapture = androidx.camera.core.VideoCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+
+        // (PRIV, PREVIEW) + (PRIV, RECORD) + (JPEG, RECORD)
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            recordSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[videoCapture],
+            legacyVideoMaximumVideoSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            previewSize
+        )
+    }
+
+    // (PRIV, PREVIEW) + (PRIV, RECORD) + (JPEG, RECORD)
+    @Test
+    fun suggestedResolutionsForMixedUseCaseInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val videoCapture = createVideoCapture(Quality.HIGHEST)
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+
+        // (PRIV, PREVIEW) + (PRIV, RECORD) + (JPEG, RECORD)
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            recordSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[videoCapture],
+            recordSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            previewSize
+        )
+    }
+
+    @Test
+    fun suggestedResolutionsInFullDevice_videoHasHigherPriorityThanImage() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val videoCapture = createVideoCapture(
+            QualitySelector.from(
+                Quality.UHD,
+                FallbackStrategy.lowerQualityOrHigherThan(Quality.UHD)
+            )
+        )
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+
+        // There are two possible combinations in Full level device
+        // (PRIV, PREVIEW) + (PRIV, RECORD) + (JPEG, RECORD) => should be applied
+        // (PRIV, PREVIEW) + (PRIV, PREVIEW) + (JPEG, MAXIMUM)
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            recordSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[videoCapture],
+            recordSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            previewSize
+        )
+    }
+
+    @Test
+    fun suggestedResInFullDevice_videoRecordSizeLowPriority_imageCanGetMaxSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_4_3) // mMaximumSize(4032x3024) is 4:3
+            .build()
+        val videoCapture = createVideoCapture(
+            QualitySelector.fromOrderedList(
+                listOf(Quality.HD, Quality.FHD, Quality.UHD)
+            )
+        )
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+
+        // There are two possible combinations in Full level device
+        // (PRIV, PREVIEW) + (PRIV, RECORD) + (JPEG, RECORD)
+        // (PRIV, PREVIEW) + (PRIV, PREVIEW) + (JPEG, MAXIMUM) => should be applied
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            maximumSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[videoCapture],
+            previewSize
+        ) // Quality.HD
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            previewSize
+        )
+    }
+
+    @Test
+    fun suggestedResolutionsWithSameSupportedListForDifferentUseCases() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        /* This test case is for b/132603284 that divide by zero issue crash happened in below
+    conditions:
+    1. There are duplicated two 1280x720 supported sizes for ImageCapture and Preview.
+    2. supportedOutputSizes for ImageCapture and Preview in
+    SupportedSurfaceCombination#getAllPossibleSizeArrangements are the same.
+    */
+        val imageCapture = ImageCapture.Builder()
+            .setTargetResolution(displaySize)
+            .build()
+        val preview = Preview.Builder()
+            .setTargetResolution(displaySize)
+            .build()
+        val imageAnalysis = ImageAnalysis.Builder()
+            .setTargetResolution(displaySize)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(preview)
+        useCases.add(imageAnalysis)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            previewSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            previewSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageAnalysis],
+            previewSize
+        )
+    }
+
+    @Test
+    fun throwsWhenSetBothTargetResolutionAndAspectRatioForDifferentUseCases() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        var previewExceptionHappened = false
+        val previewBuilder = Preview.Builder()
+            .setTargetResolution(displaySize)
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+        try {
+            previewBuilder.build()
+        } catch (e: IllegalArgumentException) {
+            previewExceptionHappened = true
+        }
+        Truth.assertThat(previewExceptionHappened).isTrue()
+        var imageCaptureExceptionHappened = false
+        val imageCaptureConfigBuilder = ImageCapture.Builder()
+            .setTargetResolution(displaySize)
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+        try {
+            imageCaptureConfigBuilder.build()
+        } catch (e: IllegalArgumentException) {
+            imageCaptureExceptionHappened = true
+        }
+        Truth.assertThat(imageCaptureExceptionHappened).isTrue()
+        var imageAnalysisExceptionHappened = false
+        val imageAnalysisConfigBuilder = ImageAnalysis.Builder()
+            .setTargetResolution(displaySize)
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+        try {
+            imageAnalysisConfigBuilder.build()
+        } catch (e: IllegalArgumentException) {
+            imageAnalysisExceptionHappened = true
+        }
+        Truth.assertThat(imageAnalysisExceptionHappened).isTrue()
+    }
+
+    @Test
+    fun legacyVideo_getSuggestedResolutionsForCustomizedSupportedResolutions() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val formatResolutionsPairList: MutableList<Pair<Int, Array<Size>>> = ArrayList()
+        formatResolutionsPairList.add(Pair.create(ImageFormat.JPEG, arrayOf(vgaSize)))
+        formatResolutionsPairList.add(
+            Pair.create(ImageFormat.YUV_420_888, arrayOf(vgaSize))
+        )
+        formatResolutionsPairList.add(Pair.create(ImageFormat.PRIVATE, arrayOf(vgaSize)))
+
+        // Sets use cases customized supported resolutions to 640x480 only.
+        val imageCapture = ImageCapture.Builder()
+            .setSupportedResolutions(formatResolutionsPairList)
+            .build()
+        val videoCapture = androidx.camera.core.VideoCapture.Builder()
+            .setSupportedResolutions(formatResolutionsPairList)
+            .build()
+        val preview = Preview.Builder()
+            .setSupportedResolutions(formatResolutionsPairList)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+
+        // Checks all suggested resolutions will become 640x480.
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            vgaSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[videoCapture],
+            vgaSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            vgaSize
+        )
+    }
+
+    @Test
+    fun suggestedResolutionsForCustomizedSupportedResolutions() {
+
+        // Checks all suggested resolutions will become 640x480.
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val formatResolutionsPairList: MutableList<Pair<Int, Array<Size>>> = ArrayList()
+        formatResolutionsPairList.add(Pair.create(ImageFormat.JPEG, arrayOf(vgaSize)))
+        formatResolutionsPairList.add(
+            Pair.create(ImageFormat.YUV_420_888, arrayOf(vgaSize))
+        )
+        formatResolutionsPairList.add(Pair.create(ImageFormat.PRIVATE, arrayOf(vgaSize)))
+
+        // Sets use cases customized supported resolutions to 640x480 only.
+        val imageCapture = ImageCapture.Builder()
+            .setSupportedResolutions(formatResolutionsPairList)
+            .build()
+        val videoCapture = createVideoCapture(Quality.SD)
+        val preview = Preview.Builder()
+            .setSupportedResolutions(formatResolutionsPairList)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(imageCapture)
+        useCases.add(videoCapture)
+        useCases.add(preview)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+
+        // Checks all suggested resolutions will become 640x480.
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            vgaSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[videoCapture],
+            vgaSize
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            vgaSize
+        )
+    }
+
+    @Test
+    fun transformSurfaceConfigWithYUVAnalysisSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.YUV_420_888, vgaSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.YUV, SurfaceConfig.ConfigSize.VGA)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithYUVPreviewSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.YUV_420_888, previewSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.YUV, SurfaceConfig.ConfigSize.PREVIEW)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithYUVRecordSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.YUV_420_888, recordSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.YUV, SurfaceConfig.ConfigSize.RECORD)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithYUVMaximumSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.YUV_420_888, maximumSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.YUV, SurfaceConfig.ConfigSize.MAXIMUM)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithJPEGAnalysisSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.JPEG, vgaSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.JPEG, SurfaceConfig.ConfigSize.VGA)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithJPEGPreviewSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.JPEG, previewSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.JPEG, SurfaceConfig.ConfigSize.PREVIEW)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithJPEGRecordSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.JPEG, recordSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.JPEG, SurfaceConfig.ConfigSize.RECORD)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun transformSurfaceConfigWithJPEGMaximumSize() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val surfaceConfig = supportedSurfaceCombination.transformSurfaceConfig(
+            ImageFormat.JPEG, maximumSize
+        )
+        val expectedSurfaceConfig =
+            SurfaceConfig.create(SurfaceConfig.ConfigType.JPEG, SurfaceConfig.ConfigSize.MAXIMUM)
+        Truth.assertThat(surfaceConfig).isEqualTo(expectedSurfaceConfig)
+    }
+
+    @Test
+    fun maximumSizeForImageFormat() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val maximumYUVSize =
+            supportedSurfaceCombination.getMaxOutputSizeByFormat(ImageFormat.YUV_420_888)
+        Truth.assertThat(maximumYUVSize).isEqualTo(maximumSize)
+        val maximumJPEGSize =
+            supportedSurfaceCombination.getMaxOutputSizeByFormat(ImageFormat.JPEG)
+        Truth.assertThat(maximumJPEGSize).isEqualTo(maximumSize)
+    }
+
+    @Test
+    fun isAspectRatioMatchWithSupportedMod16Resolution() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val preview = Preview.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .setDefaultResolution(mod16Size)
+            .build()
+        val imageCapture = ImageCapture.Builder()
+            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
+            .setDefaultResolution(mod16Size)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(preview)
+        useCases.add(imageCapture)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap: Map<UseCaseConfig<*>, Size> =
+            supportedSurfaceCombination.getSuggestedResolutions(
+                emptyList(),
+                ArrayList(useCaseToConfigMap.values)
+            )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[preview],
+            mod16Size
+        )
+        Truth.assertThat(suggestedResolutionMap).containsEntry(
+            useCaseToConfigMap[imageCapture],
+            mod16Size
+        )
+    }
+
+    @Test
+    fun sortByCompareSizesByArea_canSortSizesCorrectly() {
+        val sizes = arrayOfNulls<Size>(supportedSizes.size)
+
+        // Generates a unsorted array from mSupportedSizes.
+        val centerIndex = supportedSizes.size / 2
+        // Puts 2nd half sizes in the front
+        if (supportedSizes.size - centerIndex >= 0) {
+            System.arraycopy(
+                supportedSizes,
+                centerIndex, sizes, 0,
+                supportedSizes.size - centerIndex
+            )
+        }
+        // Puts 1st half sizes inversely in the tail
+        for (j in centerIndex - 1 downTo 0) {
+            sizes[supportedSizes.size - j - 1] = supportedSizes[j]
+        }
+
+        // The testing sizes array will be equal to mSupportedSizes after sorting.
+        Arrays.sort(sizes, CompareSizesByArea(true))
+        Truth.assertThat(listOf(*sizes)).isEqualTo(listOf(*supportedSizes))
+    }
+
+    @Test
+    fun supportedOutputSizes_noConfigSettings() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. No any aspect ratio related setting. The returned sizes list will be sorted in
+        // descending order.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(4032, 3024),
+            Size(3840, 2160),
+            Size(1920, 1440),
+            Size(1920, 1080),
+            Size(1280, 960),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_aspectRatio4x3() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetAspectRatio(AspectRatio.RATIO_4_3)
+            .build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. Sizes of aspect ratio 4/3 will be in front of the returned sizes list and the
+        // list is sorted in descending order. Other items will be put in the following that are
+        // sorted by aspect ratio delta and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(4032, 3024),
+            Size(1920, 1440),
+            Size(1280, 960),
+            Size(
+                640,
+                480
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(3840, 2160),
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_aspectRatio16x9() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetAspectRatio(
+            AspectRatio.RATIO_16_9
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. Sizes of aspect ratio 16/9 will be in front of the returned sizes list and the
+        // list is sorted in descending order. Other items will be put in the following that are
+        // sorted by aspect ratio delta and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(3840, 2160),
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(
+                800,
+                450
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(4032, 3024),
+            Size(1920, 1440),
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_targetResolution1080x1920InRotation0() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            Size(1080, 1920)
+        ).build()
+
+        // Unnecessary big enough sizes will be removed from the result list. There is default
+        // minimum size 640x480 setting. Sizes smaller than 640x480 will also be removed. The
+        // target resolution will be calibrated by default target rotation 0 degree. The
+        // auto-resolution mechanism will try to select the sizes which aspect ratio is nearest
+        // to the aspect ratio of target resolution in priority. Therefore, sizes of aspect ratio
+        // 16/9 will be in front of the returned sizes list and the list is sorted in descending
+        // order. Other items will be put in the following that are sorted by aspect ratio delta
+        // and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(
+                800,
+                450
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1920, 1440),
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_targetResolutionLargerThan640x480() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetRotation(
+            Surface.ROTATION_90
+        ).setTargetResolution(Size(1280, 960)).build()
+
+        // Unnecessary big enough sizes will be removed from the result list. There is default
+        // minimum size 640x480 setting. Target resolution larger than 640x480 won't overwrite
+        // minimum size setting. Sizes smaller than 640x480 will be removed. The auto-resolution
+        // mechanism will try to select the sizes which aspect ratio is nearest to the aspect
+        // ratio of target resolution in priority. Therefore, sizes of aspect ratio 4/3 will be
+        // in front of the returned sizes list and the list is sorted in descending order. Other
+        // items will be put in the following that are sorted by aspect ratio delta and then area
+        // size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1280, 960),
+            Size(
+                640,
+                480
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_targetResolutionSmallerThan640x480() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetRotation(
+            Surface.ROTATION_90
+        ).setTargetResolution(Size(320, 240)).build()
+
+        // Unnecessary big enough sizes will be removed from the result list. Minimum size will
+        // be overwritten as 320x240. Sizes smaller than 320x240 will also be removed. The
+        // auto-resolution mechanism will try to select the sizes which aspect ratio is nearest
+        // to the aspect ratio of target resolution in priority. Therefore, sizes of aspect ratio
+        // 4/3 will be in front of the returned sizes list and the list is sorted in descending
+        // order. Other items will be put in the following that are sorted by aspect ratio delta
+        // and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(
+                320,
+                240
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(800, 450)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_targetResolution1800x1440NearTo4x3() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetRotation(
+            Surface.ROTATION_90
+        ).setTargetResolution(Size(1800, 1440)).build()
+
+        // Unnecessary big enough sizes will be removed from the result list. There is default
+        // minimum size 640x480 setting. Sizes smaller than 640x480 will also be removed. The
+        // auto-resolution mechanism will try to select the sizes which aspect ratio is nearest
+        // to the aspect ratio of target resolution in priority. Size 1800x1440 is near to 4/3
+        // therefore, sizes of aspect ratio 4/3 will be in front of the returned sizes list and
+        // the list is sorted in descending order.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Sizes of 4/3 are near to aspect ratio of 1800/1440
+            Size(1920, 1440),
+            Size(1280, 960),
+            Size(640, 480), // Sizes of 16/9 are far to aspect ratio of 1800/1440
+            Size(3840, 2160),
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_targetResolution1280x600NearTo16x9() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            Size(1280, 600)
+        ).setTargetRotation(Surface.ROTATION_90).build()
+
+        // Unnecessary big enough sizes will be removed from the result list. There is default
+        // minimum size 640x480 setting. Sizes smaller than 640x480 will also be removed. The
+        // auto-resolution mechanism will try to select the sizes which aspect ratio is nearest
+        // to the aspect ratio of target resolution in priority. Size 1280x600 is near to 16/9,
+        // therefore, sizes of aspect ratio 16/9 will be in front of the returned sizes list and
+        // the list is sorted in descending order.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Sizes of 16/9 are near to aspect ratio of 1280/600
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450), // Sizes of 4/3 are far to aspect ratio of 1280/600
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_maxResolution1280x720() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(Size(1280, 720)).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 or
+        // larger than 1280x720 will be removed. The returned sizes list will be sorted in
+        // descending order.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_defaultResolution1280x720_noTargetResolution() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setDefaultResolution(
+            Size(
+                1280,
+                720
+            )
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. If there is no target resolution setting, it will be overwritten by default
+        // resolution as 1280x720. Unnecessary big enough sizes will also be removed. The
+        // returned sizes list will be sorted in descending order.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(1280, 720),
+            Size(960, 544),
+            Size(800, 450),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_defaultResolution1280x720_targetResolution1920x1080() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setDefaultResolution(
+            Size(1280, 720)
+        ).setTargetRotation(Surface.ROTATION_90).setTargetResolution(
+            Size(1920, 1080)
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. There is target resolution 1920x1080, it won't be overwritten by default
+        // resolution 1280x720. Unnecessary big enough sizes will also be removed. Sizes of
+        // aspect ratio 16/9 will be in front of the returned sizes list and the list is sorted
+        // in descending order.  Other items will be put in the following that are sorted by
+        // aspect ratio delta and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(
+                800,
+                450
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1920, 1440),
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_fallbackToGuaranteedResolution_whenNotFulfillConditions() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            Size(1920, 1080)
+        ).setTargetRotation(Surface.ROTATION_90).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. There is target resolution 1920x1080 (16:9). Even 640x480 does not match 16:9
+        // requirement, it will still be returned to use.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(Size(640, 480))
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenMaxSizeSmallerThanDefaultMiniSize() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(
+            Size(320, 240)
+        ).build()
+
+        // There is default minimum size 640x480 setting. Originally, sizes smaller than 640x480
+        // will be removed. Due to maximal size bound is smaller than the default minimum size
+        // bound and it is also smaller than 640x480, the default minimum size bound will be
+        // ignored. Then, sizes equal to or smaller than 320x240 will be kept in the result list.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(320, 240),
+            Size(320, 180),
+            Size(256, 144)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenMaxSizeSmallerThanSmallTargetResolution() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(
+            Size(320, 180)
+        ).setTargetResolution(Size(320, 240)).setTargetRotation(
+            Surface.ROTATION_90
+        ).build()
+
+        // The default minimum size 640x480 will be overwritten by the target resolution 320x240.
+        // Originally, sizes smaller than 320x240 will be removed. Due to maximal size bound is
+        // smaller than the minimum size bound and it is also smaller than 640x480, the minimum
+        // size bound will be ignored. Then, sizes equal to or smaller than 320x180 will be kept
+        // in the result list.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(320, 180),
+            Size(256, 144)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenBothMaxAndTargetResolutionsSmallerThan640x480() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(
+            Size(320, 240)
+        ).setTargetResolution(Size(320, 180)).setTargetRotation(
+            Surface.ROTATION_90
+        ).build()
+
+        // The default minimum size 640x480 will be overwritten by the target resolution 320x180.
+        // Originally, sizes smaller than 320x180 will be removed. Due to maximal size bound is
+        // smaller than the minimum size bound and it is also smaller than 640x480, the minimum
+        // size bound will be ignored. Then, all sizes equal to or smaller than 320x320 will be
+        // kept in the result list.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(320, 180),
+            Size(256, 144),
+            Size(320, 240)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenMaxSizeSmallerThanBigTargetResolution() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(
+            Size(1920, 1080)
+        ).setTargetResolution(Size(3840, 2160)).setTargetRotation(
+            Surface.ROTATION_90
+        ).build()
+
+        // Because the target size 3840x2160 is larger than 640x480, it won't overwrite the
+        // default minimum size 640x480. Sizes smaller than 640x480 will be removed. The
+        // auto-resolution mechanism will try to select the sizes which aspect ratio is nearest
+        // to the aspect ratio of target resolution in priority. Therefore, sizes of aspect ratio
+        // 16/9 will be in front of the returned sizes list and the list is sorted in descending
+        // order. Other items will be put in the following that are sorted by aspect ratio delta
+        // and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(
+                800,
+                450
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenNoSizeBetweenMaxSizeAndTargetResolution() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(
+            Size(320, 200)
+        ).setTargetResolution(Size(320, 190)).setTargetRotation(
+            Surface.ROTATION_90
+        ).build()
+
+        // The default minimum size 640x480 will be overwritten by the target resolution 320x190.
+        // Originally, sizes smaller than 320x190 will be removed. Due to there is no available
+        // size between the maximal size and the minimum size bound and the maximal size is
+        // smaller than 640x480, the default minimum size bound will be ignored. Then, sizes
+        // equal to or smaller than 320x200 will be kept in the result list.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(320, 180),
+            Size(256, 144)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenTargetResolutionSmallerThanAnySize() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            Size(192, 144)
+        ).setTargetRotation(Surface.ROTATION_90).build()
+
+        // The default minimum size 640x480 will be overwritten by the target resolution 192x144.
+        // Because 192x144 is smaller than any size in the supported list, no one will be
+        // filtered out by it. The result list will only keep one big enough size of aspect ratio
+        // 4:3 and 16:9.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(320, 240),
+            Size(256, 144)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenMaxResolutionSmallerThanAnySize() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(
+            Size(192, 144)
+        ).build()
+
+        // All sizes will be filtered out by the max resolution 192x144 setting and an
+        // IllegalArgumentException will be thrown.
+        assertThrows(IllegalArgumentException::class.java) {
+            supportedSurfaceCombination.getSupportedOutputSizes(useCase.currentConfig)
+        }
+    }
+
+    @Test
+    fun supportedOutputSizes_whenMod16IsIgnoredForSmallSizes() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, arrayOf(
+                Size(640, 480),
+                Size(320, 240),
+                Size(320, 180),
+                Size(296, 144),
+                Size(256, 144)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            Size(185, 90)
+        ).setTargetRotation(Surface.ROTATION_90).build()
+
+        // The default minimum size 640x480 will be overwritten by the target resolution 185x90
+        // (18.5:9). If mod 16 calculation is not ignored for the sizes smaller than 640x480, the
+        // size 256x144 will be considered to match 18.5:9 and then become the first item in the
+        // result list. After ignoring mod 16 calculation for small sizes, 256x144 will still be
+        // kept as a 16:9 resolution as the result.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(296, 144),
+            Size(256, 144),
+            Size(320, 240)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizes_whenOneMod16SizeClosestToTargetResolution() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, arrayOf(
+                Size(1920, 1080),
+                Size(1440, 1080),
+                Size(1280, 960),
+                Size(1280, 720),
+                Size(864, 480), // This is a 16:9 mod16 size that is closest to 2016x1080
+                Size(768, 432),
+                Size(640, 480),
+                Size(640, 360),
+                Size(480, 360),
+                Size(384, 288)
+            )
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            Size(1080, 2016)
+        ).build()
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf(
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(864, 480),
+            Size(768, 432),
+            Size(1440, 1080),
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizesWithPortraitPixelArraySize_aspectRatio16x9() {
+        val supportedSizes = arrayOf(
+            Size(1080, 1920),
+            Size(1080, 1440),
+            Size(960, 1280),
+            Size(720, 1280),
+            Size(1280, 720),
+            Size(480, 640),
+            Size(640, 480),
+            Size(360, 480)
+        )
+
+        // Sets the sensor orientation as 0 and pixel array size as a portrait size to simulate a
+        // phone device which majorly supports portrait output sizes.
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
+            sensorOrientation0, portraitPixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetAspectRatio(
+            AspectRatio.RATIO_16_9
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. Due to the pixel array size is portrait, sizes of aspect ratio 9/16 will be in
+        // front of the returned sizes list and the list is sorted in descending order. Other
+        // items will be put in the following that are sorted by aspect ratio delta and then area
+        // size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1080, 1920),
+            Size(
+                720,
+                1280
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1080, 1440),
+            Size(960, 1280),
+            Size(480, 640),
+            Size(640, 480),
+            Size(1280, 720)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizesOnTabletWithPortraitPixelArraySize_aspectRatio16x9() {
+        val supportedSizes = arrayOf(
+            Size(1080, 1920),
+            Size(1080, 1440),
+            Size(960, 1280),
+            Size(720, 1280),
+            Size(1280, 720),
+            Size(480, 640),
+            Size(640, 480),
+            Size(360, 480)
+        )
+
+        // Sets the sensor orientation as 90 and pixel array size as a portrait size to simulate a
+        // tablet device which majorly supports portrait output sizes.
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
+            sensorOrientation90, portraitPixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetAspectRatio(
+            AspectRatio.RATIO_16_9
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. Due to the pixel array size is portrait, sizes of aspect ratio 9/16 will be in
+        // front of the returned sizes list and the list is sorted in descending order. Other
+        // items will be put in the following that are sorted by aspect ratio delta and then area
+        // size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1080, 1920),
+            Size(
+                720,
+                1280
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1080, 1440),
+            Size(960, 1280),
+            Size(480, 640),
+            Size(640, 480),
+            Size(1280, 720)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizesOnTablet_aspectRatio16x9() {
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
+            sensorOrientation0, landscapePixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetAspectRatio(
+            AspectRatio.RATIO_16_9
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. Sizes of aspect ratio 16/9 will be in front of the returned sizes list and the
+        // list is sorted in descending order. Other items will be put in the following that are
+        // sorted by aspect ratio delta and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(3840, 2160),
+            Size(1920, 1080),
+            Size(1280, 720),
+            Size(960, 544),
+            Size(
+                800,
+                450
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(4032, 3024),
+            Size(1920, 1440),
+            Size(1280, 960),
+            Size(640, 480)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun supportedOutputSizesOnTabletWithPortraitSizes_aspectRatio16x9() {
+        val supportedSizes = arrayOf(
+            Size(1920, 1080),
+            Size(1440, 1080),
+            Size(1280, 960),
+            Size(1280, 720),
+            Size(720, 1280),
+            Size(640, 480),
+            Size(480, 640),
+            Size(480, 360)
+        )
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
+            sensorOrientation0, landscapePixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val useCase = FakeUseCaseConfig.Builder().setTargetAspectRatio(
+            AspectRatio.RATIO_16_9
+        ).build()
+
+        // There is default minimum size 640x480 setting. Sizes smaller than 640x480 will be
+        // removed. Sizes of aspect ratio 16/9 will be in front of the returned sizes list and the
+        // list is sorted in descending order. Other items will be put in the following that are
+        // sorted by aspect ratio delta and then area size.
+        val resultList: List<Size?> = supportedSurfaceCombination.getSupportedOutputSizes(
+            useCase.currentConfig
+        )
+        val expectedList = listOf( // Matched AspectRatio items, sorted by area size.
+            Size(1920, 1080),
+            Size(
+                1280,
+                720
+            ), // Mismatched AspectRatio items, sorted by aspect ratio delta then area size.
+            Size(1440, 1080),
+            Size(1280, 960),
+            Size(640, 480),
+            Size(480, 640),
+            Size(720, 1280)
+        )
+        Truth.assertThat(resultList).isEqualTo(expectedList)
+    }
+
+    @Test
+    fun determineRecordSizeFromStreamConfigurationMap() {
+        // Setup camera with non-integer camera Id
+        setupCamera(
+            cameraIdExternal, CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
+            sensorOrientation90, landscapePixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraIdExternal,
+            mockCamcorderProfileAdapter
+        )
+
+        // Checks the determined RECORD size
+        Truth.assertThat(
+            supportedSurfaceCombination.surfaceSizeDefinition.recordSize
+        ).isEqualTo(
+            legacyVideoMaximumVideoSize
+        )
+    }
+
+    @Test
+    fun canGet640x480_whenAnotherGroupMatchedInMod16Exists() {
+        val supportedSizes = arrayOf(
+            Size(4000, 3000),
+            Size(3840, 2160),
+            Size(1920, 1080),
+            Size(1024, 738), // This will create a 512/269 aspect ratio group that
+            // 640x480 will be considered to match in mod16 condition.
+            Size(800, 600),
+            Size(640, 480),
+            Size(320, 240)
+        )
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
+            sensorOrientation90, landscapePixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        // Sets the target resolution as 640x480 with target rotation as ROTATION_90 because the
+        // sensor orientation is 90.
+        val useCase = FakeUseCaseConfig.Builder().setTargetResolution(
+            vgaSize
+        ).setTargetRotation(Surface.ROTATION_90).build()
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            listOf(useCase.currentConfig)
+        )
+
+        // Checks 640x480 is final selected for the use case.
+        Truth.assertThat(suggestedResolutionMap[useCase.currentConfig]).isEqualTo(vgaSize)
+    }
+
+    @Test
+    fun canGetSupportedSizeSmallerThan640x480_whenLargerMaxResolutionIsSet() {
+        val supportedSizes = arrayOf(
+            Size(480, 480)
+        )
+        setupCamera(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
+            sensorOrientation90, landscapePixelArraySize, supportedSizes, null
+        )
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+
+        // Sets the max resolution as 720x1280
+        val useCase = FakeUseCaseConfig.Builder().setMaxResolution(displaySize).build()
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            listOf(useCase.currentConfig)
+        )
+
+        // Checks 480x480 is final selected for the use case.
+        Truth.assertThat(suggestedResolutionMap[useCase.currentConfig]).isEqualTo(
+            Size(480, 480)
+        )
+    }
+
+    @Test
+    fun previewSizeIsSelectedForImageAnalysis_imageCaptureHasNoSetSizeInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val preview = Preview.Builder().build()
+        preview.setSurfaceProvider(
+            CameraXExecutors.directExecutor(),
+            SurfaceTextureProvider.createSurfaceTextureProvider(
+                Mockito.mock(
+                    SurfaceTextureCallback::class.java
+                )
+            )
+        )
+
+        // ImageCapture has no explicit target resolution setting
+        val imageCapture = ImageCapture.Builder().build()
+
+        // A LEGACY-level above device supports the following configuration.
+        //     PRIV/PREVIEW + YUV/PREVIEW + JPEG/MAXIMUM
+        //
+        // A LIMITED-level above device supports the following configuration.
+        //     PRIV/PREVIEW + YUV/RECORD + JPEG/RECORD
+        //
+        // Even there is a RECORD size target resolution setting for ImageAnalysis, ImageCapture
+        // will still have higher priority to have a MAXIMUM size resolution if the app doesn't
+        // explicitly specify a RECORD size target resolution to ImageCapture.
+        val imageAnalysis = ImageAnalysis.Builder()
+            .setTargetRotation(Surface.ROTATION_90)
+            .setTargetResolution(recordSize)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(preview)
+        useCases.add(imageCapture)
+        useCases.add(imageAnalysis)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            ArrayList(useCaseToConfigMap.values)
+        )
+        Truth.assertThat(suggestedResolutionMap[useCaseToConfigMap[imageAnalysis]]).isEqualTo(
+            previewSize
+        )
+    }
+
+    @Test
+    fun recordSizeIsSelectedForImageAnalysis_imageCaptureHasExplicitSizeInLimitedDevice() {
+        setupCamera(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED)
+        val supportedSurfaceCombination = SupportedSurfaceCombination(
+            context, mockCameraMetadata, cameraId,
+            mockCamcorderProfileAdapter
+        )
+        val preview = Preview.Builder().build()
+        preview.setSurfaceProvider(
+            CameraXExecutors.directExecutor(),
+            SurfaceTextureProvider.createSurfaceTextureProvider(
+                Mockito.mock(
+                    SurfaceTextureCallback::class.java
+                )
+            )
+        )
+
+        // ImageCapture has no explicit RECORD size target resolution setting
+        val imageCapture = ImageCapture.Builder()
+            .setTargetRotation(Surface.ROTATION_90)
+            .setTargetResolution(recordSize)
+            .build()
+
+        // A LEGACY-level above device supports the following configuration.
+        //     PRIV/PREVIEW + YUV/PREVIEW + JPEG/MAXIMUM
+        //
+        // A LIMITED-level above device supports the following configuration.
+        //     PRIV/PREVIEW + YUV/RECORD + JPEG/RECORD
+        //
+        // A RECORD can be selected for ImageAnalysis if the ImageCapture has a explicit RECORD
+        // size target resolution setting. It means that the application know the trade-off and
+        // the ImageAnalysis has higher priority to get a larger resolution than ImageCapture.
+        val imageAnalysis = ImageAnalysis.Builder()
+            .setTargetRotation(Surface.ROTATION_90)
+            .setTargetResolution(recordSize)
+            .build()
+        val useCases: MutableList<UseCase> = ArrayList()
+        useCases.add(preview)
+        useCases.add(imageCapture)
+        useCases.add(imageAnalysis)
+        val useCaseToConfigMap = Configs.useCaseConfigMapWithDefaultSettingsFromUseCaseList(
+            cameraFactory!!.getCamera(cameraId).cameraInfoInternal,
+            useCases,
+            useCaseConfigFactory
+        )
+        val suggestedResolutionMap = supportedSurfaceCombination.getSuggestedResolutions(
+            emptyList(),
+            ArrayList(useCaseToConfigMap.values)
+        )
+        Truth.assertThat(suggestedResolutionMap[useCaseToConfigMap[imageAnalysis]]).isEqualTo(
+            recordSize
+        )
+    }
+
+    private fun setupCamera(hardwareLevel: Int, capabilities: IntArray) {
+        setupCamera(
+            hardwareLevel, sensorOrientation90, landscapePixelArraySize,
+            supportedSizes, capabilities
+        )
+    }
+
+    private fun setupCamera(hardwareLevel: Int, supportedSizes: Array<Size>) {
+        setupCamera(
+            hardwareLevel, sensorOrientation90, landscapePixelArraySize,
+            supportedSizes, null
+        )
+    }
+
+    private fun setupCamera(
+        hardwareLevel: Int,
+        sensorOrientation: Int = sensorOrientation90,
+        pixelArraySize: Size = landscapePixelArraySize,
+        supportedSizes: Array<Size> =
+            this.supportedSizes,
+        capabilities: IntArray? = null
+    ) {
+        setupCamera(
+            cameraId,
+            hardwareLevel,
+            sensorOrientation,
+            pixelArraySize,
+            supportedSizes,
+            capabilities
+        )
+    }
+
+    private fun setupCamera(
+        cameraId: String,
+        hardwareLevel: Int,
+        sensorOrientation: Int,
+        pixelArraySize: Size,
+        supportedSizes: Array<Size>,
+        capabilities: IntArray?
+    ) {
+        cameraFactory = FakeCameraFactory()
+        val characteristics = ShadowCameraCharacteristics.newCameraCharacteristics()
+        val shadowCharacteristics = Shadow.extract<ShadowCameraCharacteristics>(characteristics)
+        shadowCharacteristics.set(
+            CameraCharacteristics.LENS_FACING, CameraCharacteristics.LENS_FACING_BACK
+        )
+        shadowCharacteristics.set(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL, hardwareLevel
+        )
+        shadowCharacteristics.set(CameraCharacteristics.SENSOR_ORIENTATION, sensorOrientation)
+        shadowCharacteristics.set(
+            CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE,
+            pixelArraySize
+        )
+        if (capabilities != null) {
+            shadowCharacteristics.set(
+                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES, capabilities
+            )
+        }
+        val cameraManager = ApplicationProvider.getApplicationContext<Context>()
+            .getSystemService(Context.CAMERA_SERVICE) as CameraManager
+        (Shadow.extract<Any>(cameraManager) as ShadowCameraManager)
+            .addCamera(cameraId, characteristics)
+        val mockMap = Mockito.mock(
+            StreamConfigurationMap::class.java
+        )
+        Mockito.`when`(mockMap.getOutputSizes(ArgumentMatchers.anyInt())).thenReturn(supportedSizes)
+        // ImageFormat.PRIVATE was supported since API level 23. Before that, the supported
+        // output sizes need to be retrieved via SurfaceTexture.class.
+        Mockito.`when`(
+            mockMap.getOutputSizes(
+                SurfaceTexture::class.java
+            )
+        ).thenReturn(supportedSizes)
+        // This is setup for the test to determine RECORD size from StreamConfigurationMap
+        Mockito.`when`(
+            mockMap.getOutputSizes(
+                MediaRecorder::class.java
+            )
+        ).thenReturn(supportedSizes)
+        shadowCharacteristics.set(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP, mockMap)
+        @LensFacing val lensFacingEnum = CameraUtil.getLensFacingEnumFromInt(
+            CameraCharacteristics.LENS_FACING_BACK
+        )
+        val cameraInfo = FakeCameraInfoInternal(cameraId)
+        cameraInfo.camcorderProfileProvider = FakeCamcorderProfileProvider.Builder()
+            .addProfile(
+                CamcorderProfileUtil.asHighQuality(profileUhd),
+                profileUhd,
+                profileFhd,
+                profileHd,
+                profileSd,
+                CamcorderProfileUtil.asLowQuality(profileSd)
+            ).build()
+        cameraFactory!!.insertCamera(
+            lensFacingEnum, cameraId
+        ) { FakeCamera(cameraId, null, cameraInfo) }
+
+        // set up CameraMetaData
+        Mockito.`when`(
+            mockCameraMetadata[CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL]
+        ).thenReturn(hardwareLevel)
+        Mockito.`when`(mockCameraMetadata[CameraCharacteristics.SENSOR_ORIENTATION])
+            .thenReturn(
+                sensorOrientation
+            )
+        Mockito.`when`(
+            mockCameraMetadata[CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE]
+        ).thenReturn(pixelArraySize)
+        Mockito.`when`(mockCameraMetadata[CameraCharacteristics.LENS_FACING]).thenReturn(
+            CameraCharacteristics.LENS_FACING_BACK
+        )
+        Mockito.`when`(
+            mockCameraMetadata[CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES]
+        ).thenReturn(capabilities)
+        Mockito.`when`(
+            mockCameraMetadata[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP]
+        ).thenReturn(mockMap)
+        initCameraX(cameraId)
+    }
+
+    private fun initCameraX(cameraId: String) {
+        val cameraMetaDataMap = mutableMapOf<CameraId, CameraMetadata>()
+        cameraMetaDataMap[CameraId(cameraId)] = mockCameraMetadata
+        val cameraDevicesWithCameraMetaData =
+            FakeCameraDevicesWithCameraMetaData(cameraMetaDataMap, mockCameraMetadata)
+        Mockito.`when`(mockCameraAppComponent.getCameraDevices())
+            .thenReturn(cameraDevicesWithCameraMetaData)
+        cameraFactory!!.cameraManager = mockCameraAppComponent
+        val cameraXConfig = CameraXConfig.Builder.fromConfig(
+            defaultConfig()
+        )
+            .setDeviceSurfaceManagerProvider { context: Context?, _: Any?, _: Set<String?>? ->
+                CameraSurfaceAdapter(
+                    context!!,
+                    mockCameraAppComponent, setOf(cameraId)
+                )
+            }
+            .setCameraFactoryProvider { _: Context?,
+                _: CameraThreadConfig?,
+                _: CameraSelector?
+                ->
+                cameraFactory!!
+            }
+            .build()
+        val cameraX: CameraX = try {
+            CameraXUtil.getOrCreateInstance(context) { cameraXConfig }.get()
+        } catch (e: ExecutionException) {
+            throw IllegalStateException("Unable to initialize CameraX for test.")
+        } catch (e: InterruptedException) {
+            throw IllegalStateException("Unable to initialize CameraX for test.")
+        }
+        useCaseConfigFactory = cameraX.defaultConfigFactory
+    }
+
+    private fun isAllSubConfigListSupported(
+        supportedSurfaceCombination: SupportedSurfaceCombination,
+        combinationList: List<SurfaceCombination>
+    ): Boolean {
+        for (combination in combinationList) {
+            val configList = combination.surfaceConfigList
+            val length = configList.size
+            if (length <= 1) {
+                continue
+            }
+            for (index in 0 until length) {
+                val subConfigurationList: MutableList<SurfaceConfig> = ArrayList(configList)
+                subConfigurationList.removeAt(index)
+                val isSupported = supportedSurfaceCombination.checkSupported(subConfigurationList)
+                if (!isSupported) {
+                    return false
+                }
+            }
+        }
+        return true
+    }
+
+    /** Creates a VideoCapture with a specific Quality  */
+    private fun createVideoCapture(quality: Quality): VideoCapture<TestVideoOutput> {
+        return createVideoCapture(QualitySelector.from(quality))
+    }
+    /** Creates a VideoCapture with a customized QualitySelector  */
+    /** Creates a VideoCapture with a default QualitySelector  */
+    @JvmOverloads
+    fun createVideoCapture(
+        qualitySelector: QualitySelector = VideoSpec.QUALITY_SELECTOR_AUTO
+    ): VideoCapture<TestVideoOutput> {
+        val mediaSpecBuilder = MediaSpec.builder()
+        mediaSpecBuilder.configureVideo { builder: VideoSpec.Builder ->
+            builder.setQualitySelector(
+                qualitySelector
+            )
+        }
+        val videoOutput = TestVideoOutput()
+        videoOutput.mediaSpecObservable.setState(mediaSpecBuilder.build())
+        return VideoCapture.withOutput(videoOutput)
+    }
+
+    /** A fake implementation of VideoOutput  */
+    class TestVideoOutput : VideoOutput {
+        var mediaSpecObservable =
+            MutableStateObservable.withInitialState(MediaSpec.builder().build())
+        private var surfaceRequest: SurfaceRequest? = null
+        private var sourceState: SourceState? = null
+        override fun onSurfaceRequested(request: SurfaceRequest) {
+            surfaceRequest = request
+        }
+
+        override fun getMediaSpec(): Observable<MediaSpec> {
+            return mediaSpecObservable
+        }
+
+        override fun onSourceStateChanged(sourceState: SourceState) {
+            this.sourceState = sourceState
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt
index 76187df..6126329 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt
@@ -105,6 +105,7 @@
                 useCaseThreads,
                 CameraPipe(CameraPipe.Config(ApplicationProvider.getApplicationContext()))
             ),
+            threads = useCaseThreads,
             requestControl = requestControl
         ).also {
             it.runningUseCases = setOf(fakeUseCase)
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeCameraDevicesWithCameraMetaData.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeCameraDevicesWithCameraMetaData.kt
new file mode 100644
index 0000000..7e64564
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeCameraDevicesWithCameraMetaData.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration.testing
+
+import androidx.camera.camera2.pipe.CameraDevices
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.CameraMetadata
+import kotlinx.coroutines.runBlocking
+
+class FakeCameraDevicesWithCameraMetaData(
+    private val cameraMetadataMap: Map<CameraId, CameraMetadata>,
+    private val defaultCameraMetadata: CameraMetadata
+) : CameraDevices {
+    @Deprecated(
+        message = "findAll may block the calling thread and is deprecated.",
+        replaceWith = ReplaceWith("ids"),
+        level = DeprecationLevel.WARNING
+    )
+    override fun findAll(): List<CameraId> = runBlocking { ids() }
+    override suspend fun ids(): List<CameraId> = cameraMetadataMap.keys.toList()
+    override suspend fun getMetadata(camera: CameraId): CameraMetadata = awaitMetadata(camera)
+    override fun awaitMetadata(camera: CameraId) =
+        cameraMetadataMap[camera] ?: defaultCameraMetadata
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt
index ecfd0e3..c4c65e5 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt
@@ -32,6 +32,7 @@
 import androidx.camera.core.impl.SessionConfig
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
 
 class FakeUseCaseCameraComponentBuilder : UseCaseCameraComponent.Builder {
     private var config: UseCaseCameraConfig = UseCaseCameraConfig(emptyList())
@@ -134,6 +135,7 @@
         return CompletableDeferred(Result3A(status = Result3A.Status.OK))
     }
 
-    override fun close() {
+    override fun close(): Job {
+        return CompletableDeferred(Unit)
     }
 }
diff --git a/camera/camera-camera2-pipe-integration/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/camera/camera-camera2-pipe-integration/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..1f0955d
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/camera/camera-camera2-pipe-testing/build.gradle b/camera/camera-camera2-pipe-testing/build.gradle
index 78edf1d..b74856a 100644
--- a/camera/camera-camera2-pipe-testing/build.gradle
+++ b/camera/camera-camera2-pipe-testing/build.gradle
@@ -32,6 +32,7 @@
     // Classes and types that are only needed at runtime
     implementation(libs.kotlinStdlib)
     implementation(libs.kotlinCoroutinesGuava)
+    implementation(libs.kotlinCoroutinesTest)
     implementation(project(":camera:camera-camera2-pipe"))
 
     testImplementation(libs.testCore)
@@ -51,6 +52,13 @@
     namespace "androidx.camera.camera2.pipe.testing"
 }
 
+// Allow usage of Kotlin's @OptIn.
+tasks.withType(KotlinCompile).configureEach {
+    kotlinOptions {
+        freeCompilerArgs += ["-opt-in=kotlin.RequiresOptIn"]
+    }
+}
+
 androidx {
     name = "Jetpack Camera Camera Pipe Testing Library"
     publish = Publish.SNAPSHOT_AND_RELEASE
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraControllerSimulator.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraControllerSimulator.kt
new file mode 100644
index 0000000..be11d94
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraControllerSimulator.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.view.Surface
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CameraContext
+import androidx.camera.camera2.pipe.CameraController
+import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.graph.GraphListener
+import androidx.camera.camera2.pipe.graph.GraphRequestProcessor
+
+/**
+ * The CameraControllerSimulator is a [CameraController] implementation designed to simulate actions
+ * and reactions to an underlying camera.
+ *
+ * Most interactions with a [CameraController] are in the form of "action -> reaction" where the
+ * reaction must be simulated, and is useful for testing nuanced behavior with how a real camera
+ * controller may behave in different circumstances or edge cases. As an example, invoking [start]
+ * must be paired with [simulateCameraStarted] in most situations for a [CameraGraph] to be able to
+ * actively submit requests. This mirrors the underlying behavior of an actual Camera, which may
+ * take time to configure and become ready.
+ */
+@RequiresApi(21)
+class CameraControllerSimulator(
+    private val cameraContext: CameraContext,
+    private val graphConfig: CameraGraph.Config,
+    private val graphListener: GraphListener,
+    private val streamGraph: StreamGraph
+) : CameraController {
+    private val lock = Any()
+    private var currentSurfaceMap: Map<StreamId, Surface> = emptyMap()
+    private var currentGraphRequestProcessor: GraphRequestProcessor? = null
+
+    private var _closed = false
+    var closed: Boolean
+        get() = _closed
+        private set(value) {
+            _closed = value
+        }
+
+    private var _started = false
+    var started: Boolean
+        get() = _started
+        private set(value) {
+            _started = value
+        }
+
+    private var _currentCaptureSequenceProcessor: FakeCaptureSequenceProcessor? = null
+    var currentCaptureSequenceProcessor: FakeCaptureSequenceProcessor?
+        get() = _currentCaptureSequenceProcessor
+        private set(value) {
+            _currentCaptureSequenceProcessor = value
+        }
+
+    init {
+        check(cameraContext.cameraBackends.allIds.isNotEmpty()) {
+            "Backends provided by cameraContext.cameraBackends cannot be empty"
+        }
+        val cameraBackendId = graphConfig.cameraBackendId
+        if (cameraBackendId != null) {
+            check(cameraContext.cameraBackends.allIds.contains(cameraBackendId)) {
+                "Backends provided by cameraContext do not contain $cameraBackendId which was " +
+                    "requested by $graphConfig"
+            }
+        }
+    }
+
+    fun simulateCameraStarted() {
+        synchronized(lock) {
+            check(!closed) {
+                "Attempted to invoke simulateStarted after the CameraController was closed."
+            }
+
+            val captureSequenceProcessor = FakeCaptureSequenceProcessor(
+                graphConfig.camera,
+                graphConfig.defaultTemplate
+            )
+            val graphRequestProcessor = GraphRequestProcessor.from(captureSequenceProcessor)
+            currentCaptureSequenceProcessor = captureSequenceProcessor
+            currentGraphRequestProcessor = graphRequestProcessor
+
+            graphListener.onGraphStarted(graphRequestProcessor)
+        }
+    }
+
+    fun simulateCameraStopped() {
+        synchronized(lock) {
+            check(!closed) {
+                "Attempted to invoke simulateCameraStopped after the CameraController was closed."
+            }
+            val captureSequenceProcessor = _currentCaptureSequenceProcessor
+            val graphRequestProcessor = currentGraphRequestProcessor
+
+            currentCaptureSequenceProcessor = null
+            currentGraphRequestProcessor = null
+
+            if (captureSequenceProcessor != null && graphRequestProcessor != null) {
+                graphListener.onGraphStopped(graphRequestProcessor)
+            }
+        }
+    }
+
+    fun simulateCameraModified() {
+        synchronized(lock) {
+            val captureSequenceProcessor = _currentCaptureSequenceProcessor
+            val graphRequestProcessor = currentGraphRequestProcessor
+
+            currentCaptureSequenceProcessor = null
+            currentGraphRequestProcessor = null
+
+            if (captureSequenceProcessor != null && graphRequestProcessor != null) {
+                graphListener.onGraphStopped(graphRequestProcessor)
+            }
+        }
+    }
+
+    override fun start() {
+        synchronized(lock) {
+            check(!closed) {
+                "Attempted to invoke start after close."
+            }
+            started = true
+        }
+    }
+
+    override fun stop() {
+        synchronized(lock) {
+            started = false
+        }
+    }
+
+    override fun close() {
+        synchronized(lock) {
+            closed = true
+            started = false
+        }
+    }
+
+    override fun updateSurfaceMap(surfaceMap: Map<StreamId, Surface>) {
+        check(streamGraph.streamIds.containsAll(surfaceMap.keys))
+
+        synchronized(lock) {
+            currentSurfaceMap = surfaceMap
+
+            val captureSequenceProcessor = _currentCaptureSequenceProcessor
+            val graphRequestProcessor = currentGraphRequestProcessor
+            if (captureSequenceProcessor != null && graphRequestProcessor != null) {
+                captureSequenceProcessor.surfaceMap = surfaceMap
+                graphListener.onGraphModified(graphRequestProcessor)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt
index a6b2a04..e8d38a5 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt
@@ -16,44 +16,136 @@
 
 package androidx.camera.camera2.pipe.testing
 
+import android.content.Context
+import android.graphics.SurfaceTexture
 import android.hardware.camera2.CaptureFailure
 import android.hardware.camera2.CaptureResult
+import android.view.Surface
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.CameraId
 import androidx.camera.camera2.pipe.CameraMetadata
 import androidx.camera.camera2.pipe.CameraPipe
+import androidx.camera.camera2.pipe.CameraPipe.CameraBackendConfig
 import androidx.camera.camera2.pipe.CameraTimestamp
 import androidx.camera.camera2.pipe.FrameMetadata
 import androidx.camera.camera2.pipe.FrameNumber
 import androidx.camera.camera2.pipe.Metadata
 import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.CaptureSequences.invokeOnRequest
 import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.test.TestScope
 
-/** Simulator for observing and responding to interactions with the a [CameraGraph]. */
+/**
+ * This class creates a [CameraPipe] and [CameraGraph] instance using a [FakeCameraBackend].
+ *
+ * The CameraGraphSimulator is primarily intended to be used within a Kotlin `runTest` block, and
+ * must be created with a coroutine scope by invoking [CameraGraphSimulator.create] and passing the
+ * coroutine scope. This ensures that the created objects, dispatchers, and scopes correctly inherit
+ * from the parent [TestScope].
+ *
+ * The simulator does not make (many) assumptions about how the simulator will be used, and for this
+ * reason it does not automatically put the underlying graph into a "started" state. In most cases,
+ * the test will need start the [CameraGraph], [simulateCameraStarted], and either configure
+ * surfaces for the [CameraGraph] or call [simulateFakeSurfaceConfiguration] to put the graph into a
+ * state where it is able to send and simulate interactions with the camera. This mirrors the normal
+ * lifecycle of a [CameraGraph].
+ */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-class CameraGraphSimulator(
-    private val config: CameraGraph.Config,
-    cameraMetadata: CameraMetadata
+class CameraGraphSimulator private constructor(
+    val context: Context,
+    val cameraMetadata: CameraMetadata,
+    val graphConfig: CameraGraph.Config,
+    val cameraGraph: CameraGraph,
+    private val cameraController: CameraControllerSimulator
 ) {
-    init {
-        check(config.camera == cameraMetadata.camera)
+    companion object {
+        /**
+         * Create a CameraGraphSimulator using the current [TestScope] provided by a Kotlin
+         * `runTest` block. This will create the [CameraPipe] and [CameraGraph] using the parent
+         * test scope, which helps ensure all long running operations are wrapped up by the time
+         * the test completes and allows the test to provide more fine grained control over the
+         * interactions.
+         */
+        @OptIn(ExperimentalCoroutinesApi::class)
+        fun create(
+            scope: TestScope,
+            context: Context,
+            cameraMetadata: CameraMetadata,
+            graphConfig: CameraGraph.Config
+        ): CameraGraphSimulator {
+            val fakeCameraBackend = FakeCameraBackend(
+                fakeCameras = mapOf(cameraMetadata.camera to cameraMetadata)
+            )
+            val cameraPipe = CameraPipe(
+                CameraPipe.Config(
+                    context,
+                    cameraBackendConfig = CameraBackendConfig(
+                        internalBackend = fakeCameraBackend
+                    ),
+                    threadConfig = CameraPipe.ThreadConfig(
+                        testOnlyDispatcher = StandardTestDispatcher(scope.testScheduler),
+                        testOnlyScope = scope,
+                    )
+                )
+            )
+            val cameraGraph = cameraPipe.create(graphConfig)
+            val cameraController = checkNotNull(fakeCameraBackend.cameraControllers.lastOrNull()) {
+                "Expected cameraPipe.create to create a CameraController instance from " +
+                    "$fakeCameraBackend as part of its initialization."
+            }
+            return CameraGraphSimulator(
+                context,
+                cameraMetadata,
+                graphConfig,
+                cameraGraph,
+                cameraController
+            )
+        }
     }
 
-    private val fakeRequestProcessor = FakeRequestProcessor()
-    private val cameraPipe = CameraPipe.External()
-    public val cameraGraph = cameraPipe.create(
-        config,
-        cameraMetadata,
-        fakeRequestProcessor
-    )
+    init {
+        check(graphConfig.camera == cameraMetadata.camera) {
+            "CameraGraphSimulator must be creating with a camera id that matches the provided " +
+                "cameraMetadata! Received ${graphConfig.camera}, but expected " +
+                "${cameraMetadata.camera}"
+        }
+    }
 
-    private var frameClockNanos = atomic(0L)
-    private var frameCounter = atomic(0L)
+    private val surfaceTextureNames = atomic(0)
+    private val frameClockNanos = atomic(0L)
+    private val frameCounter = atomic(0L)
     private val pendingFrameQueue = mutableListOf<FrameSimulator>()
 
+    fun simulateCameraStarted() {
+        cameraController.simulateCameraStarted()
+    }
+
+    fun simulateCameraStopped() {
+        cameraController.simulateCameraStopped()
+    }
+
+    fun simulateCameraModified() {
+        cameraController.simulateCameraModified()
+    }
+
+    fun simulateFakeSurfaceConfiguration() {
+        for (stream in cameraGraph.streams.streams) {
+            // Pick an output -- most will only have one.
+            val output = stream.outputs.first()
+            val surface = Surface(
+                SurfaceTexture(surfaceTextureNames.getAndIncrement()).also {
+                    it.setDefaultBufferSize(output.size.width, output.size.height)
+                }
+            )
+            cameraGraph.setSurface(stream.id, surface)
+        }
+    }
+
     suspend fun simulateNextFrame(
         advanceClockByNanos: Long = 33_366_666 // (2_000_000_000 / (60  / 1.001))
     ): FrameSimulator = generateNextFrame().also {
@@ -62,15 +154,20 @@
     }
 
     private suspend fun generateNextFrame(): FrameSimulator {
+        val captureSequenceProcessor = cameraController.currentCaptureSequenceProcessor
+        check(captureSequenceProcessor != null) {
+            "simulateCameraStarted() must be called before frames can be created!"
+        }
+
         // This checks the pending frame queue and polls for the next request. If no request is
         // available it will suspend until the next interaction with the request processor.
         if (pendingFrameQueue.isEmpty()) {
             val requestSequence =
-                withTimeout(timeMillis = 250) { fakeRequestProcessor.nextRequestSequence() }
+                withTimeout(timeMillis = 250) { captureSequenceProcessor.nextRequestSequence() }
 
             // Each sequence is processed as a group, and if a sequence contains multiple requests
             // the list of requests is processed in order before polling the next sequence.
-            for (request in requestSequence.requests) {
+            for (request in requestSequence.captureRequestList) {
                 pendingFrameQueue.add(FrameSimulator(request, requestSequence))
             }
         }
@@ -86,10 +183,9 @@
      */
     inner class FrameSimulator internal constructor(
         val request: Request,
-        val requestSequence: FakeRequestProcessor.RequestSequence,
+        val requestSequence: FakeCaptureSequence,
     ) {
         private val requestMetadata = requestSequence.requestMetadata[request]!!
-        private val requestListeners = requestSequence.requestListeners[request]!!
 
         val frameNumber: FrameNumber = FrameNumber(frameCounter.incrementAndGet())
         var timestampNanos: Long? = null
@@ -97,8 +193,8 @@
         fun simulateStarted(timestampNanos: Long) {
             this.timestampNanos = timestampNanos
 
-            for (listener in requestListeners) {
-                listener.onStarted(requestMetadata, frameNumber, CameraTimestamp(timestampNanos))
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onStarted(requestMetadata, frameNumber, CameraTimestamp(timestampNanos))
             }
         }
 
@@ -113,8 +209,8 @@
                 extraMetadata = extraMetadata
             )
 
-            for (listener in requestListeners) {
-                listener.onPartialCaptureResult(requestMetadata, frameNumber, metadata)
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onPartialCaptureResult(requestMetadata, frameNumber, metadata)
             }
         }
 
@@ -134,8 +230,8 @@
                 createFakePhysicalMetadata(physicalResultMetadata)
             )
 
-            for (listener in requestListeners) {
-                listener.onTotalCaptureResult(requestMetadata, frameNumber, frameInfo)
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onTotalCaptureResult(requestMetadata, frameNumber, frameInfo)
             }
         }
 
@@ -155,26 +251,26 @@
                 createFakePhysicalMetadata(physicalResultMetadata)
             )
 
-            for (listener in requestListeners) {
-                listener.onComplete(requestMetadata, frameNumber, frameInfo)
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onComplete(requestMetadata, frameNumber, frameInfo)
             }
         }
 
         fun simulateFailure(captureFailure: CaptureFailure) {
-            for (listener in requestListeners) {
-                listener.onFailed(requestMetadata, frameNumber, captureFailure)
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onFailed(requestMetadata, frameNumber, captureFailure)
             }
         }
 
         fun simulateBufferLoss(streamId: StreamId) {
-            for (listener in requestListeners) {
-                listener.onBufferLost(requestMetadata, frameNumber, streamId)
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onBufferLost(requestMetadata, frameNumber, streamId)
             }
         }
 
         fun simulateAbort() {
-            for (listener in requestListeners) {
-                listener.onAborted(request)
+            requestSequence.invokeOnRequest(requestMetadata) {
+                it.onAborted(request)
             }
         }
 
@@ -193,7 +289,7 @@
             extraResultMetadata: Map<Metadata.Key<*>, Any?> = emptyMap(),
             extraMetadata: Map<*, Any?> = emptyMap<Any, Any>(),
         ): FakeFrameMetadata = FakeFrameMetadata(
-            camera = config.camera,
+            camera = cameraMetadata.camera,
             frameNumber = frameNumber,
             resultMetadata = resultMetadata.toMap(),
             extraResultMetadata = extraResultMetadata.toMap(),
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
new file mode 100644
index 0000000..aa1ab5f
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CameraBackend
+import androidx.camera.camera2.pipe.CameraBackendId
+import androidx.camera.camera2.pipe.CameraContext
+import androidx.camera.camera2.pipe.CameraController
+import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.CameraMetadata
+import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.graph.GraphListener
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+
+/**
+ * The FakeCameraBackend implements [CameraBackend] and creates [CameraControllerSimulator]s.
+ */
+@RequiresApi(21)
+class FakeCameraBackend(private val fakeCameras: Map<CameraId, CameraMetadata>) : CameraBackend {
+    private val lock = Any()
+    private val fakeCameraIds = fakeCameras.keys.toList()
+
+    private val _cameraControllers = mutableListOf<CameraControllerSimulator>()
+    val cameraControllers: List<CameraControllerSimulator>
+        get() = synchronized(lock) { _cameraControllers.toList() }
+
+    override val id: CameraBackendId
+        get() = FAKE_CAMERA_BACKEND
+
+    override fun readCameraIdList(): List<CameraId> = fakeCameraIds
+    override fun readCameraMetadata(cameraId: CameraId): CameraMetadata =
+        checkNotNull(fakeCameras[cameraId]) {
+            "fakeCameras does not contain $cameraId. Available cameras are: $fakeCameras"
+        }
+
+    override fun disconnectAllAsync(): Deferred<Unit> {
+        _cameraControllers.forEach {
+            it.simulateCameraStopped()
+        }
+        return CompletableDeferred(Unit)
+    }
+
+    override fun shutdownAsync(): Deferred<Unit> {
+        _cameraControllers.forEach {
+            it.simulateCameraStopped()
+        }
+        return CompletableDeferred(Unit)
+    }
+
+    override fun createCameraController(
+        cameraContext: CameraContext,
+        graphConfig: CameraGraph.Config,
+        graphListener: GraphListener,
+        streamGraph: StreamGraph
+    ): CameraController {
+        val cameraController = CameraControllerSimulator(
+            cameraContext,
+            graphConfig,
+            graphListener,
+            streamGraph
+        )
+        synchronized(lock) {
+            _cameraControllers.add(cameraController)
+        }
+        return cameraController
+    }
+
+    companion object {
+        private val FAKE_CAMERA_BACKEND = CameraBackendId("camerapipe.testing.fake_backend")
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequence.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequence.kt
new file mode 100644
index 0000000..fec4b9e
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequence.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+
+@file:RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+
+package androidx.camera.camera2.pipe.testing
+
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.FrameNumber
+import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.RequestMetadata
+import androidx.camera.camera2.pipe.CaptureSequence
+import androidx.camera.camera2.pipe.CaptureSequences.invokeOnRequests
+
+/** A CaptureSequence used for testing interactions with a [FakeCaptureSequenceProcessor] **/
+data class FakeCaptureSequence(
+    override val repeating: Boolean,
+    override val cameraId: CameraId,
+    override val captureRequestList: List<Request>,
+    override val captureMetadataList: List<RequestMetadata>,
+    val requestMetadata: Map<Request, RequestMetadata>,
+    val defaultParameters: Map<*, Any?>,
+    val requiredParameters: Map<*, Any?>,
+    override val listeners: List<Request.Listener>,
+    override val sequenceListener: CaptureSequence.CaptureSequenceListener,
+    override var sequenceNumber: Int,
+) : CaptureSequence<Request> {
+    fun invokeOnSequenceCreated() = invokeOnRequests { requestMetadata, _, listener ->
+        listener.onRequestSequenceCreated(requestMetadata)
+    }
+
+    fun invokeOnSequenceSubmitted() = invokeOnRequests { requestMetadata, _, listener ->
+        listener.onRequestSequenceSubmitted(requestMetadata)
+    }
+
+    fun invokeOnSequenceAborted() = invokeOnRequests { requestMetadata, _, listener ->
+        listener.onRequestSequenceAborted(requestMetadata)
+    }
+
+    fun invokeOnSequenceCompleted(frameNumber: FrameNumber) =
+        invokeOnRequests { requestMetadata, _, listener ->
+            listener.onRequestSequenceCompleted(requestMetadata, frameNumber)
+        }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceListener.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceListener.kt
new file mode 100644
index 0000000..f56f154
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceListener.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import androidx.camera.camera2.pipe.CaptureSequence
+import androidx.camera.camera2.pipe.CaptureSequence.CaptureSequenceListener
+
+/***
+ * Fake [CaptureSequenceListener] instance that can be used to check to see if
+ * [onCaptureSequenceComplete] has been invoked.
+ */
+class FakeCaptureSequenceListener : CaptureSequenceListener {
+    var isComplete: Boolean = false
+        private set
+
+    override fun onCaptureSequenceComplete(captureSequence: CaptureSequence<*>) {
+        isComplete = true
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt
new file mode 100644
index 0000000..d3a362f
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt
@@ -0,0 +1,298 @@
+/*
+ * Copyright 2021 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.
+ */
+
+@file:RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+
+package androidx.camera.camera2.pipe.testing
+
+import android.hardware.camera2.CaptureRequest
+import android.view.Surface
+import androidx.annotation.GuardedBy
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.Metadata
+import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.RequestMetadata
+import androidx.camera.camera2.pipe.RequestNumber
+import androidx.camera.camera2.pipe.RequestTemplate
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.CaptureSequence.CaptureSequenceListener
+import androidx.camera.camera2.pipe.CaptureSequenceProcessor
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.withTimeout
+
+/**
+ * Fake implementation of a [CaptureSequenceProcessor] that passes events to a [Channel].
+ *
+ * This allows kotlin tests to check sequences of interactions that dispatch in the background
+ * without blocking between events.
+ */
+class FakeCaptureSequenceProcessor(
+    private val cameraId: CameraId = CameraId("test-camera"),
+    private val defaultTemplate: RequestTemplate = RequestTemplate(1)
+) : CaptureSequenceProcessor<Request, FakeCaptureSequence> {
+    private val lock = Any()
+    private val sequenceIds = atomic(0)
+    private val eventChannel = Channel<Event>(Channel.UNLIMITED)
+    private val requestCounter = atomic(0L)
+
+    @GuardedBy("lock")
+    private var pendingSequence: CompletableDeferred<FakeCaptureSequence>? = null
+
+    @GuardedBy("lock")
+    private val queue: MutableList<FakeCaptureSequence> = mutableListOf()
+
+    @GuardedBy("lock")
+    private var repeatingRequestSequence: FakeCaptureSequence? = null
+
+    @GuardedBy("lock")
+    private var _rejectRequests = false
+
+    var rejectRequests: Boolean
+        get() = synchronized(lock) {
+            _rejectRequests
+        }
+        set(value) {
+            synchronized(lock) {
+                _rejectRequests = value
+            }
+        }
+
+    private var _surfaceMap: Map<StreamId, Surface> = emptyMap()
+    var surfaceMap: Map<StreamId, Surface>
+        get() = synchronized(lock) {
+            _surfaceMap
+        }
+        set(value) = synchronized(lock) {
+            _surfaceMap = value
+        }
+
+    override fun build(
+        isRepeating: Boolean,
+        requests: List<Request>,
+        defaultParameters: Map<*, Any?>,
+        requiredParameters: Map<*, Any?>,
+        listeners: List<Request.Listener>,
+        sequenceListener: CaptureSequenceListener
+    ): FakeCaptureSequence? {
+        return buildFakeCaptureSequence(
+            repeating = isRepeating,
+            requests,
+            defaultParameters,
+            requiredParameters,
+            listeners
+        )
+    }
+
+    override fun submit(captureSequence: FakeCaptureSequence): Int {
+        synchronized(lock) {
+            if (rejectRequests) {
+                check(
+                    eventChannel
+                        .trySend(Event(requestSequence = captureSequence, rejected = true))
+                        .isSuccess
+                )
+                return -1
+            }
+            queue.add(captureSequence)
+            check(
+                eventChannel
+                    .trySend(Event(requestSequence = captureSequence, submit = true))
+                    .isSuccess
+            )
+            // If there is a non-null pending sequence, make sure we complete it here.
+            pendingSequence?.also {
+                pendingSequence = null
+                it.complete(captureSequence)
+            }
+            return sequenceIds.incrementAndGet()
+        }
+    }
+
+    override fun abortCaptures() {
+        val requestSequencesToAbort: List<FakeCaptureSequence>
+        synchronized(lock) {
+            requestSequencesToAbort = queue.toList()
+            queue.clear()
+            check(eventChannel.trySend(Event(abort = true)).isSuccess)
+        }
+        for (sequence in requestSequencesToAbort) {
+            sequence.invokeOnSequenceAborted()
+        }
+    }
+
+    override fun stopRepeating() {
+        val requestSequence = synchronized(lock) {
+            check(eventChannel.trySend(Event(stop = true)).isSuccess)
+            repeatingRequestSequence.also {
+                repeatingRequestSequence = null
+            }
+        }
+        requestSequence?.invokeOnSequenceAborted()
+    }
+
+    override fun close() {
+        synchronized(lock) {
+            rejectRequests = true
+            check(eventChannel.trySend(Event(close = true)).isSuccess)
+        }
+    }
+
+    /**
+     * Get the next event from queue with an option to specify a timeout for tests.
+     */
+    suspend fun nextEvent(timeMillis: Long = 500): Event = withTimeout(timeMillis) {
+        eventChannel.receive()
+    }
+
+    suspend fun nextRequestSequence(): FakeCaptureSequence {
+        while (true) {
+            val pending: Deferred<FakeCaptureSequence>
+            synchronized(lock) {
+                var sequence = queue.removeFirstOrNull()
+                if (sequence == null) {
+                    sequence = repeatingRequestSequence
+                }
+                if (sequence != null) {
+                    return sequence
+                }
+
+                if (pendingSequence == null) {
+                    pendingSequence = CompletableDeferred()
+                }
+                pending = pendingSequence!!
+            }
+
+            pending.await()
+        }
+    }
+
+    private fun buildFakeCaptureSequence(
+        repeating: Boolean,
+        requests: List<Request>,
+        defaultParameters: Map<*, Any?>,
+        requiredParameters: Map<*, Any?>,
+        defaultListeners: List<Request.Listener>,
+    ): FakeCaptureSequence? {
+        val surfaceMap = surfaceMap
+        val requestInfoMap = mutableMapOf<Request, RequestMetadata>()
+        val requestInfoList = mutableListOf<RequestMetadata>()
+        for (request in requests) {
+            val captureParameters = mutableMapOf<CaptureRequest.Key<*>, Any?>()
+            val metadataParameters = mutableMapOf<Metadata.Key<*>, Any?>()
+            for ((k, v) in defaultParameters) {
+                if (k != null) {
+                    if (k is CaptureRequest.Key<*>) {
+                        captureParameters[k] = v
+                    } else if (k is Metadata.Key<*>) {
+                        metadataParameters[k] = v
+                    }
+                }
+            }
+            for ((k, v) in request.parameters) {
+                captureParameters[k] = v
+            }
+            for ((k, v) in requiredParameters) {
+                if (k != null) {
+                    if (k is CaptureRequest.Key<*>) {
+                        captureParameters[k] = v
+                    } else if (k is Metadata.Key<*>) {
+                        metadataParameters[k] = v
+                    }
+                }
+            }
+
+            val requestNumber = RequestNumber(requestCounter.incrementAndGet())
+            val streamMap = mutableMapOf<StreamId, Surface>()
+            for (stream in request.streams) {
+                val surface = surfaceMap[stream]
+                if (surface == null) {
+                    println("No surface was set for $stream while building request $request")
+                    return null
+                }
+                streamMap[stream] = surface
+            }
+
+            val requestMetadata = FakeRequestMetadata(
+                request = request,
+                requestParameters = captureParameters,
+                metadata = metadataParameters,
+                template = request.template ?: defaultTemplate,
+                streams = streamMap,
+                repeating = repeating,
+                requestNumber = requestNumber
+            )
+            requestInfoList.add(requestMetadata)
+            requestInfoMap[request] = requestMetadata
+        }
+
+        // Copy maps / lists for tests.
+        return FakeCaptureSequence(
+            repeating = repeating,
+            cameraId = cameraId,
+            captureRequestList = requests.toList(),
+            captureMetadataList = requestInfoList,
+            requestMetadata = requestInfoMap,
+            defaultParameters = defaultParameters.toMap(),
+            requiredParameters = requiredParameters.toMap(),
+            listeners = defaultListeners.toList(),
+            sequenceListener = FakeCaptureSequenceListener(),
+            sequenceNumber = -1
+        )
+    }
+
+    /**
+     * TODO: It's probably better to model this as a sealed class.
+     */
+    data class Event(
+        val requestSequence: FakeCaptureSequence? = null,
+        val rejected: Boolean = false,
+        val abort: Boolean = false,
+        val close: Boolean = false,
+        val stop: Boolean = false,
+        val submit: Boolean = false
+    )
+
+    companion object {
+        suspend fun FakeCaptureSequenceProcessor.awaitEvent(
+            request: Request? = null,
+            filter: (event: Event) -> Boolean
+        ): Event {
+
+            var event: Event
+            var loopCount = 0
+            while (loopCount < 10) {
+                loopCount++
+                event = this.nextEvent()
+
+                if (request != null) {
+                    val contains =
+                        event.requestSequence?.captureRequestList?.contains(request) ?: false
+                    if (filter(event) && contains) {
+                        return event
+                    }
+                } else if (filter(event)) {
+                    return event
+                }
+            }
+
+            throw IllegalStateException("Failed to observe a submit event containing $request")
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeRequestProcessor.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeRequestProcessor.kt
deleted file mode 100644
index 190d8c0..0000000
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeRequestProcessor.kt
+++ /dev/null
@@ -1,372 +0,0 @@
-/*
- * Copyright 2021 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.
- */
-
-@file:RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-
-package androidx.camera.camera2.pipe.testing
-
-import android.hardware.camera2.CaptureRequest
-import android.view.Surface
-import androidx.annotation.GuardedBy
-import androidx.annotation.RequiresApi
-import androidx.camera.camera2.pipe.FrameNumber
-import androidx.camera.camera2.pipe.Metadata
-import androidx.camera.camera2.pipe.Request
-import androidx.camera.camera2.pipe.RequestMetadata
-import androidx.camera.camera2.pipe.RequestNumber
-import androidx.camera.camera2.pipe.RequestTemplate
-import androidx.camera.camera2.pipe.StreamId
-import androidx.camera.camera2.pipe.RequestProcessor
-import kotlinx.atomicfu.atomic
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.withTimeout
-
-/**
- * Fake implementation of a [RequestProcessor] that passes events to a [Channel].
- *
- * This allows kotlin tests to check sequences of interactions that dispatch in the background
- * without blocking between events.
- */
-public class FakeRequestProcessor(
-    private val streamToSurfaceMap: Map<StreamId, Surface> = emptyMap(),
-    private val defaultTemplate: RequestTemplate = RequestTemplate(1)
-) : RequestProcessor {
-    private val lock = Any()
-    private val eventChannel = Channel<Event>(Channel.UNLIMITED)
-    private val requestCounter = atomic(0L)
-
-    @GuardedBy("lock")
-    private var pendingSequence: CompletableDeferred<RequestSequence>? = null
-
-    @GuardedBy("lock")
-    private val requestSequenceQueue: MutableList<RequestSequence> = mutableListOf()
-
-    @GuardedBy("lock")
-    private var repeatingRequestSequence: RequestSequence? = null
-
-    @GuardedBy("lock")
-    private var _rejectRequests = false
-
-    var rejectRequests: Boolean
-        get() = synchronized(lock) {
-            _rejectRequests
-        }
-        set(value) {
-            synchronized(lock) {
-                _rejectRequests = value
-            }
-        }
-
-    override fun submit(
-        request: Request,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>
-    ): Boolean = submit(listOf(request), defaultParameters, requiredParameters, defaultListeners)
-
-    override fun submit(
-        requests: List<Request>,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>
-    ): Boolean {
-        val requestSequence =
-            createRequestSequence(
-                repeating = false,
-                requests,
-                defaultParameters,
-                requiredParameters,
-                defaultListeners
-            )
-        if (rejectRequests) {
-            check(
-                eventChannel
-                    .trySend(Event(requestSequence = requestSequence, rejected = true))
-                    .isSuccess
-            )
-            return false
-        }
-
-        val signal = synchronized(lock) {
-            requestSequenceQueue.add(requestSequence)
-            pendingSequence?.also {
-                pendingSequence = null
-            }
-        }
-        requestSequence.invokeOnSequenceCreated()
-        requestSequence.invokeOnSequenceSubmitted()
-        signal?.complete(requestSequence)
-
-        check(
-            eventChannel
-                .trySend(Event(requestSequence = requestSequence, submit = true))
-                .isSuccess
-        )
-
-        return true
-    }
-
-    override fun startRepeating(
-        request: Request,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>
-    ): Boolean {
-        val requestSequence =
-            createRequestSequence(
-                repeating = true,
-                listOf(request),
-                defaultParameters,
-                requiredParameters,
-                defaultListeners
-            )
-        if (rejectRequests) {
-            check(
-                eventChannel
-                    .trySend(Event(requestSequence = requestSequence, rejected = true))
-                    .isSuccess
-            )
-            return false
-        }
-
-        val signal = synchronized(lock) {
-            repeatingRequestSequence = requestSequence
-            pendingSequence?.also {
-                pendingSequence = null
-            }
-        }
-        requestSequence.invokeOnSequenceCreated()
-        requestSequence.invokeOnSequenceSubmitted()
-        signal?.complete(requestSequence)
-
-        check(
-            eventChannel
-                .trySend(Event(requestSequence = requestSequence, startRepeating = true))
-                .isSuccess
-        )
-        return true
-    }
-
-    override fun abortCaptures() {
-        val requestSequencesToAbort: List<RequestSequence>
-        synchronized(lock) {
-            requestSequencesToAbort = requestSequenceQueue.toList()
-            requestSequenceQueue.clear()
-        }
-        for (sequence in requestSequencesToAbort) {
-            sequence.invokeOnSequenceAborted()
-        }
-        check(eventChannel.trySend(Event(abort = true)).isSuccess)
-    }
-
-    override fun stopRepeating() {
-        val requestSequence = synchronized(lock) {
-            repeatingRequestSequence.also {
-                repeatingRequestSequence = null
-            }
-        }
-        requestSequence?.invokeOnSequenceAborted()
-        check(eventChannel.trySend(Event(stop = true)).isSuccess)
-    }
-
-    override fun close() {
-        synchronized(lock) {
-            rejectRequests = true
-        }
-        check(eventChannel.trySend(Event(close = true)).isSuccess)
-    }
-
-    /**
-     * Get the next event from queue with an option to specify a timeout for tests.
-     */
-    suspend fun nextEvent(timeMillis: Long = 500): Event = withTimeout(timeMillis) {
-        eventChannel.receive()
-    }
-
-    suspend fun nextRequestSequence(): RequestSequence {
-
-        while (true) {
-            val pending: Deferred<RequestSequence>
-            synchronized(lock) {
-                var sequence = requestSequenceQueue.removeFirstOrNull()
-                if (sequence == null) {
-                    sequence = repeatingRequestSequence
-                }
-                if (sequence != null) {
-                    return sequence
-                }
-
-                if (pendingSequence == null) {
-                    pendingSequence = CompletableDeferred()
-                }
-                pending = pendingSequence!!
-            }
-
-            pending.await()
-        }
-    }
-
-    private fun createRequestSequence(
-        repeating: Boolean,
-        requests: List<Request>,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>,
-    ): RequestSequence {
-        val requestInfoMap = mutableMapOf<Request, RequestMetadata>()
-        val requestListenerMap = mutableMapOf<Request, List<Request.Listener>>()
-        for (request in requests) {
-            val captureParameters = mutableMapOf<CaptureRequest.Key<*>, Any?>()
-            val metadataParameters = mutableMapOf<Metadata.Key<*>, Any?>()
-            for ((k, v) in defaultParameters) {
-                if (k != null) {
-                    if (k is CaptureRequest.Key<*>) {
-                        captureParameters[k] = v
-                    } else if (k is Metadata.Key<*>) {
-                        metadataParameters[k] = v
-                    }
-                }
-            }
-            for ((k, v) in request.parameters) {
-                captureParameters[k] = v
-            }
-            for ((k, v) in requiredParameters) {
-                if (k != null) {
-                    if (k is CaptureRequest.Key<*>) {
-                        captureParameters[k] = v
-                    } else if (k is Metadata.Key<*>) {
-                        metadataParameters[k] = v
-                    }
-                }
-            }
-            val listeners = mutableListOf<Request.Listener>()
-            listeners.addAll(defaultListeners)
-            listeners.addAll(request.listeners)
-
-            val requestNumber = RequestNumber(requestCounter.incrementAndGet())
-
-            val requestMetadata = FakeRequestMetadata(
-                request = request,
-                requestParameters = captureParameters,
-                metadata = metadataParameters,
-                template = request.template ?: defaultTemplate,
-                streams = streamToSurfaceMap,
-                repeating = repeating,
-                requestNumber = requestNumber
-            )
-            requestInfoMap[request] = requestMetadata
-            requestListenerMap[request] = listeners
-        }
-
-        // Copy maps / lists for tests.
-        return RequestSequence(
-            requests = requests.toList(),
-            defaultParameters = defaultParameters.toMap(),
-            requiredParameters = requiredParameters.toMap(),
-            defaultListeners = defaultListeners.toList(),
-            requestMetadata = requestInfoMap,
-            requestListeners = requestListenerMap
-        )
-    }
-
-    fun reset() {
-        synchronized(lock) {
-            requestSequenceQueue.clear()
-            repeatingRequestSequence = null
-            _rejectRequests = false
-        }
-    }
-
-    data class RequestSequence(
-        val requests: List<Request>,
-        val defaultParameters: Map<*, Any?>,
-        val requiredParameters: Map<*, Any?>,
-        val defaultListeners: List<Request.Listener>,
-        val requestMetadata: Map<Request, RequestMetadata>,
-        val requestListeners: Map<Request, List<Request.Listener>>
-    ) {
-        fun invokeOnSequenceCreated() {
-            for (request in requests) {
-                for (listener in requestListeners[request]!!) {
-                    listener.onRequestSequenceCreated(requestMetadata[request]!!)
-                }
-            }
-        }
-
-        fun invokeOnSequenceSubmitted() {
-            for (request in requests) {
-                for (listener in requestListeners[request]!!) {
-                    listener.onRequestSequenceSubmitted(requestMetadata[request]!!)
-                }
-            }
-        }
-
-        fun invokeOnSequenceAborted() {
-            for (request in requests) {
-                for (listener in requestListeners[request]!!) {
-                    listener.onRequestSequenceAborted(requestMetadata[request]!!)
-                }
-            }
-        }
-
-        fun invokeOnSequenceCompleted(frameNumber: FrameNumber) {
-            for (request in requests) {
-                for (listener in requestListeners[request]!!) {
-                    listener.onRequestSequenceCompleted(requestMetadata[request]!!, frameNumber)
-                }
-            }
-        }
-    }
-
-    /**
-     * TODO: It's probably better to model this as a sealed class.
-     */
-    data class Event(
-        val requestSequence: RequestSequence? = null,
-        val rejected: Boolean = false,
-        val abort: Boolean = false,
-        val close: Boolean = false,
-        val stop: Boolean = false,
-        val submit: Boolean = false,
-        val startRepeating: Boolean = false
-    )
-}
-
-suspend fun FakeRequestProcessor.awaitEvent(
-    request: Request? = null,
-    filter: (event: FakeRequestProcessor.Event) -> Boolean
-): FakeRequestProcessor.Event {
-
-    var event: FakeRequestProcessor.Event
-    var loopCount = 0
-    while (loopCount < 10) {
-        loopCount++
-        event = this.nextEvent()
-
-        if (request != null) {
-            val contains = event.requestSequence?.requests?.contains(request) ?: false
-            if (filter(event) && contains) {
-                return event
-            }
-        } else if (filter(event)) {
-            return event
-        }
-    }
-
-    throw IllegalStateException("Failed to observe a submit event containing $request")
-}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeThreads.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeThreads.kt
new file mode 100644
index 0000000..c1c23e0
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeThreads.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.os.Handler
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.core.Threads
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+
+@RequiresApi(21)
+object FakeThreads {
+    fun fromDispatcher(dispatcher: CoroutineDispatcher): Threads {
+        val scope = CoroutineScope(dispatcher.plus(CoroutineName("CXCP-TestScope")))
+        return create(scope, dispatcher)
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun fromTestScope(scope: TestScope): Threads {
+        val dispatcher = StandardTestDispatcher(scope.testScheduler)
+        return create(scope, dispatcher)
+    }
+
+    private fun create(scope: CoroutineScope, dispatcher: CoroutineDispatcher): Threads {
+        val executor = dispatcher.asExecutor()
+
+        @Suppress("deprecation")
+        val fakeHandler = { Handler() }
+
+        return Threads(
+            scope,
+            blockingExecutor = executor,
+            blockingDispatcher = dispatcher,
+            backgroundExecutor = executor,
+            backgroundDispatcher = dispatcher,
+            lightweightExecutor = executor,
+            lightweightDispatcher = dispatcher,
+            camera2Handler = fakeHandler,
+            camera2Executor = { executor }
+        )
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt
index 8b452f0..7b83524 100644
--- a/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt
+++ b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.camera.camera2.pipe.testing
 
+import android.content.Context
 import android.hardware.camera2.CameraCharacteristics
 import android.hardware.camera2.CaptureResult
 import android.os.Build
@@ -32,15 +33,18 @@
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeout
 import kotlinx.coroutines.withTimeoutOrNull
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 public class CameraGraphSimulatorTest {
@@ -58,12 +62,18 @@
         streams = listOf(streamConfig)
     )
 
-    private val simulator = CameraGraphSimulator(graphConfig, metadata)
-    private val stream = simulator.cameraGraph.streams[streamConfig]!!
+    private val context = ApplicationProvider.getApplicationContext() as Context
 
     @Ignore("b/188446185")
     @Test
-    fun simulatorCanSimulateRepeatingFrames() = runBlocking {
+    fun simulatorCanSimulateRepeatingFrames() = runTest {
+        val simulator = CameraGraphSimulator.create(
+            this,
+            context,
+            metadata,
+            graphConfig
+        )
+        val stream = simulator.cameraGraph.streams[streamConfig]!!
         val listener = FakeRequestListener()
         val request = Request(
             streams = listOf(stream.id),
@@ -73,6 +83,7 @@
             it.startRepeating(request)
         }
         simulator.cameraGraph.start()
+        simulator.simulateCameraStarted()
 
         val frame = simulator.simulateNextFrame()
 
@@ -156,7 +167,14 @@
     }
 
     @Test
-    fun simulatorAbortsRequests() = runBlocking {
+    fun simulatorAbortsRequests() = runTest {
+        val simulator = CameraGraphSimulator.create(
+            this,
+            context,
+            metadata,
+            graphConfig
+        )
+        val stream = simulator.cameraGraph.streams[streamConfig]!!
         val listener = FakeRequestListener()
         val request = Request(
             streams = listOf(stream.id),
@@ -173,7 +191,14 @@
     }
 
     @Test
-    fun simulatorCanIssueBufferLoss() = runBlocking {
+    fun simulatorCanIssueBufferLoss() = runTest {
+        val simulator = CameraGraphSimulator.create(
+            this,
+            context,
+            metadata,
+            graphConfig
+        )
+        val stream = simulator.cameraGraph.streams[streamConfig]!!
         val listener = FakeRequestListener()
         val request = Request(
             streams = listOf(stream.id),
@@ -183,7 +208,10 @@
         simulator.cameraGraph.acquireSession().use {
             it.submit(request = request)
         }
+
         simulator.cameraGraph.start()
+        simulator.simulateCameraStarted()
+        simulator.simulateFakeSurfaceConfiguration()
 
         val frame = simulator.simulateNextFrame()
         assertThat(frame.request).isSameInstanceAs(request)
@@ -197,7 +225,14 @@
 
     @Ignore("b/188446185")
     @Test
-    fun simulatorCanIssueMultipleFrames() = runBlocking {
+    fun simulatorCanIssueMultipleFrames() = runTest {
+        val simulator = CameraGraphSimulator.create(
+            this,
+            context,
+            metadata,
+            graphConfig
+        )
+        val stream = simulator.cameraGraph.streams[streamConfig]!!
         val listener = FakeRequestListener()
         val request = Request(
             streams = listOf(stream.id),
@@ -208,6 +243,7 @@
             it.startRepeating(request = request)
         }
         simulator.cameraGraph.start()
+        simulator.simulateCameraStarted()
 
         val frame1 = simulator.simulateNextFrame()
         val frame2 = simulator.simulateNextFrame()
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraController.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraController.kt
index 6a5c5d0..086e22b 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraController.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraController.kt
@@ -30,7 +30,7 @@
  * [GraphListener.onGraphStopped] should be invoked. If the state of the camera changes in any way
  * where a previously submitted request that was previously reject that might now be succeed (such
  * as configuring surfaces such that new surfaces would now be accepted)
- * [GraphListener.onGraphUpdated] should be invoked.
+ * [GraphListener.onGraphModified] should be invoked.
  *
  * Once [close] is invoked, this instance should not respond to any additional events.
  */
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt
index f39e94f..6bddd36 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt
@@ -33,6 +33,8 @@
 import androidx.camera.camera2.pipe.config.ThreadConfigModule
 import java.util.concurrent.Executor
 import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
 
 internal val cameraPipeIds = atomic(0)
 
@@ -98,13 +100,19 @@
      *   split into a separate field since many camera operations are extremely latency sensitive.
      * - [defaultCameraHandler] is used on older API versions to interact with CameraAPIs. This is
      *   split into a separate field since many camera operations are extremely latency sensitive.
+     * - [testOnlyDispatcher] is used for testing to overwrite all internal dispatchers to the
+     *   testOnly version. If specified, default executors and handlers are ignored.
+     * - [testOnlyScope] is used for testing to overwrite the internal global scope with the test
+     *   method scope.
      */
     public data class ThreadConfig(
         val defaultLightweightExecutor: Executor? = null,
         val defaultBackgroundExecutor: Executor? = null,
         val defaultBlockingExecutor: Executor? = null,
         val defaultCameraExecutor: Executor? = null,
-        val defaultCameraHandler: HandlerThread? = null
+        val defaultCameraHandler: HandlerThread? = null,
+        val testOnlyDispatcher: CoroutineDispatcher? = null,
+        val testOnlyScope: CoroutineScope? = null
     )
 
     /**
@@ -152,6 +160,10 @@
      * External may be used if the underlying implementation needs to delegate to another library
      * or system.
      */
+    @Deprecated(
+        "CameraPipe.External is deprecated, use customCameraBackend on " +
+            "GraphConfig instead."
+    )
     class External(threadConfig: ThreadConfig = ThreadConfig()) {
         private val component: ExternalCameraPipeComponent = DaggerExternalCameraPipeComponent
             .builder()
@@ -161,9 +173,12 @@
         /**
          * This creates a new [CameraGraph] instance that is configured to use an externally
          * defined [RequestProcessor].
-         *
-         * TODO: Consider changing cameraDevices to be a single device + physical metadata.
          */
+        @Suppress("DEPRECATION")
+        @Deprecated(
+            "CameraPipe.External is deprecated, use customCameraBackend on " +
+                "GraphConfig instead."
+        )
         public fun create(
             config: CameraGraph.Config,
             cameraMetadata: CameraMetadata,
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CaptureSequence.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CaptureSequence.kt
new file mode 100644
index 0000000..3f2cf42
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CaptureSequence.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe
+
+/**
+ * An ordered list of [TCaptureRequest] objects, listeners, and associated metadata that will be
+ * submitted and captured together when submitted to the camera.
+ *
+ * A CaptureSequence should be created from a [CaptureSequenceProcessor].
+ */
+public interface CaptureSequence<out TCaptureRequest> {
+    val cameraId: CameraId
+    val repeating: Boolean
+    val captureRequestList: List<TCaptureRequest>
+    val captureMetadataList: List<RequestMetadata>
+    val listeners: List<Request.Listener>
+    val sequenceListener: CaptureSequenceListener
+
+    /** This value must be set to the return value of [CaptureSequenceProcessor.submit] */
+    var sequenceNumber: Int
+
+    interface CaptureSequenceListener {
+        fun onCaptureSequenceComplete(captureSequence: CaptureSequence<*>)
+    }
+}
+
+/**
+ * Utility functions for interacting with [CaptureSequence] callbacks and listeners.
+ */
+object CaptureSequences {
+    /**
+     * Efficient, inlined utility function for invoking a call on each of the listeners defined on a
+     * [CaptureSequence] instance using the provided [RequestMetadata] object.
+     */
+    inline fun <T> CaptureSequence<T>.invokeOnRequests(
+        crossinline fn: (RequestMetadata, Int, Request.Listener) -> Any
+    ) {
+        // Always invoke the internal listener first on all of the internal listeners for the
+        // entire sequence before invoking the listeners specified in the specific requests
+        for (i in captureMetadataList.indices) {
+            val request = captureMetadataList[i]
+            for (listenerIndex in listeners.indices) {
+                fn(request, i, listeners[listenerIndex])
+            }
+        }
+
+        // Invoke the listeners that were defined on the individual requests.
+        for (i in captureMetadataList.indices) {
+            val request = captureMetadataList[i]
+            for (listenerIndex in request.request.listeners.indices) {
+                fn(request, i, request.request.listeners[listenerIndex])
+            }
+        }
+    }
+
+    /**
+     * Efficient, inlined utility function for invoking a call on each of the listeners defined on a
+     * [CaptureSequence] instance using the provided [RequestMetadata] object.
+     */
+    inline fun <T> CaptureSequence<T>.invokeOnRequest(
+        request: RequestMetadata,
+        crossinline fn: (Request.Listener) -> Any
+    ) {
+        // Always invoke the sequence listeners first so that internal state can be updated before
+        // specific requests receive the callback.
+        for (i in listeners.indices) {
+            fn(listeners[i])
+        }
+
+        // Invoke the listeners that were defined on this request.
+        for (i in request.request.listeners.indices) {
+            fn(request.request.listeners[i])
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CaptureSequenceProcessor.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CaptureSequenceProcessor.kt
new file mode 100644
index 0000000..0fcecd7
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CaptureSequenceProcessor.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe
+
+/** Create and submit [CaptureSequence]s to an active camera instance. */
+public interface CaptureSequenceProcessor<
+    out TCaptureRequest, TCaptureSequence : CaptureSequence<TCaptureRequest>> {
+
+    /**
+     * Build a [CaptureSequence] instance.
+     *
+     * @param isRepeating determines if this CaptureSequence should repeat until replaced by another
+     * repeating CaptureSequence, or closed, or stopRepeating is invoked.
+     * @param requests the list of [Request] to use when constructing this [CaptureSequence]
+     * @param defaultParameters are the parameters to start with when building an individual
+     * [TCaptureRequest] object. Parameters not specified on a [Request] will use these parameters
+     * by default.
+     * @param requiredParameters are parameters that will override all [defaultParameters] *and*
+     * parameters that are defined on the [Request].
+     * @param listeners are global and internal [Request.Listener]s that should be invoked every
+     * time the listeners on the [Request] are invoked. Since these often track and update internal
+     * state they should be invoked before listeners on the individual [Request].
+     * @param sequenceListener is an extra listener that should be invoked whenever a specific
+     * [CaptureSequence] should no longer receive any additional events.
+     *
+     * @return a [TCaptureSequence] instance that can be used to capture images using the underlying
+     * camera by passing this [submit]. This method will return null if the underlying camera has
+     * been closed or disconnected, and will throw unchecked exceptions if invalid values are passed
+     * to the [build] call.
+     */
+    fun build(
+        isRepeating: Boolean,
+        requests: List<Request>,
+        defaultParameters: Map<*, Any?>,
+        requiredParameters: Map<*, Any?>,
+        listeners: List<Request.Listener>,
+        sequenceListener: CaptureSequence.CaptureSequenceListener,
+    ): TCaptureSequence?
+
+    /** Issue a previously created [CaptureSequence] to the active camera instance. */
+    fun submit(captureSequence: TCaptureSequence): Int
+
+    /**
+     * Opportunistically abort any ongoing captures by the camera. This may or may not complete
+     * quickly depending on the underlying camera.
+     */
+    fun abortCaptures()
+
+    /** Opportunistically cancel any currently active repeating [TCaptureSequence]. */
+    fun stopRepeating()
+
+    /**
+     * Signal that this [CaptureSequenceProcessor] is no longer in use. Active requests may continue
+     * to be processed, and [abortCaptures] and [stopRepeating] may still be invoked.
+     */
+    fun close()
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/RequestProcessor.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/RequestProcessor.kt
index d0b3373..97475b8 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/RequestProcessor.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/RequestProcessor.kt
@@ -34,6 +34,7 @@
  * - Callbacks are expected to be invoked at *very* high frequency.
  * - One RequestProcessor instance per CameraCaptureSession
  */
+@Deprecated("Use CaptureSequence and CaptureSequenceProcessor instead.")
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 interface RequestProcessor {
 
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
index c216774..4ac4bba 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
@@ -44,13 +44,14 @@
     private val config: CameraGraph.Config,
     private val graphListener: GraphListener,
     private val captureSessionFactory: CaptureSessionFactory,
-    private val requestProcessorFactory: Camera2RequestProcessorFactory,
+    private val captureSequenceProcessorFactory: Camera2CaptureSequenceProcessorFactory,
     private val virtualCameraManager: VirtualCameraManager,
     private val cameraSurfaceManager: CameraSurfaceManager
 ) : CameraController {
+    private var closed = false
     private var currentCamera: VirtualCamera? = null
     private var currentSession: VirtualSessionState? = null
-    private var surfaceMap: Map<StreamId, Surface>? = null
+    private var currentSurfaceMap: Map<StreamId, Surface>? = null
 
     override fun start() {
         val camera = virtualCameraManager.open(
@@ -58,6 +59,10 @@
             config.flags.allowMultipleActiveCameras
         )
         synchronized(this) {
+            if (closed) {
+                return
+            }
+
             check(currentCamera == null)
             check(currentSession == null)
 
@@ -65,15 +70,15 @@
             val session = VirtualSessionState(
                 graphListener,
                 captureSessionFactory,
-                requestProcessorFactory,
+                captureSequenceProcessorFactory,
                 cameraSurfaceManager,
                 scope
             )
             currentSession = session
 
-            val surfaces: Map<StreamId, Surface>? = surfaceMap
+            val surfaces: Map<StreamId, Surface>? = currentSurfaceMap
             if (surfaces != null) {
-                session.onSurfaceMapUpdated(surfaces)
+                session.configureSurfaceMap(surfaces)
             }
         }
         scope.launch { bindSessionToCamera() }
@@ -83,6 +88,10 @@
         val camera: VirtualCamera?
         val session: VirtualSessionState?
         synchronized(this) {
+            if (closed) {
+                return
+            }
+
             camera = currentCamera
             session = currentSession
 
@@ -97,16 +106,35 @@
     }
 
     override fun close() {
-        // TODO: Consider changing the behavior so that start / stop are not invokable after calling
-        //   close.
-        stop()
+        val camera: VirtualCamera?
+        val session: VirtualSessionState?
+        synchronized(this) {
+            if (closed) {
+                return
+            }
+            closed = true
+            camera = currentCamera
+            session = currentSession
+
+            currentCamera = null
+            currentSession = null
+        }
+
+        scope.launch {
+            session?.disconnect()
+            camera?.disconnect()
+        }
     }
 
     override fun updateSurfaceMap(surfaceMap: Map<StreamId, Surface>) {
+        // TODO: Add logic to decide if / when to re-configure the Camera2 CaptureSession.
         synchronized(this) {
-            this.surfaceMap = surfaceMap
-            currentSession?.onSurfaceMapUpdated(surfaceMap)
-        }
+            if (closed) {
+                return
+            }
+            currentSurfaceMap = surfaceMap
+            currentSession
+        }?.configureSurfaceMap(surfaceMap)
     }
 
     private suspend fun bindSessionToCamera() {
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequence.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequence.kt
index b2e4c52..59b7f4f 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequence.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequence.kt
@@ -30,6 +30,9 @@
 import androidx.camera.camera2.pipe.RequestMetadata
 import androidx.camera.camera2.pipe.RequestNumber
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.CaptureSequence
+import androidx.camera.camera2.pipe.CaptureSequences.invokeOnRequest
+import androidx.camera.camera2.pipe.CaptureSequences.invokeOnRequests
 
 /**
  * This class responds to events from a set of one or more requests. It uses the tag field on
@@ -38,18 +41,20 @@
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 internal class Camera2CaptureSequence(
-    private val internalListeners: List<Request.Listener>,
-    private val requests: Map<RequestNumber, RequestInfo>,
-    private val captureRequests: List<CaptureRequest>,
+    override val cameraId: CameraId,
+    override val repeating: Boolean,
+    override val captureRequestList: List<CaptureRequest>,
+    override val captureMetadataList: List<RequestMetadata>,
+    override val listeners: List<Request.Listener>,
+    override val sequenceListener: CaptureSequence.CaptureSequenceListener,
+    private val requestNumberMap: Map<RequestNumber, RequestMetadata>,
     private val surfaceMap: Map<Surface, StreamId>,
-    private val inFlightRequests: MutableList<Camera2CaptureSequence>,
-    private val camera: CameraId
-) : CameraCaptureSession.CaptureCallback() {
-    private val debugId = requestSequenceDebugIds.incrementAndGet()
+) : CameraCaptureSession.CaptureCallback(), CaptureSequence<CaptureRequest> {
+    private val debugId = captureSequenceDebugIds.incrementAndGet()
 
     @Volatile
     private var _sequenceNumber: Int? = null
-    var sequenceNumber: Int
+    override var sequenceNumber: Int
         get() {
             if (_sequenceNumber == null) {
                 // If the sequence id has not been submitted, it means the call to capture or
@@ -84,7 +89,7 @@
 
         // Load the request and throw if we are not able to find an associated request. Under
         // normal circumstances this should never happen.
-        val request = readRequest(requestNumber)
+        val request = readRequestMetadata(requestNumber)
 
         invokeOnRequest(request) {
             it.onStarted(
@@ -102,11 +107,11 @@
     ) {
         val requestNumber = readRequestNumber(captureRequest)
         val frameNumber = FrameNumber(partialCaptureResult.frameNumber)
-        val frameMetadata = AndroidFrameMetadata(partialCaptureResult, camera)
+        val frameMetadata = AndroidFrameMetadata(partialCaptureResult, cameraId)
 
         // Load the request and throw if we are not able to find an associated request. Under
         // normal circumstances this should never happen.
-        val request = readRequest(requestNumber)
+        val request = readRequestMetadata(requestNumber)
 
         invokeOnRequest(request) {
             it.onPartialCaptureResult(
@@ -122,21 +127,18 @@
         captureRequest: CaptureRequest,
         captureResult: TotalCaptureResult
     ) {
-        // Remove this request from the set of requests that are currently tracked.
-        synchronized(inFlightRequests) {
-            inFlightRequests.remove(this)
-        }
+        sequenceListener.onCaptureSequenceComplete(this)
 
         val requestNumber = readRequestNumber(captureRequest)
         val frameNumber = FrameNumber(captureResult.frameNumber)
 
         // Load the request and throw if we are not able to find an associated request. Under
         // normal circumstances this should never happen.
-        val request = readRequest(requestNumber)
+        val request = readRequestMetadata(requestNumber)
 
         val frameInfo = AndroidFrameInfo(
             captureResult,
-            camera,
+            cameraId,
             request
         )
 
@@ -164,17 +166,14 @@
         captureRequest: CaptureRequest,
         captureFailure: CaptureFailure
     ) {
-        // Remove this request from the set of requests that are currently tracked.
-        synchronized(inFlightRequests) {
-            inFlightRequests.remove(this)
-        }
+        sequenceListener.onCaptureSequenceComplete(this)
 
         val requestNumber = readRequestNumber(captureRequest)
         val frameNumber = FrameNumber(captureFailure.frameNumber)
 
         // Load the request and throw if we are not able to find an associated request. Under
         // normal circumstances this should never happen.
-        val request = readRequest(requestNumber)
+        val request = readRequestMetadata(requestNumber)
 
         invokeOnRequest(request) {
             it.onFailed(
@@ -199,7 +198,7 @@
 
         // Load the request and throw if we are not able to find an associated request. Under
         // normal circumstances this should never happen.
-        val request = readRequest(requestNumber)
+        val request = readRequestMetadata(requestNumber)
 
         invokeOnRequest(request) {
             it.onBufferLost(
@@ -210,38 +209,16 @@
         }
     }
 
-    /**
-     * Custom implementation that informs all listeners that the request had not completed when
-     * abort was called.
-     */
-    fun invokeOnAborted() {
-        invokeOnRequests { request, _, listener ->
-            listener.onAborted(request.request)
-        }
-    }
-
-    fun invokeOnRequestSequenceCreated() {
-        invokeOnRequests { request, _, listener ->
-            listener.onRequestSequenceCreated(request)
-        }
-    }
-
-    fun invokeOnRequestSequenceSubmitted() {
-        invokeOnRequests { request, _, listener ->
-            listener.onRequestSequenceSubmitted(request)
-        }
-    }
-
     override fun onCaptureSequenceCompleted(
         captureSession: CameraCaptureSession,
         captureSequenceId: Int,
         captureFrameNumber: Long
     ) {
+        sequenceListener.onCaptureSequenceComplete(this)
+
         check(sequenceNumber == captureSequenceId) {
-            "Complete was invoked on $sequenceNumber, but the sequence was not fully submitted!"
-        }
-        synchronized(inFlightRequests) {
-            inFlightRequests.remove(this)
+            "onCaptureSequenceCompleted was invoked on $sequenceNumber, but expected " +
+                "$captureSequenceId!"
         }
 
         val frameNumber = FrameNumber(captureFrameNumber)
@@ -254,13 +231,11 @@
         captureSession: CameraCaptureSession,
         captureSequenceId: Int
     ) {
-        check(sequenceNumber == captureSequenceId) {
-            "Abort was invoked on $sequenceNumber, but the sequence was not fully submitted!"
-        }
+        sequenceListener.onCaptureSequenceComplete(this)
 
-        // Remove this request from the set of requests that are currently tracked.
-        synchronized(inFlightRequests) {
-            inFlightRequests.remove(this)
+        check(sequenceNumber == captureSequenceId) {
+            "onCaptureSequenceAborted was invoked on $sequenceNumber, but expected " +
+                "$captureSequenceId!"
         }
 
         invokeOnRequests { request, _, listener ->
@@ -271,52 +246,11 @@
     private fun readRequestNumber(request: CaptureRequest): RequestNumber =
         checkNotNull(request.tag as RequestNumber)
 
-    private fun readRequest(requestNumber: RequestNumber): RequestInfo {
-        return checkNotNull(requests[requestNumber]) {
+    private fun readRequestMetadata(requestNumber: RequestNumber): RequestMetadata {
+        return checkNotNull(requestNumberMap[requestNumber]) {
             "Unable to find the request for $requestNumber!"
         }
     }
 
-    private inline fun invokeOnRequests(
-        crossinline fn: (RequestMetadata, Int, Request.Listener) -> Any
-    ) {
-
-        // Always invoke the internal listener first on all of the internal listeners for the
-        // entire sequence before invoking the listeners specified in the specific requests
-        for (i in captureRequests.indices) {
-            val requestNumber = readRequestNumber(captureRequests[i])
-            val request = checkNotNull(requests[requestNumber])
-
-            for (listenerIndex in internalListeners.indices) {
-                fn(request, i, internalListeners[listenerIndex])
-            }
-        }
-
-        for (i in captureRequests.indices) {
-            val requestNumber = readRequestNumber(captureRequests[i])
-            val request = checkNotNull(requests[requestNumber])
-
-            for (listenerIndex in request.request.listeners.indices) {
-                fn(request, i, request.request.listeners[listenerIndex])
-            }
-        }
-    }
-
-    private inline fun invokeOnRequest(
-        request: RequestInfo,
-        crossinline fn: (Request.Listener) -> Any
-    ) {
-        // Always invoke the internal listener first so that internal state can be updated before
-        // other listeners ask for it.
-        for (i in internalListeners.indices) {
-            fn(internalListeners[i])
-        }
-
-        // Invoke the listeners that were defined on this request.
-        for (i in request.request.listeners.indices) {
-            fn(request.request.listeners[i])
-        }
-    }
-
-    override fun toString(): String = "CaptureSequence-$debugId"
+    override fun toString(): String = "Camera2CaptureSequence-$debugId"
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2RequestProcessor.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequenceProcessor.kt
similarity index 60%
rename from camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2RequestProcessor.kt
rename to camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequenceProcessor.kt
index fe1a51e0..b831791 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2RequestProcessor.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequenceProcessor.kt
@@ -18,54 +18,54 @@
 
 package androidx.camera.camera2.pipe.compat
 
-import android.hardware.camera2.CameraAccessException
+import android.hardware.camera2.CameraCaptureSession
 import android.hardware.camera2.CaptureRequest
 import android.util.ArrayMap
 import android.view.Surface
-import androidx.annotation.GuardedBy
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CaptureSequence
+import androidx.camera.camera2.pipe.CaptureSequenceProcessor
 import androidx.camera.camera2.pipe.Metadata
 import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestMetadata
 import androidx.camera.camera2.pipe.RequestNumber
-import androidx.camera.camera2.pipe.RequestProcessor
 import androidx.camera.camera2.pipe.RequestTemplate
 import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.core.Log
 import androidx.camera.camera2.pipe.core.Threads
 import androidx.camera.camera2.pipe.writeParameters
-import java.util.Collections.singletonList
-import java.util.Collections.singletonMap
 import javax.inject.Inject
 import kotlinx.atomicfu.atomic
 
-internal interface Camera2RequestProcessorFactory {
+internal interface Camera2CaptureSequenceProcessorFactory {
     fun create(
         session: CameraCaptureSessionWrapper,
         surfaceMap: Map<StreamId, Surface>
-    ): RequestProcessor
+    ): CaptureSequenceProcessor<*, *>
 }
 
-internal class StandardCamera2RequestProcessorFactory @Inject constructor(
+internal class StandardCamera2CaptureSequenceProcessorFactory @Inject constructor(
     private val threads: Threads,
     private val graphConfig: CameraGraph.Config,
-) : Camera2RequestProcessorFactory {
+) : Camera2CaptureSequenceProcessorFactory {
+    @Suppress("UNCHECKED_CAST")
     override fun create(
         session: CameraCaptureSessionWrapper,
         surfaceMap: Map<StreamId, Surface>
-    ): RequestProcessor =
+    ): CaptureSequenceProcessor<*, CaptureSequence<Any>> {
         @Suppress("SyntheticAccessor")
-        Camera2RequestProcessor(
+        return Camera2CaptureSequenceProcessor(
             session,
             threads,
             graphConfig.defaultTemplate,
             surfaceMap
-        )
+        ) as CaptureSequenceProcessor<Any, CaptureSequence<Any>>
+    }
 }
 
-internal val requestProcessorDebugIds = atomic(0)
-internal val requestSequenceDebugIds = atomic(0L)
+internal val captureSequenceProcessorDebugIds = atomic(0)
+internal val captureSequenceDebugIds = atomic(0L)
 internal val requestTags = atomic(0L)
 internal fun nextRequestTag(): RequestNumber = RequestNumber(requestTags.incrementAndGet())
 
@@ -75,96 +75,27 @@
  * This class is designed to synchronously handle interactions with a [CameraCaptureSessionWrapper].
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-internal class Camera2RequestProcessor(
+internal class Camera2CaptureSequenceProcessor(
     private val session: CameraCaptureSessionWrapper,
     private val threads: Threads,
     private val template: RequestTemplate,
     private val surfaceMap: Map<StreamId, Surface>
-) : RequestProcessor {
-
-    @GuardedBy("inFlightRequests")
-    private val inFlightRequests = mutableListOf<Camera2CaptureSequence>()
-    private val debugId = requestProcessorDebugIds.incrementAndGet()
-    private val closed = atomic(false)
-
-    override fun submit(
-        request: Request,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>
-    ): Boolean {
-        return configureAndCapture(
-            singletonList(request),
-            defaultParameters,
-            requiredParameters,
-            defaultListeners,
-            isRepeating = false
-        )
-    }
-
-    override fun submit(
+) : CaptureSequenceProcessor<CaptureRequest, Camera2CaptureSequence> {
+    private val debugId = captureSequenceProcessorDebugIds.incrementAndGet()
+    override fun build(
+        isRepeating: Boolean,
         requests: List<Request>,
         defaultParameters: Map<*, Any?>,
         requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>
-    ): Boolean {
-        return configureAndCapture(
-            requests,
-            defaultParameters,
-            requiredParameters,
-            defaultListeners,
-            isRepeating = false
-        )
-    }
-
-    override fun startRepeating(
-        request: Request,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>
-    ): Boolean {
-        return configureAndCapture(
-            singletonList(request),
-            defaultParameters,
-            requiredParameters,
-            defaultListeners,
-            isRepeating = true
-        )
-    }
-
-    override fun abortCaptures() {
-        val requestsToAbort = synchronized(inFlightRequests) {
-            val copy = inFlightRequests.toList()
-            inFlightRequests.clear()
-            copy
+        listeners: List<Request.Listener>,
+        sequenceListener: CaptureSequence.CaptureSequenceListener
+    ): Camera2CaptureSequence? {
+        check(requests.isNotEmpty()) {
+            "build(...) should never be called with an empty request list!"
         }
-        for (sequence in requestsToAbort) {
-            sequence.invokeOnAborted()
-        }
-        session.abortCaptures()
-    }
+        val requestMap = ArrayMap<RequestNumber, Camera2RequestMetadata>(requests.size)
+        val requestList = ArrayList<Camera2RequestMetadata>(requests.size)
 
-    override fun stopRepeating() {
-        session.stopRepeating()
-    }
-
-    override fun close() {
-        closed.compareAndSet(expect = false, update = true)
-    }
-
-    private fun configureAndCapture(
-        requests: List<Request>,
-        defaultParameters: Map<*, Any?>,
-        requiredParameters: Map<*, Any?>,
-        defaultListeners: List<Request.Listener>,
-        isRepeating: Boolean
-    ): Boolean {
-        // Reject incoming requests if this instance has been stopped or closed.
-        if (closed.value) {
-            return false
-        }
-
-        val requestMap = ArrayMap<RequestNumber, RequestInfo>(requests.size)
         val captureRequests = ArrayList<CaptureRequest>(requests.size)
 
         val surfaceToStreamMap = ArrayMap<Surface, StreamId>()
@@ -199,7 +130,7 @@
                     // Surface object for this request. If this condition is violated, then we
                     // return false because we cannot submit these request(s) until there is a valid
                     // StreamId -> Surface mapping for all streams.
-                    return false
+                    return null
                 }
             }
 
@@ -207,7 +138,7 @@
             // submit it.
             if (!hasSurface) {
                 Log.info { "  Failed to bind any surfaces for $request!" }
-                return false
+                return null
             }
 
             // Create the request builder. There is a risk this will throw an exception or return null
@@ -218,7 +149,7 @@
                 requestBuilder = session.device.createCaptureRequest(requestTemplate)
             } catch (exception: ObjectUnavailableException) {
                 Log.info { "  Failed to create a CaptureRequest.Builder from $requestTemplate!" }
-                return false
+                return null
             }
 
             // Apply the output surfaces to the requestBuilder
@@ -263,7 +194,7 @@
             captureRequests.add(captureRequest)
 
             @Suppress("SyntheticAccessor")
-            requestMap[requestTag] = RequestInfo(
+            val metadata = Camera2RequestMetadata(
                 captureRequest,
                 defaultParameters,
                 requiredParameters,
@@ -273,94 +204,72 @@
                 request,
                 requestTag
             )
+            requestMap[requestTag] = metadata
+            requestList.add(metadata)
         }
 
         // Create the captureSequence listener
         @Suppress("SyntheticAccessor")
-        val captureSequence = Camera2CaptureSequence(
-            defaultListeners,
-            if (requests.size == 1) {
-                singletonMap(requestMap.keyAt(0), requestMap.valueAt(0))
-            } else {
-                requestMap
-            },
+        return Camera2CaptureSequence(
+            session.device.cameraId,
+            isRepeating,
             captureRequests,
-            surfaceToStreamMap,
-            inFlightRequests,
-            session.device.cameraId
+            requestList,
+            listeners,
+            sequenceListener,
+            requestMap,
+            surfaceToStreamMap
         )
+    }
 
-        // Non-repeating requests must always be aware of abort calls.
-        if (!isRepeating) {
-            synchronized(inFlightRequests) {
-                inFlightRequests.add(captureSequence)
+    override fun submit(captureSequence: Camera2CaptureSequence): Int {
+        val captureCallback = captureSequence as CameraCaptureSession.CaptureCallback
+        // TODO: Update these calls to use executors on newer versions of the OS
+        return if (captureSequence.captureRequestList.size == 1) {
+            if (captureSequence.repeating) {
+                session.setRepeatingRequest(
+                    captureSequence.captureRequestList[0],
+                    captureCallback,
+                    threads.camera2Handler
+                )
+            } else {
+                session.capture(
+                    captureSequence.captureRequestList[0],
+                    captureSequence,
+                    threads.camera2Handler
+                )
             }
-        }
-
-        var captured = false
-        return try {
-            Log.debug { "Submitting $captureSequence" }
-            capture(captureRequests, captureSequence, isRepeating)
-            captured = true
-            Log.debug { "Submitted $captureSequence" }
-            true
-        } catch (closedException: ObjectUnavailableException) {
-            false
-        } catch (accessException: CameraAccessException) {
-            false
-        } finally {
-            // If ANY unhandled exception occurs, don't throw, but make sure we remove it from the
-            // list of in-flight requests.
-            if (!captured && !isRepeating) {
-                captureSequence.invokeOnAborted()
+        } else {
+            if (captureSequence.repeating) {
+                session.setRepeatingBurst(
+                    captureSequence.captureRequestList,
+                    captureSequence,
+                    threads.camera2Handler
+                )
+            } else {
+                session.captureBurst(
+                    captureSequence.captureRequestList,
+                    captureSequence,
+                    threads.camera2Handler
+                )
             }
         }
     }
 
-    private fun capture(
-        captureRequests: List<CaptureRequest>,
-        captureSequence: Camera2CaptureSequence,
-        isRepeating: Boolean
-    ) {
-        captureSequence.invokeOnRequestSequenceCreated()
+    override fun abortCaptures() {
+        session.abortCaptures()
+    }
 
-        // NOTE: This is a funny synchronization call. The purpose is to avoid a rare but possible
-        // situation where calling capture causes one of the callback methods to be invoked before
-        // sequenceNumber has been set on the callback. Both this call and the synchronized
-        // behavior on the CaptureSequence listener have been designed to minimize the number of
-        // synchronized calls.
-        synchronized(lock = captureSequence) {
-            // TODO: Update these calls to use executors on newer versions of the OS
-            val sequenceNumber: Int = if (captureRequests.size == 1) {
-                if (isRepeating) {
-                    session.setRepeatingRequest(
-                        captureRequests[0],
-                        captureSequence,
-                        threads.camera2Handler
-                    )
-                } else {
-                    session.capture(captureRequests[0], captureSequence, threads.camera2Handler)
-                }
-            } else {
-                if (isRepeating) {
-                    session.setRepeatingBurst(
-                        captureRequests,
-                        captureSequence,
-                        threads.camera2Handler
-                    )
-                } else {
-                    session.captureBurst(captureRequests, captureSequence, threads.camera2Handler)
-                }
-            }
-            captureSequence.sequenceNumber = sequenceNumber
-        }
+    override fun stopRepeating() {
+        session.stopRepeating()
+    }
 
-        // Invoke callbacks without holding a lock.
-        captureSequence.invokeOnRequestSequenceSubmitted()
+    override fun close() {
+        // Close should not shut down
     }
 
     override fun toString(): String {
-        return "RequestProcessor-$debugId"
+        return "Camera2RequestProcessor-$debugId"
     }
 }
 
@@ -369,7 +278,7 @@
  */
 @RequiresApi(21)
 @Suppress("SyntheticAccessor") // Using an inline class generates a synthetic constructor
-internal class RequestInfo(
+internal class Camera2RequestMetadata(
     private val captureRequest: CaptureRequest,
     private val defaultParameters: Map<*, Any?>,
     private val requiredParameters: Map<*, Any?>,
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ExternalRequestProcessor.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ExternalRequestProcessor.kt
new file mode 100644
index 0000000..9f6bbeb
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ExternalRequestProcessor.kt
@@ -0,0 +1,226 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("DEPRECATION")
+
+package androidx.camera.camera2.pipe.compat
+
+import android.hardware.camera2.CaptureRequest
+import android.view.Surface
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CameraController
+import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.Metadata
+import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.RequestMetadata
+import androidx.camera.camera2.pipe.RequestNumber
+import androidx.camera.camera2.pipe.RequestProcessor
+import androidx.camera.camera2.pipe.RequestTemplate
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.CaptureSequence
+import androidx.camera.camera2.pipe.CaptureSequenceProcessor
+import androidx.camera.camera2.pipe.graph.GraphListener
+import androidx.camera.camera2.pipe.graph.GraphRequestProcessor
+import kotlinx.atomicfu.atomic
+
+@RequiresApi(21)
+class ExternalCameraController(
+    private val graphConfig: CameraGraph.Config,
+    private val graphListener: GraphListener,
+    private val requestProcessor: RequestProcessor
+) : CameraController {
+    private val graphProcessor: GraphRequestProcessor = GraphRequestProcessor.from(
+        ExternalCaptureSequenceProcessor(graphConfig, requestProcessor)
+    )
+    private var started = atomic(false)
+
+    override fun start() {
+        if (started.compareAndSet(expect = false, update = true)) {
+            graphListener.onGraphStarted(graphProcessor)
+        }
+    }
+
+    override fun stop() {
+        if (started.compareAndSet(expect = true, update = false)) {
+            graphListener.onGraphStopped(graphProcessor)
+        }
+    }
+
+    override fun close() {
+    }
+
+    override fun updateSurfaceMap(surfaceMap: Map<StreamId, Surface>) {
+    }
+}
+
+@Suppress("DEPRECATION")
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+internal class ExternalCaptureSequenceProcessor(
+    private val graphConfig: CameraGraph.Config,
+    private val processor: RequestProcessor
+) : CaptureSequenceProcessor<Request, ExternalCaptureSequenceProcessor.ExternalCaptureSequence> {
+    private val internalRequestNumbers = atomic(0L)
+    private val internalSequenceNumbers = atomic(0)
+    private val closed = atomic(false)
+    private var _surfaceMap: Map<StreamId, Surface>? = null
+
+    var surfaceMap: Map<StreamId, Surface>?
+        get() = synchronized(this) { _surfaceMap }
+        set(value) = synchronized(this) { _surfaceMap = value }
+
+    override fun build(
+        isRepeating: Boolean,
+        requests: List<Request>,
+        defaultParameters: Map<*, Any?>,
+        requiredParameters: Map<*, Any?>,
+        listeners: List<Request.Listener>,
+        sequenceListener: CaptureSequence.CaptureSequenceListener
+    ): ExternalCaptureSequence? {
+        if (closed.value) {
+            return null
+        }
+        val streamToSurfaceMap = surfaceMap ?: return null
+        val metadata = requests.map { request ->
+            val parameters = defaultParameters + request.parameters + requiredParameters
+
+            ExternalRequestMetadata(
+                graphConfig.defaultTemplate,
+                streamToSurfaceMap,
+                parameters,
+                isRepeating,
+                request,
+                RequestNumber(internalRequestNumbers.incrementAndGet())
+            )
+        }
+
+        return ExternalCaptureSequence(
+            graphConfig.camera,
+            isRepeating,
+            requests,
+            metadata,
+            defaultParameters,
+            requiredParameters,
+            listeners,
+            sequenceListener
+        )
+    }
+
+    override fun submit(captureSequence: ExternalCaptureSequence): Int {
+        check(!closed.value)
+        check(captureSequence.captureRequestList.isNotEmpty())
+
+        if (captureSequence.repeating) {
+            check(captureSequence.captureRequestList.size == 1)
+            processor.startRepeating(
+                captureSequence.captureRequestList.single(),
+                captureSequence.defaultParameters,
+                captureSequence.requiredParameters,
+                captureSequence.listeners
+            )
+        } else {
+            if (captureSequence.captureRequestList.size == 1) {
+                processor.submit(
+                    captureSequence.captureRequestList.single(),
+                    captureSequence.defaultParameters,
+                    captureSequence.requiredParameters,
+                    captureSequence.listeners
+                )
+            } else {
+                processor.submit(
+                    captureSequence.captureRequestList,
+                    captureSequence.defaultParameters,
+                    captureSequence.requiredParameters,
+                    captureSequence.listeners
+                )
+            }
+        }
+        return internalSequenceNumbers.incrementAndGet()
+    }
+
+    override fun abortCaptures() {
+        processor.abortCaptures()
+    }
+
+    override fun stopRepeating() {
+        processor.stopRepeating()
+    }
+
+    override fun close() {
+        if (closed.compareAndSet(expect = false, update = true)) {
+            processor.close()
+        }
+    }
+
+    internal class ExternalCaptureSequence(
+        override val cameraId: CameraId,
+        override val repeating: Boolean,
+        override val captureRequestList: List<Request>,
+        override val captureMetadataList: List<RequestMetadata>,
+        val defaultParameters: Map<*, Any?>,
+        val requiredParameters: Map<*, Any?>,
+        override val listeners: List<Request.Listener>,
+        override val sequenceListener: CaptureSequence.CaptureSequenceListener,
+    ) : CaptureSequence<Request> {
+        @Volatile
+        private var _sequenceNumber: Int? = null
+        override var sequenceNumber: Int
+            get() {
+                if (_sequenceNumber == null) {
+                    // If the sequence id has not been submitted, it means the call to capture or
+                    // setRepeating has not yet returned. The callback methods should never be
+                    // synchronously invoked, so the only case this should happen is if a second
+                    // thread attempted to invoke one of the callbacks before the initial call
+                    // completed. By locking against the captureSequence object here and in the
+                    // capture call, we can block the callback thread until the sequenceId is
+                    // available.
+                    synchronized(this) {
+                        return checkNotNull(_sequenceNumber) {
+                            "SequenceNumber has not been set for $this!"
+                        }
+                    }
+                }
+                return checkNotNull(_sequenceNumber) {
+                    "SequenceNumber has not been set for $this!"
+                }
+            }
+            set(value) {
+                _sequenceNumber = value
+            }
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    internal class ExternalRequestMetadata(
+        override val template: RequestTemplate,
+        override val streams: Map<StreamId, Surface>,
+        private val parameters: Map<*, Any?>,
+        override val repeating: Boolean,
+        override val request: Request,
+        override val requestNumber: RequestNumber
+    ) : RequestMetadata {
+        override fun <T> get(key: CaptureRequest.Key<T>): T? = parameters[key] as T?
+        override fun <T> get(key: Metadata.Key<T>): T? = parameters[key] as T?
+        override fun <T> getOrDefault(key: CaptureRequest.Key<T>, default: T): T =
+            get(key) ?: default
+
+        override fun <T> getOrDefault(key: Metadata.Key<T>, default: T): T = get(key) ?: default
+
+        override fun unwrap(): CaptureRequest? {
+            // CustomRequestMetadata does not extend a Camera2 CaptureRequest.
+            return null
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCamera.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCamera.kt
index 73df044..7675b09 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCamera.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCamera.kt
@@ -31,12 +31,15 @@
 import androidx.camera.camera2.pipe.core.Timestamps
 import androidx.camera.camera2.pipe.core.Timestamps.formatMs
 import androidx.camera.camera2.pipe.core.Token
+import kotlin.coroutines.EmptyCoroutineContext
 import kotlinx.atomicfu.atomic
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 
@@ -97,6 +100,7 @@
  */
 internal interface VirtualCamera {
     val state: Flow<CameraState>
+    val value: CameraState
     fun disconnect()
 }
 
@@ -111,12 +115,26 @@
     @GuardedBy("lock")
     private var closed = false
 
-    private val _state = MutableStateFlow<CameraState>(CameraStateUnopened)
-    override val state: StateFlow<CameraState>
-        get() = _state
+    // This is intended so that it will only ever replay the most recent event to new subscribers,
+    // but to never drop events for existing subscribers.
+    private val _stateFlow = MutableSharedFlow<CameraState>(replay = 1, extraBufferCapacity = 3)
+    private val _states = _stateFlow.distinctUntilChanged()
+
+    @GuardedBy("lock")
+    private var _lastState: CameraState = CameraStateUnopened
+    override val state: Flow<CameraState>
+        get() = _states
+
+    override val value: CameraState
+        get() = synchronized(lock) { _lastState }
 
     private var job: Job? = null
-    private var token: Token? = null
+    private var wakelockToken: Token? = null
+
+    init {
+        // Emit the initial unopened state.
+        check(_stateFlow.tryEmit(_lastState))
+    }
 
     internal suspend fun connect(state: Flow<CameraState>, wakelockToken: Token?) = coroutineScope {
         synchronized(lock) {
@@ -125,10 +143,16 @@
                 return@coroutineScope
             }
 
-            job = launch {
-                state.collect { _state.value = it }
+            job = launch(EmptyCoroutineContext) {
+                state.collect {
+                    synchronized(lock) {
+                        if (!closed) {
+                            emitState(it)
+                        }
+                    }
+                }
             }
-            token = wakelockToken
+            this@VirtualCameraState.wakelockToken = wakelockToken
         }
     }
 
@@ -142,22 +166,31 @@
             Log.info { "Disconnecting $this" }
 
             job?.cancel()
-            token?.release()
+            wakelockToken?.release()
 
             // Emulate a CameraClosing -> CameraClosed sequence.
-            if (_state.value !is CameraStateClosed) {
-                if (_state.value !is CameraStateClosing) {
-                    _state.value = CameraStateClosing
+            if (value !is CameraStateClosed) {
+                if (_lastState !is CameraStateClosing) {
+                    emitState(CameraStateClosing)
                 }
-                @SuppressWarnings("SyntheticAccessor")
-                _state.value = CameraStateClosed(
-                    cameraId,
-                    cameraClosedReason = ClosedReason.APP_DISCONNECTED
+                emitState(
+                    CameraStateClosed(
+                        cameraId,
+                        cameraClosedReason = ClosedReason.APP_DISCONNECTED
+                    )
                 )
             }
         }
     }
 
+    @GuardedBy("lock")
+    private fun emitState(state: CameraState) {
+        _lastState = state
+        check(_stateFlow.tryEmit(state)) {
+            "Failed to emit $state in ${this@VirtualCameraState}"
+        }
+    }
+
     override fun toString(): String = "VirtualCamera-$debugId"
 }
 
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCameraManager.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCameraManager.kt
index f94abb0..75348ad 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCameraManager.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualCameraManager.kt
@@ -146,7 +146,7 @@
 
             // Sanity Check: If the camera we are attempting to open is now closed or disconnected,
             // skip this virtual camera request.
-            if (request.virtualCamera.state.value !is CameraStateUnopened) {
+            if (request.virtualCamera.value !is CameraStateUnopened) {
                 requests.remove(request)
                 continue
             }
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualSessionState.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualSessionState.kt
index 81aba38..a5e2f3f 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualSessionState.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/VirtualSessionState.kt
@@ -22,7 +22,6 @@
 import androidx.annotation.GuardedBy
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.CameraSurfaceManager
-import androidx.camera.camera2.pipe.RequestProcessor
 import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.core.Debug
 import androidx.camera.camera2.pipe.core.Log
@@ -30,7 +29,7 @@
 import androidx.camera.camera2.pipe.core.Timestamps
 import androidx.camera.camera2.pipe.core.Timestamps.formatMs
 import androidx.camera.camera2.pipe.graph.GraphListener
-import androidx.camera.camera2.pipe.graph.StreamGraphImpl
+import androidx.camera.camera2.pipe.graph.GraphRequestProcessor
 import java.io.Closeable
 import java.util.Collections.synchronizedMap
 import kotlinx.atomicfu.atomic
@@ -50,10 +49,10 @@
 internal class VirtualSessionState(
     private val graphListener: GraphListener,
     private val captureSessionFactory: CaptureSessionFactory,
-    private val requestProcessorFactory: Camera2RequestProcessorFactory,
+    private val captureSequenceProcessorFactory: Camera2CaptureSequenceProcessorFactory,
     private val cameraSurfaceManager: CameraSurfaceManager,
     private val scope: CoroutineScope
-) : CameraCaptureSessionWrapper.StateCallback, StreamGraphImpl.SurfaceListener {
+) : CameraCaptureSessionWrapper.StateCallback {
     private val debugId = virtualSessionDebugIds.incrementAndGet()
     private val lock = Any()
 
@@ -101,8 +100,7 @@
 
     @GuardedBy("lock")
     private val _surfaceTokenMap: MutableMap<Surface, Closeable> = mutableMapOf()
-
-    override fun onSurfaceMapUpdated(surfaces: Map<StreamId, Surface>) {
+    fun configureSurfaceMap(surfaces: Map<StreamId, Surface>) {
         synchronized(lock) {
             if (state == State.CLOSING || state == State.CLOSED) {
                 return@synchronized
@@ -178,7 +176,9 @@
             if (cameraCaptureSession == null && session != null) {
                 captureSession = ConfiguredCameraCaptureSession(
                     session,
-                    requestProcessorFactory.create(session, activeSurfaceMap)
+                    GraphRequestProcessor.from(
+
+                        captureSequenceProcessorFactory.create(session, activeSurfaceMap))
                 )
                 cameraCaptureSession = captureSession
             } else {
@@ -206,7 +206,7 @@
                     "Configured $this in ${duration.formatMs()}"
                 }
 
-                graphListener.onGraphStarted(it.processor)
+                graphListener.onGraphModified(it.processor)
             }
         }
     }
@@ -321,7 +321,7 @@
             }
 
             if (tryResubmit && retryAllowed) {
-                graphListener.onGraphUpdated(captureSession.processor)
+                graphListener.onGraphModified(captureSession.processor)
             }
             Debug.traceStop()
         }
@@ -417,6 +417,6 @@
 
     private data class ConfiguredCameraCaptureSession(
         val session: CameraCaptureSessionWrapper,
-        val processor: RequestProcessor
+        val processor: GraphRequestProcessor
     )
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
index 6af025d..f0f2595 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
@@ -24,8 +24,8 @@
 import androidx.camera.camera2.pipe.compat.Camera2Backend
 import androidx.camera.camera2.pipe.compat.Camera2CameraController
 import androidx.camera.camera2.pipe.compat.Camera2CaptureSessionsModule
-import androidx.camera.camera2.pipe.compat.Camera2RequestProcessorFactory
-import androidx.camera.camera2.pipe.compat.StandardCamera2RequestProcessorFactory
+import androidx.camera.camera2.pipe.compat.Camera2CaptureSequenceProcessorFactory
+import androidx.camera.camera2.pipe.compat.StandardCamera2CaptureSequenceProcessorFactory
 import androidx.camera.camera2.pipe.core.Threads
 import androidx.camera.camera2.pipe.graph.GraphListener
 import androidx.camera.camera2.pipe.graph.StreamGraphImpl
@@ -95,8 +95,8 @@
 internal abstract class Camera2ControllerModule {
     @Binds
     abstract fun bindCamera2RequestProcessorFactory(
-        factoryStandard: StandardCamera2RequestProcessorFactory
-    ): Camera2RequestProcessorFactory
+        factoryStandard: StandardCamera2CaptureSequenceProcessorFactory
+    ): Camera2CaptureSequenceProcessorFactory
 
     @Binds
     abstract fun bindCameraController(
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
index bb13f5f..33ecf4a 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
@@ -14,22 +14,22 @@
  * limitations under the License.
  */
 
-@file:RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+// TODO(b/200306659): Remove and replace with annotation on package-info.java
+@file:Suppress("DEPRECATION")
+@file:RequiresApi(21)
 
 package androidx.camera.camera2.pipe.config
 
-import android.view.Surface
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.CameraController
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.CameraMetadata
 import androidx.camera.camera2.pipe.RequestProcessor
-import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.compat.ExternalCameraController
 import androidx.camera.camera2.pipe.graph.GraphListener
 import dagger.Module
 import dagger.Provides
 import dagger.Subcomponent
-import kotlinx.atomicfu.atomic
 
 @CameraGraphScope
 @Subcomponent(
@@ -60,26 +60,12 @@
     @Provides
     fun provideCameraMetadata(): CameraMetadata = cameraMetadata
 
+    @CameraGraphScope
     @Provides
     fun provideGraphController(graphListener: GraphListener): CameraController =
-        object : CameraController {
-            var started = atomic(false)
-            override fun start() {
-                if (started.compareAndSet(expect = false, update = true)) {
-                    graphListener.onGraphStarted(requestProcessor)
-                }
-            }
-
-            override fun stop() {
-                if (started.compareAndSet(expect = true, update = false)) {
-                    graphListener.onGraphStopped(requestProcessor)
-                }
-            }
-
-            override fun close() {
-            }
-
-            override fun updateSurfaceMap(surfaceMap: Map<StreamId, Surface>) {
-            }
-        }
+        ExternalCameraController(
+            config,
+            graphListener,
+            requestProcessor
+        )
 }
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ThreadConfigModule.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ThreadConfigModule.kt
index 38c5d6c..a7327a0 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ThreadConfigModule.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ThreadConfigModule.kt
@@ -31,10 +31,12 @@
 import dagger.Module
 import dagger.Provides
 import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineName
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.asExecutor
 
 /**
  * Configure and provide a single [Threads] object to other parts of the library.
@@ -64,6 +66,15 @@
     @Singleton
     @Provides
     fun provideThreads(): Threads {
+        val testOnlyDispatcher = threadConfig.testOnlyDispatcher
+        val testOnlyScope = threadConfig.testOnlyScope
+        if (testOnlyDispatcher != null && testOnlyScope != null) {
+            return provideTestOnlyThreads(testOnlyDispatcher, testOnlyScope)
+        }
+        check(testOnlyDispatcher == null || testOnlyScope == null) {
+            "testOnlyDispatcher and testOnlyScope must be specified together!"
+        }
+
         val blockingExecutor =
             threadConfig.defaultBlockingExecutor ?: AndroidThreads.factory
                 .withPrefix("CXCP-IO-")
@@ -118,4 +129,34 @@
             camera2Executor = cameraExecutorFn
         )
     }
+
+    private fun provideTestOnlyThreads(
+        testDispatcher: CoroutineDispatcher,
+        testScope: CoroutineScope
+    ): Threads {
+        val testExecutor = testDispatcher.asExecutor()
+
+        // TODO: This should delegate to the testDispatcher instead of using a HandlerThread.
+        val cameraHandlerFn = {
+            val handlerThread = HandlerThread(
+                "CXCP-Camera-H",
+                cameraThreadPriority
+            ).also {
+                it.start()
+            }
+            Handler(handlerThread.looper)
+        }
+
+        return Threads(
+            globalScope = testScope,
+            blockingExecutor = testExecutor,
+            blockingDispatcher = testDispatcher,
+            backgroundExecutor = testExecutor,
+            backgroundDispatcher = testDispatcher,
+            lightweightExecutor = testExecutor,
+            lightweightDispatcher = testDispatcher,
+            camera2Handler = cameraHandlerFn,
+            camera2Executor = { testExecutor }
+        )
+    }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/CameraGraphSessionImpl.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/CameraGraphSessionImpl.kt
index 7af7a85..ae44581 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/CameraGraphSessionImpl.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/CameraGraphSessionImpl.kt
@@ -49,6 +49,7 @@
 
     override fun submit(requests: List<Request>) {
         check(!closed.value) { "Cannot call submit on $this after close." }
+        check(requests.isNotEmpty()) { "Cannot call submit with an empty list of Requests!" }
         graphProcessor.submit(requests)
     }
 
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphListener.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphListener.kt
index 0e3e0bf..598657f 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphListener.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphListener.kt
@@ -17,24 +17,24 @@
 package androidx.camera.camera2.pipe.graph
 
 import androidx.annotation.RequiresApi
-import androidx.camera.camera2.pipe.RequestProcessor
 
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 interface GraphListener {
     /**
      * Used to indicate that the graph has been initialized and is ready to actively process
-     * requests using the provided [RequestProcessor] interface.
+     * requests using the provided [GraphRequestProcessor] interface.
      */
-    fun onGraphStarted(requestProcessor: RequestProcessor)
+    fun onGraphStarted(requestProcessor: GraphRequestProcessor)
 
     /**
-     * Used to indicate that a previously initialized [RequestProcessor] is no longer available.
+     * Used to indicate that a previously initialized [GraphRequestProcessor] is no longer
+     * available.
      */
-    fun onGraphStopped(requestProcessor: RequestProcessor)
+    fun onGraphStopped(requestProcessor: GraphRequestProcessor)
 
     /**
-     * Used to indicate that the internal state of the [RequestProcessor] has changed. This is
+     * Used to indicate that the internal state of the [GraphRequestProcessor] has changed. This is
      * a signal that previously queued requests may now succeed if they previously failed.
      */
-    fun onGraphUpdated(requestProcessor: RequestProcessor)
+    fun onGraphModified(requestProcessor: GraphRequestProcessor)
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphProcessor.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphProcessor.kt
index bc6e84f..598e8bf 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphProcessor.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphProcessor.kt
@@ -21,8 +21,8 @@
 import androidx.annotation.GuardedBy
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CaptureSequenceProcessor
 import androidx.camera.camera2.pipe.Request
-import androidx.camera.camera2.pipe.RequestProcessor
 import androidx.camera.camera2.pipe.config.CameraGraphScope
 import androidx.camera.camera2.pipe.config.ForCameraGraph
 import androidx.camera.camera2.pipe.core.Debug
@@ -37,11 +37,11 @@
 import kotlinx.coroutines.withContext
 
 /**
- * The [GraphProcessor] is responsible for queuing and submitting requests to a single
- * [RequestProcessor] instance, and for maintaining state across one or more [RequestProcessor]
- * instances.
+ * The [GraphProcessor] is responsible for queuing and then submitting them to a
+ * [CaptureSequenceProcessor] when it becomes available. This enables interactions to be queued up
+ * and submitted before the camera is available.
  */
-internal interface GraphProcessor : GraphListener {
+internal interface GraphProcessor {
     fun submit(request: Request)
     fun submit(requests: List<Request>)
     suspend fun submit(parameters: Map<*, Any?>): Boolean
@@ -56,14 +56,14 @@
     fun invalidate()
 
     /**
-     * Abort all submitted requests that have not yet been submitted to the [RequestProcessor] as
-     * well as aborting requests on the [RequestProcessor] itself.
+     * Abort all submitted requests that have not yet been submitted, as well as asking the
+     * [CaptureSequenceProcessor] to abort any submitted requests, which may or may not succeed.
      */
     fun abort()
 
     /**
      * Closing the [GraphProcessor] will abort all queued requests. Any requests submitted after the
-     * [GraphProcessor] is closed will be immediately aborted.
+     * [GraphProcessor] is closed will immediately be aborted.
      */
     fun close()
 }
@@ -78,7 +78,7 @@
     private val graphState3A: GraphState3A,
     @ForCameraGraph private val graphScope: CoroutineScope,
     @ForCameraGraph private val graphListeners: List<@JvmSuppressWildcards Request.Listener>
-) : GraphProcessor {
+) : GraphProcessor, GraphListener {
     private val lock = Any()
 
     @GuardedBy("lock")
@@ -91,7 +91,7 @@
     private var nextRepeatingRequest: Request? = null
 
     @GuardedBy("lock")
-    private var _requestProcessor: RequestProcessor? = null
+    private var _requestProcessor: GraphRequestProcessor? = null
 
     @GuardedBy("lock")
     private var submitting = false
@@ -102,8 +102,8 @@
     @GuardedBy("lock")
     private var closed = false
 
-    override fun onGraphStarted(requestProcessor: RequestProcessor) {
-        var oldRequestProcessor: RequestProcessor? = null
+    override fun onGraphStarted(requestProcessor: GraphRequestProcessor) {
+        var old: GraphRequestProcessor? = null
         synchronized(lock) {
             if (closed) {
                 requestProcessor.close()
@@ -111,30 +111,29 @@
             }
 
             if (_requestProcessor != null && _requestProcessor !== requestProcessor) {
-                oldRequestProcessor = _requestProcessor
+                old = _requestProcessor
             }
             _requestProcessor = requestProcessor
         }
 
-        val processorToClose = oldRequestProcessor
+        val processorToClose = old
         if (processorToClose != null) {
             synchronized(processorToClose) {
                 processorToClose.close()
             }
         }
-
         resubmit()
     }
 
-    override fun onGraphStopped(requestProcessor: RequestProcessor) {
-        var oldRequestProcessor: RequestProcessor? = null
+    override fun onGraphStopped(requestProcessor: GraphRequestProcessor) {
+        var old: GraphRequestProcessor? = null
         synchronized(lock) {
             if (closed) {
                 return
             }
 
             if (requestProcessor === _requestProcessor) {
-                oldRequestProcessor = _requestProcessor
+                old = _requestProcessor
                 _requestProcessor = null
             } else {
                 warn {
@@ -144,7 +143,7 @@
             }
         }
 
-        val processorToClose = oldRequestProcessor
+        val processorToClose = old
         if (processorToClose != null) {
             synchronized(processorToClose) {
                 processorToClose.close()
@@ -152,12 +151,12 @@
         }
     }
 
-    override fun onGraphUpdated(requestProcessor: RequestProcessor) {
+    override fun onGraphModified(requestProcessor: GraphRequestProcessor) {
         synchronized(lock) {
             if (closed) {
                 return
             }
-            if (requestProcessor != _requestProcessor) {
+            if (requestProcessor !== _requestProcessor) {
                 return
             }
         }
@@ -177,7 +176,7 @@
     }
 
     override fun stopRepeating() {
-        val processor: RequestProcessor?
+        val processor: GraphRequestProcessor?
 
         synchronized(lock) {
             processor = _requestProcessor
@@ -222,7 +221,7 @@
      */
     override suspend fun submit(parameters: Map<*, Any?>): Boolean =
         withContext(threads.lightweightDispatcher) {
-            val processor: RequestProcessor?
+            val processor: GraphRequestProcessor?
             val request: Request?
             val requiredParameters: MutableMap<Any, Any?> = mutableMapOf()
 
@@ -239,10 +238,11 @@
             return@withContext when {
                 processor == null || request == null -> false
                 else -> processor.submit(
-                    request,
+                    isRepeating = false,
+                    requests = listOf(request),
                     defaultParameters = cameraGraphConfig.defaultParameters,
                     requiredParameters = requiredParameters,
-                    defaultListeners = graphListeners
+                    listeners = graphListeners
                 )
             }
         }
@@ -256,7 +256,7 @@
     }
 
     override fun abort() {
-        val processor: RequestProcessor?
+        val processor: GraphRequestProcessor?
         val requests: List<List<Request>>
 
         synchronized(lock) {
@@ -283,7 +283,7 @@
     }
 
     override fun close() {
-        val processor: RequestProcessor?
+        val processor: GraphRequestProcessor?
         synchronized(lock) {
             if (closed) {
                 return
@@ -321,7 +321,7 @@
     }
 
     private fun tryStartRepeating() {
-        val processor: RequestProcessor?
+        val processor: GraphRequestProcessor?
         val request: Request?
 
         synchronized(lock) {
@@ -350,18 +350,18 @@
         }
 
         if (processor != null && request != null) {
-
             Debug.traceStart { "$this#startRepeating" }
             synchronized(processor) {
                 val requiredParameters = mutableMapOf<Any, Any?>()
                 graphState3A.writeTo(requiredParameters)
                 requiredParameters.putAllMetadata(cameraGraphConfig.requiredParameters)
 
-                if (processor.startRepeating(
-                        request,
+                if (processor.submit(
+                        isRepeating = true,
+                        requests = listOf(request),
                         defaultParameters = cameraGraphConfig.defaultParameters,
                         requiredParameters = requiredParameters,
-                        defaultListeners = graphListeners
+                        listeners = graphListeners
                     )
                 ) {
                     // ONLY update the current repeating request if the update succeeds
@@ -385,7 +385,7 @@
 
     private fun submitLoop() {
         var burst: List<Request>
-        var processor: RequestProcessor
+        var processor: GraphRequestProcessor
 
         synchronized(lock) {
             if (closed) return
@@ -416,27 +416,20 @@
                     graphState3A.writeTo(requiredParameters)
                     requiredParameters.putAllMetadata(cameraGraphConfig.requiredParameters)
 
-                    if (burst.size == 1) {
-                        processor.submit(
-                            burst[0],
-                            defaultParameters = cameraGraphConfig.defaultParameters,
-                            requiredParameters = requiredParameters,
-                            defaultListeners = graphListeners
-                        )
-                    } else {
-                        processor.submit(
-                            burst,
-                            defaultParameters = cameraGraphConfig.defaultParameters,
-                            requiredParameters = requiredParameters,
-                            defaultListeners = graphListeners
-                        )
-                    }
+                    processor.submit(
+                        isRepeating = false,
+                        requests = burst,
+                        defaultParameters = cameraGraphConfig.defaultParameters,
+                        requiredParameters = requiredParameters,
+                        listeners = graphListeners
+                    )
                 }
             } finally {
                 Debug.traceStop()
                 synchronized(lock) {
                     if (submitted) {
-                        check(submitQueue.removeAt(0) === burst)
+                        // submitQueue can potentially be cleared by abort() before entering here.
+                        check(submitQueue.isEmpty() || submitQueue.removeAt(0) === burst)
 
                         val nullableBurst = submitQueue.firstOrNull()
                         if (nullableBurst == null) {
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphRequestProcessor.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphRequestProcessor.kt
new file mode 100644
index 0000000..2d3e6e2
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/GraphRequestProcessor.kt
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.graph
+
+import android.hardware.camera2.CameraAccessException
+import androidx.annotation.GuardedBy
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.CaptureSequence
+import androidx.camera.camera2.pipe.CaptureSequenceProcessor
+import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.compat.ObjectUnavailableException
+import androidx.camera.camera2.pipe.core.Log
+import androidx.camera.camera2.pipe.CaptureSequences.invokeOnRequests
+import kotlinx.atomicfu.atomic
+
+internal val graphRequestProcessorIds = atomic(0)
+
+/**
+ * The GraphRequestProcessor wraps and tracks the internal state for requests that are submitted to
+ * a [CaptureSequenceProcessor] instance.
+ *
+ * GraphRequestProcessors are intended to be in conjunction with a [GraphListener].
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+@Suppress("NOTHING_TO_INLINE")
+public class GraphRequestProcessor private constructor(
+    private val captureSequenceProcessor: CaptureSequenceProcessor<Any, CaptureSequence<Any>>
+) {
+    companion object {
+        /** Create a [GraphRequestProcessor] from a [CaptureSequenceProcessor] instance. */
+        fun from(captureSequenceProcessor: CaptureSequenceProcessor<*, *>): GraphRequestProcessor {
+            @Suppress("UNCHECKED_CAST")
+            return GraphRequestProcessor(
+                captureSequenceProcessor as CaptureSequenceProcessor<Any, CaptureSequence<Any>>
+            )
+        }
+    }
+
+    private val debugId = graphRequestProcessorIds.incrementAndGet()
+    private val active = atomic(false)
+    @GuardedBy("activeCaptureSequences")
+    private val activeCaptureSequences = mutableListOf<CaptureSequence<*>>()
+    private val activeBurstListener = object : CaptureSequence.CaptureSequenceListener {
+        override fun onCaptureSequenceComplete(captureSequence: CaptureSequence<*>) {
+            // Listen to the completion of active capture sequences and remove them from the list
+            // of currently active capture sequences. Since repeating requests are not required to
+            // execute, only non-repeating capture sequences are tracked.
+            if (!captureSequence.repeating) {
+                synchronized(activeCaptureSequences) {
+                    activeCaptureSequences.remove(captureSequence)
+                }
+            }
+        }
+    }
+
+    internal fun abortCaptures() {
+        // Note: abortCaptures is not affected by active state.
+
+        // TODO: Consider adding a synchronization lock of some kind to prevent requests from being
+        //   submitted while an abort is actively occurring. This could increase the risk of
+        //   deadlock. Not locking could increase the risk that additional capture sequences are
+        //   submitted in-between reading the list of activeCaptureSequences and calling the
+        //   abortCaptures method on the captureSequenceProcessor.
+
+        // Create a copy of the list of non-repeating capture sequences (thread safe), clear the
+        // list, then invoke the onAborted listeners for all capture sequences that were in progress
+        // at the time abort was invoked.
+        val requestsToAbort = synchronized(activeCaptureSequences) {
+            val copy = activeCaptureSequences.toList()
+            activeCaptureSequences.clear()
+            copy
+        }
+
+        // Invoke onAbort to indicate that the actual abort is about to happen.
+        for (sequence in requestsToAbort) {
+            sequence.invokeOnAborted()
+        }
+
+        // Finally, invoke abortCaptures on the underlying captureSequenceProcessor instance.
+        captureSequenceProcessor.abortCaptures()
+    }
+
+    internal fun stopRepeating() {
+        // Note: stopRepeating is not affected by active state.
+        captureSequenceProcessor.stopRepeating()
+    }
+
+    internal fun close() {
+        if (active.compareAndSet(expect = false, update = true)) {
+            captureSequenceProcessor.close()
+        }
+    }
+
+    internal fun submit(
+        isRepeating: Boolean,
+        requests: List<Request>,
+        defaultParameters: Map<*, Any?>,
+        requiredParameters: Map<*, Any?>,
+        listeners: List<Request.Listener>,
+    ): Boolean {
+        // Reject incoming requests if this instance has been stopped or closed.
+        if (active.value) {
+            return false
+        }
+
+        // This can fail for various reasons and may throw exceptions.
+        val captureSequence = captureSequenceProcessor.build(
+            isRepeating,
+            requests,
+            defaultParameters,
+            requiredParameters,
+            listeners,
+            activeBurstListener
+        )
+
+        // Reject incoming requests if this instance has been stopped or closed.
+        if (captureSequence == null || active.value) {
+
+            // We do not need to invoke the sequenceCompleteListener since it has not been added to
+            // the list of activeCaptureSequences yet.
+            return false
+        }
+
+        // Reject incorrectly structured capture sequences:
+        check(captureSequence.captureRequestList.size == captureSequence.captureMetadataList.size) {
+            "CaptureSequence ($captureSequence) has mismatched request and metadata lists!"
+        }
+
+        // Non-repeating requests must always be aware of abort calls.
+        if (!captureSequence.repeating) {
+            synchronized(activeCaptureSequences) {
+                activeCaptureSequences.add(captureSequence)
+            }
+        }
+
+        var captured = false
+        return try {
+            Log.debug { "Submitting $captureSequence" }
+            captureSequence.invokeOnRequestSequenceCreated()
+
+            // NOTE: This is an unusual synchronization call. The purpose is to avoid a rare but
+            // possible situation where calling submit causes one of the callback methods to be
+            // invoked before this method call returns and sequenceNumber has been set on the
+            // callback. Both this call and the synchronized behavior on the captureSequence have
+            // been designed to minimize the number of synchronized calls.
+            val result = synchronized(lock = captureSequence) {
+                // Check closed state right before submitting.
+                if (active.value) {
+                    Log.warn { "Did not submit $captureSequence, $this was closed!" }
+                    return false
+                }
+                val sequenceNumber = captureSequenceProcessor.submit(captureSequence)
+                captureSequence.sequenceNumber = sequenceNumber
+                sequenceNumber
+            }
+
+            if (result != -1) {
+                captureSequence.invokeOnRequestSequenceSubmitted()
+                captured = true
+                Log.debug { "Submitted $captureSequence" }
+                true
+            } else {
+                Log.warn { "Did not submit $captureSequence, SequenceNumber was -1" }
+                false
+            }
+        } catch (closedException: ObjectUnavailableException) {
+            false
+        } catch (accessException: CameraAccessException) {
+            false
+        } finally {
+            // If ANY unhandled exception occurs, don't throw, but make sure we remove it from the
+            // list of in-flight requests.
+            if (!captured && !captureSequence.repeating) {
+                synchronized(activeCaptureSequences) {
+                    activeCaptureSequences.remove(captureSequence)
+                }
+                captureSequence.invokeOnAborted()
+            }
+        }
+    }
+
+    override fun toString(): String = "GraphRequestProcessor-$debugId"
+
+    /**
+     * Custom implementation that informs all listeners that the request had not completed when
+     * abort was called.
+     */
+    private inline fun <T> CaptureSequence<T>.invokeOnAborted() {
+        invokeOnRequests { request, _, listener ->
+            listener.onAborted(request.request)
+        }
+    }
+
+    private inline fun <T> CaptureSequence<T>.invokeOnRequestSequenceCreated() {
+        invokeOnRequests { request, _, listener ->
+            listener.onRequestSequenceCreated(request)
+        }
+    }
+
+    private inline fun <T> CaptureSequence<T>.invokeOnRequestSequenceSubmitted() {
+        invokeOnRequests { request, _, listener ->
+            listener.onRequestSequenceSubmitted(request)
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/CameraPipeTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/CameraPipeTest.kt
index aa9f1d3..14026d0 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/CameraPipeTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/CameraPipeTest.kt
@@ -18,19 +18,17 @@
 
 import android.content.Context
 import android.os.Build
-import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import androidx.camera.camera2.pipe.testing.RobolectricCameras
-import androidx.camera.camera2.pipe.testing.awaitEvent
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.runBlocking
-import org.junit.Ignore
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class CameraPipeTest {
@@ -60,71 +58,15 @@
     }
 
     @Test
-    fun iterateCameraIds() {
+    fun iterateCameraIds() = runTest {
         val fakeCameraId = RobolectricCameras.create()
         val context = ApplicationProvider.getApplicationContext() as Context
         val cameraPipe = CameraPipe(CameraPipe.Config(context))
         val cameras = cameraPipe.cameras()
-        val cameraList = runBlocking { cameras.ids() }
+        val cameraList = cameras.ids()
 
         assertThat(cameraList).isNotNull()
         assertThat(cameraList.size).isEqualTo(1)
         assertThat(cameraList).contains(fakeCameraId)
     }
-
-    @Ignore("b/180539013")
-    @Test
-    fun createExternalCameraGraph() {
-        val fakeRequestProcessor = FakeRequestProcessor()
-        val fakeCameraMetadata = FakeCameraMetadata()
-
-        val config = CameraGraph.Config(
-            camera = fakeCameraMetadata.camera,
-            streams = listOf(),
-            defaultTemplate = RequestTemplate(0)
-        )
-
-        val cameraGraph = CameraPipe.External().create(
-            config,
-            fakeCameraMetadata,
-            fakeRequestProcessor
-        )
-        assertThat(cameraGraph).isNotNull()
-
-        val request = Request(streams = emptyList())
-        cameraGraph.start()
-
-        // Check that repeating request can be issued
-        runBlocking {
-            cameraGraph.acquireSession().use {
-                it.startRepeating(request)
-            }
-
-            val repeatingEvent = fakeRequestProcessor.nextEvent()
-            assertThat(repeatingEvent.startRepeating).isTrue()
-            assertThat(repeatingEvent.requestSequence!!.requests.first()).isSameInstanceAs(request)
-
-            cameraGraph.stop()
-
-            val closeEvent = fakeRequestProcessor.awaitEvent { it.close }
-            assertThat(closeEvent.close).isTrue()
-        }
-
-        fakeRequestProcessor.reset()
-
-        // Check that repeating request is saved and reused.
-        runBlocking {
-            cameraGraph.start()
-
-            val repeatingEvent = fakeRequestProcessor.nextEvent()
-            if (!repeatingEvent.startRepeating) {
-                throw RuntimeException("$repeatingEvent")
-            }
-
-            assertThat(repeatingEvent.startRepeating).isTrue()
-            assertThat(repeatingEvent.requestSequence!!.requests.first()).isSameInstanceAs(request)
-
-            cameraGraph.stop()
-        }
-    }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2RequestProcessorTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequenceProcessorTest.kt
similarity index 75%
rename from camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2RequestProcessorTest.kt
rename to camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequenceProcessorTest.kt
index 29e1e8f..1bf84cc 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2RequestProcessorTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CaptureSequenceProcessorTest.kt
@@ -31,10 +31,13 @@
 import androidx.camera.camera2.pipe.StreamFormat
 import androidx.camera.camera2.pipe.graph.StreamGraphImpl
 import androidx.camera.camera2.pipe.testing.FakeCameraDeviceWrapper
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequenceListener
 import androidx.camera.camera2.pipe.testing.FakeThreads
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import androidx.camera.camera2.pipe.testing.RobolectricCameras
 import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -42,13 +45,14 @@
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.internal.DoNotInstrument
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @DoNotInstrument
 @Config(minSdk = Build.VERSION_CODES.P, maxSdk = 29)
-internal class Camera2RequestProcessorTest {
+internal class Camera2CaptureSequenceProcessorTest {
     // TODO: This fails with "Failed to allocate native CameraMetadata" on robolectric prior
     //  to Android P. Update the test class to include support for older versions when a new
-    //  version of robolectric is dropped into androidX.
+    //  version of robolectric is dropped into AndroidX.
 
     private val mainLooper = Shadows.shadowOf(Looper.getMainLooper())
     private val cameraId = RobolectricCameras.create(
@@ -131,10 +135,10 @@
     }
 
     @Test
-    fun requestIsCreatedAndSubmitted() {
-        val requestProcessor = Camera2RequestProcessor(
+    fun requestIsCreatedAndSubmitted() = runTest {
+        val captureSequenceProcessor = Camera2CaptureSequenceProcessor(
             fakeCaptureSession,
-            FakeThreads.forTests,
+            FakeThreads.fromTestScope(this),
             RequestTemplate(1),
             mapOf(
                 stream1.id to surface1,
@@ -142,8 +146,9 @@
             )
         )
 
-        val result = requestProcessor.submit(
-            Request(listOf(stream1.id, stream2.id)),
+        val sequence = captureSequenceProcessor.build(
+            isRepeating = false,
+            requests = listOf(Request(listOf(stream1.id, stream2.id))),
             defaultParameters = mapOf<Any, Any?>(
                 CaptureRequest.CONTROL_AE_MODE to CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH,
                 CaptureRequest.CONTROL_AF_MODE to CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
@@ -151,10 +156,13 @@
             requiredParameters = mapOf<Any, Any?>(
                 CaptureRequest.CONTROL_AF_MODE to CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO
             ),
-            defaultListeners = listOf()
+            listeners = listOf(),
+            sequenceListener = FakeCaptureSequenceListener()
         )
 
-        assertThat(result).isTrue()
+        val result = captureSequenceProcessor.submit(sequence!!)
+
+        assertThat(result).isGreaterThan(0)
         assertThat(fakeCaptureSession.lastCapture).hasSize(1)
         assertThat(fakeCaptureSession.lastRepeating).isNull()
 
@@ -162,40 +170,50 @@
     }
 
     @Test
-    fun requestIsSubmittedWithPartialSurfaces() {
-        val requestProcessor = Camera2RequestProcessor(
+    fun requestIsSubmittedWithPartialSurfaces() = runTest {
+        val captureSequenceProcessor = Camera2CaptureSequenceProcessor(
             fakeCaptureSession,
-            FakeThreads.forTests,
+            FakeThreads.fromTestScope(this),
             RequestTemplate(1),
             mapOf(
                 stream1.id to surface1
             )
         )
-        val result = requestProcessor.submit(
-            Request(listOf(stream1.id, stream2.id)),
+        val captureSequence = captureSequenceProcessor.build(
+            isRepeating = false,
+            requests = listOf(Request(listOf(stream1.id, stream2.id))),
             defaultParameters = mapOf<Any, Any?>(),
             requiredParameters = mapOf<Any, Any?>(),
-            defaultListeners = listOf()
+            listeners = emptyList(),
+            sequenceListener = FakeCaptureSequenceListener()
         )
-        assertThat(result).isTrue()
+        assertThat(captureSequence).isNotNull()
+
+        val result = captureSequenceProcessor.submit(captureSequence!!)
+        assertThat(result).isGreaterThan(0)
     }
 
     @Test
-    fun requestIsNotSubmittedWithEmptySurfaceList() {
-        val requestProcessor = Camera2RequestProcessor(
+    fun requestIsNotSubmittedWithEmptySurfaceList() = runTest {
+        val captureSequenceProcessor = Camera2CaptureSequenceProcessor(
             fakeCaptureSession,
-            FakeThreads.forTests,
+            FakeThreads.fromTestScope(this),
             RequestTemplate(1),
             mapOf(
                 stream1.id to surface1
             )
         )
-        val result = requestProcessor.submit(
-            Request(listOf(stream2.id)),
+
+        // Key part is that only stream1 has a surface, but stream2 is requested.
+        val captureSequence = captureSequenceProcessor.build(
+            isRepeating = false,
+            requests = listOf(Request(listOf(stream2.id))),
             defaultParameters = mapOf<Any, Any?>(),
             requiredParameters = mapOf<Any, Any?>(),
-            defaultListeners = listOf()
+            listeners = emptyList(),
+            sequenceListener = FakeCaptureSequenceListener()
         )
-        assertThat(result).isFalse()
+
+        assertThat(captureSequence).isNull()
     }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2MetadataCacheTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2MetadataCacheTest.kt
index 467a517..be6d3f5 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2MetadataCacheTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2MetadataCacheTest.kt
@@ -24,17 +24,20 @@
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import androidx.camera.camera2.pipe.testing.RobolectricCameras
 import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.internal.DoNotInstrument
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @DoNotInstrument
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class Camera2MetadataCacheTest {
     @Test
-    fun metadataIsCachedAndShimmed() {
+    fun metadataIsCachedAndShimmed() = runTest {
         val camera0 = RobolectricCameras.create(
             mapOf(
                 CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL to CameraCharacteristics
@@ -57,7 +60,7 @@
 
         val cache = Camera2MetadataCache(
             RobolectricCameras.application,
-            FakeThreads.forTests,
+            FakeThreads.fromTestScope(this),
             Permissions(RobolectricCameras.application),
             CameraPipe.CameraMetadataConfig()
         )
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionFactoryTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionFactoryTest.kt
index 1f8958b..83da8a4 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionFactoryTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionFactoryTest.kt
@@ -26,17 +26,19 @@
 import androidx.camera.camera2.pipe.CameraPipe
 import androidx.camera.camera2.pipe.CameraStream
 import androidx.camera.camera2.pipe.CameraSurfaceManager
-import androidx.camera.camera2.pipe.RequestProcessor
+import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.StreamFormat
 import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.config.Camera2ControllerScope
-import androidx.camera.camera2.pipe.config.CameraGraphScope
 import androidx.camera.camera2.pipe.config.CameraPipeModules
 import androidx.camera.camera2.pipe.config.SharedCameraGraphModules
+import androidx.camera.camera2.pipe.config.CameraGraphScope
 import androidx.camera.camera2.pipe.config.ThreadConfigModule
+import androidx.camera.camera2.pipe.CaptureSequenceProcessor
 import androidx.camera.camera2.pipe.graph.StreamGraphImpl
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequence
 import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequenceProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import androidx.camera.camera2.pipe.testing.RobolectricCameras
 import androidx.test.core.app.ApplicationProvider
@@ -111,11 +113,12 @@
             virtualSessionState = VirtualSessionState(
                 FakeGraphProcessor(),
                 sessionFactory,
-                object : Camera2RequestProcessorFactory {
+                object : Camera2CaptureSequenceProcessorFactory {
                     override fun create(
                         session: CameraCaptureSessionWrapper,
                         surfaceMap: Map<StreamId, Surface>
-                    ): RequestProcessor = FakeRequestProcessor()
+                    ): CaptureSequenceProcessor<Request, FakeCaptureSequence> =
+                        FakeCaptureSequenceProcessor()
                 },
                 CameraSurfaceManager(),
                 this
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/VirtualCameraTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/VirtualCameraTest.kt
index 64e6f33..cca0004 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/VirtualCameraTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/VirtualCameraTest.kt
@@ -24,15 +24,13 @@
 import androidx.camera.camera2.pipe.testing.RobolectricCameras
 import com.google.common.truth.Truth.assertThat
 import java.util.concurrent.TimeUnit
-import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.flow.asFlow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Test
@@ -55,16 +53,16 @@
     }
 
     @Test
-    fun virtualCameraStateCanBeDisconnected() = runTest(UnconfinedTestDispatcher()) {
+    fun virtualCameraStateCanBeDisconnected() = runTest {
         // This test asserts that the virtual camera starts in an unopened state and is changed to
         // "Closed" when disconnect is invoked on the VirtualCamera.
         val virtualCamera = VirtualCameraState(cameraId)
-        assertThat(virtualCamera.state.value).isInstanceOf(CameraStateUnopened.javaClass)
+        assertThat(virtualCamera.value).isInstanceOf(CameraStateUnopened::class.java)
 
         virtualCamera.disconnect()
-        assertThat(virtualCamera.state.value).isInstanceOf(CameraStateClosed::class.java)
+        assertThat(virtualCamera.value).isInstanceOf(CameraStateClosed::class.java)
 
-        val closedState = virtualCamera.state.value as CameraStateClosed
+        val closedState = virtualCamera.value as CameraStateClosed
         assertThat(closedState.cameraClosedReason).isEqualTo(ClosedReason.APP_DISCONNECTED)
 
         // Disconnecting a virtual camera does not propagate statistics.
@@ -103,18 +101,17 @@
 
         virtualCamera.state.first { it !is CameraStateUnopened }
 
-        assertThat(virtualCamera.state.value).isInstanceOf(CameraStateOpen::class.java)
+        assertThat(virtualCamera.value).isInstanceOf(CameraStateOpen::class.java)
         virtualCamera.disconnect()
-        assertThat(virtualCamera.state.value).isInstanceOf(CameraStateClosed::class.java)
+        assertThat(virtualCamera.value).isInstanceOf(CameraStateClosed::class.java)
 
-        val closedState = virtualCamera.state.value as CameraStateClosed
+        val closedState = virtualCamera.value as CameraStateClosed
         assertThat(closedState.cameraId).isEqualTo(cameraId)
         assertThat(closedState.cameraClosedReason).isEqualTo(ClosedReason.APP_DISCONNECTED)
     }
 
-    @Suppress("DEPRECATION") // fails with runTest {} api - b/220870228
     @Test
-    fun virtualCameraStateRespondsToClose() = runBlockingTest {
+    fun virtualCameraStateRespondsToClose() = runTest {
         // This tests that a listener attached to the virtualCamera.state property will receive all
         // of the events, starting from CameraStateUnopened.
         val virtualCamera = VirtualCameraState(cameraId)
@@ -135,7 +132,7 @@
         )
 
         val events = mutableListOf<CameraState>()
-        val job = launch(start = CoroutineStart.UNDISPATCHED) {
+        val job = launch {
             virtualCamera.state.collect {
                 events.add(it)
             }
@@ -150,8 +147,7 @@
             }
         )
 
-        // Suspend until the state is closed
-        virtualCamera.state.first { it is CameraStateClosed }
+        advanceUntilIdle()
         job.cancelAndJoin()
 
         val expectedStates = listOf(CameraStateUnopened).plus(states)
@@ -161,7 +157,6 @@
 
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
-@OptIn(ExperimentalCoroutinesApi::class)
 internal class AndroidCameraDeviceTest {
     private val mainLooper = shadowOf(getMainLooper())
     private val cameraId = RobolectricCameras.create()
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/TokenLockTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/TokenLockTest.kt
index 7c28337..20d3057 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/TokenLockTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/TokenLockTest.kt
@@ -19,7 +19,6 @@
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.async
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.runBlocking
@@ -30,7 +29,6 @@
 import org.junit.runners.JUnit4
 
 @RunWith(JUnit4::class)
-@OptIn(ExperimentalCoroutinesApi::class)
 internal class TokenLockTest {
     @Test
     fun testTokenLockReportsNoAvailableCapacityWhenClosed() {
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/WakeLockTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/WakeLockTest.kt
index 24e106a9..993d7a1 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/WakeLockTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/WakeLockTest.kt
@@ -20,17 +20,17 @@
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
 
-@RunWith(JUnit4::class)
 @OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(JUnit4::class)
 internal class WakeLockTest {
 
     @Test
-    fun testWakeLockInvokesCallbackAfterTokenIsReleased() = runBlocking {
+    fun testWakeLockInvokesCallbackAfterTokenIsReleased() = runTest {
         val result = CompletableDeferred<Boolean>()
 
         val wakelock = WakeLock(this) {
@@ -42,7 +42,7 @@
     }
 
     @Test
-    fun testWakelockDoesNotCompleteUntilAllTokensAreReleased() = runBlocking {
+    fun testWakelockDoesNotCompleteUntilAllTokensAreReleased() = runTest {
         val result = CompletableDeferred<Boolean>()
 
         val wakelock = WakeLock(this) {
@@ -62,7 +62,7 @@
     }
 
     @Test
-    fun testClosingWakelockInvokesCallback() = runBlocking {
+    fun testClosingWakelockInvokesCallback() = runTest {
         val result = CompletableDeferred<Boolean>()
         val wakelock = WakeLock(this, 100) {
             result.complete(true)
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
index 33f40d7..5cfeeb6 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
@@ -16,147 +16,189 @@
 
 package androidx.camera.camera2.pipe.graph
 
+import android.content.Context
 import android.hardware.camera2.CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL
 import android.hardware.camera2.CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL
 import android.os.Build
+import androidx.camera.camera2.pipe.CameraBackendFactory
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.Request
-import androidx.camera.camera2.pipe.testing.FakeCameraController
+import androidx.camera.camera2.pipe.internal.CameraBackendsImpl
+import androidx.camera.camera2.pipe.testing.FakeCameraBackend
+import androidx.camera.camera2.pipe.testing.CameraControllerSimulator
 import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
 import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
+import androidx.camera.camera2.pipe.testing.FakeThreads
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
-import androidx.camera.camera2.pipe.testing.RobolectricCameras
+import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.runBlocking
-import org.junit.Before
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.internal.DoNotInstrument
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @DoNotInstrument
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class CameraGraphImplTest {
-    private val fakeCameraId = RobolectricCameras.create()
-    private val fakeMetadata = FakeCameraMetadata(
+    private val context = ApplicationProvider.getApplicationContext() as Context
+    private val metadata = FakeCameraMetadata(
         mapOf(INFO_SUPPORTED_HARDWARE_LEVEL to INFO_SUPPORTED_HARDWARE_LEVEL_FULL),
-        cameraId = fakeCameraId
     )
     private val fakeGraphProcessor = FakeGraphProcessor()
-    private val fakeCameraController = FakeCameraController()
-    private lateinit var impl: CameraGraphImpl
+    private lateinit var cameraController: CameraControllerSimulator
 
-    @Before
-    fun setUp() {
-        val config = CameraGraph.Config(
-            camera = fakeCameraId,
+    private fun initializeCameraGraphImpl(scope: TestScope): CameraGraphImpl {
+        val graphConfig = CameraGraph.Config(
+            camera = metadata.camera,
             streams = listOf(),
         )
-        val streamGraph = StreamGraphImpl(
-            fakeMetadata,
-            config
+        val threads = FakeThreads.fromTestScope(scope)
+        val backend = FakeCameraBackend(
+            fakeCameras = mapOf(metadata.camera to metadata)
         )
-        val surfaceGraph = SurfaceGraph(streamGraph, fakeCameraController)
-
-        impl = CameraGraphImpl(
-            config,
-            fakeMetadata,
+        val backends = CameraBackendsImpl(
+            defaultBackendId = backend.id,
+            cameraBackends = mapOf(backend.id to CameraBackendFactory { backend }),
+            context,
+            threads
+        )
+        val cameraContext = CameraBackendsImpl.CameraBackendContext(
+            context,
+            threads,
+            backends
+        )
+        val streamGraph = StreamGraphImpl(
+            metadata,
+            graphConfig
+        )
+        cameraController = CameraControllerSimulator(
+            cameraContext,
+            graphConfig,
+            fakeGraphProcessor,
+            streamGraph
+        )
+        val surfaceGraph = SurfaceGraph(streamGraph, cameraController)
+        return CameraGraphImpl(
+            graphConfig,
+            metadata,
             fakeGraphProcessor,
             streamGraph,
             surfaceGraph,
-            fakeCameraController,
+            cameraController,
             GraphState3A(),
             Listener3A()
         )
     }
 
     @Test
-    fun createCameraGraphImpl() {
-        assertThat(impl).isNotNull()
+    fun createCameraGraphImpl() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        assertThat(cameraGraphImpl).isNotNull()
     }
 
     @Test
-    fun testAcquireSession() = runBlocking {
-        val session = impl.acquireSession()
+    fun testAcquireSession() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = cameraGraphImpl.acquireSession()
         assertThat(session).isNotNull()
     }
 
     @Test
-    fun testAcquireSessionOrNull() {
-        val session = impl.acquireSessionOrNull()
+    fun testAcquireSessionOrNull() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = cameraGraphImpl.acquireSessionOrNull()
         assertThat(session).isNotNull()
     }
 
     @Test
-    fun testAcquireSessionOrNullAfterAcquireSession() = runBlocking {
-        val session = impl.acquireSession()
+    fun testAcquireSessionOrNullAfterAcquireSession() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = cameraGraphImpl.acquireSession()
         assertThat(session).isNotNull()
 
         // Since a session is already active, an attempt to acquire another session will fail.
-        val session1 = impl.acquireSessionOrNull()
+        val session1 = cameraGraphImpl.acquireSessionOrNull()
         assertThat(session1).isNull()
 
         // Closing an active session should allow a new session instance to be created.
         session.close()
 
-        val session2 = impl.acquireSessionOrNull()
+        val session2 = cameraGraphImpl.acquireSessionOrNull()
         assertThat(session2).isNotNull()
     }
 
     @Test
-    fun sessionSubmitsRequestsToGraphProcessor() {
-        val session = checkNotNull(impl.acquireSessionOrNull())
+    fun sessionSubmitsRequestsToGraphProcessor() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = checkNotNull(cameraGraphImpl.acquireSessionOrNull())
         val request = Request(listOf())
         session.submit(request)
+        advanceUntilIdle()
 
         assertThat(fakeGraphProcessor.requestQueue).contains(listOf(request))
     }
 
     @Test
-    fun sessionSetsRepeatingRequestOnGraphProcessor() {
-        val session = checkNotNull(impl.acquireSessionOrNull())
+    fun sessionSetsRepeatingRequestOnGraphProcessor() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = checkNotNull(cameraGraphImpl.acquireSessionOrNull())
         val request = Request(listOf())
         session.startRepeating(request)
+        advanceUntilIdle()
 
         assertThat(fakeGraphProcessor.repeatingRequest).isSameInstanceAs(request)
     }
 
     @Test
-    fun sessionAbortsRequestOnGraphProcessor() {
-        val session = checkNotNull(impl.acquireSessionOrNull())
+    fun sessionAbortsRequestOnGraphProcessor() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = checkNotNull(cameraGraphImpl.acquireSessionOrNull())
         val request = Request(listOf())
         session.submit(request)
         session.abort()
+        advanceUntilIdle()
 
         assertThat(fakeGraphProcessor.requestQueue).isEmpty()
     }
 
     @Test
-    fun closingSessionDoesNotCloseGraphProcessor() {
-        val session = impl.acquireSessionOrNull()
+    fun closingSessionDoesNotCloseGraphProcessor() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        val session = cameraGraphImpl.acquireSessionOrNull()
         checkNotNull(session).close()
+        advanceUntilIdle()
 
         assertThat(fakeGraphProcessor.closed).isFalse()
     }
 
     @Test
-    fun closingCameraGraphClosesGraphProcessor() {
-        impl.close()
+    fun closingCameraGraphClosesGraphProcessor() = runTest {
+        val cameraGraphImpl = initializeCameraGraphImpl(this)
+        cameraGraphImpl.close()
         assertThat(fakeGraphProcessor.closed).isTrue()
     }
 
     @Test
-    fun stoppingCameraGraphStopsGraphProcessor() {
-        assertThat(fakeCameraController.active).isFalse()
-        impl.start()
-        assertThat(fakeCameraController.active).isTrue()
-        impl.stop()
-        assertThat(fakeCameraController.active).isFalse()
-        impl.start()
-        assertThat(fakeCameraController.active).isTrue()
-        impl.close()
+    fun stoppingCameraGraphStopsGraphProcessor() = runTest {
+        val cameraGraph = initializeCameraGraphImpl(this)
+
+        assertThat(cameraController.started).isFalse()
+        assertThat(fakeGraphProcessor.closed).isFalse()
+        cameraGraph.start()
+        assertThat(cameraController.started).isTrue()
+        cameraGraph.stop()
+        assertThat(cameraController.started).isFalse()
+        assertThat(fakeGraphProcessor.closed).isFalse()
+        cameraGraph.start()
+        assertThat(cameraController.started).isTrue()
+        cameraGraph.close()
+        assertThat(cameraController.started).isFalse()
         assertThat(fakeGraphProcessor.closed).isTrue()
-        assertThat(fakeCameraController.active).isFalse()
     }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AForCaptureTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AForCaptureTest.kt
index c108059..85276fc 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AForCaptureTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AForCaptureTest.kt
@@ -14,28 +14,25 @@
  * limitations under the License.
  */
 
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
 package androidx.camera.camera2.pipe.graph
 
 import android.hardware.camera2.CaptureRequest
 import android.hardware.camera2.CaptureResult
 import android.os.Build
 import androidx.camera.camera2.pipe.FrameNumber
-import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestNumber
 import androidx.camera.camera2.pipe.Result3A
-import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
 import androidx.camera.camera2.pipe.testing.FakeFrameMetadata
-import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
 import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.async
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
@@ -43,9 +40,11 @@
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 class Controller3AForCaptureTest {
-    private val graphState3A = GraphState3A()
-    private val graphProcessor = FakeGraphProcessor(graphState3A = graphState3A)
-    private val requestProcessor = FakeRequestProcessor()
+    private val graphTestContext = GraphTestContext()
+    private val graphState3A = graphTestContext.graphProcessor.graphState3A
+    private val graphProcessor = graphTestContext.graphProcessor
+    private val captureSequenceProcessor = graphTestContext.captureSequenceProcessor
+
     private val listener3A = Listener3A()
     private val controller3A = Controller3A(
         graphProcessor,
@@ -54,18 +53,15 @@
         listener3A
     )
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testLock3AForCapture(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testLock3AForCapture() = runTest {
         val result = controller3A.lock3AForCapture()
         assertThat(result.isCompleted).isFalse()
 
         // Since requirement is to trigger both AF and AE precapture metering. The result of
         // lock3AForCapture call will complete once AE and AF have reached their desired states. In
         // this response i.e cameraResponse1, AF is still scanning so the result won't be complete.
-        val cameraResponse = GlobalScope.async {
+        val cameraResponse = async {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -90,7 +86,7 @@
 
         // One we are notified that the AE and AF are in the desired states, the result of
         // lock3AForCapture call will complete.
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -116,7 +112,7 @@
 
         // We now check if the correct sequence of requests were submitted by lock3AForCapture call.
         // There should be a request to trigger AF and AE precapture metering.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -133,16 +129,13 @@
         }
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
-    private fun testUnlock3APostCaptureAndroidMAndAbove(): Unit = runBlocking {
-        initGraphProcessor()
-
+    private fun testUnlock3APostCaptureAndroidMAndAbove() = runTest {
         val result = controller3A.unlock3APostCapture()
         assertThat(result.isCompleted).isFalse()
 
         // In this response i.e cameraResponse1, AF is still scanning so the result won't be
         // complete.
-        val cameraResponse = GlobalScope.async {
+        val cameraResponse = async {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -168,7 +161,7 @@
         // Once we are notified that the AF is in unlocked state, the result of unlock3APostCapture
         // call will complete. For AE we don't need to to check for a specific state, receiving the
         // capture result corresponding to the submitted request suffices.
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -194,7 +187,7 @@
 
         // We now check if the correct sequence of requests were submitted by unlock3APostCapture
         // call. There should be a request to cancel AF and AE precapture metering.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_CANCEL
         )
@@ -202,14 +195,11 @@
             .isEqualTo(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_CANCEL)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
-    private fun testUnlock3APostCaptureAndroidLAndBelow(): Unit = runBlocking {
-        initGraphProcessor()
-
+    private fun testUnlock3APostCaptureAndroidLAndBelow() = runTest {
         val result = controller3A.unlock3APostCapture()
         assertThat(result.isCompleted).isFalse()
 
-        val cameraResponse = GlobalScope.async {
+        val cameraResponse = async {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -232,7 +222,7 @@
 
         // We now check if the correct sequence of requests were submitted by unlock3APostCapture
         // call. There should be a request to cancel AF and lock ae.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_CANCEL
         )
@@ -240,18 +230,8 @@
             .isEqualTo(true)
 
         // Then another request to unlock ae.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK])
             .isEqualTo(false)
     }
-
-    private fun initGraphProcessor() {
-        graphProcessor.onGraphStarted(requestProcessor)
-        graphProcessor.startRepeating(Request(streams = listOf(StreamId(1))))
-    }
-
-    companion object {
-        // The time duration in milliseconds between two frame results.
-        private const val FRAME_RATE_MS = 33L
-    }
 }
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ALock3ATest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ALock3ATest.kt
index 8812f87..c2ba0df 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ALock3ATest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ALock3ATest.kt
@@ -23,27 +23,21 @@
 import android.os.Build
 import androidx.camera.camera2.pipe.FrameNumber
 import androidx.camera.camera2.pipe.Lock3ABehavior
-import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestNumber
 import androidx.camera.camera2.pipe.Result3A
-import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
 import androidx.camera.camera2.pipe.testing.FakeFrameMetadata
-import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
 import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.async
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
@@ -52,9 +46,11 @@
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class Controller3ALock3ATest {
-    private val graphState3A = GraphState3A()
-    private val graphProcessor = FakeGraphProcessor(graphState3A = graphState3A)
-    private val requestProcessor = FakeRequestProcessor()
+    private val graphTestContext = GraphTestContext()
+    private val graphState3A = graphTestContext.graphProcessor.graphState3A
+    private val graphProcessor = graphTestContext.graphProcessor
+    private val captureSequenceProcessor = graphTestContext.captureSequenceProcessor
+
     private val listener3A = Listener3A()
     private val fakeMetadata = FakeCameraMetadata(
         mapOf(
@@ -64,11 +60,8 @@
     )
     private val controller3A = Controller3A(graphProcessor, fakeMetadata, graphState3A, listener3A)
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfImmediateAeImmediate(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfImmediateAeImmediate() = runTest {
         val result = controller3A.lock3A(
             afLockBehavior = Lock3ABehavior.IMMEDIATE,
             aeLockBehavior = Lock3ABehavior.IMMEDIATE
@@ -79,7 +72,7 @@
         // are sent right away. The result of lock3A call will complete once AE and AF have reached
         // their desired states. In this response i.e cameraResponse1, AF is still scanning so the
         // result won't be complete.
-        val cameraResponse = GlobalScope.async {
+        val cameraResponse = async {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -104,7 +97,7 @@
 
         // One we we are notified that the AE and AF are in locked state, the result of lock3A call
         // will complete.
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -130,13 +123,13 @@
 
         // We not check if the correct sequence of requests were submitted by lock3A call. The
         // request should be a repeating request to lock AE.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
 
         // The second request should be a single request to lock AF.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -145,11 +138,8 @@
         )
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfImmediateAeAfterCurrentScan(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfImmediateAeAfterCurrentScan() = runTest {
         val globalScope = CoroutineScope(UnconfinedTestDispatcher())
 
         val lock3AAsyncTask = globalScope.async {
@@ -190,9 +180,9 @@
 
         // Check the correctness of the requests submitted by lock3A.
         // One repeating request was sent to monitor the state of AE to get converged.
-        requestProcessor.nextEvent().requestSequence
+        captureSequenceProcessor.nextEvent().requestSequence
         // Once AE is converged, another repeatingrequest is sent to lock AE.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
@@ -222,18 +212,15 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
 
         // A single request to lock AF must have been used as well.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
         globalScope.cancel()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfImmediateAeAfterNewScan(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfImmediateAeAfterNewScan() = runTest {
         val globalScope = CoroutineScope(UnconfinedTestDispatcher())
 
         val lock3AAsyncTask = globalScope.async {
@@ -273,7 +260,7 @@
 
         // For a new AE scan we first send a request to unlock AE just in case it was
         // previously or internally locked.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             false
         )
@@ -303,13 +290,13 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
 
         // There should be one more request to lock AE after new scan is done.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
 
         // And one request to lock AF.
-        val request3 = requestProcessor.nextEvent().requestSequence
+        val request3 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request3!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -320,11 +307,8 @@
         globalScope.cancel()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfAfterCurrentScanAeImmediate(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfAfterCurrentScanAeImmediate() = runTest {
         val globalScope = CoroutineScope(UnconfinedTestDispatcher())
 
         val lock3AAsyncTask = globalScope.async {
@@ -386,15 +370,15 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
 
         // There should be one request to monitor AF to finish it's scan.
-        requestProcessor.nextEvent()
+        captureSequenceProcessor.nextEvent()
         // One request to lock AE
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
 
         // And one request to lock AF.
-        val request3 = requestProcessor.nextEvent().requestSequence
+        val request3 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request3!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -404,11 +388,8 @@
         globalScope.cancel()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfAfterNewScanScanAeImmediate(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfAfterNewScanScanAeImmediate() = runTest {
         val globalScope = CoroutineScope(UnconfinedTestDispatcher())
 
         val lock3AAsyncTask = globalScope.async {
@@ -470,21 +451,21 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
 
         // One request to cancel AF to start a new scan.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_CANCEL
         )
         // There should be one request to monitor AF to finish it's scan.
-        requestProcessor.nextEvent()
+        captureSequenceProcessor.nextEvent()
 
         // There should be one request to monitor lock AE.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
 
         // And one request to lock AF.
-        val request3 = requestProcessor.nextEvent().requestSequence
+        val request3 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request3!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -494,11 +475,8 @@
         globalScope.cancel()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfAfterCurrentScanAeAfterCurrentScan(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfAfterCurrentScanAeAfterCurrentScan() = runTest {
         val globalScope = CoroutineScope(UnconfinedTestDispatcher())
 
         val lock3AAsyncTask = globalScope.async {
@@ -560,17 +538,17 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
 
         // There should be one request to monitor AF to finish it's scan.
-        val event = requestProcessor.nextEvent()
-        assertThat(event.startRepeating).isTrue()
+        val event = captureSequenceProcessor.nextEvent()
+        assertThat(event.requestSequence!!.repeating).isTrue()
         assertThat(event.rejected).isFalse()
         assertThat(event.abort).isFalse()
         assertThat(event.close).isFalse()
-        assertThat(event.submit).isFalse()
+        assertThat(event.submit).isTrue()
 
         // One request to lock AE
-        val request2Event = requestProcessor.nextEvent()
-        assertThat(request2Event.startRepeating).isTrue()
-        assertThat(request2Event.submit).isFalse()
+        val request2Event = captureSequenceProcessor.nextEvent()
+        assertThat(request2Event.requestSequence!!.repeating).isTrue()
+        assertThat(request2Event.submit).isTrue()
         val request2 = request2Event.requestSequence!!
         assertThat(request2).isNotNull()
         assertThat(request2.requiredParameters).isNotEmpty()
@@ -579,8 +557,8 @@
         )
 
         // And one request to lock AF.
-        val request3Event = requestProcessor.nextEvent()
-        assertThat(request3Event.startRepeating).isFalse()
+        val request3Event = captureSequenceProcessor.nextEvent()
+        assertThat(request3Event.requestSequence!!.repeating).isFalse()
         assertThat(request3Event.submit).isTrue()
         val request3 = request3Event.requestSequence!!
         assertThat(request3.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
@@ -593,11 +571,8 @@
         globalScope.cancel()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfAfterNewScanScanAeAfterNewScan(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfAfterNewScanScanAeAfterNewScan() = runTest {
         val globalScope = CoroutineScope(UnconfinedTestDispatcher())
         val lock3AAsyncTask = globalScope.async {
             controller3A.lock3A(
@@ -658,24 +633,24 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
 
         // One request to cancel AF to start a new scan.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_CANCEL
         )
         // There should be one request to unlock AE and monitor the current AF scan to finish.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             false
         )
 
         // There should be one request to monitor lock AE.
-        val request3 = requestProcessor.nextEvent().requestSequence
+        val request3 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request3!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
 
         // And one request to lock AF.
-        val request4 = requestProcessor.nextEvent().requestSequence
+        val request4 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request4!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -685,11 +660,8 @@
         globalScope.cancel()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testLock3AWithRegions(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testLock3AWithRegions() = runTest {
         val afMeteringRegion = MeteringRectangle(1, 1, 100, 100, 2)
         val aeMeteringRegion = MeteringRectangle(10, 15, 140, 140, 3)
         val result = controller3A.lock3A(
@@ -704,7 +676,7 @@
         // are sent right away. The result of lock3A call will complete once AE and AF have reached
         // their desired states. In this response i.e cameraResponse1, AF is still scanning so the
         // result won't be complete.
-        val cameraResponse = GlobalScope.async {
+        val cameraResponse = async {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -729,7 +701,7 @@
 
         // One we we are notified that the AE and AF are in locked state, the result of lock3A call
         // will complete.
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -763,13 +735,13 @@
 
         // We not check if the correct sequence of requests were submitted by lock3A call. The
         // request should be a repeating request to lock AE.
-        val request1 = requestProcessor.nextEvent().requestSequence
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK]).isEqualTo(
             true
         )
 
         // The second request should be a single request to lock AF.
-        val request2 = requestProcessor.nextEvent().requestSequence
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
         assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER]).isEqualTo(
             CaptureRequest.CONTROL_AF_TRIGGER_START
         )
@@ -778,11 +750,8 @@
         )
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testLock3AWithUnsupportedAutoFocusTrigger(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testLock3AWithUnsupportedAutoFocusTrigger() = runTest {
         val fakeMetadata = FakeCameraMetadata(
             mapOf(
                 CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES to
@@ -795,11 +764,6 @@
         assertThat(result.frameMetadata).isEqualTo(null)
     }
 
-    private fun initGraphProcessor() {
-        graphProcessor.onGraphStarted(requestProcessor)
-        graphProcessor.startRepeating(Request(streams = listOf(StreamId(1))))
-    }
-
     companion object {
         // The time duration in milliseconds between two frame results.
         private const val FRAME_RATE_MS = 33L
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASetTorchTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASetTorchTest.kt
index a2f96c8..d045cfb 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASetTorchTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASetTorchTest.kt
@@ -21,32 +21,28 @@
 import android.os.Build
 import androidx.camera.camera2.pipe.AeMode
 import androidx.camera.camera2.pipe.FrameNumber
-import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestNumber
 import androidx.camera.camera2.pipe.Result3A
-import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.TorchState
 import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
 import androidx.camera.camera2.pipe.testing.FakeFrameMetadata
-import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
 import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class Controller3ASetTorchTest {
-    private val graphState3A = GraphState3A()
-    private val graphProcessor = FakeGraphProcessor(graphState3A = graphState3A)
-    private val requestProcessor = FakeRequestProcessor()
+    private val graphTestContext = GraphTestContext()
+    private val graphState3A = graphTestContext.graphProcessor.graphState3A
+    private val graphProcessor = graphTestContext.graphProcessor
     private val listener3A = Listener3A()
     private val controller3A = Controller3A(
         graphProcessor,
@@ -55,17 +51,14 @@
         listener3A
     )
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testSetTorchOn() = runBlocking {
-        initGraphProcessor()
-
+    fun testSetTorchOn() = runTest {
         val result = controller3A.setTorch(TorchState.ON)
         assertThat(graphState3A.aeMode!!.value).isEqualTo(CaptureRequest.CONTROL_AE_MODE_ON)
         assertThat(graphState3A.flashMode!!.value).isEqualTo(CaptureRequest.FLASH_MODE_TORCH)
         assertThat(result.isCompleted).isFalse()
 
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -88,17 +81,14 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testSetTorchOff() = runBlocking {
-        initGraphProcessor()
-
+    fun testSetTorchOff() = runTest {
         val result = controller3A.setTorch(TorchState.OFF)
         assertThat(graphState3A.aeMode!!.value).isEqualTo(CaptureRequest.CONTROL_AE_MODE_ON)
         assertThat(graphState3A.flashMode!!.value).isEqualTo(CaptureRequest.FLASH_MODE_OFF)
         assertThat(result.isCompleted).isFalse()
 
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -121,11 +111,8 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testSetTorchDoesNotChangeAeModeIfNotNeeded() = runBlocking {
-        initGraphProcessor()
-
+    fun testSetTorchDoesNotChangeAeModeIfNotNeeded() = runTest {
         graphState3A.update(aeMode = AeMode.OFF)
 
         val result = controller3A.setTorch(TorchState.ON)
@@ -135,7 +122,7 @@
         )
         assertThat(result.isCompleted).isFalse()
 
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -157,9 +144,4 @@
         assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
-
-    private fun initGraphProcessor() {
-        graphProcessor.onGraphStarted(requestProcessor)
-        graphProcessor.startRepeating(Request(streams = listOf(StreamId(1))))
-    }
 }
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASubmit3ATest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASubmit3ATest.kt
index 5b2f741..3f89e5a 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASubmit3ATest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3ASubmit3ATest.kt
@@ -24,31 +24,27 @@
 import androidx.camera.camera2.pipe.AfMode
 import androidx.camera.camera2.pipe.AwbMode
 import androidx.camera.camera2.pipe.FrameNumber
-import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestNumber
 import androidx.camera.camera2.pipe.Result3A
-import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
 import androidx.camera.camera2.pipe.testing.FakeFrameMetadata
-import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
 import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class Controller3ASubmit3ATest {
-    private val graphState3A = GraphState3A()
-    private val graphProcessor = FakeGraphProcessor(graphState3A = graphState3A)
-    private val requestProcessor = FakeRequestProcessor()
+    private val graphTestContext = GraphTestContext()
+    private val graphState3A = graphTestContext.graphProcessor.graphState3A
+    private val graphProcessor = graphTestContext.graphProcessor
     private val listener3A = Listener3A()
     private val controller3A = Controller3A(
         graphProcessor,
@@ -58,21 +54,16 @@
     )
 
     @Test
-    fun testSubmit3ADoesNotUpdateState3A(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testSubmit3ADoesNotUpdateState3A() = runTest {
         val result = controller3A.submit3A(afMode = AfMode.OFF)
         assertThat(graphState3A.afMode?.value).isNotEqualTo(CaptureRequest.CONTROL_AF_MODE_OFF)
         assertThat(result.isCompleted).isFalse()
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfModeSubmit(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfModeSubmit() = runTest {
         val result = controller3A.submit3A(afMode = AfMode.OFF)
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -94,13 +85,10 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAeModeSubmit(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAeModeSubmit() = runTest {
         val result = controller3A.submit3A(aeMode = AeMode.ON_ALWAYS_FLASH)
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -123,13 +111,10 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAwbModeSubmit(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAwbModeSubmit() = runTest {
         val result = controller3A.submit3A(awbMode = AwbMode.CLOUDY_DAYLIGHT)
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -152,13 +137,10 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfRegionsSubmit(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAfRegionsSubmit() = runTest {
         val result = controller3A.submit3A(afRegions = listOf(MeteringRectangle(1, 1, 100, 100, 2)))
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -181,13 +163,10 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAeRegionsSubmit(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAeRegionsSubmit() = runTest {
         val result = controller3A.submit3A(aeRegions = listOf(MeteringRectangle(1, 1, 100, 100, 2)))
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -210,18 +189,15 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAwbRegionsSubmit(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testAwbRegionsSubmit() = runTest {
         val result = controller3A.submit3A(
             awbRegions = listOf(
                 MeteringRectangle(1, 1, 100, 100, 2)
             )
         )
 
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -245,7 +221,7 @@
     }
 
     @Test
-    fun testWithGraphProcessorFailure(): Unit = runBlocking {
+    fun testWithGraphProcessorFailure() = runTest {
         // There are different conditions that can lead to the request processor not being able
         // to successfully submit the desired request. For this test we are closing the processor.
         graphProcessor.close()
@@ -255,9 +231,4 @@
         assertThat(result.frameMetadata).isNull()
         assertThat(result.status).isEqualTo(Result3A.Status.SUBMIT_FAILED)
     }
-
-    private fun initGraphProcessor() {
-        graphProcessor.onGraphStarted(requestProcessor)
-        graphProcessor.startRepeating(Request(streams = listOf(StreamId(1))))
-    }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUnlock3ATest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUnlock3ATest.kt
index a940f9f..c0b6cb5 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUnlock3ATest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUnlock3ATest.kt
@@ -21,33 +21,30 @@
 import android.hardware.camera2.CaptureResult
 import android.os.Build
 import androidx.camera.camera2.pipe.FrameNumber
-import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestNumber
 import androidx.camera.camera2.pipe.Result3A
-import androidx.camera.camera2.pipe.StreamId
 import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
 import androidx.camera.camera2.pipe.testing.FakeFrameMetadata
-import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
 import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
-import com.google.common.truth.Truth
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.async
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class Controller3AUnlock3ATest {
-    private val graphState3A = GraphState3A()
-    private val graphProcessor = FakeGraphProcessor(graphState3A = graphState3A)
-    private val requestProcessor = FakeRequestProcessor()
+    private val graphTestContext = GraphTestContext()
+    private val graphState3A = graphTestContext.graphProcessor.graphState3A
+    private val graphProcessor = graphTestContext.graphProcessor
+    private val captureSequenceProcessor = graphTestContext.captureSequenceProcessor
     private val listener3A = Listener3A()
     private val fakeMetadata = FakeCameraMetadata(
         mapOf(
@@ -62,17 +59,14 @@
         listener3A
     )
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testUnlockAe(): Unit = runBlocking {
-        initGraphProcessor()
-
-        val unLock3AAsyncTask = GlobalScope.async {
+    fun testUnlockAe() = runTest {
+        val unLock3AAsyncTask = async {
             controller3A.unlock3A(ae = true)
         }
 
         // Launch a task to repeatedly invoke a given capture result.
-        GlobalScope.launch {
+        val repeatingJob = launch {
             while (true) {
                 listener3A.onRequestSequenceCreated(
                     FakeRequestMetadata(
@@ -96,14 +90,17 @@
 
         val result = unLock3AAsyncTask.await()
         // Result of unlock3A call shouldn't be complete yet since the AE is locked.
-        Truth.assertThat(result.isCompleted).isFalse()
+        assertThat(result.isCompleted).isFalse()
 
         // There should be one request to lock AE.
-        val request1 = requestProcessor.nextEvent().requestSequence
-        Truth.assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK])
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
+        assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK])
             .isEqualTo(false)
 
-        GlobalScope.launch {
+        repeatingJob.cancel()
+        repeatingJob.join()
+
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -122,19 +119,16 @@
         }
 
         val result3A = result.await()
-        Truth.assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
-        Truth.assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
+        assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
+        assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testUnlockAf(): Unit = runBlocking {
-        initGraphProcessor()
-
-        val unLock3AAsyncTask = GlobalScope.async { controller3A.unlock3A(af = true) }
+    fun testUnlockAf() = runTest {
+        val unLock3AAsyncTask = async { controller3A.unlock3A(af = true) }
 
         // Launch a task to repeatedly invoke a given capture result.
-        GlobalScope.launch {
+        val repeatingJob = launch {
             while (true) {
                 listener3A.onRequestSequenceCreated(
                     FakeRequestMetadata(
@@ -158,14 +152,17 @@
 
         val result = unLock3AAsyncTask.await()
         // Result of unlock3A call shouldn't be complete yet since the AF is locked.
-        Truth.assertThat(result.isCompleted).isFalse()
+        assertThat(result.isCompleted).isFalse()
 
         // There should be one request to unlock AF.
-        val request1 = requestProcessor.nextEvent().requestSequence
-        Truth.assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER])
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
+        assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER])
             .isEqualTo(CaptureRequest.CONTROL_AF_TRIGGER_CANCEL)
 
-        GlobalScope.launch {
+        repeatingJob.cancel()
+        repeatingJob.join()
+
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -184,21 +181,18 @@
         }
 
         val result3A = result.await()
-        Truth.assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
-        Truth.assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
+        assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
+        assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testUnlockAwb(): Unit = runBlocking {
-        initGraphProcessor()
-
-        val unLock3AAsyncTask = GlobalScope.async {
+    fun testUnlockAwb() = runTest {
+        val unLock3AAsyncTask = async {
             controller3A.unlock3A(awb = true)
         }
 
         // Launch a task to repeatedly invoke a given capture result.
-        GlobalScope.launch {
+        val repeatingJob = launch {
             while (true) {
                 listener3A.onRequestSequenceCreated(
                     FakeRequestMetadata(
@@ -222,14 +216,17 @@
 
         val result = unLock3AAsyncTask.await()
         // Result of unlock3A call shouldn't be complete yet since the AWB is locked.
-        Truth.assertThat(result.isCompleted).isFalse()
+        assertThat(result.isCompleted).isFalse()
 
         // There should be one request to lock AWB.
-        val request1 = requestProcessor.nextEvent().requestSequence
-        Truth.assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AWB_LOCK])
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
+        assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AWB_LOCK])
             .isEqualTo(false)
 
-        GlobalScope.launch {
+        repeatingJob.cancel()
+        repeatingJob.join()
+
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -248,19 +245,16 @@
         }
 
         val result3A = result.await()
-        Truth.assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
-        Truth.assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
+        assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
+        assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testUnlockAeAf(): Unit = runBlocking {
-        initGraphProcessor()
-
-        val unLock3AAsyncTask = GlobalScope.async { controller3A.unlock3A(ae = true, af = true) }
+    fun testUnlockAeAf() = runTest {
+        val unLock3AAsyncTask = async { controller3A.unlock3A(ae = true, af = true) }
 
         // Launch a task to repeatedly invoke a given capture result.
-        GlobalScope.launch {
+        val repeatingJob = launch {
             while (true) {
                 listener3A.onRequestSequenceCreated(
                     FakeRequestMetadata(
@@ -285,18 +279,21 @@
 
         val result = unLock3AAsyncTask.await()
         // Result of unlock3A call shouldn't be complete yet since the AF is locked.
-        Truth.assertThat(result.isCompleted).isFalse()
+        assertThat(result.isCompleted).isFalse()
 
         // There should be one request to unlock AF.
-        val request1 = requestProcessor.nextEvent().requestSequence
-        Truth.assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER])
+        val request1 = captureSequenceProcessor.nextEvent().requestSequence
+        assertThat(request1!!.requiredParameters[CaptureRequest.CONTROL_AF_TRIGGER])
             .isEqualTo(CaptureRequest.CONTROL_AF_TRIGGER_CANCEL)
         // Then request to unlock AE.
-        val request2 = requestProcessor.nextEvent().requestSequence
-        Truth.assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK])
+        val request2 = captureSequenceProcessor.nextEvent().requestSequence
+        assertThat(request2!!.requiredParameters[CaptureRequest.CONTROL_AE_LOCK])
             .isEqualTo(false)
 
-        GlobalScope.launch {
+        repeatingJob.cancel()
+        repeatingJob.join()
+
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -316,15 +313,12 @@
         }
 
         val result3A = result.await()
-        Truth.assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
-        Truth.assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
+        assertThat(result3A.frameMetadata!!.frameNumber.value).isEqualTo(101L)
+        assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testUnlockAfWhenAfNotSupported(): Unit = runBlocking {
-        initGraphProcessor()
-
+    fun testUnlockAfWhenAfNotSupported() = runTest {
         val fakeMetadata = FakeCameraMetadata(
             mapOf(
                 CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES to
@@ -333,13 +327,8 @@
         )
         val controller3A = Controller3A(graphProcessor, fakeMetadata, graphState3A, listener3A)
         val result = controller3A.unlock3A(af = true).await()
-        Truth.assertThat(result.status).isEqualTo(Result3A.Status.OK)
-        Truth.assertThat(result.frameMetadata).isEqualTo(null)
-    }
-
-    private fun initGraphProcessor() {
-        graphProcessor.onGraphStarted(requestProcessor)
-        graphProcessor.startRepeating(Request(streams = listOf(StreamId(1))))
+        assertThat(result.status).isEqualTo(Result3A.Status.OK)
+        assertThat(result.frameMetadata).isEqualTo(null)
     }
 
     companion object {
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUpdate3ATest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUpdate3ATest.kt
index a314fe1..81b46ec 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUpdate3ATest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/Controller3AUpdate3ATest.kt
@@ -32,25 +32,25 @@
 import androidx.camera.camera2.pipe.testing.FakeFrameMetadata
 import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
 import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequenceProcessor
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class Controller3AUpdate3ATest {
     private val graphState3A = GraphState3A()
     private val graphProcessor = FakeGraphProcessor(graphState3A = graphState3A)
-    private val requestProcessor = FakeRequestProcessor()
+    private val fakeCaptureSequenceProcessor = FakeCaptureSequenceProcessor()
+    private val fakeGraphRequestProcessor = GraphRequestProcessor.from(fakeCaptureSequenceProcessor)
     private val listener3A = Listener3A()
     private val controller3A = Controller3A(
         graphProcessor,
@@ -80,13 +80,12 @@
         assertThat(result.getCompletionExceptionOrNull() is CancellationException)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfModeUpdate(): Unit = runBlocking {
+    fun testAfModeUpdate() = runTest {
         initGraphProcessor()
 
         val result = controller3A.update3A(afMode = AfMode.OFF)
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -108,13 +107,12 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAeModeUpdate(): Unit = runBlocking {
+    fun testAeModeUpdate() = runTest {
         initGraphProcessor()
 
         val result = controller3A.update3A(aeMode = AeMode.ON_ALWAYS_FLASH)
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -137,13 +135,12 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAwbModeUpdate(): Unit = runBlocking {
+    fun testAwbModeUpdate() = runTest {
         initGraphProcessor()
 
         val result = controller3A.update3A(awbMode = AwbMode.CLOUDY_DAYLIGHT)
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -166,13 +163,12 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAfRegionsUpdate(): Unit = runBlocking {
+    fun testAfRegionsUpdate() = runTest {
         initGraphProcessor()
 
         val result = controller3A.update3A(afRegions = listOf(MeteringRectangle(1, 1, 100, 100, 2)))
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -195,13 +191,12 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAeRegionsUpdate(): Unit = runBlocking {
+    fun testAeRegionsUpdate() = runTest {
         initGraphProcessor()
 
         val result = controller3A.update3A(aeRegions = listOf(MeteringRectangle(1, 1, 100, 100, 2)))
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -224,9 +219,8 @@
         assertThat(result3A.status).isEqualTo(Result3A.Status.OK)
     }
 
-    @OptIn(DelicateCoroutinesApi::class)
     @Test
-    fun testAwbRegionsUpdate(): Unit = runBlocking {
+    fun testAwbRegionsUpdate() = runTest {
         initGraphProcessor()
 
         val result = controller3A.update3A(
@@ -234,7 +228,7 @@
                 MeteringRectangle(1, 1, 100, 100, 2)
             )
         )
-        GlobalScope.launch {
+        launch {
             listener3A.onRequestSequenceCreated(
                 FakeRequestMetadata(
                     requestNumber = RequestNumber(1)
@@ -258,7 +252,7 @@
     }
 
     private fun initGraphProcessor() {
-        graphProcessor.onGraphStarted(requestProcessor)
+        graphProcessor.onGraphStarted(fakeGraphRequestProcessor)
         graphProcessor.startRepeating(Request(streams = listOf(StreamId(1))))
     }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CorrectedFrameMetadataTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CorrectedFrameMetadataTest.kt
index 11de0d0..fdaebc3 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CorrectedFrameMetadataTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CorrectedFrameMetadataTest.kt
@@ -30,7 +30,6 @@
 @RunWith(RobolectricCameraPipeTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class CorrectedFrameMetadataTest {
-
     @Test
     fun canOverrideFrameMetadata() {
         val metadata = FakeFrameMetadata(
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphProcessorTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphProcessorTest.kt
index c684827..ca34aba 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphProcessorTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphProcessorTest.kt
@@ -16,26 +16,25 @@
 
 package androidx.camera.camera2.pipe.graph
 
+import android.graphics.SurfaceTexture
 import android.hardware.camera2.CaptureRequest
 import android.os.Build
-import androidx.camera.camera2.pipe.CameraGraph
-import androidx.camera.camera2.pipe.CameraId
+import android.view.Surface
 import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequenceProcessor
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequenceProcessor.Companion.awaitEvent
+import androidx.camera.camera2.pipe.testing.FakeGraphConfigs
 import androidx.camera.camera2.pipe.testing.FakeRequestListener
-import androidx.camera.camera2.pipe.testing.FakeRequestProcessor
 import androidx.camera.camera2.pipe.testing.FakeThreads
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
-import androidx.camera.camera2.pipe.testing.awaitEvent
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withTimeoutOrNull
 import org.junit.Test
@@ -47,10 +46,19 @@
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class GraphProcessorTest {
     private val globalListener = FakeRequestListener()
-
     private val graphState3A = GraphState3A()
-    private val fakeProcessor1 = FakeRequestProcessor()
-    private val fakeProcessor2 = FakeRequestProcessor()
+    private val streamId = StreamId(0)
+    private val surfaceMap = mapOf(streamId to Surface(SurfaceTexture(1)))
+
+    private val fakeProcessor1 = FakeCaptureSequenceProcessor().also {
+        it.surfaceMap = surfaceMap
+    }
+    private val fakeProcessor2 = FakeCaptureSequenceProcessor().also {
+        it.surfaceMap = surfaceMap
+    }
+
+    private val graphRequestProcessor1 = GraphRequestProcessor.from(fakeProcessor1)
+    private val graphRequestProcessor2 = GraphRequestProcessor.from(fakeProcessor2)
 
     private val requestListener1 = FakeRequestListener()
     private val request1 = Request(listOf(StreamId(0)), listeners = listOf(requestListener1))
@@ -58,347 +66,315 @@
     private val requestListener2 = FakeRequestListener()
     private val request2 = Request(listOf(StreamId(0)), listeners = listOf(requestListener2))
 
-    private val graphConfig = CameraGraph.Config(
-        camera = CameraId.fromCamera2Id("CameraId-Test"),
-        streams = listOf(),
-        requiredParameters = mapOf(
-            CaptureRequest.CONTROL_AE_MODE to CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
+    @Test
+    fun graphProcessorSubmitsRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
         )
-    )
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.submit(request1)
+        advanceUntilIdle()
+
+        // Make sure the requests get submitted to the request processor
+        val event = fakeProcessor1.nextEvent()
+        assertThat(event.requestSequence!!.captureRequestList).containsExactly(request1)
+        assertThat(event.requestSequence!!.requiredParameters).containsEntry(
+            CaptureRequest.JPEG_THUMBNAIL_QUALITY, 42
+        )
+    }
 
     @Test
-    fun graphProcessorSubmitsRequests() {
-        runBlocking(Dispatchers.Default) {
+    fun graphProcessorSubmitsRequestsToMostRecentProcessor() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
 
-            // The graph processor uses 'launch' within the coroutine scope to invoke updates on the
-            // requestProcessor instance. runBlocking forces all jobs to complete before testing the
-            // state of results.
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.submit(request1)
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.onGraphStarted(graphRequestProcessor2)
+        graphProcessor.submit(request1)
 
-            // Make sure the requests get submitted to the request processor
-            val event = fakeProcessor1.nextEvent()
-            assertThat(event.requestSequence!!.requests).containsExactly(request1)
-            assertThat(event.requestSequence!!.requiredParameters).containsEntry(
-                CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
-            )
+        val event1 = fakeProcessor1.nextEvent()
+        assertThat(event1.close).isTrue()
+
+        val event2 = fakeProcessor2.nextEvent()
+        assertThat(event2.submit).isTrue()
+        assertThat(event2.requestSequence!!.captureRequestList).containsExactly(request1)
+    }
+
+    @Test
+    fun graphProcessorSubmitsQueuedRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+
+        graphProcessor.submit(request1)
+        graphProcessor.submit(request2)
+
+        // Request1 and 2 should be queued and will be submitted even when the request
+        // processor is set after the requests are submitted.
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+
+        val event1 = fakeProcessor1.awaitEvent(request = request1) { it.submit }
+        assertThat(event1.requestSequence!!.captureRequestList).hasSize(1)
+        assertThat(event1.requestSequence!!.captureRequestList).contains(request1)
+
+        val event2 = fakeProcessor1.nextEvent()
+        assertThat(event2.requestSequence!!.captureRequestList).hasSize(1)
+        assertThat(event2.requestSequence!!.captureRequestList).contains(request2)
+    }
+
+    @Test
+    fun graphProcessorSubmitsBurstsOfRequestsTogetherWithExtras() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+
+        graphProcessor.submit(listOf(request1, request2))
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        val event = fakeProcessor1.awaitEvent(request = request1) { it.submit }
+        assertThat(event.requestSequence!!.captureRequestList).hasSize(2)
+        assertThat(event.requestSequence!!.captureRequestList).contains(request1)
+        assertThat(event.requestSequence!!.captureRequestList).contains(request2)
+    }
+
+    @Test
+    fun graphProcessorDoesNotForgetRejectedRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+
+        fakeProcessor1.rejectRequests = true
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+
+        graphProcessor.submit(request1)
+        val event1 = fakeProcessor1.nextEvent()
+        assertThat(event1.rejected).isTrue()
+        assertThat(event1.requestSequence!!.captureRequestList[0]).isSameInstanceAs(request1)
+
+        graphProcessor.submit(request2)
+        val event2 = fakeProcessor1.nextEvent()
+        assertThat(event2.rejected).isTrue()
+        assertThat(event2.requestSequence!!.captureRequestList[0]).isSameInstanceAs(request1)
+
+        graphProcessor.onGraphStarted(graphRequestProcessor2)
+        assertThat(fakeProcessor2.nextEvent().requestSequence!!.captureRequestList[0])
+            .isSameInstanceAs(request1)
+        assertThat(fakeProcessor2.nextEvent().requestSequence!!.captureRequestList[0])
+            .isSameInstanceAs(request2)
+    }
+
+    @Test
+    fun graphProcessorContinuesSubmittingRequestsWhenFirstRequestIsRejected() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+
+        // Note: setting the requestProcessor, and calling submit() can both trigger a call
+        // to submit a request.
+        fakeProcessor1.rejectRequests = true
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.submit(request1)
+
+        // Check to make sure that submit is called at least once, and that request1 is rejected
+        // from the request processor.
+        fakeProcessor1.awaitEvent(request = request1) { it.rejected }
+
+        // Stop rejecting requests
+        fakeProcessor1.rejectRequests = false
+
+        graphProcessor.submit(request2)
+        // Cycle events until we get a submitted event with request1
+        val event2 = fakeProcessor1.awaitEvent(request = request1) { it.submit }
+        assertThat(event2.rejected).isFalse()
+
+        // Assert that immediately after we get a successfully submitted request, the
+        //  next request is also submitted.
+        val event3 = fakeProcessor1.nextEvent()
+        assertThat(event3.requestSequence!!.captureRequestList).contains(request2)
+        assertThat(event3.submit).isTrue()
+        assertThat(event3.rejected).isFalse()
+    }
+
+    @Test
+    fun graphProcessorSetsRepeatingRequest() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.startRepeating(request1)
+        graphProcessor.startRepeating(request2)
+        advanceUntilIdle()
+
+        val event = fakeProcessor1.awaitEvent(request = request2) {
+            it.submit && it.requestSequence?.repeating == true
+        }
+        assertThat(event.requestSequence!!.requiredParameters).containsEntry(
+            CaptureRequest.JPEG_THUMBNAIL_QUALITY, 42
+        )
+    }
+
+    @Test
+    fun graphProcessorTracksRepeatingRequest() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.startRepeating(request1)
+        advanceUntilIdle()
+
+        fakeProcessor1.awaitEvent(request = request1) {
+            it.submit && it.requestSequence?.repeating == true
+        }
+
+        graphProcessor.onGraphStarted(graphRequestProcessor2)
+        advanceUntilIdle()
+
+        fakeProcessor2.awaitEvent(request = request1) {
+            it.submit && it.requestSequence?.repeating == true
         }
     }
 
     @Test
-    fun graphProcessorSubmitsRequestsToMostRecentProcessor() {
-        // The graph processor uses 'launch' within the coroutine scope to invoke updates on the
-        // requestProcessor instance. runBlocking forces all jobs to complete before testing the
-        // state of results.
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
+    fun graphProcessorTracksRejectedRepeatingRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
 
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.onGraphStarted(fakeProcessor2)
-            graphProcessor.submit(request1)
+        fakeProcessor1.rejectRequests = true
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.startRepeating(request1)
+        fakeProcessor1.awaitEvent(request = request1) { it.rejected }
 
-            val event1 = fakeProcessor1.nextEvent()
-            assertThat(event1.close).isTrue()
-
-            val event2 = fakeProcessor2.nextEvent()
-            assertThat(event2.submit).isTrue()
-            assertThat(event2.requestSequence!!.requests).containsExactly(request1)
+        graphProcessor.onGraphStarted(graphRequestProcessor2)
+        fakeProcessor2.awaitEvent(request = request1) {
+            it.submit && it.requestSequence?.repeating == true
         }
     }
 
     @Test
-    fun graphProcessorSubmitsQueuedRequests() {
-        // The graph processor uses 'launch' within the coroutine scope to invoke updates on the
-        // requestProcessor instance. runBlocking forces all jobs to complete before testing the
-        // state of results.
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
+    fun graphProcessorSubmitsRepeatingRequestAndQueuedRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
 
-            graphProcessor.submit(request1)
-            graphProcessor.submit(request2)
+        graphProcessor.startRepeating(request1)
+        graphProcessor.submit(request2)
+        delay(50)
 
-            // Request1 and 2 should be queued and will be submitted even when the request
-            // processor is set after the requests are submitted.
-            graphProcessor.onGraphStarted(fakeProcessor1)
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
 
-            val event1 = fakeProcessor1.awaitEvent(request = request1) { it.submit }
-            assertThat(event1.requestSequence!!.requests).hasSize(1)
-            assertThat(event1.requestSequence!!.requests).contains(request1)
+        var hasRequest1Event = false
+        var hasRequest2Event = false
 
-            val event2 = fakeProcessor1.nextEvent()
-            assertThat(event2.requestSequence!!.requests).hasSize(1)
-            assertThat(event2.requestSequence!!.requests).contains(request2)
-        }
-    }
-
-    @Test
-    fun graphProcessorSubmitsBurstsOfRequestsTogetherWithExtras() {
-        // The graph processor uses 'launch' within the coroutine scope to invoke updates on the
-        // requestProcessor instance. runBlocking forces all jobs to complete before testing the
-        // state of results.
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            graphProcessor.submit(listOf(request1, request2))
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            val event = fakeProcessor1.awaitEvent(request = request1) { it.submit }
-            assertThat(event.requestSequence!!.requests).hasSize(2)
-            assertThat(event.requestSequence!!.requests).contains(request1)
-            assertThat(event.requestSequence!!.requests).contains(request2)
-        }
-    }
-
-    @Test
-    fun graphProcessorDoesNotForgetRejectedRequests() {
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            fakeProcessor1.rejectRequests = true
-            graphProcessor.onGraphStarted(fakeProcessor1)
-
-            graphProcessor.submit(request1)
-            val event1 = fakeProcessor1.nextEvent()
-            assertThat(event1.rejected).isTrue()
-            assertThat(event1.requestSequence!!.requests[0]).isSameInstanceAs(request1)
-
-            graphProcessor.submit(request2)
-            val event2 = fakeProcessor1.nextEvent()
-            assertThat(event2.rejected).isTrue()
-            assertThat(event2.requestSequence!!.requests[0]).isSameInstanceAs(request1)
-
-            graphProcessor.onGraphStarted(fakeProcessor2)
-            assertThat(fakeProcessor2.nextEvent().requestSequence!!.requests[0]).isSameInstanceAs(
-                request1
-            )
-            assertThat(fakeProcessor2.nextEvent().requestSequence!!.requests[0]).isSameInstanceAs(
-                request2
-            )
-        }
-    }
-
-    @Test
-    fun graphProcessorContinuesSubmittingRequestsWhenFirstRequestIsRejected() {
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            // Note: setting the requestProcessor, and calling submit() can both trigger a call
-            // to submit a request.
-            fakeProcessor1.rejectRequests = true
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.submit(request1)
-
-            // Check to make sure that submit is called at least once, and that request1 is rejected
-            // from the request processor.
-            fakeProcessor1.awaitEvent(request = request1) { it.rejected }
-
-            // Stop rejecting requests
-            fakeProcessor1.rejectRequests = false
-
-            graphProcessor.submit(request2)
-            // Cycle events until we get a submitted event with request1
-            val event2 = fakeProcessor1.awaitEvent(request = request1) { it.submit }
-            assertThat(event2.rejected).isFalse()
-
-            // Assert that immediately after we get a successfully submitted request, the
-            //  next request is also submitted.
-            val event3 = fakeProcessor1.nextEvent()
-            assertThat(event3.requestSequence!!.requests).contains(request2)
-            assertThat(event3.submit).isTrue()
-            assertThat(event3.rejected).isFalse()
-        }
-    }
-
-    @Test
-    fun graphProcessorSetsRepeatingRequest() {
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.startRepeating(request1)
-            graphProcessor.startRepeating(request2)
-            val event = fakeProcessor1.awaitEvent(request = request2) { it.startRepeating }
-            assertThat(event.requestSequence!!.requiredParameters).containsEntry(
-                CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
-            )
-        }
-    }
-
-    @Test
-    fun graphProcessorTracksRepeatingRequest() {
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.startRepeating(request1)
-            fakeProcessor1.awaitEvent(request = request1) { it.startRepeating }
-
-            graphProcessor.onGraphStarted(fakeProcessor2)
-            fakeProcessor2.awaitEvent(request = request1) { it.startRepeating }
-        }
-    }
-
-    @Test
-    fun graphProcessorTracksRejectedRepeatingRequests() {
-        runBlocking(Dispatchers.Default) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            fakeProcessor1.rejectRequests = true
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.startRepeating(request1)
-            fakeProcessor1.awaitEvent(request = request1) { it.rejected }
-
-            graphProcessor.onGraphStarted(fakeProcessor2)
-            fakeProcessor2.awaitEvent(request = request1) { it.startRepeating }
-        }
-    }
-
-    @Test
-    fun graphProcessorSubmitsRepeatingRequestAndQueuedRequests() {
-        runTest(UnconfinedTestDispatcher()) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            graphProcessor.startRepeating(request1)
-            graphProcessor.submit(request2)
-            delay(50)
-
-            graphProcessor.onGraphStarted(fakeProcessor1)
-
-            var hasRequest1Event = false
-            var hasRequest2Event = false
-
-            // Loop until we see at least one repeating request, and one submit event.
-            launch {
-                while (!hasRequest1Event && !hasRequest2Event) {
-                    val event = fakeProcessor1.nextEvent()
-                    hasRequest1Event = hasRequest1Event ||
-                        event.requestSequence?.requests?.contains(request1) ?: false
-                    hasRequest2Event = hasRequest2Event ||
-                        event.requestSequence?.requests?.contains(request2) ?: false
-                }
-            }.join()
-        }
-    }
-
-    @Test
-    fun graphProcessorAbortsQueuedRequests() {
-        runTest(UnconfinedTestDispatcher()) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-
-            graphProcessor.startRepeating(request1)
-            graphProcessor.submit(request2)
-
-            // Abort queued and in-flight requests.
-            graphProcessor.abort()
-            graphProcessor.onGraphStarted(fakeProcessor1)
-
-            val abortEvent1 = withTimeoutOrNull(timeMillis = 50L) {
-                requestListener1.onAbortedFlow.firstOrNull()
+        // Loop until we see at least one repeating request, and one submit event.
+        launch {
+            while (!hasRequest1Event && !hasRequest2Event) {
+                val event = fakeProcessor1.nextEvent()
+                hasRequest1Event = hasRequest1Event ||
+                    event.requestSequence?.captureRequestList?.contains(request1) ?: false
+                hasRequest2Event = hasRequest2Event ||
+                    event.requestSequence?.captureRequestList?.contains(request2) ?: false
             }
-            val abortEvent2 = requestListener2.onAbortedFlow.first()
-            val globalAbortEvent = globalListener.onAbortedFlow.first()
-
-            assertThat(abortEvent1).isNull()
-            assertThat(abortEvent2.request).isSameInstanceAs(request2)
-            assertThat(globalAbortEvent.request).isSameInstanceAs(request2)
-
-            val nextSequence = fakeProcessor1.nextRequestSequence()
-            assertThat(nextSequence.requests.first()).isSameInstanceAs(request1)
-            assertThat(nextSequence.requestMetadata[request1]!!.repeating).isTrue()
-        }
+        }.join()
     }
 
     @Test
-    fun closingGraphProcessorAbortsSubsequentRequests() {
-        runTest(UnconfinedTestDispatcher()) {
-            val graphProcessor = GraphProcessorImpl(
-                FakeThreads.forTests,
-                graphConfig,
-                graphState3A,
-                this,
-                arrayListOf(globalListener)
-            )
-            graphProcessor.close()
+    fun graphProcessorAbortsQueuedRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
 
-            // Abort queued and in-flight requests.
-            graphProcessor.onGraphStarted(fakeProcessor1)
-            graphProcessor.startRepeating(request1)
-            graphProcessor.submit(request2)
+        graphProcessor.startRepeating(request1)
+        graphProcessor.submit(request2)
 
-            val abortEvent1 = withTimeoutOrNull(timeMillis = 50L) {
-                requestListener1.onAbortedFlow.firstOrNull()
-            }
-            val abortEvent2 = requestListener2.onAbortedFlow.first()
-            assertThat(abortEvent1).isNull()
-            assertThat(abortEvent2.request).isSameInstanceAs(request2)
+        // Abort queued and in-flight requests.
+        graphProcessor.abort()
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
 
-            assertThat(fakeProcessor1.nextEvent().close).isTrue()
+        val abortEvent1 = withTimeoutOrNull(timeMillis = 50L) {
+            requestListener1.onAbortedFlow.firstOrNull()
         }
+        val abortEvent2 = requestListener2.onAbortedFlow.first()
+        val globalAbortEvent = globalListener.onAbortedFlow.first()
+
+        assertThat(abortEvent1).isNull()
+        assertThat(abortEvent2.request).isSameInstanceAs(request2)
+        assertThat(globalAbortEvent.request).isSameInstanceAs(request2)
+
+        val nextSequence = fakeProcessor1.nextRequestSequence()
+        assertThat(nextSequence.captureRequestList.first()).isSameInstanceAs(request1)
+        assertThat(nextSequence.requestMetadata[request1]!!.repeating).isTrue()
+    }
+
+    @Test
+    fun closingGraphProcessorAbortsSubsequentRequests() = runTest {
+        val graphProcessor = GraphProcessorImpl(
+            FakeThreads.fromTestScope(this),
+            FakeGraphConfigs.graphConfig,
+            graphState3A,
+            this,
+            arrayListOf(globalListener)
+        )
+        graphProcessor.close()
+
+        // Abort queued and in-flight requests.
+        graphProcessor.onGraphStarted(graphRequestProcessor1)
+        graphProcessor.startRepeating(request1)
+        graphProcessor.submit(request2)
+
+        val abortEvent1 = withTimeoutOrNull(timeMillis = 50L) {
+            requestListener1.onAbortedFlow.firstOrNull()
+        }
+        val abortEvent2 = requestListener2.onAbortedFlow.first()
+        assertThat(abortEvent1).isNull()
+        assertThat(abortEvent2.request).isSameInstanceAs(request2)
+
+        assertThat(fakeProcessor1.nextEvent().close).isTrue()
     }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphTestContext.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphTestContext.kt
new file mode 100644
index 0000000..e0ad73c
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/GraphTestContext.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.graph
+
+import android.graphics.SurfaceTexture
+import android.view.Surface
+import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.testing.FakeCaptureSequenceProcessor
+import androidx.camera.camera2.pipe.testing.FakeGraphProcessor
+
+internal class GraphTestContext {
+    val streamId = StreamId(0)
+    val surfaceMap = mapOf(streamId to Surface(SurfaceTexture(1)))
+    val captureSequenceProcessor = FakeCaptureSequenceProcessor()
+    val graphRequestProcessor = GraphRequestProcessor.from(captureSequenceProcessor)
+    val graphProcessor = FakeGraphProcessor()
+
+    init {
+        captureSequenceProcessor.surfaceMap = surfaceMap
+        graphProcessor.onGraphStarted(graphRequestProcessor)
+        graphProcessor.startRepeating(Request(streams = listOf(streamId)))
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/StreamGraphImplTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/StreamGraphImplTest.kt
index 4037146..c7459d4 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/StreamGraphImplTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/StreamGraphImplTest.kt
@@ -23,7 +23,7 @@
 import androidx.camera.camera2.pipe.CameraStream
 import androidx.camera.camera2.pipe.OutputStream
 import androidx.camera.camera2.pipe.StreamFormat
-import androidx.camera.camera2.pipe.testing.FakeCameraGraphConfig
+import androidx.camera.camera2.pipe.testing.FakeGraphConfigs
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
@@ -35,7 +35,7 @@
 @DoNotInstrument
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 internal class StreamGraphImplTest {
-    private val config = FakeCameraGraphConfig()
+    private val config = FakeGraphConfigs
 
     @Test
     fun testPrecomputedTestData() {
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
index dc5def5..1ac5aa2 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
@@ -19,9 +19,9 @@
 import android.graphics.SurfaceTexture
 import android.os.Build
 import android.view.Surface
-import androidx.camera.camera2.pipe.testing.FakeCameraController
-import androidx.camera.camera2.pipe.testing.FakeCameraGraphConfig
+import androidx.camera.camera2.pipe.testing.FakeGraphConfigs
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
+import androidx.camera.camera2.pipe.testing.FakeCameraController
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -32,7 +32,7 @@
 @DoNotInstrument
 @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
 class SurfaceGraphTest {
-    private val config = FakeCameraGraphConfig()
+    private val config = FakeGraphConfigs
 
     private val streamMap = StreamGraphImpl(config.fakeMetadata, config.graphConfig)
     private val controller = FakeCameraController()
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraController.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraController.kt
index 136fe8e..d465a0f 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraController.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraController.kt
@@ -21,21 +21,21 @@
 import androidx.camera.camera2.pipe.StreamId
 
 internal class FakeCameraController : CameraController {
-    var active = false
+    var started = false
     var closed = false
     var surfaceMap: Map<StreamId, Surface>? = null
 
     override fun start() {
-        active = true
+        started = true
     }
 
     override fun stop() {
-        active = false
+        started = false
     }
 
     override fun close() {
         closed = true
-        active = false
+        started = false
     }
 
     override fun updateSurfaceMap(surfaceMap: Map<StreamId, Surface>) {
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraGraphConfig.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphConfigs.kt
similarity index 73%
rename from camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraGraphConfig.kt
rename to camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphConfigs.kt
index 47f4e62..2c02068 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeCameraGraphConfig.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphConfigs.kt
@@ -17,6 +17,7 @@
 package androidx.camera.camera2.pipe.testing
 
 import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CaptureRequest
 import android.util.Size
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.CameraId
@@ -28,17 +29,34 @@
  * Fake CameraGraph configuration that can be used for more complicated tests that need a realistic
  * configuration for tests.
  */
-internal class FakeCameraGraphConfig {
+internal object FakeGraphConfigs {
     private val camera1 = CameraId("TestCamera-1")
     private val camera2 = CameraId("TestCamera-2")
 
     val fakeMetadata = FakeCameraMetadata(
         mapOf(
             CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL to
-                CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL
+                CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
+            CameraCharacteristics.LENS_FACING to
+                CameraCharacteristics.LENS_FACING_BACK
         ),
         cameraId = camera1
     )
+    val fakeMetadata2 = FakeCameraMetadata(
+        mapOf(
+            CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL to
+                CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
+            CameraCharacteristics.LENS_FACING to
+                CameraCharacteristics.LENS_FACING_FRONT
+        ),
+        cameraId = camera2
+    )
+    val fakeCameraBackend = FakeCameraBackend(
+        mapOf(
+            fakeMetadata.camera to fakeMetadata,
+            fakeMetadata2.camera to fakeMetadata2
+        )
+    )
 
     val streamConfig1 = CameraStream.Config.create(
         size = Size(100, 100),
@@ -72,6 +90,12 @@
             sharedStreamConfig1,
             sharedStreamConfig2
         ),
-        streamSharingGroups = listOf(listOf(streamConfig1, streamConfig2))
+        streamSharingGroups = listOf(listOf(streamConfig1, streamConfig2)),
+        defaultParameters = mapOf(
+            CaptureRequest.JPEG_THUMBNAIL_QUALITY to 24
+        ),
+        requiredParameters = mapOf(
+            CaptureRequest.JPEG_THUMBNAIL_QUALITY to 42
+        )
     )
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphProcessor.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphProcessor.kt
index c7c8e89..1c9b2d0 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphProcessor.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeGraphProcessor.kt
@@ -17,8 +17,9 @@
 package androidx.camera.camera2.pipe.testing
 
 import androidx.camera.camera2.pipe.Request
-import androidx.camera.camera2.pipe.RequestProcessor
+import androidx.camera.camera2.pipe.graph.GraphListener
 import androidx.camera.camera2.pipe.graph.GraphProcessor
+import androidx.camera.camera2.pipe.graph.GraphRequestProcessor
 import androidx.camera.camera2.pipe.graph.GraphState3A
 import androidx.camera.camera2.pipe.putAllMetadata
 
@@ -26,10 +27,10 @@
  * Fake implementation of a [GraphProcessor] for tests.
  */
 internal class FakeGraphProcessor(
-    private val graphState3A: GraphState3A = GraphState3A(),
-    private val defaultParameters: Map<*, Any?> = emptyMap<Any, Any?>(),
-    private val defaultListeners: List<Request.Listener> = emptyList()
-) : GraphProcessor {
+    val graphState3A: GraphState3A = GraphState3A(),
+    val defaultParameters: Map<*, Any?> = emptyMap<Any, Any?>(),
+    val defaultListeners: List<Request.Listener> = emptyList()
+) : GraphProcessor, GraphListener {
     var active = true
         private set
     var closed = false
@@ -40,7 +41,7 @@
         get() = _requestQueue
 
     private val _requestQueue = mutableListOf<List<Request>>()
-    private var processor: RequestProcessor? = null
+    private var processor: GraphRequestProcessor? = null
 
     override fun startRepeating(request: Request) {
         repeatingRequest = request
@@ -57,7 +58,6 @@
     override fun submit(requests: List<Request>) {
         _requestQueue.add(requests)
     }
-
     override suspend fun submit(parameters: Map<*, Any?>): Boolean {
         if (closed) {
             return false
@@ -71,10 +71,11 @@
         return when {
             currProcessor == null || currRepeatingRequest == null -> false
             else -> currProcessor.submit(
-                currRepeatingRequest,
+                isRepeating = false,
+                requests = listOf(currRepeatingRequest),
                 defaultParameters = defaultParameters,
                 requiredParameters = requiredParameters,
-                defaultListeners = defaultListeners
+                listeners = defaultListeners
             )
         }
     }
@@ -93,13 +94,13 @@
         _requestQueue.clear()
     }
 
-    override fun onGraphStarted(requestProcessor: RequestProcessor) {
+    override fun onGraphStarted(requestProcessor: GraphRequestProcessor) {
         val old = processor
         processor = requestProcessor
         old?.close()
     }
 
-    override fun onGraphStopped(requestProcessor: RequestProcessor) {
+    override fun onGraphStopped(requestProcessor: GraphRequestProcessor) {
         val old = processor
         if (requestProcessor === old) {
             processor = null
@@ -107,7 +108,7 @@
         }
     }
 
-    override fun onGraphUpdated(requestProcessor: RequestProcessor) {
+    override fun onGraphModified(requestProcessor: GraphRequestProcessor) {
         invalidate()
     }
 
@@ -124,11 +125,12 @@
             return
         }
 
-        currProcessor.startRepeating(
-            currRepeatingRequest,
+        currProcessor.submit(
+            isRepeating = true,
+            requests = listOf(currRepeatingRequest),
             defaultParameters = defaultParameters,
             requiredParameters = requiredParameters,
-            defaultListeners = defaultListeners
+            listeners = defaultListeners
         )
     }
 }
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeThreads.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeThreads.kt
deleted file mode 100644
index a5432ae..0000000
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/FakeThreads.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright 2020 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.testing
-
-import android.os.Handler
-import androidx.camera.camera2.pipe.core.Threads
-import java.util.concurrent.Executor
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineName
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.asCoroutineDispatcher
-import kotlinx.coroutines.asExecutor
-
-internal object FakeThreads {
-    @Suppress("deprecation")
-    val fakeHandler = { Handler() }
-    val forTests = Threads(
-        CoroutineScope(SupervisorJob() + CoroutineName("CXCP-TestScope") + Dispatchers.Default),
-        blockingExecutor = Dispatchers.IO.asExecutor(),
-        blockingDispatcher = Dispatchers.IO,
-        backgroundExecutor = Dispatchers.Default.asExecutor(),
-        backgroundDispatcher = Dispatchers.Default,
-        lightweightExecutor = Dispatchers.Default.asExecutor(),
-        lightweightDispatcher = Dispatchers.Default,
-        camera2Handler = fakeHandler,
-        camera2Executor = { Dispatchers.IO.asExecutor() }
-    )
-
-    fun fromExecutor(executor: Executor): Threads {
-        return fromDispatcher(executor.asCoroutineDispatcher())
-    }
-
-    fun fromDispatcher(dispatcher: CoroutineDispatcher): Threads {
-        val executor = dispatcher.asExecutor()
-
-        @Suppress("deprecation")
-        val fakeHandler = { Handler() }
-
-        return Threads(
-            CoroutineScope(dispatcher.plus(CoroutineName("CXCP-TestScope"))),
-            blockingExecutor = executor,
-            blockingDispatcher = dispatcher,
-            backgroundExecutor = executor,
-            backgroundDispatcher = dispatcher,
-            lightweightExecutor = executor,
-            lightweightDispatcher = dispatcher,
-            camera2Handler = fakeHandler,
-            camera2Executor = { dispatcher.asExecutor() }
-        )
-    }
-}
\ No newline at end of file
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/UpdateCounting3AStateListener.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/UpdateCounting3AStateListener.kt
index a2fe64b..56b6a37 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/UpdateCounting3AStateListener.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/testing/UpdateCounting3AStateListener.kt
@@ -28,7 +28,6 @@
     private val listener: Result3AStateListener
 ) : Result3AStateListener {
     var updateCount = 0
-
     override fun onRequestSequenceCreated(requestNumber: RequestNumber) {
         listener.onRequestSequenceCreated(requestNumber)
     }
diff --git a/camera/camera-camera2/api/public_plus_experimental_1.2.0-beta02.txt b/camera/camera-camera2/api/public_plus_experimental_1.2.0-beta02.txt
index ebebd4c..ea5cacc 100644
--- a/camera/camera-camera2/api/public_plus_experimental_1.2.0-beta02.txt
+++ b/camera/camera-camera2/api/public_plus_experimental_1.2.0-beta02.txt
@@ -33,6 +33,7 @@
     method @RequiresApi(28) public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setPhysicalCameraId(String);
     method public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setSessionCaptureCallback(android.hardware.camera2.CameraCaptureSession.CaptureCallback);
     method public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setSessionStateCallback(android.hardware.camera2.CameraCaptureSession.StateCallback);
+    method @RequiresApi(33) public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setStreamUseCase(long);
   }
 
   @RequiresApi(21) @androidx.camera.camera2.interop.ExperimentalCamera2Interop public class CaptureRequestOptions {
diff --git a/camera/camera-camera2/api/public_plus_experimental_current.txt b/camera/camera-camera2/api/public_plus_experimental_current.txt
index ebebd4c..ea5cacc 100644
--- a/camera/camera-camera2/api/public_plus_experimental_current.txt
+++ b/camera/camera-camera2/api/public_plus_experimental_current.txt
@@ -33,6 +33,7 @@
     method @RequiresApi(28) public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setPhysicalCameraId(String);
     method public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setSessionCaptureCallback(android.hardware.camera2.CameraCaptureSession.CaptureCallback);
     method public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setSessionStateCallback(android.hardware.camera2.CameraCaptureSession.StateCallback);
+    method @RequiresApi(33) public androidx.camera.camera2.interop.Camera2Interop.Extender<T!> setStreamUseCase(long);
   }
 
   @RequiresApi(21) @androidx.camera.camera2.interop.ExperimentalCamera2Interop public class CaptureRequestOptions {
diff --git a/camera/camera-camera2/build.gradle b/camera/camera-camera2/build.gradle
index 190ef69..dd117c7 100644
--- a/camera/camera-camera2/build.gradle
+++ b/camera/camera-camera2/build.gradle
@@ -38,7 +38,7 @@
     testImplementation(libs.junit)
     testImplementation(libs.truth)
     testImplementation(libs.robolectric)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(libs.kotlinCoroutinesTest)
     testImplementation("androidx.annotation:annotation-experimental:1.1.0")
     testImplementation("androidx.concurrent:concurrent-futures-ktx:1.1.0")
@@ -56,7 +56,7 @@
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.testUiautomator)
-    androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has it's own MockMaker
+    androidTestImplementation(libs.mockitoCore4, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation("androidx.appcompat:appcompat:1.1.0")
     androidTestImplementation(project(":camera:camera-testing"))
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraImplTest.java b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraImplTest.java
index 00153de..aad2b48 100644
--- a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraImplTest.java
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraImplTest.java
@@ -81,7 +81,6 @@
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
-import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TestRule;
@@ -531,7 +530,6 @@
     }
 
     @Test
-    @Ignore("b/239752223")
     public void cameraTransitionsThroughPendingState_whenNoCamerasAvailable() {
         @SuppressWarnings("unchecked") // Cannot mock generic type inline
         Observable.Observer<CameraInternal.State> mockObserver =
@@ -575,7 +573,6 @@
 
     @SuppressWarnings("unchecked")
     @Test
-    @Ignore("b/239752223")
     public void openNewCaptureSessionImmediateBeforePreviousCaptureSessionClosed()
             throws InterruptedException {
         mCamera2CameraImpl.open();
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExcludedSupportedSizesQuirk.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExcludedSupportedSizesQuirk.java
index 4854150..b82574b 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExcludedSupportedSizesQuirk.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExcludedSupportedSizesQuirk.java
@@ -29,18 +29,22 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 
 /**
  * <p>QuirkSummary
- *     Bug Id: b/157448499, b/192129158
+ *     Bug Id: b/157448499, b/192129158, b/245495234
  *     Description: Quirk required to exclude certain supported surface sizes that are
  *                  problematic. These sizes are dependent on the device, camera and image format.
  *                  An example is the resolution size 4000x3000 which is supported on OnePlus 6,
  *                  but causes a WYSIWYG issue between preview and image capture. Another example
  *                  is on Huawei P20 Lite, the Preview screen will become too bright when 400x400
  *                  or 720x720 Preview resolutions are used together with a large zoom in value.
- *                  The same symptom happens on ImageAnalysis.
- *     Device(s): OnePlus 6, OnePlus 6T, Huawei P20
+ *                  The same symptom happens on ImageAnalysis. On Samsung J7 Prime (SM-G610M) or
+ *                  J7 (SM-J710MN) API 27 devices, the Preview images will be stretched if
+ *                  1920x1080 resolution is used.
+ *     Device(s): OnePlus 6, OnePlus 6T, Huawei P20, Samsung J7 Prime (SM-G610M) API 27, Samsung
+ *     J7 (SM-J710MN) API 27
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 public class ExcludedSupportedSizesQuirk implements Quirk {
@@ -48,7 +52,8 @@
     private static final String TAG = "ExcludedSupportedSizesQuirk";
 
     static boolean load() {
-        return isOnePlus6() || isOnePlus6T() || isHuaweiP20Lite();
+        return isOnePlus6() || isOnePlus6T() || isHuaweiP20Lite() || isSamsungJ7PrimeApi27Above()
+                || isSamsungJ7Api27Above();
     }
 
     private static boolean isOnePlus6() {
@@ -64,6 +69,18 @@
         return "HUAWEI".equalsIgnoreCase(Build.BRAND) && "HWANE".equalsIgnoreCase(Build.DEVICE);
     }
 
+    private static boolean isSamsungJ7PrimeApi27Above() {
+        return "SAMSUNG".equalsIgnoreCase(Build.BRAND.toUpperCase(Locale.US))
+                && "ON7XELTE".equalsIgnoreCase(Build.DEVICE.toUpperCase(Locale.US))
+                && Build.VERSION.SDK_INT >= 27;
+    }
+
+    private static boolean isSamsungJ7Api27Above() {
+        return "SAMSUNG".equalsIgnoreCase(Build.BRAND.toUpperCase(Locale.US))
+                && "J7XELTE".equalsIgnoreCase(Build.DEVICE.toUpperCase(Locale.US))
+                && Build.VERSION.SDK_INT >= 27;
+    }
+
     /**
      * Retrieves problematic supported surface sizes that have to be excluded on the current
      * device, for the given camera id and image format.
@@ -79,6 +96,9 @@
         if (isHuaweiP20Lite()) {
             return getHuaweiP20LiteExcludedSizes(cameraId, imageFormat);
         }
+        if (isSamsungJ7PrimeApi27Above() || isSamsungJ7Api27Above()) {
+            return getSamsungJ7PrimeApi27AboveExcludedSizes(imageFormat);
+        }
         Logger.w(TAG, "Cannot retrieve list of supported sizes to exclude on this device.");
         return Collections.emptyList();
     }
@@ -114,4 +134,13 @@
         }
         return sizes;
     }
+
+    @NonNull
+    private List<Size> getSamsungJ7PrimeApi27AboveExcludedSizes(int imageFormat) {
+        final List<Size> sizes = new ArrayList<>();
+        if (imageFormat == ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE) {
+            sizes.add(new Size(1920, 1080));
+        }
+        return sizes;
+    }
 }
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExtraCroppingQuirk.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExtraCroppingQuirk.java
index 3d4d8e6..b46ea90 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExtraCroppingQuirk.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/ExtraCroppingQuirk.java
@@ -17,6 +17,7 @@
 package androidx.camera.camera2.internal.compat.quirk;
 
 import android.os.Build;
+import android.util.Range;
 import android.util.Size;
 
 import androidx.annotation.NonNull;
@@ -25,9 +26,9 @@
 import androidx.camera.core.impl.Quirk;
 import androidx.camera.core.impl.SurfaceConfig;
 
-import java.util.Arrays;
-import java.util.List;
+import java.util.HashMap;
 import java.util.Locale;
+import java.util.Map;
 
 /**
  * Quirk that requires specific resolutions as the workaround.
@@ -50,13 +51,17 @@
 @RequiresApi(21)
 public class ExtraCroppingQuirk implements Quirk {
 
-    private static final List<String> SAMSUNG_DISTORTION_MODELS = Arrays.asList(
-            "SM-T580", // Samsung Galaxy Tab A (2016)
-            "SM-J710MN", // Samsung Galaxy J7 (2016)
-            "SM-A320FL", // Samsung Galaxy A3 (2017)
-            "SM-G570M", // Samsung Galaxy J5 Prime
-            "SM-G610F", // Samsung Galaxy J7 Prime
-            "SM-G610M"); // Samsung Galaxy J7 Prime
+    private static final Map<String, Range<Integer>> SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP =
+            new HashMap<>();
+
+    static {
+        SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.put("SM-T580", null);
+        SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.put("SM-J710MN", new Range<>(21, 26));
+        SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.put("SM-A320FL", null);
+        SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.put("SM-G570M", null);
+        SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.put("SM-G610F", null);
+        SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.put("SM-G610M", new Range<>(21, 26));
+    }
 
     static boolean load() {
         return isSamsungDistortion();
@@ -93,8 +98,18 @@
      * Checks for device model with Samsung output distortion bug (b/190203334).
      */
     private static boolean isSamsungDistortion() {
-        return "samsung".equalsIgnoreCase(Build.BRAND)
-                && SAMSUNG_DISTORTION_MODELS.contains(Build.MODEL.toUpperCase(Locale.US));
+        boolean isDeviceModelContained = "samsung".equalsIgnoreCase(Build.BRAND)
+                && SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.containsKey(
+                Build.MODEL.toUpperCase(Locale.US));
+
+        if (!isDeviceModelContained) {
+            return false;
+        }
+
+        Range<Integer> apiLevelRange =
+                SAMSUNG_DISTORTION_MODELS_TO_API_LEVEL_MAP.get(Build.MODEL.toUpperCase(Locale.US));
+
+        return apiLevelRange == null ? true : apiLevelRange.contains(Build.VERSION.SDK_INT);
     }
 
 }
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/interop/Camera2Interop.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/interop/Camera2Interop.java
index 33cfa4f..7b5d065 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/interop/Camera2Interop.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/interop/Camera2Interop.java
@@ -103,18 +103,25 @@
         }
 
         /**
-         * Sets a CameraDevice template on the given configuration. Requires API 33 or above.
+         * Sets a stream use case flag on the given extendable builder.
          *
-         * <p>See {@link android.hardware.camera2.CameraMetadata} for valid stream use cases.
-         * See {@link android.hardware.camera2.params.OutputConfiguration} to see how
-         * Camera2 framework uses this.
+         * <p>Requires API 33 or above.
+         *
+         * <p>Calling this method will set the stream use case for all CameraX outputs for the
+         * same stream session. Valid use cases available on devices can be found in
+         * {@link android.hardware.camera2.CameraCharacteristics#SCALER_AVAILABLE_STREAM_USE_CASES}
+         *
+         * <p>No app should call this without double-checking the supported list first, or at least
+         * {@link android.hardware.camera2.CameraMetadata#REQUEST_AVAILABLE_CAPABILITIES_STREAM_USE_CASE}
+         * capability which guarantees quite a few use cases.
+         *
+         * <p>See {@link android.hardware.camera2.params.OutputConfiguration#setStreamUseCase}
+         * to see how Camera2 framework uses this.
          *
          * @param streamUseCase The stream use case to set.
          * @return The current Extender.
-         * @hide
          */
         @RequiresApi(33)
-        @RestrictTo(Scope.LIBRARY)
         @NonNull
         public Extender<T> setStreamUseCase(long streamUseCase) {
             mBaseBuilder.getMutableConfig().insertOption(STREAM_USE_CASE_OPTION, streamUseCase);
@@ -227,5 +234,6 @@
     }
 
     // Ensure this class isn't instantiated
-    private Camera2Interop() {}
+    private Camera2Interop() {
+    }
 }
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/SupportedSurfaceCombinationTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/SupportedSurfaceCombinationTest.java
index 82399e1..07ffbcb 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/SupportedSurfaceCombinationTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/SupportedSurfaceCombinationTest.java
@@ -2567,7 +2567,7 @@
                     try {
                         return new Camera2DeviceSurfaceManager(context,
                                 mMockCamcorderProfileHelper,
-                                (CameraManagerCompat) cameraManager, availableCameraIds);
+                                CameraManagerCompat.from(mContext), availableCameraIds);
                     } catch (CameraUnavailableException e) {
                         throw new InitializationException(e);
                     }
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/quirk/ResolutionCorrectorQuirkTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/quirk/ResolutionCorrectorQuirkTest.java
index 6faf911..b60fa0f 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/quirk/ResolutionCorrectorQuirkTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/quirk/ResolutionCorrectorQuirkTest.java
@@ -46,13 +46,13 @@
         ReflectionHelpers.setStaticField(Build.class, "MODEL", "SM-T580");
         assertThat(ExtraCroppingQuirk.load()).isTrue();
         ReflectionHelpers.setStaticField(Build.class, "MODEL", "SM-J710MN");
-        assertThat(ExtraCroppingQuirk.load()).isTrue();
+        assertThat(ExtraCroppingQuirk.load()).isEqualTo(Build.VERSION.SDK_INT <= 26);
         ReflectionHelpers.setStaticField(Build.class, "MODEL", "SM-A320FL");
         assertThat(ExtraCroppingQuirk.load()).isTrue();
         ReflectionHelpers.setStaticField(Build.class, "MODEL", "SM-G570M");
         assertThat(ExtraCroppingQuirk.load()).isTrue();
         ReflectionHelpers.setStaticField(Build.class, "MODEL", "SM-G610M");
-        assertThat(ExtraCroppingQuirk.load()).isTrue();
+        assertThat(ExtraCroppingQuirk.load()).isEqualTo(Build.VERSION.SDK_INT <= 26);
         ReflectionHelpers.setStaticField(Build.class, "MODEL", "SM-G610F");
         assertThat(ExtraCroppingQuirk.load()).isTrue();
     }
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/ExcludedSupportedSizesContainerTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/ExcludedSupportedSizesContainerTest.java
index be4979d..6f01115 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/ExcludedSupportedSizesContainerTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/ExcludedSupportedSizesContainerTest.java
@@ -49,30 +49,53 @@
 
     private static final Size SIZE_4000_3000 = new Size(4000, 3000);
     private static final Size SIZE_4160_3120 = new Size(4160, 3120);
+    private static final Size SIZE_1920_1080 = new Size(1920, 1080);
     private static final Size SIZE_720_720 = new Size(720, 720);
     private static final Size SIZE_400_400 = new Size(400, 400);
 
     @ParameterizedRobolectricTestRunner.Parameters
     public static Collection<Object[]> data() {
         final List<Object[]> data = new ArrayList<>();
-        data.add(new Object[]{
-                new Config("OnePlus", "OnePlus6", "0", JPEG, SIZE_4000_3000, SIZE_4160_3120)});
-        data.add(new Object[]{new Config("OnePlus", "OnePlus6", "1", JPEG)});
-        data.add(new Object[]{new Config("OnePlus", "OnePlus6", "0", PRIVATE)});
-        data.add(new Object[]{
-                new Config("OnePlus", "OnePlus6T", "0", JPEG, SIZE_4000_3000, SIZE_4160_3120)});
-        data.add(new Object[]{new Config("OnePlus", "OnePlus6T", "1", JPEG)});
-        data.add(new Object[]{new Config("OnePlus", "OnePlus6T", "0", PRIVATE)});
-        data.add(new Object[]{new Config("OnePlus", "OnePlus3", "0", JPEG)});
-        data.add(new Object[]{new Config(null, null, "0", JPEG)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus6", "0", JPEG, null, SIZE_4000_3000,
+                SIZE_4160_3120)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus6", "1", JPEG, null)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus6", "0", PRIVATE, null)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus6T", "0", JPEG, null, SIZE_4000_3000,
+                SIZE_4160_3120)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus6T", "1", JPEG, null)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus6T", "0", PRIVATE, null)});
+        data.add(new Object[]{new Config("OnePlus", "OnePlus3", "0", JPEG, null)});
+        data.add(new Object[]{new Config(null, null, "0", JPEG, null)});
         // Huawei P20 Lite
         data.add(new Object[]{
-                new Config("HUAWEI", "HWANE", "0", PRIVATE, SIZE_720_720, SIZE_400_400)});
-        data.add(new Object[]{new Config("HUAWEI", "HWANE", "1", PRIVATE)});
+                new Config("HUAWEI", "HWANE", "0", PRIVATE, null, SIZE_720_720, SIZE_400_400)});
+        data.add(new Object[]{new Config("HUAWEI", "HWANE", "1", PRIVATE, null)});
         data.add(new Object[]{
-                new Config("HUAWEI", "HWANE", "0", YUV_420_888, SIZE_720_720, SIZE_400_400)});
-        data.add(new Object[]{new Config("HUAWEI", "HWANE", "1", YUV_420_888)});
-        data.add(new Object[]{new Config("HUAWEI", "HWANE", "0", JPEG)});
+                new Config("HUAWEI", "HWANE", "0", YUV_420_888, null, SIZE_720_720, SIZE_400_400)});
+        data.add(new Object[]{new Config("HUAWEI", "HWANE", "1", YUV_420_888, null)});
+        data.add(new Object[]{new Config("HUAWEI", "HWANE", "0", JPEG, null)});
+        // Samsung J7 Prime (SM-G610M)
+        data.add(new Object[]{
+                new Config("SAMSUNG", "ON7XELTE", "0", PRIVATE, () -> Build.VERSION.SDK_INT >= 27,
+                        SIZE_1920_1080)});
+        data.add(new Object[]{
+                new Config("SAMSUNG", "ON7XELTE", "0", JPEG, () -> Build.VERSION.SDK_INT >= 27)});
+        data.add(new Object[]{
+                new Config("SAMSUNG", "ON7XELTE", "1", PRIVATE, () -> Build.VERSION.SDK_INT >= 27,
+                        SIZE_1920_1080)});
+        data.add(new Object[]{new Config("SAMSUNG", "ON7XELTE", "1", YUV_420_888,
+                () -> Build.VERSION.SDK_INT >= 27)});
+        // Samsung J7 (SM-J710MN)
+        data.add(new Object[]{
+                new Config("SAMSUNG", "J7XELTE", "0", PRIVATE, () -> Build.VERSION.SDK_INT >= 27,
+                        SIZE_1920_1080)});
+        data.add(new Object[]{
+                new Config("SAMSUNG", "J7XELTE", "0", JPEG, () -> Build.VERSION.SDK_INT >= 27)});
+        data.add(new Object[]{
+                new Config("SAMSUNG", "J7XELTE", "1", PRIVATE, () -> Build.VERSION.SDK_INT >= 27,
+                        SIZE_1920_1080)});
+        data.add(new Object[]{new Config("SAMSUNG", "J7XELTE", "1", YUV_420_888,
+                () -> Build.VERSION.SDK_INT >= 27)});
         return data;
     }
 
@@ -98,7 +121,11 @@
         // Get sizes to exclude
         final List<Size> excludedSizes = excludedSupportedSizesContainer.get(mConfig.mImageFormat);
 
-        assertThat(excludedSizes).containsExactly((Object[]) mConfig.mExcludedSizes);
+        if (mConfig.mApiLevelChecker == null || mConfig.mApiLevelChecker.isApiLevelContained()) {
+            assertThat(excludedSizes).containsExactly((Object[]) mConfig.mExcludedSizes);
+        } else {
+            assertThat(excludedSizes).isEmpty();
+        }
     }
 
     static class Config {
@@ -109,16 +136,24 @@
         @NonNull
         final String mCameraId;
         final int mImageFormat;
+        @Nullable
+        final ApiLevelChecker mApiLevelChecker;
         @NonNull
         final Size[] mExcludedSizes;
 
-        Config(@Nullable String brand, @Nullable String device,
-                @NonNull String cameraId, int imageFormat, @NonNull Size... excludedSizes) {
+        Config(@Nullable String brand, @Nullable String device, @NonNull String cameraId,
+                int imageFormat, @Nullable ApiLevelChecker apiLevelChecker,
+                @NonNull Size... excludedSizes) {
             mBrand = brand;
             mDevice = device;
             mCameraId = cameraId;
             mImageFormat = imageFormat;
+            mApiLevelChecker = apiLevelChecker;
             mExcludedSizes = excludedSizes;
         }
     }
+
+    private interface ApiLevelChecker {
+        boolean isApiLevelContained();
+    }
 }
diff --git a/camera/camera-core/build.gradle b/camera/camera-core/build.gradle
index de3bb7b..6933114 100644
--- a/camera/camera-core/build.gradle
+++ b/camera/camera-core/build.gradle
@@ -46,7 +46,7 @@
     testImplementation(libs.junit)
     testImplementation(libs.truth)
     testImplementation(libs.robolectric)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(project(":camera:camera-testing"), {
         exclude group: "androidx.camera", module: "camera-core"
     })
@@ -58,7 +58,7 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
-    androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has it's own MockMaker
+    androidTestImplementation(libs.mockitoCore4, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(project(":camera:camera-testing"))
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/SafeCloseImageReaderProxyTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/SafeCloseImageReaderProxyTest.java
index 64ae16c..c802b5d 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/SafeCloseImageReaderProxyTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/SafeCloseImageReaderProxyTest.java
@@ -22,7 +22,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import android.util.Pair;
 
@@ -113,7 +113,7 @@
                 mTag)), 1);
 
         // Assert
-        verifyZeroInteractions(onImageAvailableListener);
+        verifyNoMoreInteractions(onImageAvailableListener);
     }
 
     @Test
@@ -138,7 +138,7 @@
                 mTag)), 1);
 
         // Assert
-        verifyZeroInteractions(onImageAvailableListener);
+        verifyNoMoreInteractions(onImageAvailableListener);
     }
 
     @Test
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java
index 993fcff..044158d 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java
@@ -53,6 +53,9 @@
 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 public class ImagePipeline {
 
+    static final byte JPEG_QUALITY_MAX_QUALITY = 100;
+    static final byte JPEG_QUALITY_MIN_LATENCY = 95;
+
     static final ExifRotationAvailability EXIF_ROTATION_AVAILABILITY =
             new ExifRotationAvailability();
     // Use case configs.
@@ -225,14 +228,17 @@
     int getCameraRequestJpegQuality(@NonNull TakePictureRequest request) {
         boolean isOnDisk = request.getOnDiskCallback() != null;
         boolean hasCropping = hasCropping(request.getCropRect(), mPipelineIn.getSize());
-        boolean isMaxQuality =
-                request.getCaptureMode() == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY;
-        if (isOnDisk && hasCropping && isMaxQuality) {
+        if (isOnDisk && hasCropping) {
             // For saving to disk, the image is decoded to Bitmap, cropped and encoded to JPEG
-            // again. In that case, use 100 to avoid compression quality loss. The trade-off of
-            // using a high quality is poorer performance. So we only do that if the capture mode
-            // is CAPTURE_MODE_MAXIMIZE_QUALITY.
-            return 100;
+            // again. In that case, use a high JPEG quality for the hardware compression to avoid
+            // quality loss.
+            if (request.getCaptureMode() == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) {
+                // The trade-off of using a high quality is poorer performance. So we only do
+                // that if the capture mode is CAPTURE_MODE_MAXIMIZE_QUALITY.
+                return JPEG_QUALITY_MAX_QUALITY;
+            } else {
+                return JPEG_QUALITY_MIN_LATENCY;
+            }
         }
         return request.getJpegQuality();
     }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureManager.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureManager.java
index 686727f..bc5bb76 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureManager.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureManager.java
@@ -199,10 +199,14 @@
 
             @Override
             public void onFailure(@NonNull Throwable throwable) {
-                cameraRequest.onCaptureFailure(new ImageCaptureException(
-                        ERROR_CAPTURE_FAILED,
-                        "Failed to submit capture request",
-                        throwable));
+                if (throwable instanceof ImageCaptureException) {
+                    cameraRequest.onCaptureFailure((ImageCaptureException) throwable);
+                } else {
+                    cameraRequest.onCaptureFailure(new ImageCaptureException(
+                            ERROR_CAPTURE_FAILED,
+                            "Failed to submit capture request",
+                            throwable));
+                }
                 mImageCaptureControl.unlockFlashMode();
             }
         }, mainThreadExecutor());
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceEffect.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceEffect.java
index 7087c9d..7d127aa 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceEffect.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceEffect.java
@@ -64,6 +64,11 @@
     // Only access this on GL thread.
     private int mInputSurfaceCount = 0;
 
+    /** Constructs DefaultSurfaceEffect */
+    public DefaultSurfaceEffect() {
+        this(ShaderProvider.DEFAULT);
+    }
+
     /**
      * Constructs DefaultSurfaceEffect
      *
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt
index 60230fe..9baec2a 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt
@@ -26,6 +26,8 @@
 import androidx.camera.core.ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
 import androidx.camera.core.ImageCapture.CaptureMode
 import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.imagecapture.ImagePipeline.JPEG_QUALITY_MAX_QUALITY
+import androidx.camera.core.imagecapture.ImagePipeline.JPEG_QUALITY_MIN_LATENCY
 import androidx.camera.core.imagecapture.Utils.CROP_RECT
 import androidx.camera.core.imagecapture.Utils.FULL_RECT
 import androidx.camera.core.imagecapture.Utils.HEIGHT
@@ -147,26 +149,26 @@
     }
 
     @Test
-    fun createRequestWithCroppingAndMaxQuality_cameraRequestJpegQualityIs100() {
+    fun createRequestWithCroppingAndMaxQuality_cameraRequestJpegQualityIsMaxQuality() {
         assertThat(
             getCameraRequestJpegQuality(
                 CROP_RECT,
                 CAPTURE_MODE_MAXIMIZE_QUALITY
             )
         ).isEqualTo(
-            100
+            JPEG_QUALITY_MAX_QUALITY
         )
     }
 
     @Test
-    fun createRequestWithCroppingAndMinLatency_cameraRequestJpegQualityIsOriginal() {
+    fun createRequestWithCroppingAndMinLatency_cameraRequestJpegQualityIsMinLatency() {
         assertThat(
             getCameraRequestJpegQuality(
                 CROP_RECT,
                 CAPTURE_MODE_MINIMIZE_LATENCY
             )
         ).isEqualTo(
-            JPEG_QUALITY
+            JPEG_QUALITY_MIN_LATENCY
         )
     }
 
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt
index 0129158..ccd2d63 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt
@@ -19,6 +19,8 @@
 import android.os.Build
 import android.os.Looper.getMainLooper
 import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCapture.ERROR_CAMERA_CLOSED
+import androidx.camera.core.ImageCapture.ERROR_CAPTURE_FAILED
 import androidx.camera.core.ImageCapture.OutputFileResults
 import androidx.camera.core.ImageCaptureException
 import androidx.camera.core.impl.CaptureConfig
@@ -112,7 +114,23 @@
     }
 
     @Test
-    fun submitRequestFails_appGetsErrorCallback() {
+    fun submitRequestFailsWithImageCaptureException_appGetsTheSameException() {
+        // Arrange: configure ImageCaptureControl to always fail.
+        val request = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
+        val cause = ImageCaptureException(ERROR_CAMERA_CLOSED, "", null)
+        imageCaptureControl.response = Futures.immediateFailedFuture(cause)
+
+        // Act.
+        takePictureManager.offerRequest(request)
+        shadowOf(getMainLooper()).idle()
+
+        // Assert.
+        assertThat(request.exceptionReceived!!).isEqualTo(cause)
+        assertThat(takePictureManager.hasInFlightRequest()).isFalse()
+    }
+
+    @Test
+    fun submitRequestFailsWithGenericException_appGetsWrappedException() {
         // Arrange: configure ImageCaptureControl to always fail.
         val request = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
         val cause = Exception()
@@ -123,6 +141,7 @@
         shadowOf(getMainLooper()).idle()
 
         // Assert.
+        assertThat(request.exceptionReceived!!.imageCaptureError).isEqualTo(ERROR_CAPTURE_FAILED)
         assertThat(request.exceptionReceived!!.cause).isEqualTo(cause)
         assertThat(takePictureManager.hasInFlightRequest()).isFalse()
     }
diff --git a/camera/camera-extensions/build.gradle b/camera/camera-extensions/build.gradle
index 6422e1e..e38552e 100644
--- a/camera/camera-extensions/build.gradle
+++ b/camera/camera-extensions/build.gradle
@@ -35,7 +35,7 @@
     compileOnly(project(":camera:camera-extensions-stub"))
 
     testImplementation(libs.junit)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(libs.robolectric)
     testImplementation(libs.truth)
     testImplementation(project(":camera:camera-testing"))
@@ -49,7 +49,7 @@
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has its own MockMaker
     androidTestImplementation(libs.kotlinCoroutinesAndroid)
     androidTestImplementation(libs.kotlinStdlib)
-    androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has its own MockMaker
+    androidTestImplementation(libs.mockitoCore4, excludes.bytebuddy) // DexMaker has its own MockMaker
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.multidex)
     androidTestImplementation(project(":camera:camera-lifecycle"))
diff --git a/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingCaptureProcessorTest.kt b/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingCaptureProcessorTest.kt
index 801b221..54cb68d 100644
--- a/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingCaptureProcessorTest.kt
+++ b/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingCaptureProcessorTest.kt
@@ -55,7 +55,7 @@
         callOnInitAndVerify()
         adaptingCaptureProcessor.close()
         adaptingCaptureProcessor.process(imageProxyBundle)
-        Mockito.verifyZeroInteractions(captureProcessorImpl)
+        Mockito.verifyNoMoreInteractions(captureProcessorImpl)
     }
 
     @Test
@@ -63,7 +63,7 @@
         adaptingCaptureProcessor.close()
         adaptingCaptureProcessor.onOutputSurface(Mockito.mock(Surface::class.java), 0)
         adaptingCaptureProcessor.onInit()
-        Mockito.verifyZeroInteractions(captureProcessorImpl)
+        Mockito.verifyNoMoreInteractions(captureProcessorImpl)
     }
 
     @Test
@@ -71,7 +71,7 @@
         adaptingCaptureProcessor.close()
         adaptingCaptureProcessor.onResolutionUpdate(Size(640, 480))
         adaptingCaptureProcessor.onInit()
-        Mockito.verifyZeroInteractions(captureProcessorImpl)
+        Mockito.verifyNoMoreInteractions(captureProcessorImpl)
     }
 
     @Test
@@ -86,7 +86,7 @@
         callOnInitAndVerify()
         adaptingCaptureProcessor.onDeInit()
         adaptingCaptureProcessor.process(imageProxyBundle)
-        Mockito.verifyZeroInteractions(captureProcessorImpl)
+        Mockito.verifyNoMoreInteractions(captureProcessorImpl)
     }
 
     private fun createFakeImageProxyBundle(
diff --git a/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingPreviewProcessorTest.java b/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingPreviewProcessorTest.java
index f801b25..ba9d15b 100644
--- a/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingPreviewProcessorTest.java
+++ b/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingPreviewProcessorTest.java
@@ -22,7 +22,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import android.media.Image;
 import android.os.Build;
@@ -73,7 +73,7 @@
         mAdaptingPreviewProcessor.process(mImageProxyBundle);
         mAdaptingPreviewProcessor.onInit();
 
-        verifyZeroInteractions(mImpl);
+        verifyNoMoreInteractions(mImpl);
     }
 
     @Test
@@ -82,7 +82,7 @@
         mAdaptingPreviewProcessor.onOutputSurface(mock(Surface.class), 0);
         mAdaptingPreviewProcessor.onInit();
 
-        verifyZeroInteractions(mImpl);
+        verifyNoMoreInteractions(mImpl);
     }
 
     @Test
@@ -91,7 +91,7 @@
         mAdaptingPreviewProcessor.onResolutionUpdate(new Size(640, 480));
         mAdaptingPreviewProcessor.onInit();
 
-        verifyZeroInteractions(mImpl);
+        verifyNoMoreInteractions(mImpl);
     }
 
     @Test
@@ -107,7 +107,7 @@
         mAdaptingPreviewProcessor.onDeInit();
         mAdaptingPreviewProcessor.process(mImageProxyBundle);
 
-        verifyZeroInteractions(mImpl);
+        verifyNoMoreInteractions(mImpl);
     }
 
     private void callOnInitAndVerify() {
diff --git a/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingRequestUpdateProcessorTest.java b/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingRequestUpdateProcessorTest.java
index 832cfa0..ff8b784 100644
--- a/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingRequestUpdateProcessorTest.java
+++ b/camera/camera-extensions/src/test/java/androidx/camera/extensions/internal/AdaptingRequestUpdateProcessorTest.java
@@ -18,7 +18,7 @@
 
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
 import android.hardware.camera2.CaptureResult;
@@ -73,7 +73,7 @@
 
         mAdaptingRequestUpdateProcessor.getCaptureStage();
 
-        verifyZeroInteractions(mPreviewExtenderImpl);
+        verifyNoMoreInteractions(mPreviewExtenderImpl);
     }
 
     @Test
@@ -82,7 +82,7 @@
 
         mAdaptingRequestUpdateProcessor.process(mImageInfo);
 
-        verifyZeroInteractions(mImpl);
+        verifyNoMoreInteractions(mImpl);
     }
 
     /**
diff --git a/camera/camera-mlkit-vision/build.gradle b/camera/camera-mlkit-vision/build.gradle
index e8c3d42..3715943 100644
--- a/camera/camera-mlkit-vision/build.gradle
+++ b/camera/camera-mlkit-vision/build.gradle
@@ -32,7 +32,7 @@
         }
     }
 
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(libs.testRunner)
     testImplementation(libs.robolectric)
     testImplementation(libs.kotlinStdlib)
diff --git a/camera/camera-testing/build.gradle b/camera/camera-testing/build.gradle
index 0f1d9ca..ec539d7 100644
--- a/camera/camera-testing/build.gradle
+++ b/camera/camera-testing/build.gradle
@@ -46,7 +46,7 @@
     testImplementation(libs.junit)
     testImplementation(libs.truth)
     testImplementation(libs.robolectric)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
 }
 
 android {
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraFactory.java b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraFactory.java
index 7937410..08a26e7 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraFactory.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraFactory.java
@@ -55,6 +55,9 @@
     @Nullable
     private final CameraSelector mAvailableCamerasSelector;
 
+    @Nullable
+    private Object mCameraManager = null;
+
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     final Map<String, Pair<Integer, Callable<CameraInternal>>> mCameraMap = new HashMap<>();
 
@@ -167,9 +170,13 @@
         return filteredCameraIds;
     }
 
+    public void setCameraManager(@Nullable Object cameraManager) {
+        mCameraManager = cameraManager;
+    }
+
     @Nullable
     @Override
     public Object getCameraManager() {
-        return null;
+        return mCameraManager;
     }
 }
diff --git a/camera/camera-video/build.gradle b/camera/camera-video/build.gradle
index ce37b7e..27b9ded 100644
--- a/camera/camera-video/build.gradle
+++ b/camera/camera-video/build.gradle
@@ -43,7 +43,7 @@
     testImplementation(libs.junit)
     testImplementation(libs.truth)
     testImplementation(libs.robolectric)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation("androidx.core:core-ktx:1.1.0")
     testImplementation(project(":camera:camera-testing"), {
         exclude group: "androidx.camera", module: "camera-core"
@@ -56,7 +56,7 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
-    androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has it's own MockMaker
+    androidTestImplementation(libs.mockitoCore4, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.autoValueAnnotations)
     androidTestImplementation(project(":camera:camera-lifecycle"))
@@ -66,9 +66,7 @@
     androidTestImplementation(project(":concurrent:concurrent-futures-ktx"))
     androidTestImplementation(project(":internal-testutils-truth"))
     androidTestImplementation(project(":camera:camera-camera2-pipe-integration"))
-    androidTestImplementation libs.mockitoKotlin, {
-        exclude group: 'org.mockito' // to keep control on the mockito version
-    }
+    androidTestImplementation(libs.mockitoKotlin4)
     androidTestAnnotationProcessor(libs.autoValue)
 }
 
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/AudioVideoSyncTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/AudioVideoSyncTest.kt
new file mode 100644
index 0000000..5c5334f
--- /dev/null
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/AudioVideoSyncTest.kt
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video
+
+import android.content.Context
+import android.graphics.SurfaceTexture
+import android.media.MediaRecorder
+import android.os.Build
+import android.util.Size
+import androidx.camera.camera2.Camera2Config
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import androidx.camera.core.internal.CameraUseCaseAdapter
+import androidx.camera.testing.AudioUtil
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.CameraXUtil
+import androidx.camera.testing.LabTestRule
+import androidx.camera.testing.SurfaceTextureProvider
+import androidx.camera.video.internal.compat.quirk.DeactivateEncoderSurfaceBeforeStopEncoderQuirk
+import androidx.camera.video.internal.compat.quirk.DeviceQuirks
+import androidx.core.util.Consumer
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import com.google.common.truth.Truth.assertThat
+import java.io.File
+import java.util.concurrent.TimeUnit
+import kotlin.math.abs
+import org.junit.After
+import org.junit.Assume
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = 21)
+class AudioVideoSyncTest {
+
+    @get:Rule
+    val useCamera = CameraUtil.grantCameraPermissionAndPreTest(
+        CameraUtil.PreTestCameraIdList(Camera2Config.defaultConfig())
+    )
+
+    @get:Rule
+    val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+        android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
+        android.Manifest.permission.RECORD_AUDIO
+    )
+
+    @get:Rule
+    val labTest: LabTestRule = LabTestRule()
+
+    private val cameraConfig = Camera2Config.defaultConfig()
+    private val instrumentation = InstrumentationRegistry.getInstrumentation()
+    private val context: Context = ApplicationProvider.getApplicationContext()
+    private val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
+
+    @Suppress("UNCHECKED_CAST")
+    private val videoRecordEventListener =
+        Mockito.mock(Consumer::class.java) as Consumer<VideoRecordEvent>
+
+    private lateinit var cameraUseCaseAdapter: CameraUseCaseAdapter
+    private lateinit var recorder: Recorder
+    private lateinit var preview: Preview
+    private lateinit var surfaceTexturePreview: Preview
+
+    @Before
+    fun setUp() {
+        Assume.assumeTrue(CameraUtil.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK))
+        // Skip for b/168175357, b/233661493
+        Assume.assumeFalse(
+            "Skip tests for Cuttlefish MediaCodec issues",
+            Build.MODEL.contains("Cuttlefish") &&
+                (Build.VERSION.SDK_INT == 29 || Build.VERSION.SDK_INT == 33)
+        )
+        Assume.assumeTrue(AudioUtil.canStartAudioRecord(MediaRecorder.AudioSource.CAMCORDER))
+
+        CameraXUtil.initialize(
+            context,
+            cameraConfig
+        ).get()
+        cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(context, cameraSelector)
+
+        recorder = Recorder.Builder().build()
+
+        // Using Preview so that the surface provider could be set to control when to issue the
+        // surface request.
+        preview = Preview.Builder().build()
+
+        // Add another Preview to provide an additional surface for b/168187087.
+        surfaceTexturePreview = Preview.Builder().build()
+        instrumentation.runOnMainSync {
+            surfaceTexturePreview.setSurfaceProvider(
+                SurfaceTextureProvider.createSurfaceTextureProvider(
+                    object : SurfaceTextureProvider.SurfaceTextureCallback {
+                        override fun onSurfaceTextureReady(
+                            surfaceTexture: SurfaceTexture,
+                            resolution: Size
+                        ) {
+                            // No-op
+                        }
+
+                        override fun onSafeToRelease(surfaceTexture: SurfaceTexture) {
+                            surfaceTexture.release()
+                        }
+                    }
+                )
+            )
+        }
+
+        Assume.assumeTrue(
+            "This combination (preview, surfaceTexturePreview) is not supported.",
+            cameraUseCaseAdapter.isUseCasesCombinationSupported(
+                preview,
+                surfaceTexturePreview
+            )
+        )
+
+        cameraUseCaseAdapter = CameraUtil.createCameraAndAttachUseCase(
+            context,
+            cameraSelector,
+            // Must put surfaceTexturePreview before preview while addUseCases, otherwise
+            // an issue on Samsung device will occur. See b/196755459.
+            surfaceTexturePreview,
+            preview
+        )
+        recorder.onSourceStateChanged(VideoOutput.SourceState.ACTIVE_NON_STREAMING)
+    }
+
+    @After
+    fun tearDown() {
+        if (this::cameraUseCaseAdapter.isInitialized) {
+            instrumentation.runOnMainSync {
+                cameraUseCaseAdapter.removeUseCases(cameraUseCaseAdapter.useCases)
+            }
+            recorder.onSourceStateChanged(VideoOutput.SourceState.INACTIVE)
+        }
+
+        CameraXUtil.shutdown().get(10, TimeUnit.SECONDS)
+    }
+
+    @LabTestRule.LabTestOnly
+    @Test
+    fun canRecord_withAvSyncInStart() {
+        val diffThresholdUs = 50000L // 50,000 is about 0.05 second
+
+        Mockito.clearInvocations(videoRecordEventListener)
+        invokeSurfaceRequest(recorder)
+        val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
+        val recording = recorder.prepareRecording(context, FileOutputOptions.Builder(file).build())
+            .withAudioEnabled()
+            .start(CameraXExecutors.directExecutor(), videoRecordEventListener)
+
+        val inOrder = Mockito.inOrder(videoRecordEventListener)
+        inOrder.verify(videoRecordEventListener, Mockito.timeout(5000L))
+            .accept(ArgumentMatchers.any(VideoRecordEvent.Start::class.java))
+        inOrder.verify(videoRecordEventListener, Mockito.timeout(15000L)
+            .atLeast(5))
+            .accept(ArgumentMatchers.any(VideoRecordEvent.Status::class.java))
+
+        // check if the time difference between the first video and audio data is within a threshold
+        val firstAudioTime = recorder.mFirstRecordingAudioDataTimeUs
+        val firstVideoTime = recorder.mFirstRecordingVideoDataTimeUs
+        val timeDiff = abs(firstAudioTime - firstVideoTime)
+        assertThat(timeDiff).isLessThan(diffThresholdUs)
+
+        recording.stopSafely()
+        file.delete()
+    }
+
+    private fun invokeSurfaceRequest(recorder: Recorder) {
+        instrumentation.runOnMainSync {
+            preview.setSurfaceProvider { request: SurfaceRequest ->
+                recorder.onSurfaceRequested(request)
+            }
+            recorder.onSourceStateChanged(VideoOutput.SourceState.ACTIVE_STREAMING)
+        }
+    }
+
+    // It fails on devices with certain chipset if the codec is stopped when the camera is still
+    // producing frames to the provided surface. This method first stop the camera from
+    // producing frames then stops the recording safely on the problematic devices.
+    private fun Recording.stopSafely() {
+        val deactivateSurfaceBeforeStop =
+            DeviceQuirks.get(DeactivateEncoderSurfaceBeforeStopEncoderQuirk::class.java) != null
+        if (deactivateSurfaceBeforeStop) {
+            instrumentation.runOnMainSync {
+                preview.setSurfaceProvider(null)
+            }
+        }
+        stop()
+        if (deactivateSurfaceBeforeStop && Build.VERSION.SDK_INT >= 23) {
+            invokeSurfaceRequest(recorder)
+        }
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
index 1e722eb..a76a609 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
@@ -74,7 +74,6 @@
 import java.util.concurrent.Executor
 import java.util.concurrent.Semaphore
 import java.util.concurrent.TimeUnit
-import kotlin.math.abs
 import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Dispatchers
@@ -87,7 +86,6 @@
 import org.junit.Assume.assumeFalse
 import org.junit.Assume.assumeTrue
 import org.junit.Before
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestName
@@ -415,7 +413,6 @@
     }
 
     @Test
-    @Ignore("b/239752223")
     fun canPauseResume() {
         clearInvocations(videoRecordEventListener)
         invokeSurfaceRequest()
@@ -481,35 +478,6 @@
         file.delete()
     }
 
-    @LabTestRule.LabTestOnly
-    @Test
-    fun canRecordWithAvSyncInStart() {
-        val diffThresholdUs = 50000L // 50,000 is about 0.05 second
-
-        clearInvocations(videoRecordEventListener)
-        invokeSurfaceRequest()
-        val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
-        val recording = recorder.prepareRecording(context, FileOutputOptions.Builder(file).build())
-            .withAudioEnabled()
-            .start(CameraXExecutors.directExecutor(), videoRecordEventListener)
-
-        val inOrder = inOrder(videoRecordEventListener)
-        inOrder.verify(videoRecordEventListener, timeout(5000L))
-            .accept(any(VideoRecordEvent.Start::class.java))
-        inOrder.verify(videoRecordEventListener, timeout(15000L)
-            .atLeast(5))
-            .accept(any(VideoRecordEvent.Status::class.java))
-
-        // check if the time difference between the first video and audio data is within a threshold
-        val firstAudioTime = recorder.mFirstRecordingAudioDataTimeUs
-        val firstVideoTime = recorder.mFirstRecordingVideoDataTimeUs
-        val timeDiff = abs(firstAudioTime - firstVideoTime)
-        assertThat(timeDiff).isLessThan(diffThresholdUs)
-
-        recording.stopSafely()
-        file.delete()
-    }
-
     @Test
     fun canReceiveRecordingStats() {
         clearInvocations(videoRecordEventListener)
@@ -584,7 +552,6 @@
     }
 
     @Test
-    @Ignore("b/239752223")
     fun setFileSizeLimit() {
         val fileSizeLimit = 500L * 1024L // 500 KB
         runFileSizeLimitTest(fileSizeLimit)
@@ -594,7 +561,6 @@
     // the encoder. This will ensure that the recording will be finalized even if it has no data
     // written to it.
     @Test
-    @Ignore("b/239752223")
     fun setFileSizeLimitLowerThanInitialDataSize() {
         val fileSizeLimit = 1L // 1 byte
         runFileSizeLimitTest(fileSizeLimit)
@@ -1036,7 +1002,6 @@
     }
 
     @Test
-    @Ignore("b/239752223")
     fun canRecordWithoutAudio() {
         clearInvocations(videoRecordEventListener)
         invokeSurfaceRequest()
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt
index 46b34eb..02f2e3b 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt
@@ -211,7 +211,7 @@
         // Arrange.
         val qualityList = QualitySelector.getSupportedQualities(cameraInfo)
         qualityList.forEach loop@{ quality ->
-            val targetResolution = QualitySelector.getResolution(cameraInfo, quality)
+            val targetResolution = QualitySelector.getResolution(cameraInfo, quality)!!
             val videoOutput = TestVideoOutput(
                 mediaSpec = MediaSpec.builder().configureVideo {
                     it.setQualitySelector(QualitySelector.from(quality))
@@ -228,47 +228,8 @@
             }
 
             // Assert.
-            val surfaceRequest = videoOutput.nextSurfaceRequest(5, TimeUnit.SECONDS)
             assertWithMessage("Set quality value by $quality")
-                .that(surfaceRequest.resolution).isEqualTo(targetResolution)
-
-            // Cleanup.
-            withContext(Dispatchers.Main) {
-                cameraUseCaseAdapter.apply {
-                    removeUseCases(listOf(videoCapture))
-                }
-            }
-        }
-    }
-
-    @Test
-    fun addUseCases_setQualityWithRotation_getCorrectResolution() = runBlocking {
-        assumeTrue(QualitySelector.getSupportedQualities(cameraInfo).isNotEmpty())
-        // Cuttlefish API 29 has inconsistent resolution issue. See b/184015059.
-        assumeFalse(Build.MODEL.contains("Cuttlefish") && Build.VERSION.SDK_INT == 29)
-
-        val targetResolution = QualitySelector.getResolution(cameraInfo, Quality.LOWEST)
-
-        arrayOf(
-            Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270
-        ).forEach { rotation ->
-            // Arrange.
-            val videoOutput = TestVideoOutput(
-                mediaSpec = MediaSpec.builder().configureVideo {
-                    it.setQualitySelector(QualitySelector.from(Quality.LOWEST))
-                }.build()
-            )
-            val videoCapture = VideoCapture.withOutput(videoOutput)
-
-            // Act.
-            withContext(Dispatchers.Main) {
-                cameraUseCaseAdapter.addUseCases(listOf(videoCapture))
-            }
-
-            // Assert.
-            val surfaceRequest = videoOutput.nextSurfaceRequest(5, TimeUnit.SECONDS)
-            assertWithMessage("Set rotation value by $rotation")
-                .that(surfaceRequest.resolution).isEqualTo(targetResolution)
+                .that(videoCapture.attachedSurfaceResolution).isEqualTo(targetResolution)
 
             // Cleanup.
             withContext(Dispatchers.Main) {
@@ -429,8 +390,6 @@
         }
 
         fun setStreamInfo(streamInfo: StreamInfo) = streamInfoObservable.setState(streamInfo)
-
-        fun setMediaSpec(mediaSpec: MediaSpec) = mediaSpecObservable.setState(mediaSpec)
     }
 
     private suspend fun SurfaceRequest.provideUpdatingSurface(): StateFlow<Int> {
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
index 94a180b..89933d5 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
@@ -29,14 +29,13 @@
 import androidx.camera.camera2.pipe.integration.CameraPipeConfig
 import androidx.camera.core.Camera
 import androidx.camera.core.CameraInfo
-import androidx.camera.core.CameraXConfig
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraXConfig
 import androidx.camera.core.ImageAnalysis
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.ImageCaptureException
-import androidx.camera.core.ImageProxy
 import androidx.camera.core.Preview
-import androidx.camera.core.impl.utils.CameraOrientationUtil
+import androidx.camera.core.impl.utils.TransformUtils.rotateSize
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import androidx.camera.lifecycle.ProcessCameraProvider
 import androidx.camera.testing.CameraPipeConfigTestRule
@@ -229,13 +228,14 @@
         completeVideoRecording(videoCapture, file)
 
         // Verify.
-        verifyMetadataRotation(targetRotation, file)
+        val expectedRotation = if (videoCapture.node != null) 0
+        else cameraInfo.getSensorRotationDegrees(targetRotation)
+        verifyMetadataRotation(expectedRotation, file)
+
         // Cleanup.
         file.delete()
     }
 
-    // TODO: Add other metadata info check, e.g. location, after Recorder add more metadata.
-
     @Test
     fun getCorrectResolution_when_setSupportedQuality() {
         // Pre-arrange.
@@ -282,7 +282,14 @@
             completeVideoRecording(videoCapture, file)
 
             // Verify.
-            verifyVideoResolution(targetResolution, file)
+            val expectResolution = if (videoCapture.node != null) {
+                val relativeRotation =
+                    cameraInfo.getSensorRotationDegrees(videoCapture.targetRotation)
+                rotateSize(targetResolution, relativeRotation)
+            } else {
+                targetResolution
+            }
+            verifyVideoResolution(expectResolution, file)
 
             // Cleanup.
             file.delete()
@@ -390,7 +397,7 @@
         latchForVideoSaved = CountDownLatch(1)
         latchForVideoRecording = CountDownLatch(5)
         val latchForImageAnalysis = CountDownLatch(5)
-        analysis.setAnalyzer(CameraXExecutors.directExecutor()) { it: ImageProxy ->
+        analysis.setAnalyzer(CameraXExecutors.directExecutor()) {
             latchForImageAnalysis.countDown()
             it.close()
         }
@@ -773,64 +780,50 @@
         savedCallback.verifyCaptureResult()
     }
 
-    private fun verifyMetadataRotation(targetRotation: Int, file: File) {
-        // Whether the camera lens and display are facing opposite directions.
-        val isOpposite = cameraSelector.lensFacing == CameraSelector.LENS_FACING_BACK
-        val relativeRotation = CameraOrientationUtil.getRelativeImageRotation(
-            CameraOrientationUtil.surfaceRotationToDegrees(targetRotation),
-            CameraUtil.getSensorOrientation(cameraSelector.lensFacing!!)!!,
-            isOpposite
-        )
-        val videoRotation = getRotationInMetadata(Uri.fromFile(file))
+    private fun verifyMetadataRotation(expectedRotation: Int, file: File) {
+        MediaMetadataRetriever().useAndRelease {
+            it.setDataSource(context, Uri.fromFile(file))
+            val videoRotation =
+                it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)!!.toInt()
 
-        // Checks the rotation from video file's metadata is matched with the relative rotation.
-        assertWithMessage(
-            TAG + ", $targetRotation rotation test failure:" +
-                ", videoRotation: $videoRotation" +
-                ", relativeRotation: $relativeRotation"
-        ).that(videoRotation).isEqualTo(relativeRotation)
+            // Checks the rotation from video file's metadata is matched with the relative rotation.
+            assertWithMessage(
+                TAG + ", rotation test failure: " +
+                    "videoRotation: $videoRotation" +
+                    ", expectedRotation: $expectedRotation"
+            ).that(videoRotation).isEqualTo(expectedRotation)
+        }
     }
 
-    private fun verifyVideoResolution(targetResolution: Size, file: File) {
-        val mediaRetriever = MediaMetadataRetriever()
-        lateinit var resolution: Size
-        mediaRetriever.apply {
-            setDataSource(context, Uri.fromFile(file))
-            val height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!
+    private fun verifyVideoResolution(expectedResolution: Size, file: File) {
+        MediaMetadataRetriever().useAndRelease {
+            it.setDataSource(context, Uri.fromFile(file))
+            val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!
                 .toInt()
-            val width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!
+            val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!
                 .toInt()
-            resolution = Size(width, height)
-        }
+            val resolution = Size(width, height)
 
-        // Compare with the resolution of video and the targetResolution in QualitySelector
-        assertWithMessage(
-            TAG + ", verifyVideoResolution failure:" +
-                ", videoResolution: $resolution" +
-                ", targetResolution: $targetResolution"
-        ).that(resolution).isEqualTo(targetResolution)
+            // Compare with the resolution of video and the targetResolution in QualitySelector
+            assertWithMessage(
+                TAG + ", verifyVideoResolution failure:" +
+                    ", videoResolution: $resolution" +
+                    ", expectedResolution: $expectedResolution"
+            ).that(resolution).isEqualTo(expectedResolution)
+        }
     }
 
     private fun verifyRecordingResult(file: File, hasAudio: Boolean = false) {
-        val mediaRetriever = MediaMetadataRetriever()
-        mediaRetriever.apply {
-            setDataSource(context, Uri.fromFile(file))
-            val video = extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
-            val audio = extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
+        MediaMetadataRetriever().useAndRelease {
+            it.setDataSource(context, Uri.fromFile(file))
+            val video = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
+            val audio = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
 
             assertThat(video).isEqualTo("yes")
             assertThat(audio).isEqualTo(if (hasAudio) "yes" else null)
         }
     }
 
-    private fun getRotationInMetadata(uri: Uri): Int {
-        val mediaRetriever = MediaMetadataRetriever()
-        return mediaRetriever.let {
-            it.setDataSource(context, uri)
-            it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt()!!
-        }
-    }
-
     private fun getSurfaceProvider(): Preview.SurfaceProvider {
         return SurfaceTextureProvider.createSurfaceTextureProvider(
             object : SurfaceTextureProvider.SurfaceTextureCallback {
@@ -870,4 +863,12 @@
             assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue()
         }
     }
-}
\ No newline at end of file
+}
+
+private fun MediaMetadataRetriever.useAndRelease(block: (MediaMetadataRetriever) -> Unit) {
+    try {
+        block(this)
+    } finally {
+        release()
+    }
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
index bbca48f..9f9d5b3 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
@@ -94,6 +94,7 @@
 import androidx.camera.core.impl.utils.futures.FutureCallback;
 import androidx.camera.core.impl.utils.futures.Futures;
 import androidx.camera.core.internal.ThreadConfig;
+import androidx.camera.core.processing.DefaultSurfaceEffect;
 import androidx.camera.core.processing.SettableSurface;
 import androidx.camera.core.processing.SurfaceEdge;
 import androidx.camera.core.processing.SurfaceEffectInternal;
@@ -101,6 +102,7 @@
 import androidx.camera.video.StreamInfo.StreamState;
 import androidx.camera.video.impl.VideoCaptureConfig;
 import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
+import androidx.camera.video.internal.compat.quirk.PreviewDelayWhenVideoCaptureIsBoundQuirk;
 import androidx.camera.video.internal.compat.quirk.PreviewStretchWhenVideoCaptureIsBoundQuirk;
 import androidx.camera.video.internal.config.MimeInfo;
 import androidx.camera.video.internal.encoder.InvalidConfigException;
@@ -150,6 +152,8 @@
     private static final Defaults DEFAULT_CONFIG = new Defaults();
     private static final boolean HAS_PREVIEW_STRETCH_QUIRK =
             DeviceQuirks.get(PreviewStretchWhenVideoCaptureIsBoundQuirk.class) != null;
+    private static final boolean HAS_PREVIEW_DELAY_QUIRK =
+            DeviceQuirks.get(PreviewDelayWhenVideoCaptureIsBoundQuirk.class) != null;
 
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     DeferrableSurface mDeferrableSurface;
@@ -298,6 +302,7 @@
 
         mStreamInfo = fetchObservableValue(getOutput().getStreamInfo(),
                 StreamInfo.STREAM_INFO_ANY_INACTIVE);
+        mNode = createNodeIfNeeded();
         mSessionConfigBuilder = createPipeline(cameraId, config, finalSelectedResolution);
         applyStreamInfoToSessionConfigBuilder(mSessionConfigBuilder, mStreamInfo);
         updateSessionConfig(mSessionConfigBuilder.build());
@@ -323,6 +328,11 @@
     /**
      * Sets a {@link SurfaceEffectInternal}.
      *
+     * <p>The effect is used to setup post-processing pipeline.
+     *
+     * <p>Note: the value will only be used when VideoCapture is bound. Calling this method after
+     * VideoCapture is bound takes no effect until VideoCapture is rebound.
+     *
      * @hide
      */
     @RestrictTo(Scope.LIBRARY_GROUP)
@@ -340,6 +350,11 @@
     public void onDetached() {
         clearPipeline();
 
+        if (mNode != null) {
+            mNode.release();
+            mNode = null;
+        }
+
         mVideoEncoderInfo = null;
     }
 
@@ -475,7 +490,7 @@
         Range<Integer> targetFpsRange = requireNonNull(
                 config.getTargetFramerate(Defaults.DEFAULT_FPS_RANGE));
         Timebase timebase;
-        if (mSurfaceEffect != null) {
+        if (mNode != null) {
             MediaSpec mediaSpec = requireNonNull(getMediaSpec());
             Rect cropRect = requireNonNull(getCropRect(resolution));
             timebase = camera.getCameraInfoInternal().getTimebase();
@@ -483,7 +498,6 @@
                     () -> getVideoEncoderInfo(config.getVideoEncoderInfoFinder(),
                             VideoCapabilities.from(camera.getCameraInfo()), timebase, mediaSpec,
                             resolution, targetFpsRange));
-            mNode = new SurfaceEffectNode(camera, APPLY_CROP_ROTATE_AND_MIRRORING, mSurfaceEffect);
             SettableSurface cameraSurface = new SettableSurface(
                     SurfaceEffect.VIDEO_CAPTURE,
                     resolution,
@@ -518,7 +532,7 @@
         SessionConfig.Builder sessionConfigBuilder = SessionConfig.Builder.createFrom(config);
         sessionConfigBuilder.addErrorListener(
                 (sessionConfig, error) -> resetPipeline(cameraId, config, resolution));
-        if (HAS_PREVIEW_STRETCH_QUIRK) {
+        if (HAS_PREVIEW_STRETCH_QUIRK || HAS_PREVIEW_DELAY_QUIRK) {
             sessionConfigBuilder.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
         }
 
@@ -536,10 +550,6 @@
             mDeferrableSurface.close();
             mDeferrableSurface = null;
         }
-        if (mNode != null) {
-            mNode.release();
-            mNode = null;
-        }
 
         mSurfaceRequest = null;
         mStreamInfo = StreamInfo.STREAM_INFO_ANY_INACTIVE;
@@ -683,6 +693,23 @@
         setupSurfaceUpdateNotifier(sessionConfigBuilder, isStreamActive);
     }
 
+    @Nullable
+    private SurfaceEffectNode createNodeIfNeeded() {
+        if (mSurfaceEffect != null || HAS_PREVIEW_DELAY_QUIRK) {
+            Logger.d(TAG, "SurfaceEffect is enabled.");
+            return new SurfaceEffectNode(requireNonNull(getCamera()),
+                    APPLY_CROP_ROTATE_AND_MIRRORING,
+                    mSurfaceEffect != null ? mSurfaceEffect : new DefaultSurfaceEffect());
+        }
+        return null;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    SurfaceEffectNode getNode() {
+        return mNode;
+    }
+
     @MainThread
     @NonNull
     private Rect adjustCropRectIfNeeded(@NonNull Rect cropRect, @NonNull Size resolution,
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/AudioSource.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/AudioSource.java
index c5f6992..6e7f5e6 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/AudioSource.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/AudioSource.java
@@ -16,6 +16,12 @@
 
 package androidx.camera.video.internal;
 
+import static android.media.AudioFormat.ENCODING_PCM_16BIT;
+import static android.media.AudioFormat.ENCODING_PCM_24BIT_PACKED;
+import static android.media.AudioFormat.ENCODING_PCM_32BIT;
+import static android.media.AudioFormat.ENCODING_PCM_8BIT;
+import static android.media.AudioFormat.ENCODING_PCM_FLOAT;
+
 import static androidx.camera.video.internal.AudioSource.InternalState.CONFIGURED;
 import static androidx.camera.video.internal.AudioSource.InternalState.RELEASED;
 import static androidx.camera.video.internal.AudioSource.InternalState.STARTED;
@@ -45,6 +51,8 @@
 import androidx.camera.video.internal.compat.Api24Impl;
 import androidx.camera.video.internal.compat.Api29Impl;
 import androidx.camera.video.internal.compat.Api31Impl;
+import androidx.camera.video.internal.compat.quirk.AudioTimestampFramePositionIncorrectQuirk;
+import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
 import androidx.camera.video.internal.encoder.InputBuffer;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
 import androidx.core.util.Preconditions;
@@ -107,6 +115,12 @@
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     final int mBufferSize;
 
+    final int mSampleRate;
+
+    final int mBytesPerFrame;
+
+    long mTotalFramesRead = 0;
+
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     InternalState mState = CONFIGURED;
 
@@ -174,7 +188,10 @@
 
         mExecutor = CameraXExecutors.newSequentialExecutor(executor);
         mBufferSize = minBufferSize * 2;
+        mSampleRate = settings.getSampleRate();
         try {
+            mBytesPerFrame = getBytesPerFrame(settings.getAudioFormat(),
+                    settings.getChannelCount());
             if (Build.VERSION.SDK_INT >= 23) {
                 AudioFormat audioFormatObj = new AudioFormat.Builder()
                         .setSampleRate(settings.getSampleRate())
@@ -417,6 +434,7 @@
                         byteBuffer.limit(length);
                         inputBuffer.setPresentationTimeUs(generatePresentationTimeUs());
                         inputBuffer.submit();
+                        mTotalFramesRead += length / mBytesPerFrame;
                     } else {
                         Logger.w(TAG, "Unable to read data from AudioRecord.");
                         inputBuffer.cancel();
@@ -474,6 +492,7 @@
             notifyError(new AudioSourceAccessException("Unable to start the audio record.", e));
             return;
         }
+        mTotalFramesRead = 0;
         mIsSendingAudio = true;
         sendNextAudio();
     }
@@ -514,11 +533,12 @@
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     long generatePresentationTimeUs() {
         long presentationTimeUs = -1;
-        if (Build.VERSION.SDK_INT >= 24) {
+        if (Build.VERSION.SDK_INT >= 24 && !hasAudioTimestampQuirk()) {
             AudioTimestamp audioTimestamp = new AudioTimestamp();
             if (Api24Impl.getTimestamp(mAudioRecord, audioTimestamp,
                     AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS) {
-                presentationTimeUs = TimeUnit.NANOSECONDS.toMicros(audioTimestamp.nanoTime);
+                presentationTimeUs = computeInterpolatedTimeUs(mSampleRate, mTotalFramesRead,
+                        audioTimestamp);
             } else {
                 Logger.w(TAG, "Unable to get audio timestamp");
             }
@@ -529,6 +549,19 @@
         return presentationTimeUs;
     }
 
+    private static boolean hasAudioTimestampQuirk() {
+        return DeviceQuirks.get(AudioTimestampFramePositionIncorrectQuirk.class) != null;
+    }
+
+    private static long computeInterpolatedTimeUs(int sampleRate, long framePosition,
+            @NonNull AudioTimestamp timestamp) {
+        long frameDiff = framePosition - timestamp.framePosition;
+        long compensateTimeInNanoSec = TimeUnit.SECONDS.toNanos(1) * frameDiff / sampleRate;
+        long resultInNanoSec = timestamp.nanoTime + compensateTimeInNanoSec;
+
+        return resultInNanoSec < 0 ? 0 : TimeUnit.NANOSECONDS.toMicros(resultInNanoSec);
+    }
+
     /** Check if the combination of sample rate, channel count and audio format is supported. */
     public static boolean isSettingsSupported(int sampleRate, int channelCount, int audioFormat) {
         if (sampleRate <= 0 || channelCount <= 0) {
@@ -553,6 +586,24 @@
                 audioFormat);
     }
 
+    private static int getBytesPerFrame(int audioFormat, int channelCount) {
+        Preconditions.checkState(channelCount > 0);
+
+        switch (audioFormat) {
+            case ENCODING_PCM_8BIT:
+                return channelCount;
+            case ENCODING_PCM_16BIT:
+                return channelCount * 2;
+            case ENCODING_PCM_24BIT_PACKED:
+                return channelCount * 3;
+            case ENCODING_PCM_32BIT:
+            case ENCODING_PCM_FLOAT:
+                return channelCount * 4;
+            default:
+                throw new IllegalArgumentException("Invalid audio format: " + audioFormat);
+        }
+    }
+
     /**
      * Settings required to configure the audio source.
      */
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/AudioTimestampFramePositionIncorrectQuirk.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/AudioTimestampFramePositionIncorrectQuirk.java
new file mode 100644
index 0000000..e8128dd
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/AudioTimestampFramePositionIncorrectQuirk.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.compat.quirk;
+
+import android.media.AudioTimestamp;
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.Quirk;
+
+/**
+ * <p>QuirkSummary
+ *     Bug Id: 245518008
+ *     Description: Quirk which denotes {@link android.media.AudioTimestamp#framePosition} queried
+ *                  by {@link android.media.AudioRecord#getTimestamp(AudioTimestamp, int)} returns
+ *                  incorrect info. On Redmi 6A, frame position becomes negative after recording
+ *                  multiple times.
+ *
+ *     Device(s): Redmi 6A
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class AudioTimestampFramePositionIncorrectQuirk implements Quirk {
+
+    static boolean load() {
+        return isRedmi6A();
+    }
+
+    private static boolean isRedmi6A() {
+        return "Xiaomi".equalsIgnoreCase(Build.BRAND) && "Redmi 6A".equalsIgnoreCase(Build.MODEL);
+    }
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/CameraUseInconsistentTimebaseQuirk.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/CameraUseInconsistentTimebaseQuirk.java
index f56a676..63e0121 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/CameraUseInconsistentTimebaseQuirk.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/CameraUseInconsistentTimebaseQuirk.java
@@ -21,7 +21,7 @@
 
 import androidx.annotation.RequiresApi;
 import androidx.camera.core.impl.Quirk;
-import androidx.camera.video.internal.workaround.CorrectVideoTimeByTimebase;
+import androidx.camera.video.internal.workaround.VideoTimebaseConverter;
 
 import java.util.Arrays;
 import java.util.HashSet;
@@ -33,7 +33,7 @@
  *     Description: Quirk that denotes some Samsung devices use inconsistent timebase for camera
  *                  frame.
  *     Device(s): Some Samsung devices
- *     @see CorrectVideoTimeByTimebase
+ *     @see VideoTimebaseConverter
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 public class CameraUseInconsistentTimebaseQuirk implements Quirk {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/DeviceQuirksLoader.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/DeviceQuirksLoader.java
index 151ba52..7670a3f 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/DeviceQuirksLoader.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/DeviceQuirksLoader.java
@@ -80,6 +80,12 @@
         if (PreviewStretchWhenVideoCaptureIsBoundQuirk.load()) {
             quirks.add(new PreviewStretchWhenVideoCaptureIsBoundQuirk());
         }
+        if (PreviewDelayWhenVideoCaptureIsBoundQuirk.load()) {
+            quirks.add(new PreviewDelayWhenVideoCaptureIsBoundQuirk());
+        }
+        if (AudioTimestampFramePositionIncorrectQuirk.load()) {
+            quirks.add(new AudioTimestampFramePositionIncorrectQuirk());
+        }
 
         return quirks;
     }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/ExcludeStretchedVideoQualityQuirk.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/ExcludeStretchedVideoQualityQuirk.java
index 4cb6541..8e7d374 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/ExcludeStretchedVideoQualityQuirk.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/ExcludeStretchedVideoQualityQuirk.java
@@ -24,27 +24,41 @@
 
 /**
  * <p>QuirkSummary
- *     Bug Id: 202792648
+ *     Bug Id: 202792648, 245495234
  *     Description: The captured video is stretched while selecting the quality is greater or
  *                  equality to FHD resolution
- *     Device(s): Samsung J4 (sm-j400g)
+ *     Device(s): Samsung J4 (sm-j400g), Samsung J7 Prime (sm-g610m) API level 27 or above,
+ *     Samsung J7 (sm-J710mn) API level 27 or above
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 public class ExcludeStretchedVideoQualityQuirk implements VideoQualityQuirk {
     static boolean load() {
-        return isSamsungJ4();
+        return isSamsungJ4() || isSamsungJ7PrimeApi27Above() || isSamsungJ7Api27Above();
     }
 
     private static boolean isSamsungJ4() {
         return "Samsung".equalsIgnoreCase(Build.BRAND) && "SM-J400G".equalsIgnoreCase(Build.MODEL);
     }
 
+    private static boolean isSamsungJ7PrimeApi27Above() {
+        return "Samsung".equalsIgnoreCase(Build.BRAND) && "SM-G610M".equalsIgnoreCase(Build.MODEL)
+                && Build.VERSION.SDK_INT >= 27;
+    }
+
+    private static boolean isSamsungJ7Api27Above() {
+        return "Samsung".equalsIgnoreCase(Build.BRAND) && "SM-J710MN".equalsIgnoreCase(Build.MODEL)
+                && Build.VERSION.SDK_INT >= 27;
+    }
+
     /** Checks if the given Quality type is a problematic quality. */
     @Override
     public boolean isProblematicVideoQuality(@NonNull Quality quality) {
         if (isSamsungJ4()) {
             return quality == Quality.FHD || quality == Quality.UHD;
         }
+        if (isSamsungJ7PrimeApi27Above() || isSamsungJ7Api27Above()) {
+            return quality == Quality.FHD;
+        }
         return false;
     }
 }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/PreviewDelayWhenVideoCaptureIsBoundQuirk.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/PreviewDelayWhenVideoCaptureIsBoundQuirk.java
new file mode 100644
index 0000000..6847b67
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/compat/quirk/PreviewDelayWhenVideoCaptureIsBoundQuirk.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.compat.quirk;
+
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.Quirk;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * <p>QuirkSummary
+ *     Bug Id: b/223643510
+ *     Description: Quirk indicates Preview is delayed on some Huawei devices when the Preview uses
+ *                  certain resolutions and VideoCapture is bound.
+ *     Device(s): Some Huawei devices.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class PreviewDelayWhenVideoCaptureIsBoundQuirk implements Quirk {
+
+    private static final Set<String> HUAWEI_DEVICE_LIST = new HashSet<>(Arrays.asList(
+            "HWELE",  // P30
+            "HWVOG",  // P30 Pro
+            "HWYAL",  // Nova 5T
+            "HWLYA",  // Mate 20 Pro
+            "HWCOL",  // Honor 10
+            "HWPAR"   // Nova 3
+    ));
+
+    static boolean load() {
+        return "Huawei".equalsIgnoreCase(Build.MANUFACTURER)
+                && HUAWEI_DEVICE_LIST.contains(Build.DEVICE.toUpperCase(Locale.US));
+    }
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/EncoderImpl.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/EncoderImpl.java
index 7a966e4..bf72036 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/EncoderImpl.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/EncoderImpl.java
@@ -40,6 +40,7 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.camera.core.Logger;
+import androidx.camera.core.impl.Timebase;
 import androidx.camera.core.impl.annotation.ExecutedBy;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
 import androidx.camera.core.impl.utils.futures.FutureCallback;
@@ -50,8 +51,8 @@
 import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
 import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk;
 import androidx.camera.video.internal.compat.quirk.VideoEncoderSuspendDoesNotIncludeSuspendTimeQuirk;
-import androidx.camera.video.internal.workaround.CorrectVideoTimeByTimebase;
 import androidx.camera.video.internal.workaround.EncoderFinder;
+import androidx.camera.video.internal.workaround.VideoTimebaseConverter;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
 import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
 import androidx.core.util.Preconditions;
@@ -174,6 +175,8 @@
      */
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     final Deque<Range<Long>> mActivePauseResumeTimeRanges = new ArrayDeque<>();
+    final Timebase mInputTimebase;
+    final TimeProvider mTimeProvider = new SystemTimeProvider();
 
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     @GuardedBy("mLock")
@@ -227,6 +230,8 @@
             throw new InvalidConfigException("Unknown encoder config type");
         }
 
+        mInputTimebase = encoderConfig.getInputTimebase();
+        Logger.d(mTag, "mInputTimebase = " + mInputTimebase);
         mMediaFormat = encoderConfig.toMediaFormat();
         Logger.d(mTag, "mMediaFormat = " + mMediaFormat);
         mMediaCodec = mEncoderFinder.findEncoder(mMediaFormat);
@@ -967,8 +972,8 @@
     }
 
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
-    static long generatePresentationTimeUs() {
-        return TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
+    long generatePresentationTimeUs() {
+        return mTimeProvider.uptimeUs();
     }
 
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
@@ -985,7 +990,7 @@
     @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
     class MediaCodecCallback extends MediaCodec.Callback {
         @Nullable
-        private final CorrectVideoTimeByTimebase mCorrectVideoTimestamp;
+        private final VideoTimebaseConverter mVideoTimestampConverter;
 
         private boolean mHasSendStartCallback = false;
         private boolean mHasFirstData = false;
@@ -1001,11 +1006,16 @@
         private boolean mIsKeyFrameRequired = false;
 
         MediaCodecCallback() {
-            if (mIsVideoEncoder
-                    && DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
-                mCorrectVideoTimestamp = new CorrectVideoTimeByTimebase();
+            if (mIsVideoEncoder) {
+                Timebase inputTimebase;
+                if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
+                    inputTimebase = null;
+                } else {
+                    inputTimebase = mInputTimebase;
+                }
+                mVideoTimestampConverter = new VideoTimebaseConverter(mTimeProvider, inputTimebase);
             } else {
-                mCorrectVideoTimestamp = null;
+                mVideoTimestampConverter = null;
             }
         }
 
@@ -1055,10 +1065,6 @@
                             Logger.d(mTag, DebugUtils.readableBufferInfo(bufferInfo));
                         }
 
-                        if (mCorrectVideoTimestamp != null) {
-                            mCorrectVideoTimestamp.correctTimestamp(bufferInfo);
-                        }
-
                         // Handle start of stream
                         if (!mHasSendStartCallback) {
                             mHasSendStartCallback = true;
@@ -1069,7 +1075,7 @@
                             }
                         }
 
-                        if (!checkBufferInfo(bufferInfo)) {
+                        if (checkBufferInfo(bufferInfo)) {
                             if (!mHasFirstData) {
                                 mHasFirstData = true;
                             }
@@ -1164,31 +1170,36 @@
         /**
          * Checks the {@link android.media.MediaCodec.BufferInfo} and updates related states.
          *
-         * @return {@code true} if the buffer should be dropped, otherwise {@code false}.
+         * @return {@code true} if the buffer is valid, otherwise {@code false}.
          */
         @ExecutedBy("mEncoderExecutor")
         private boolean checkBufferInfo(@NonNull MediaCodec.BufferInfo bufferInfo) {
             if (mHasEndData) {
                 Logger.d(mTag, "Drop buffer by already reach end of stream.");
-                return true;
+                return false;
             }
 
             if (bufferInfo.size <= 0) {
                 Logger.d(mTag, "Drop buffer by invalid buffer size.");
-                return true;
+                return false;
             }
 
             // Sometimes the codec config data was notified by output callback, they should have
             // been sent out by onOutputFormatChanged(), so ignore it.
             if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
                 Logger.d(mTag, "Drop buffer by codec config.");
-                return true;
+                return false;
+            }
+
+            if (mVideoTimestampConverter != null) {
+                bufferInfo.presentationTimeUs =
+                        mVideoTimestampConverter.convertToUptimeUs(bufferInfo.presentationTimeUs);
             }
 
             // MediaCodec may send out of order buffer
             if (bufferInfo.presentationTimeUs <= mLastPresentationTimeUs) {
                 Logger.d(mTag, "Drop buffer by out of order buffer from MediaCodec.");
-                return true;
+                return false;
             }
             mLastPresentationTimeUs = bufferInfo.presentationTimeUs;
 
@@ -1208,12 +1219,12 @@
                     signalCodecStop();
                     mPendingCodecStop = false;
                 }
-                return true;
+                return false;
             }
 
             if (updatePauseRangeStateAndCheckIfBufferPaused(bufferInfo)) {
                 Logger.d(mTag, "Drop buffer by pause.");
-                return true;
+                return false;
             }
 
             // We should check if the adjusted time is valid. see b/189114207.
@@ -1222,7 +1233,7 @@
                 if (mIsVideoEncoder && isKeyFrame(bufferInfo)) {
                     mIsKeyFrameRequired = true;
                 }
-                return true;
+                return false;
             }
 
             if (!mHasFirstData && !mIsKeyFrameRequired && mIsVideoEncoder) {
@@ -1233,12 +1244,12 @@
                 if (!isKeyFrame(bufferInfo)) {
                     Logger.d(mTag, "Drop buffer by not a key frame.");
                     requestKeyFrameToMediaCodec();
-                    return true;
+                    return false;
                 }
                 mIsKeyFrameRequired = false;
             }
 
-            return false;
+            return true;
         }
 
         @ExecutedBy("mEncoderExecutor")
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/SystemTimeProvider.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/SystemTimeProvider.java
new file mode 100644
index 0000000..599bbca
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/SystemTimeProvider.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.camera.video.internal.encoder;
+
+import android.os.SystemClock;
+
+import androidx.annotation.RequiresApi;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A TimeProvider implementation based on System time.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class SystemTimeProvider implements TimeProvider {
+
+    @Override
+    public long uptimeUs() {
+        return TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
+    }
+
+    @Override
+    public long realtimeUs() {
+        return TimeUnit.NANOSECONDS.toMicros(SystemClock.elapsedRealtimeNanos());
+    }
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/TimeProvider.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/TimeProvider.java
new file mode 100644
index 0000000..634767c
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/encoder/TimeProvider.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.encoder;
+
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.Timebase;
+
+/**
+ * The time provider used to provider timestamps.
+ *
+ * <p>There are two sets of methods based on {@link Timebase#UPTIME} and {@link Timebase#REALTIME}.
+ *
+ * @see SystemTimeProvider
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public interface TimeProvider {
+
+    /** Returns the timestamp in microseconds based on {@link Timebase#UPTIME}. */
+    long uptimeUs();
+
+    /** Returns the timestamp in microseconds based on {@link Timebase#REALTIME}. */
+    long realtimeUs();
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/workaround/CorrectVideoTimeByTimebase.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/workaround/CorrectVideoTimeByTimebase.java
deleted file mode 100644
index 7578dd9..0000000
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/workaround/CorrectVideoTimeByTimebase.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright 2021 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.workaround;
-
-import android.media.MediaCodec;
-import android.os.SystemClock;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
-import androidx.camera.core.Logger;
-import androidx.camera.video.internal.compat.quirk.CameraUseInconsistentTimebaseQuirk;
-
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * Corrects the video timestamps if video buffer contains REALTIME timestamp.
- *
- * <p>As described on b/197805856, some Samsung devices use inconsistent timebase for camera
- * frame. The workaround detects and corrects the timestamp by generating a new timestamp.
- * Note: this will sacrifice the precise timestamp of video buffer.
- *
- * @see CameraUseInconsistentTimebaseQuirk
- */
-@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-public class CorrectVideoTimeByTimebase {
-    private static final String TAG = "CorrectVideoTimeByTimebase";
-
-    @Nullable
-    private AtomicBoolean mNeedToCorrectVideoTimebase = null;
-
-    /**
-     * Corrects the video timestamp if necessary.
-     *
-     * <p>This method will modify the {@link MediaCodec.BufferInfo#presentationTimeUs} if necessary.
-     *
-     * @param bufferInfo the buffer info.
-     */
-    public void correctTimestamp(@NonNull MediaCodec.BufferInfo bufferInfo) {
-        // For performance concern, only check the requirement once.
-        if (mNeedToCorrectVideoTimebase == null) {
-            // Skip invalid buffer
-            if (bufferInfo.size <= 0 || bufferInfo.presentationTimeUs <= 0L
-                    || (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
-                return;
-            }
-
-            long uptimeUs = TimeUnit.MILLISECONDS.toMicros(SystemClock.uptimeMillis());
-            long realtimeUs = TimeUnit.MILLISECONDS.toMicros(SystemClock.elapsedRealtime());
-            // Expected to be uptime
-            boolean closeToRealTime = Math.abs(bufferInfo.presentationTimeUs - realtimeUs)
-                    < Math.abs(bufferInfo.presentationTimeUs - uptimeUs);
-            if (closeToRealTime) {
-                Logger.w(TAG, "Detected video buffer timestamp is close to real time.");
-            }
-            mNeedToCorrectVideoTimebase = new AtomicBoolean(closeToRealTime);
-        }
-
-        if (mNeedToCorrectVideoTimebase.get()) {
-            bufferInfo.presentationTimeUs -= TimeUnit.MILLISECONDS.toMicros(
-                    SystemClock.elapsedRealtime() - SystemClock.uptimeMillis());
-        }
-    }
-}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/workaround/VideoTimebaseConverter.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/workaround/VideoTimebaseConverter.java
new file mode 100644
index 0000000..6a14e07
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/workaround/VideoTimebaseConverter.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2021 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.workaround;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.Logger;
+import androidx.camera.core.impl.Timebase;
+import androidx.camera.video.internal.compat.quirk.CameraUseInconsistentTimebaseQuirk;
+import androidx.camera.video.internal.encoder.TimeProvider;
+
+/**
+ * Converts the video timestamps to {@link Timebase#UPTIME} if video buffer contains
+ * {@link Timebase#REALTIME} timestamp.
+ *
+ * <p>The workaround accepts an {@code null} input timebase. This is useful when the timebase is
+ * unknown, such as the problem described in b/197805856. If the input timebase is null, an
+ * automatic detection mechanism is used to determine the timebase, which is by checking the input
+ * timestamp is close to UPTIME or REALTIME. For performance reason, the detection will only check
+ * the first input timestamp.
+ *
+ * @see CameraUseInconsistentTimebaseQuirk
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class VideoTimebaseConverter {
+    private static final String TAG = "VideoTimebaseConverter";
+
+    private final TimeProvider mTimeProvider;
+
+    private long mUptimeToRealtimeOffsetUs = -1L;
+    private Timebase mInputTimebase;
+
+    /**
+     * Constructs the VideoTimebaseConverter.
+     *
+     * @param timeProvider  the time provider.
+     * @param inputTimebase the input video frame timebase. {@code null} if the timebase is unknown.
+     */
+    public VideoTimebaseConverter(@NonNull TimeProvider timeProvider,
+            @Nullable Timebase inputTimebase) {
+        mTimeProvider = timeProvider;
+        mInputTimebase = inputTimebase;
+    }
+
+    /**
+     * Converts the video timestamp to {@link Timebase#UPTIME} if necessary.
+     *
+     * @param timestampUs the video frame timestamp in micro seconds. The timebase is supposed
+     *                    to be the input timebase in constructor.
+     */
+    public long convertToUptimeUs(long timestampUs) {
+        if (mInputTimebase == null) {
+            if (isCloseToRealtime(timestampUs)) {
+                Logger.w(TAG, "Detected video buffer timestamp is close to realtime.");
+                mInputTimebase = Timebase.REALTIME;
+            } else {
+                mInputTimebase = Timebase.UPTIME;
+            }
+        }
+        switch (mInputTimebase) {
+            case REALTIME:
+                if (mUptimeToRealtimeOffsetUs == -1) {
+                    mUptimeToRealtimeOffsetUs = calculateUptimeToRealtimeOffsetUs();
+                }
+                return timestampUs - mUptimeToRealtimeOffsetUs;
+            case UPTIME:
+                return timestampUs;
+            default:
+                throw new AssertionError("Unknown timebase: " + mInputTimebase);
+        }
+    }
+
+    private boolean isCloseToRealtime(long timeUs) {
+        long uptimeUs = mTimeProvider.uptimeUs();
+        long realtimeUs = mTimeProvider.realtimeUs();
+        return Math.abs(timeUs - realtimeUs) < Math.abs(timeUs - uptimeUs);
+    }
+
+    // The algorithm is from camera framework Camera3Device.cpp
+    private long calculateUptimeToRealtimeOffsetUs() {
+        // Try three times to get the clock offset, choose the one with the minimum gap in
+        // measurements.
+        long bestGap = Long.MAX_VALUE;
+        long measured = 0L;
+        for (int i = 0; i < 3; i++) {
+            long uptime1 = mTimeProvider.uptimeUs();
+            long realtime = mTimeProvider.realtimeUs();
+            long uptime2 = mTimeProvider.uptimeUs();
+            long gap = uptime2 - uptime1;
+            if (i == 0 || gap < bestGap) {
+                bestGap = gap;
+                measured = realtime - ((uptime1 + uptime2) >> 1);
+            }
+        }
+        return Math.max(0, measured);
+    }
+}
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
index cac814d..a2d7fc1 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
@@ -25,16 +25,19 @@
 import android.view.Surface
 import androidx.arch.core.util.Function
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraSelector.LENS_FACING_BACK
 import androidx.camera.core.CameraXConfig
 import androidx.camera.core.SurfaceRequest
 import androidx.camera.core.UseCase
 import androidx.camera.core.impl.CamcorderProfileProxy
 import androidx.camera.core.impl.CameraFactory
+import androidx.camera.core.impl.CameraInfoInternal
 import androidx.camera.core.impl.ImageOutputConfig
 import androidx.camera.core.impl.MutableStateObservable
 import androidx.camera.core.impl.Observable
 import androidx.camera.core.impl.Timebase
 import androidx.camera.core.impl.utils.TransformUtils.rectToSize
+import androidx.camera.core.impl.utils.TransformUtils.rotateSize
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import androidx.camera.core.internal.CameraUseCaseAdapter
 import androidx.camera.core.processing.SurfaceEffectInternal
@@ -94,6 +97,7 @@
     private val context: Context = ApplicationProvider.getApplicationContext()
     private lateinit var cameraUseCaseAdapter: CameraUseCaseAdapter
     private lateinit var cameraFactory: CameraFactory
+    private lateinit var cameraInfo: CameraInfoInternal
     private lateinit var surfaceManager: FakeCameraDeviceSurfaceManager
     private var surfaceRequestsToRelease = mutableListOf<SurfaceRequest>()
 
@@ -103,6 +107,7 @@
             cameraUseCaseAdapter.apply {
                 detachUseCases()
                 removeUseCases(useCases)
+                shadowOf(Looper.getMainLooper()).idle()
             }
         }
         surfaceRequestsToRelease.forEach {
@@ -132,22 +137,83 @@
     }
 
     @Test
-    fun addUseCases_receiveOnSurfaceRequest() {
-        // Arrange.
-        setupCamera()
+    fun addUseCases_sendCorrectResolution() {
+        testSetRotationWillSendCorrectResolution()
+    }
+
+    @Test
+    fun enableEffect_sensorRotationIs0AndSetTargetRotation_sendCorrectResolution() {
+        testSetRotationWillSendCorrectResolution(
+            sensorRotation = 0,
+            effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor())
+        )
+    }
+
+    @Test
+    fun enableEffect_sensorRotationIs90AndSetTargetRotation_sendCorrectResolution() {
+        testSetRotationWillSendCorrectResolution(
+            sensorRotation = 90,
+            effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor())
+        )
+    }
+
+    @Test
+    fun enableEffect_sensorRotationIs180AndSetTargetRotation_sendCorrectResolution() {
+        testSetRotationWillSendCorrectResolution(
+            sensorRotation = 180,
+            effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor())
+        )
+    }
+
+    @Test
+    fun enableEffect_sensorRotationIs270AndSetTargetRotation_sendCorrectResolution() {
+        testSetRotationWillSendCorrectResolution(
+            sensorRotation = 270,
+            effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor())
+        )
+    }
+
+    private fun testSetRotationWillSendCorrectResolution(
+        sensorRotation: Int = 0,
+        effect: SurfaceEffectInternal? = null
+    ) {
+        setupCamera(sensorRotation = sensorRotation)
         createCameraUseCaseAdapter()
 
-        var surfaceRequest: SurfaceRequest? = null
-        val videoOutput = createVideoOutput(surfaceRequestListener = { request, _ ->
-            surfaceRequest = request
-        })
-        val videoCapture = createVideoCapture(videoOutput)
+        listOf(
+            Surface.ROTATION_0,
+            Surface.ROTATION_90,
+            Surface.ROTATION_180,
+            Surface.ROTATION_270
+        ).forEach { targetRotation ->
+            // Arrange.
+            var surfaceRequest: SurfaceRequest? = null
+            val videoOutput = createVideoOutput(
+                mediaSpec = MediaSpec.builder().configureVideo {
+                    it.setQualitySelector(QualitySelector.from(Quality.HD))
+                }.build(),
+                surfaceRequestListener = { request, _ ->
+                    surfaceRequest = request
+                })
+            val videoCapture = createVideoCapture(videoOutput)
+            effect?.let { videoCapture.setEffect(it) }
+            videoCapture.targetRotation = targetRotation
 
-        // Act.
-        addAndAttachUseCases(videoCapture)
+            // Act.
+            addAndAttachUseCases(videoCapture)
 
-        // Assert.
-        assertThat(surfaceRequest).isNotNull()
+            // Assert.
+            val expectedResolution = if (effect != null) {
+                rotateSize(RESOLUTION_720P, cameraInfo.getSensorRotationDegrees(targetRotation))
+            } else {
+                RESOLUTION_720P
+            }
+            assertThat(surfaceRequest).isNotNull()
+            assertThat(surfaceRequest!!.resolution).isEqualTo(expectedResolution)
+
+            // Clean-up.
+            detachAndRemoveUseCases(videoCapture)
+        }
     }
 
     @Test
@@ -161,7 +227,7 @@
     }
 
     @Test
-    fun addUseCasesWithSurfaceEffect__cameraIsUptime_requestIsUptime() {
+    fun addUseCasesWithSurfaceEffect_cameraIsUptime_requestIsUptime() {
         testTimebase(
             effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor()),
             cameraTimebase = Timebase.UPTIME,
@@ -170,7 +236,7 @@
     }
 
     @Test
-    fun addUseCasesWithSurfaceEffect__cameraIsRealtime_requestIsRealtime() {
+    fun addUseCasesWithSurfaceEffect_cameraIsRealtime_requestIsRealtime() {
         testTimebase(
             effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor()),
             cameraTimebase = Timebase.REALTIME,
@@ -198,7 +264,6 @@
 
         // Act.
         addAndAttachUseCases(videoCapture)
-        shadowOf(Looper.getMainLooper()).idle()
 
         // Assert.
         assertThat(timebase).isEqualTo(expectedTimebase)
@@ -501,7 +566,6 @@
         // Act: bind and provide Surface.
         videoCapture.setEffect(effect)
         addAndAttachUseCases(videoCapture)
-        shadowOf(Looper.getMainLooper()).idle()
 
         // Assert: surfaceOutput received.
         assertThat(effect.surfaceOutput).isNotNull()
@@ -515,7 +579,6 @@
 
         // Act: unbind.
         detachAndRemoveUseCases(videoCapture)
-        shadowOf(Looper.getMainLooper()).idle()
 
         // Assert: effect and effect surface is released.
         assertThat(effect.isReleased).isTrue()
@@ -617,13 +680,12 @@
         val videoCapture = createVideoCapture(
             videoOutput, videoEncoderInfoFinder = { videoEncoderInfo }
         )
-        val effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor(), false)
+        val effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor())
         videoCapture.setEffect(effect)
         videoCapture.setViewPortCropRect(cropRect)
 
         // Act.
         addAndAttachUseCases(videoCapture)
-        shadowOf(Looper.getMainLooper()).idle()
 
         // Assert.
         assertThat(surfaceRequest).isNotNull()
@@ -701,11 +763,13 @@
     private fun addAndAttachUseCases(vararg useCases: UseCase) {
         cameraUseCaseAdapter.addUseCases(useCases.asList())
         cameraUseCaseAdapter.attachUseCases()
+        shadowOf(Looper.getMainLooper()).idle()
     }
 
     private fun detachAndRemoveUseCases(vararg useCases: UseCase) {
         cameraUseCaseAdapter.detachUseCases()
         cameraUseCaseAdapter.removeUseCases(useCases.asList())
+        shadowOf(Looper.getMainLooper()).idle()
     }
 
     private fun createCameraUseCaseAdapter() {
@@ -728,10 +792,11 @@
 
     private fun setupCamera(
         cameraId: String = CAMERA_ID_0,
+        sensorRotation: Int = 0,
         vararg profiles: CamcorderProfileProxy = CAMERA_0_PROFILES,
         timebase: Timebase = Timebase.UPTIME,
     ) {
-        val cameraInfo = FakeCameraInfoInternal(cameraId).apply {
+        cameraInfo = FakeCameraInfoInternal(cameraId, sensorRotation, LENS_FACING_BACK).apply {
             camcorderProfileProvider =
                 FakeCamcorderProfileProvider.Builder().addProfile(*profiles).build()
             setTimebase(timebase)
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/workaround/FakeTimeProvider.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/workaround/FakeTimeProvider.kt
new file mode 100644
index 0000000..eb2df49
--- /dev/null
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/workaround/FakeTimeProvider.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.workaround
+
+import androidx.camera.video.internal.encoder.TimeProvider
+import java.util.concurrent.TimeUnit
+
+/**
+ * A fake TimeProvider implementation.
+ */
+class FakeTimeProvider(var uptimeNs: Long = 0L, var realtimeNs: Long = 0L) : TimeProvider {
+
+    override fun uptimeUs() = TimeUnit.NANOSECONDS.toMicros(uptimeNs)
+
+    override fun realtimeUs() = TimeUnit.NANOSECONDS.toMicros(realtimeNs)
+}
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/workaround/VideoTimebaseConverterTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/workaround/VideoTimebaseConverterTest.kt
new file mode 100644
index 0000000..eec1a76
--- /dev/null
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/workaround/VideoTimebaseConverterTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.workaround
+
+import android.os.Build
+import androidx.camera.core.impl.Timebase
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.TimeUnit
+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 VideoTimebaseConverterTest {
+
+    private val systemTimeProvider =
+        FakeTimeProvider(TimeUnit.MICROSECONDS.toNanos(1000L), TimeUnit.MICROSECONDS.toNanos(2000L))
+
+    @Test
+    fun uptimeTimebase_noConversion() {
+        // Arrange.
+        val videoTimebaseConverter = VideoTimebaseConverter(systemTimeProvider, Timebase.UPTIME)
+
+        // Act.
+        val outputTime1 = videoTimebaseConverter.convertToUptimeUs(800L)
+        val outputTime2 = videoTimebaseConverter.convertToUptimeUs(900L)
+
+        // Assert.
+        assertThat(outputTime1).isEqualTo(800L)
+        assertThat(outputTime2).isEqualTo(900L)
+    }
+
+    @Test
+    fun realtimeTimebase_doConversion() {
+        // Arrange.
+        val videoTimebaseConverter = VideoTimebaseConverter(systemTimeProvider, Timebase.REALTIME)
+
+        // Act.
+        val outputTime1 = videoTimebaseConverter.convertToUptimeUs(1800L)
+        val outputTime2 = videoTimebaseConverter.convertToUptimeUs(1900L)
+
+        // Assert.
+        assertThat(outputTime1).isEqualTo(800L)
+        assertThat(outputTime2).isEqualTo(900L)
+    }
+
+    @Test
+    fun unknownTimebase_closeToUptime_noConversion() {
+        // Arrange.
+        val videoTimebaseConverter = VideoTimebaseConverter(systemTimeProvider, null)
+
+        // Act.
+        val outputTime1 = videoTimebaseConverter.convertToUptimeUs(800L)
+        val outputTime2 = videoTimebaseConverter.convertToUptimeUs(900L)
+
+        // Assert.
+        assertThat(outputTime1).isEqualTo(800L)
+        assertThat(outputTime2).isEqualTo(900L)
+    }
+
+    @Test
+    fun unknownTimebase_closeToRealtime_doConversion() {
+        // Arrange.
+        val videoTimebaseConverter = VideoTimebaseConverter(systemTimeProvider, null)
+
+        // Act.
+        val outputTime1 = videoTimebaseConverter.convertToUptimeUs(1800L)
+        val outputTime2 = videoTimebaseConverter.convertToUptimeUs(1900L)
+
+        // Assert.
+        assertThat(outputTime1).isEqualTo(800L)
+        assertThat(outputTime2).isEqualTo(900L)
+    }
+}
diff --git a/camera/camera-view/build.gradle b/camera/camera-view/build.gradle
index 56a6b62..341b014 100644
--- a/camera/camera-view/build.gradle
+++ b/camera/camera-view/build.gradle
@@ -40,7 +40,7 @@
     annotationProcessor(libs.autoValue)
 
     testImplementation(libs.testRunner)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(libs.robolectric)
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.truth)
@@ -50,7 +50,7 @@
     testImplementation(project(":camera:camera-testing"))
 
     androidTestImplementation(libs.multidex)
-    androidTestImplementation(libs.mockitoCore)
+    androidTestImplementation(libs.mockitoCore4)
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
@@ -61,7 +61,7 @@
     androidTestImplementation(project(":camera:camera-camera2"))
     androidTestImplementation(project(":camera:camera-testing"))
     androidTestImplementation(project(":camera:camera-camera2-pipe-integration"))
-    androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has it's own MockMaker
+    androidTestImplementation(libs.mockitoCore4, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0")
 }
diff --git a/camera/camera-view/src/test/java/androidx/camera/view/internal/compat/quirk/SurfaceViewStretchedQuirkTest.java b/camera/camera-view/src/test/java/androidx/camera/view/internal/compat/quirk/SurfaceViewStretchedQuirkTest.java
index 423a076..0a8ed8b 100644
--- a/camera/camera-view/src/test/java/androidx/camera/view/internal/compat/quirk/SurfaceViewStretchedQuirkTest.java
+++ b/camera/camera-view/src/test/java/androidx/camera/view/internal/compat/quirk/SurfaceViewStretchedQuirkTest.java
@@ -32,7 +32,7 @@
  */
 @RunWith(RobolectricTestRunner.class)
 @DoNotInstrument
-@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP, maxSdk = 32) // maxSdk due to b/247175194
 public class SurfaceViewStretchedQuirkTest {
 
     @Test
diff --git a/camera/camera-viewfinder/build.gradle b/camera/camera-viewfinder/build.gradle
index aa1e133..feb5a537 100644
--- a/camera/camera-viewfinder/build.gradle
+++ b/camera/camera-viewfinder/build.gradle
@@ -39,7 +39,7 @@
     annotationProcessor(libs.autoValue)
 
     testImplementation(libs.testRunner)
-    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoCore4)
     testImplementation(libs.robolectric)
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.truth)
@@ -47,7 +47,7 @@
     testImplementation(libs.testCore)
 
     androidTestImplementation(libs.multidex)
-    androidTestImplementation(libs.mockitoCore)
+    androidTestImplementation(libs.mockitoCore4)
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
@@ -56,7 +56,7 @@
     androidTestImplementation(libs.testUiautomator)
     androidTestImplementation(libs.kotlinStdlib)
     androidTestImplementation(libs.truth)
-    androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has it's own MockMaker
+    androidTestImplementation(libs.mockitoCore4, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has it's own MockMaker
 }
 
diff --git a/camera/camera-viewfinder/src/test/java/androidx/camera/viewfinder/internal/quirk/SurfaceViewStretchedQuirkTest.java b/camera/camera-viewfinder/src/test/java/androidx/camera/viewfinder/internal/quirk/SurfaceViewStretchedQuirkTest.java
index 8f994c9..49ea437 100644
--- a/camera/camera-viewfinder/src/test/java/androidx/camera/viewfinder/internal/quirk/SurfaceViewStretchedQuirkTest.java
+++ b/camera/camera-viewfinder/src/test/java/androidx/camera/viewfinder/internal/quirk/SurfaceViewStretchedQuirkTest.java
@@ -32,7 +32,7 @@
  */
 @RunWith(RobolectricTestRunner.class)
 @DoNotInstrument
-@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP, maxSdk = 32) // maxSdk due to b/247175194
 public class SurfaceViewStretchedQuirkTest {
 
     @Test
diff --git a/car/app/app-samples/showcase/common/src/main/AndroidManifest.xml b/car/app/app-samples/showcase/common/src/main/AndroidManifest.xml
index 542568f..f44bb20 100644
--- a/car/app/app-samples/showcase/common/src/main/AndroidManifest.xml
+++ b/car/app/app-samples/showcase/common/src/main/AndroidManifest.xml
@@ -17,9 +17,24 @@
 <manifest
     xmlns:android="http://schemas.android.com/apk/res/android">
 
-    <!-- For accessing current location in PlaceListMapTemplate -->
-    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
-    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
+    <permission-group android:name="android.permission-group.SHOWCASE"
+        android:label="@string/perm_group"
+        android:description="@string/perm_group_description" />
+
+    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
+        android:permissionGroup="android.permission-group.SHOWCASE"
+        android:label="@string/perm_fine_location"
+        android:description="@string/perm_fine_location_desc" />
+
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
+        android:permissionGroup="android.permission-group.SHOWCASE"
+        android:label="@string/perm_coarse_location"
+        android:description="@string/perm_coarse_location_desc" />
+
+    <uses-permission android:name="android.permission.RECORD_AUDIO"
+        android:permissionGroup="android.permission-group.SHOWCASE"
+        android:label="@string/perm_record_audio"
+        android:description="@string/perm_record_audio_desc" />
 
     <application
         android:supportsRtl="true">
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java
new file mode 100644
index 0000000..415fd73
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java
@@ -0,0 +1,295 @@
+/*
+ * 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.car.app.sample.showcase.common.audio;
+
+import static android.Manifest.permission.RECORD_AUDIO;
+import static android.media.AudioAttributes.CONTENT_TYPE_MUSIC;
+import static android.media.AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE;
+import static android.media.AudioFormat.CHANNEL_OUT_MONO;
+import static android.media.AudioFormat.ENCODING_DEFAULT;
+import static android.media.AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
+import static android.os.Build.VERSION.SDK_INT;
+
+import static androidx.car.app.media.CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE;
+import static androidx.car.app.media.CarAudioRecord.AUDIO_CONTENT_SAMPLING_RATE;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.media.AudioAttributes;
+import android.media.AudioFocusRequest;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import android.os.Build.VERSION_CODES;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RequiresPermission;
+import androidx.car.app.CarContext;
+import androidx.car.app.CarToast;
+import androidx.car.app.media.CarAudioRecord;
+import androidx.car.app.utils.LogTags;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Manages recording the microphone and accessing the stored data from the microphone. */
+public class VoiceInteraction {
+    private static final String FILE_NAME = "recording.wav";
+
+    private final CarContext mCarContext;
+
+    public VoiceInteraction(@NonNull CarContext carContext) {
+        mCarContext = carContext;
+    }
+
+    /**
+     * Starts recording the car microphone, then plays it back.
+     */
+    @RequiresPermission(RECORD_AUDIO)
+    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
+    public void voiceInteractionDemo() {
+        // Some of the functions for recording require API level 26, so verify that first
+        if (SDK_INT < VERSION_CODES.O) {
+            CarToast.makeText(mCarContext, "API level is less than 26, "
+                            + "cannot use this functionality!",
+                    CarToast.LENGTH_LONG).show();
+            return;
+        }
+
+        // Check if we have permissions to record audio
+        if (!checkAudioPermission()) {
+            return;
+        }
+
+        // Start the thread for recording and playing back the audio
+        createRecordingThread().start();
+    }
+
+    /**
+     * Create thread which executes the record and the playback functions
+     */
+    @NonNull
+    @RequiresApi(api = VERSION_CODES.O)
+    @RequiresPermission(RECORD_AUDIO)
+    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
+    public Thread createRecordingThread() {
+        Thread recordingThread = new Thread(
+                () -> {
+                    // Request audio focus
+                    AudioFocusRequest audioFocusRequest = null;
+                    try {
+                        CarAudioRecord record = CarAudioRecord.create(mCarContext);
+                        // Take audio focus so that user's media is not recorded
+                        AudioAttributes audioAttributes =
+                                new AudioAttributes.Builder()
+                                        .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+                                        .setUsage(USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
+                                        .build();
+
+                        audioFocusRequest = new AudioFocusRequest.Builder(
+                                AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
+                                .setAudioAttributes(audioAttributes)
+                                .setOnAudioFocusChangeListener(state -> {
+                                    if (state == AudioManager.AUDIOFOCUS_LOSS) {
+                                        // Stop recording if audio focus is lost
+                                        record.stopRecording();
+                                    }
+                                })
+                                .build();
+
+                        if (mCarContext.getSystemService(AudioManager.class)
+                                .requestAudioFocus(audioFocusRequest)
+                                != AUDIOFOCUS_REQUEST_GRANTED) {
+                            CarToast.makeText(mCarContext, "Audio Focus Request not granted",
+                                    CarToast.LENGTH_LONG).show();
+                            return;
+                        }
+                        recordAudio(record);
+                        playBackAudio();
+                    } catch (Exception e) {
+                        Log.e(LogTags.TAG, "Voice Interaction Error: ", e);
+                        throw new RuntimeException(e);
+                    } finally {
+                        // Abandon the FocusRequest so that user's media can be resumed
+                        mCarContext.getSystemService(AudioManager.class).abandonAudioFocusRequest(
+                                audioFocusRequest);
+                    }
+                },
+                "AudioRecorder Thread");
+        return recordingThread;
+    }
+
+    @RequiresPermission(RECORD_AUDIO)
+    private void playBackAudio() {
+
+        InputStream inputStream;
+        try {
+            inputStream = mCarContext.openFileInput(FILE_NAME);
+        } catch (FileNotFoundException e) {
+            throw new RuntimeException(e);
+        }
+
+        AudioTrack audioTrack = new AudioTrack.Builder()
+                .setAudioAttributes(new AudioAttributes.Builder()
+                        .setUsage(USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
+                        .setContentType(CONTENT_TYPE_MUSIC)
+                        .build())
+                .setAudioFormat(new AudioFormat.Builder()
+                        .setEncoding(ENCODING_DEFAULT)
+                        .setSampleRate(AUDIO_CONTENT_SAMPLING_RATE)
+                        .setChannelMask(CHANNEL_OUT_MONO)
+                        .build())
+                .setBufferSizeInBytes(AUDIO_CONTENT_BUFFER_SIZE)
+                .build();
+        audioTrack.play();
+        try {
+            byte[] audioData = new byte[AUDIO_CONTENT_BUFFER_SIZE];
+            while (inputStream.available() > 0) {
+                int readByteLength = inputStream.read(audioData, 0, audioData.length);
+
+                if (readByteLength < 0) {
+                    // End of file
+                    break;
+                }
+                audioTrack.write(audioData, 0, readByteLength);
+            }
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+        audioTrack.stop();
+
+    }
+
+    @RequiresApi(api = VERSION_CODES.O)
+    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
+    @RequiresPermission(RECORD_AUDIO)
+    private void recordAudio(CarAudioRecord record) {
+
+        record.startRecording();
+
+        List<Byte> bytes = new ArrayList<>();
+        boolean isRecording = true;
+        while (isRecording) {
+            // gets the voice output from microphone to byte format
+            byte[] bData = new byte[AUDIO_CONTENT_BUFFER_SIZE];
+            int len = record.read(bData, 0, AUDIO_CONTENT_BUFFER_SIZE);
+
+            if (len > 0) {
+                for (int i = 0; i < len; i++) {
+                    bytes.add(bData[i]);
+                }
+            } else {
+                isRecording = false;
+            }
+        }
+
+        try {
+            OutputStream outputStream = mCarContext.openFileOutput(FILE_NAME, Context.MODE_PRIVATE);
+            addHeader(outputStream, bytes.size());
+            for (Byte b : bytes) {
+                outputStream.write(b);
+            }
+
+            outputStream.flush();
+            outputStream.close();
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+        record.stopRecording();
+    }
+
+    private static void addHeader(OutputStream outputStream, int totalAudioLen) throws IOException {
+        int totalDataLen = totalAudioLen + 36;
+        byte[] header = new byte[44];
+        int dataElementSize = 8;
+        long longSampleRate = AUDIO_CONTENT_SAMPLING_RATE;
+
+        // See http://soundfile.sapp.org/doc/WaveFormat/
+        header[0] = 'R';  // RIFF/WAVE header
+        header[1] = 'I';
+        header[2] = 'F';
+        header[3] = 'F';
+        header[4] = (byte) (totalAudioLen & 0xff);
+        header[5] = (byte) ((totalDataLen >> 8) & 0xff);
+        header[6] = (byte) ((totalDataLen >> 16) & 0xff);
+        header[7] = (byte) ((totalDataLen >> 24) & 0xff);
+        header[8] = 'W';
+        header[9] = 'A';
+        header[10] = 'V';
+        header[11] = 'E';
+        header[12] = 'f';  // 'fmt ' chunk
+        header[13] = 'm';
+        header[14] = 't';
+        header[15] = ' ';
+        header[16] = 16;  // 4 bytes: size of 'fmt ' chunk
+        header[17] = 0;
+        header[18] = 0;
+        header[19] = 0;
+        header[20] = 1;  // format = 1 PCM
+        header[21] = 0;
+        header[22] = 1; // Num channels (mono)
+        header[23] = 0;
+        header[24] = (byte) (longSampleRate & 0xff); // sample rate
+        header[25] = (byte) ((longSampleRate >> 8) & 0xff);
+        header[26] = (byte) ((longSampleRate >> 16) & 0xff);
+        header[27] = (byte) ((longSampleRate >> 24) & 0xff);
+        header[28] = (byte) (longSampleRate & 0xff); // byte rate
+        header[29] = (byte) ((longSampleRate >> 8) & 0xff);
+        header[30] = (byte) ((longSampleRate >> 16) & 0xff);
+        header[31] = (byte) ((longSampleRate >> 24) & 0xff);
+        header[32] = 1;  // block align
+        header[33] = 0;
+        header[34] = (byte) (dataElementSize & 0xff);  // bits per sample
+        header[35] = (byte) ((dataElementSize >> 8) & 0xff);
+        header[36] = 'd';
+        header[37] = 'a';
+        header[38] = 't';
+        header[39] = 'a';
+        header[40] = (byte) (totalAudioLen & 0xff);
+        header[41] = (byte) ((totalAudioLen >> 8) & 0xff);
+        header[42] = (byte) ((totalAudioLen >> 16) & 0xff);
+        header[43] = (byte) ((totalAudioLen >> 24) & 0xff);
+
+        outputStream.write(header, 0, 44);
+    }
+
+    // Returns whether or not the user has granted audio permissions
+    private boolean checkAudioPermission() {
+        if (mCarContext.checkSelfPermission(RECORD_AUDIO)
+                != PackageManager.PERMISSION_GRANTED) {
+            CarToast.makeText(mCarContext, "Grant mic permission on device",
+                    CarToast.LENGTH_LONG).show();
+            List<String> permissions = Collections.singletonList(RECORD_AUDIO);
+            mCarContext.requestPermissions(permissions, (grantedPermissions,
+                    rejectedPermissions) -> {
+                if (grantedPermissions.contains(RECORD_AUDIO)) {
+                    voiceInteractionDemo();
+                }
+            });
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/UserInteractionsDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/UserInteractionsDemoScreen.java
new file mode 100644
index 0000000..8bd55d5
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/UserInteractionsDemoScreen.java
@@ -0,0 +1,178 @@
+/*
+ * 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.car.app.sample.showcase.common.screens;
+
+import static androidx.car.app.model.Action.BACK;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.CarContext;
+import androidx.car.app.Screen;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.ActionStrip;
+import androidx.car.app.model.CarIcon;
+import androidx.car.app.model.Item;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.ListTemplate;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.OnClickListener;
+import androidx.car.app.model.Row;
+import androidx.car.app.model.Template;
+import androidx.car.app.sample.showcase.common.R;
+import androidx.car.app.sample.showcase.common.audio.VoiceInteraction;
+import androidx.car.app.sample.showcase.common.screens.userinteractions.RequestPermissionMenuDemoScreen;
+import androidx.core.graphics.drawable.IconCompat;
+
+/** A screen demonstrating User Interactions */
+public final class UserInteractionsDemoScreen extends Screen {
+    private static final int MAX_STEPS_ALLOWED = 4;
+
+    private final int mStep;
+    private boolean mIsBackOperation;
+
+    public UserInteractionsDemoScreen(int step, @NonNull CarContext carContext) {
+        super(carContext);
+        this.mStep = step;
+    }
+
+    @NonNull
+    @Override
+    public Template onGetTemplate() {
+
+        // Last step must either be a PaneTemplate, MessageTemplate or NavigationTemplate.
+        if (mStep == MAX_STEPS_ALLOWED) {
+            return templateForTaskLimitReached();
+        }
+
+        ItemList.Builder builder = new ItemList.Builder();
+
+        builder.addItem(buildRowForVoiceInteractionDemo());
+        builder.addItem(buildRowForRequestPermissionsDemo());
+        builder.addItem(buildRowForTaskRestrictionDemo());
+
+        if (mIsBackOperation) {
+            builder.addItem(buildRowForAdditionalData());
+        }
+
+        return new ListTemplate.Builder()
+                .setSingleList(builder.build())
+                .setTitle(getCarContext().getString(R.string.user_interactions_demo_title))
+                .setHeaderAction(BACK)
+                .setActionStrip(
+                        new ActionStrip.Builder()
+                                .addAction(
+                                        new Action.Builder()
+                                                .setTitle(getCarContext().getString(
+                                                        R.string.home_caps_action_title))
+                                                .setOnClickListener(
+                                                        () -> getScreenManager().popToRoot())
+                                                .build())
+                                .build())
+                .build();
+
+    }
+
+    /**
+     * Returns the row for VoiceInteraction Demo
+     */
+    private Item buildRowForVoiceInteractionDemo() {
+        return new Row.Builder()
+                .setTitle(getCarContext().getString(R.string.voice_access_demo_title))
+                .setImage(new CarIcon.Builder(
+                        IconCompat.createWithResource(
+                                getCarContext(),
+                                R.drawable.ic_mic))
+                        .build(), Row.IMAGE_TYPE_ICON)
+                .setOnClickListener(new VoiceInteraction(getCarContext())::voiceInteractionDemo)
+                .build();
+    }
+
+    /**
+     * Returns the row for TaskRestriction Demo
+     */
+    private Item buildRowForTaskRestrictionDemo() {
+        return new Row.Builder()
+                .setTitle(getCarContext().getString(R.string.task_step_of_title, mStep,
+                        MAX_STEPS_ALLOWED))
+                .addText(getCarContext().getString(R.string.task_step_of_text))
+                .setImage(new CarIcon.Builder(
+                        IconCompat.createWithResource(
+                                getCarContext(), R.drawable.baseline_task_24))
+                        .build(), Row.IMAGE_TYPE_ICON)
+                .setOnClickListener(
+                        () ->
+                                getScreenManager()
+                                        .pushForResult(
+                                                new UserInteractionsDemoScreen(
+                                                        mStep + 1, getCarContext()),
+                                                result -> mIsBackOperation = true))
+                .build();
+    }
+
+    /**
+     * Returns the row for RequestPermissions Demo
+     */
+    private Item buildRowForRequestPermissionsDemo() {
+        return new Row.Builder()
+                .setTitle(getCarContext().getString(
+                        R.string.request_permission_menu_demo_title))
+                .setImage(new CarIcon.Builder(
+                        IconCompat.createWithResource(
+                                getCarContext(),
+                                R.drawable.baseline_question_mark_24))
+                        .build(), Row.IMAGE_TYPE_ICON)
+                .setOnClickListener(() -> getScreenManager().push(
+                        new RequestPermissionMenuDemoScreen(getCarContext())))
+                .build();
+    }
+
+    /**
+     * Returns the row for AdditionalData
+     */
+    private Item buildRowForAdditionalData() {
+        return new Row.Builder()
+                .setTitle(getCarContext().getString(R.string.additional_data_title))
+                .addText(getCarContext().getString(R.string.additional_data_text))
+                .build();
+    }
+
+    /**
+     * Returns the MessageTemplate with "Task Limit Reached" message after user completes 4 task
+     * steps
+     */
+    private MessageTemplate templateForTaskLimitReached() {
+        OnClickListener onClickListener = () ->
+                getScreenManager()
+                        .pushForResult(
+                                new UserInteractionsDemoScreen(
+                                        mStep + 1,
+                                        getCarContext()),
+                                result ->
+                                        mIsBackOperation = true);
+
+        return new MessageTemplate.Builder(
+                getCarContext().getString(R.string.task_limit_reached_msg))
+                .setHeaderAction(BACK)
+                .addAction(
+                        new Action.Builder()
+                                .setTitle(getCarContext().getString(
+                                        R.string.try_anyway_action_title))
+                                .setOnClickListener(onClickListener)
+                                .build())
+                .build();
+    }
+
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/PreSeedPermissionScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/PreSeedPermissionScreen.java
new file mode 100644
index 0000000..f82949d
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/PreSeedPermissionScreen.java
@@ -0,0 +1,62 @@
+/*
+ * 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.car.app.sample.showcase.common.screens.userinteractions;
+
+import static androidx.car.app.model.Action.BACK;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.CarContext;
+import androidx.car.app.Screen;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.Template;
+import androidx.car.app.sample.showcase.common.R;
+import androidx.car.app.sample.showcase.common.ShowcaseService;
+
+/** A screen that demonstrates exiting the app and pre-seeding it with a request for permissions */
+public class PreSeedPermissionScreen extends Screen {
+    public PreSeedPermissionScreen(@NonNull CarContext carContext) {
+        super(carContext);
+    }
+
+    @NonNull
+    @Override
+    public Template onGetTemplate() {
+        return new MessageTemplate.Builder(getCarContext().getString(R.string.finish_app_msg))
+                .setTitle(getCarContext().getString(R.string.preseed_permission_app_title))
+                .setHeaderAction(BACK)
+                .addAction(
+                        new Action.Builder()
+                                .setOnClickListener(
+                                        () -> {
+                                            getCarContext()
+                                                    .getSharedPreferences(
+                                                            ShowcaseService.SHARED_PREF_KEY,
+                                                            Context.MODE_PRIVATE)
+                                                    .edit()
+                                                    .putBoolean(
+                                                            ShowcaseService.PRE_SEED_KEY, true)
+                                                    .apply();
+                                            getCarContext().finishCarApp();
+                                        })
+                                .setTitle(getCarContext().getString(R.string.exit_action_title))
+                                .build())
+                .build();
+    }
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionMenuDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionMenuDemoScreen.java
new file mode 100644
index 0000000..875d1df
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionMenuDemoScreen.java
@@ -0,0 +1,67 @@
+/*
+ * 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.car.app.sample.showcase.common.screens.userinteractions;
+
+import static androidx.car.app.model.Action.BACK;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.CarContext;
+import androidx.car.app.Screen;
+import androidx.car.app.model.ItemList;
+import androidx.car.app.model.ListTemplate;
+import androidx.car.app.model.Row;
+import androidx.car.app.model.Template;
+import androidx.car.app.sample.showcase.common.R;
+import androidx.lifecycle.DefaultLifecycleObserver;
+
+/** Screen to list different permission demos */
+public final class RequestPermissionMenuDemoScreen extends Screen
+        implements DefaultLifecycleObserver {
+
+    public RequestPermissionMenuDemoScreen(@NonNull CarContext carContext) {
+        super(carContext);
+        getLifecycle().addObserver(this);
+    }
+
+    @NonNull
+    @Override
+    public Template onGetTemplate() {
+        ItemList.Builder listBuilder = new ItemList.Builder();
+
+        listBuilder.addItem(
+                new Row.Builder()
+                        .setTitle(getCarContext().getString(R.string.request_permissions_title))
+                        .setOnClickListener(() ->
+                                getScreenManager().push(
+                                        new RequestPermissionScreen(getCarContext())))
+                        .setBrowsable(true)
+                        .build());
+        listBuilder.addItem(
+                new Row.Builder()
+                        .setTitle(getCarContext().getString(R.string.preseed_permission_demo_title))
+                        .setOnClickListener(() ->
+                                getScreenManager().push(
+                                        new PreSeedPermissionScreen(getCarContext())))
+                        .setBrowsable(true)
+                        .build());
+        return new ListTemplate.Builder()
+                .setSingleList(listBuilder.build())
+                .setTitle(getCarContext().getString(R.string.request_permission_menu_demo_title))
+                .setHeaderAction(BACK)
+                .build();
+    }
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java
new file mode 100644
index 0000000..91f6583
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java
@@ -0,0 +1,199 @@
+/*
+ * 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.car.app.sample.showcase.common.screens.userinteractions;
+
+import static android.content.pm.PackageManager.FEATURE_AUTOMOTIVE;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.location.LocationManager;
+import android.provider.Settings;
+
+import androidx.annotation.NonNull;
+import androidx.car.app.CarAppPermission;
+import androidx.car.app.CarContext;
+import androidx.car.app.CarToast;
+import androidx.car.app.Screen;
+import androidx.car.app.model.Action;
+import androidx.car.app.model.CarColor;
+import androidx.car.app.model.LongMessageTemplate;
+import androidx.car.app.model.MessageTemplate;
+import androidx.car.app.model.OnClickListener;
+import androidx.car.app.model.ParkedOnlyOnClickListener;
+import androidx.car.app.model.Template;
+import androidx.car.app.sample.showcase.common.R;
+import androidx.core.location.LocationManagerCompat;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A screen to show a request for a runtime permission from the user.
+ *
+ * <p>Scans through the possible dangerous permissions and shows which ones have not been
+ * granted in the message. Clicking on the action button will launch the permission request on
+ * the phone.
+ *
+ * <p>If all permissions are granted, corresponding message is displayed with a refresh button which
+ * will scan again when clicked.
+ */
+public class RequestPermissionScreen extends Screen {
+    /**
+     * This field can and should be removed once b/192386096 and/or b/192385602 have been resolved.
+     * Currently it is not possible to know the level of the screen stack and determine the
+     * header action according to that. A boolean flag is added to determine that temporarily.
+     */
+    private final boolean mPreSeedMode;
+    private final String mCarAppPermissionsPrefix = "androidx.car.app";
+
+    /**
+     * Action which invalidates the template.
+     *
+     * <p>This can give the user a chance to revoke the permissions and then refresh will pickup
+     * the permissions that need to be granted.
+     */
+    private final Action mRefreshAction = new Action.Builder()
+            .setTitle(getCarContext().getString(R.string.refresh_action_title))
+            .setBackgroundColor(CarColor.BLUE)
+            .setOnClickListener(this::invalidate)
+            .build();
+
+    public RequestPermissionScreen(@NonNull CarContext carContext) {
+        this(carContext, false);
+    }
+
+    public RequestPermissionScreen(@NonNull CarContext carContext, boolean preSeedMode) {
+        super(carContext);
+        this.mPreSeedMode = preSeedMode;
+    }
+
+    @NonNull
+    @Override
+    @SuppressWarnings("deprecation")
+    public Template onGetTemplate() {
+        Action headerAction = mPreSeedMode ? Action.APP_ICON : Action.BACK;
+        List<String> permissions = new ArrayList<>();
+        String[] declaredPermissions;
+        try {
+            PackageInfo info =
+                    getCarContext().getPackageManager().getPackageInfo(
+                            getCarContext().getPackageName(),
+                            PackageManager.GET_PERMISSIONS);
+            declaredPermissions = info.requestedPermissions;
+        } catch (PackageManager.NameNotFoundException e) {
+            return new MessageTemplate.Builder(
+                    getCarContext().getString(R.string.package_not_found_error_msg))
+                    .setHeaderAction(headerAction)
+                    .addAction(mRefreshAction)
+                    .build();
+        }
+
+        if (declaredPermissions != null) {
+            for (String declaredPermission : declaredPermissions) {
+                // Don't include permissions against the car app host as they are all normal but
+                // show up as ungranted by the system.
+                if (declaredPermission.startsWith(mCarAppPermissionsPrefix)) {
+                    continue;
+                }
+                try {
+                    CarAppPermission.checkHasPermission(getCarContext(), declaredPermission);
+                } catch (SecurityException e) {
+                    permissions.add(declaredPermission);
+                }
+            }
+        }
+        if (permissions.isEmpty()) {
+            return new MessageTemplate.Builder(
+                    getCarContext().getString(R.string.permissions_granted_msg))
+                    .setHeaderAction(headerAction)
+                    .addAction(new Action.Builder()
+                            .setTitle(getCarContext().getString(R.string.close_action_title))
+                            .setOnClickListener(this::finish)
+                            .build())
+                    .build();
+        }
+
+        StringBuilder message = new StringBuilder()
+                .append(getCarContext().getString(R.string.needs_access_msg_prefix));
+        for (String permission : permissions) {
+            message.append(permission);
+            message.append("\n");
+        }
+
+        OnClickListener listener = ParkedOnlyOnClickListener.create(() -> {
+            getCarContext().requestPermissions(
+                    permissions,
+                    (approved, rejected) -> {
+                        CarToast.makeText(
+                                getCarContext(),
+                                String.format("Approved: %s Rejected: %s", approved, rejected),
+                                CarToast.LENGTH_LONG).show();
+                    });
+            if (!getCarContext().getPackageManager().hasSystemFeature(FEATURE_AUTOMOTIVE)) {
+                CarToast.makeText(getCarContext(),
+                        getCarContext().getString(R.string.phone_screen_permission_msg),
+                        CarToast.LENGTH_LONG).show();
+            }
+        });
+
+        Action action = new Action.Builder()
+                .setTitle(getCarContext().getString(R.string.grant_access_action_title))
+                .setBackgroundColor(CarColor.BLUE)
+                .setOnClickListener(listener)
+                .build();
+
+
+        Action action2 = null;
+        LocationManager locationManager =
+                (LocationManager) getCarContext().getSystemService(Context.LOCATION_SERVICE);
+        if (!LocationManagerCompat.isLocationEnabled(locationManager)) {
+            message.append(
+                    getCarContext().getString(R.string.enable_location_permission_on_device_msg));
+            message.append("\n");
+            action2 = new Action.Builder()
+                    .setTitle(getCarContext().getString(R.string.enable_location_action_title))
+                    .setBackgroundColor(CarColor.BLUE)
+                    .setOnClickListener(ParkedOnlyOnClickListener.create(() -> {
+                        getCarContext().startActivity(
+                                new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).addFlags(
+                                        Intent.FLAG_ACTIVITY_NEW_TASK));
+                        if (!getCarContext().getPackageManager().hasSystemFeature(
+                                FEATURE_AUTOMOTIVE)) {
+                            CarToast.makeText(getCarContext(),
+                                    getCarContext().getString(
+                                            R.string.enable_location_permission_on_phone_msg),
+                                    CarToast.LENGTH_LONG).show();
+                        }
+                    }))
+                    .build();
+        }
+
+
+        LongMessageTemplate.Builder builder = new LongMessageTemplate.Builder(message)
+                .setTitle(getCarContext().getString(R.string.required_permissions_title))
+                .addAction(action)
+                .setHeaderAction(headerAction);
+
+        if (action2 != null) {
+            builder.addAction(action2);
+        }
+
+        return builder.build();
+    }
+}
diff --git a/car/app/app-samples/showcase/common/src/main/res/drawable-hdpi/ic_mic.xml b/car/app/app-samples/showcase/common/src/main/res/drawable-hdpi/ic_mic.xml
new file mode 100644
index 0000000..57d2b5191
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/res/drawable-hdpi/ic_mic.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2022 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="40dp"
+    android:height="40dp"
+    android:viewportWidth="40"
+    android:viewportHeight="40">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M20,22.708Q18.125,22.708 16.833,21.354Q15.542,20 15.542,18.083V7.792Q15.542,5.917 16.833,4.625Q18.125,3.333 20,3.333Q21.875,3.333 23.167,4.625Q24.458,5.917 24.458,7.792V18.083Q24.458,20 23.167,21.354Q21.875,22.708 20,22.708ZM20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042Q20,13.042 20,13.042ZM18.625,35V29.5Q14.208,29 11.271,25.75Q8.333,22.5 8.333,18.083H11.125Q11.125,21.75 13.729,24.292Q16.333,26.833 20,26.833Q23.667,26.833 26.271,24.292Q28.875,21.75 28.875,18.083H31.667Q31.667,22.5 28.729,25.75Q25.792,29 21.375,29.5V35ZM20,19.917Q20.75,19.917 21.229,19.375Q21.708,18.833 21.708,18.083V7.792Q21.708,7.083 21.208,6.604Q20.708,6.125 20,6.125Q19.292,6.125 18.792,6.604Q18.292,7.083 18.292,7.792V18.083Q18.292,18.833 18.771,19.375Q19.25,19.917 20,19.917Z"/>
+</vector>
\ No newline at end of file
diff --git a/car/app/app-samples/showcase/common/src/main/res/drawable/baseline_question_mark_24.xml b/car/app/app-samples/showcase/common/src/main/res/drawable/baseline_question_mark_24.xml
new file mode 100644
index 0000000..fbfe98d
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/res/drawable/baseline_question_mark_24.xml
@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#FFFFFF"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M11.07,12.85c0.77,-1.39 2.25,-2.21 3.11,-3.44c0.91,-1.29 0.4,-3.7 -2.18,-3.7c-1.69,0 -2.52,1.28 -2.87,2.34L6.54,6.96C7.25,4.83 9.18,3 11.99,3c2.35,0 3.96,1.07 4.78,2.41c0.7,1.15 1.11,3.3 0.03,4.9c-1.2,1.77 -2.35,2.31 -2.97,3.45c-0.25,0.46 -0.35,0.76 -0.35,2.24h-2.89C10.58,15.22 10.46,13.95 11.07,12.85zM14,20c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2S14,18.9 14,20z"/>
+</vector>
diff --git a/car/app/app-samples/showcase/common/src/main/res/drawable/baseline_task_24.xml b/car/app/app-samples/showcase/common/src/main/res/drawable/baseline_task_24.xml
new file mode 100644
index 0000000..fce940d
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/res/drawable/baseline_task_24.xml
@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#FFFFFF"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM10.94,18L7.4,14.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L10.94,18zM13,9V3.5L18.5,9H13z"/>
+</vector>
diff --git a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
index c4d6f70..45f07ef 100644
--- a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
+++ b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
@@ -130,6 +130,8 @@
   <string name="finish_app_msg">This will finish the app, and when you return it will pre-seed a permission screen</string>
   <string name="finish_app_title">Finish App Demo</string>
   <string name="finish_app_demo_title">Pre-seed the Permission Screen on next run Demo</string>
+  <string name="preseed_permission_app_title">Pre-seed permission App Demo</string>
+  <string name="preseed_permission_demo_title">Pre-seed the Permission Screen on next run Demo</string>
 
   <!-- LoadingDemoScreen -->
   <string name="loading_demo_title">Loading Demo</string>
@@ -387,6 +389,21 @@
   <string name="cal_api_level_prefix" translatable="false">CAL API Level: %d</string>
   <string name="showcase_demos_title">Showcase Demos</string>
 
+  <!-- User Interactions Screen -->
+  <string name="voice_access_demo_title">Voice Access Demo Screen</string>
+  <string name="user_interactions_demo_title">User Interactions</string>
+  <string name="request_permission_menu_demo_title">Request Permissions Demos</string>
+
+  <!-- Manifest file permissions -->
+  <string name="perm_group">Permission Group</string>
+  <string name="perm_group_description">Permission Group for Showcase App</string>
+  <string name="perm_fine_location">Access to Fine Location</string>
+  <string name="perm_fine_location_desc">Permission for Access to Fine Location</string>
+  <string name="perm_coarse_location">Access to Coarse Location</string>
+  <string name="perm_coarse_location_desc">Permission for Access to Coarse Location</string>
+  <string name="perm_record_audio">Access to Record Audio</string>
+  <string name="perm_record_audio_desc">Permission for Access to Record Audio</string>
+
   <!-- Location Strings -->
   <string name="location_1_title" translatable="false">Google Kirkland</string>
   <string name="location_1_address" translatable="false">747 6th St South, Kirkland, WA 98033</string>
diff --git a/compose/material3/material3/api/public_plus_experimental_1.0.0-beta03.txt b/compose/material3/material3/api/public_plus_experimental_1.0.0-beta03.txt
index ef8d098..b4c64fa 100644
--- a/compose/material3/material3/api/public_plus_experimental_1.0.0-beta03.txt
+++ b/compose/material3/material3/api/public_plus_experimental_1.0.0-beta03.txt
@@ -601,8 +601,8 @@
   }
 
   public final class OutlinedTextFieldKt {
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
   }
 
   public final class ProgressIndicatorDefaults {
@@ -861,9 +861,10 @@
   }
 
   @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Immutable public final class TextFieldDefaults {
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void BorderBox(boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional androidx.compose.ui.graphics.Shape shape, optional float focusedBorderThickness, optional float unfocusedBorderThickness);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void OutlinedTextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional kotlin.jvm.functions.Function0<kotlin.Unit> border);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void TextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void FilledContainerBox(boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional androidx.compose.ui.graphics.Shape shape);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void OutlinedBorderContainerBox(boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional androidx.compose.ui.graphics.Shape shape, optional float focusedBorderThickness, optional float unfocusedBorderThickness);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void OutlinedTextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional kotlin.jvm.functions.Function0<kotlin.Unit> container);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void TextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional kotlin.jvm.functions.Function0<kotlin.Unit> container);
     method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getFilledShape();
     method public float getFocusedBorderThickness();
     method public float getMinHeight();
@@ -871,9 +872,9 @@
     method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getOutlinedShape();
     method public float getUnfocusedBorderThickness();
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.ui.Modifier indicatorLine(androidx.compose.ui.Modifier, boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional float focusedIndicatorLineThickness, optional float unfocusedIndicatorLineThickness);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors outlinedTextFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedBorderColor, optional long unfocusedBorderColor, optional long disabledBorderColor, optional long errorBorderColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors outlinedTextFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedBorderColor, optional long unfocusedBorderColor, optional long disabledBorderColor, optional long errorBorderColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor, optional long focusedSupportingTextColor, optional long unfocusedSupportingTextColor, optional long disabledSupportingTextColor, optional long errorSupportingTextColor);
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.foundation.layout.PaddingValues outlinedTextFieldPadding(optional float start, optional float top, optional float end, optional float bottom);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors textFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedIndicatorColor, optional long unfocusedIndicatorColor, optional long disabledIndicatorColor, optional long errorIndicatorColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors textFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedIndicatorColor, optional long unfocusedIndicatorColor, optional long disabledIndicatorColor, optional long errorIndicatorColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor, optional long focusedSupportingTextColor, optional long unfocusedSupportingTextColor, optional long disabledSupportingTextColor, optional long errorSupportingTextColor);
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.foundation.layout.PaddingValues textFieldWithLabelPadding(optional float start, optional float end, optional float top, optional float bottom);
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.foundation.layout.PaddingValues textFieldWithoutLabelPadding(optional float start, optional float top, optional float end, optional float bottom);
     property public final float FocusedBorderThickness;
@@ -892,8 +893,8 @@
   }
 
   public final class TextFieldKt {
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
   }
 
   public final class TextKt {
diff --git a/compose/material3/material3/api/public_plus_experimental_current.txt b/compose/material3/material3/api/public_plus_experimental_current.txt
index ef8d098..b4c64fa 100644
--- a/compose/material3/material3/api/public_plus_experimental_current.txt
+++ b/compose/material3/material3/api/public_plus_experimental_current.txt
@@ -601,8 +601,8 @@
   }
 
   public final class OutlinedTextFieldKt {
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void OutlinedTextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
   }
 
   public final class ProgressIndicatorDefaults {
@@ -861,9 +861,10 @@
   }
 
   @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Immutable public final class TextFieldDefaults {
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void BorderBox(boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional androidx.compose.ui.graphics.Shape shape, optional float focusedBorderThickness, optional float unfocusedBorderThickness);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void OutlinedTextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional kotlin.jvm.functions.Function0<kotlin.Unit> border);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void TextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void FilledContainerBox(boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional androidx.compose.ui.graphics.Shape shape);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void OutlinedBorderContainerBox(boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional androidx.compose.ui.graphics.Shape shape, optional float focusedBorderThickness, optional float unfocusedBorderThickness);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void OutlinedTextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional kotlin.jvm.functions.Function0<kotlin.Unit> container);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public void TextFieldDecorationBox(String value, kotlin.jvm.functions.Function0<kotlin.Unit> innerTextField, boolean enabled, boolean singleLine, androidx.compose.ui.text.input.VisualTransformation visualTransformation, androidx.compose.foundation.interaction.InteractionSource interactionSource, optional boolean isError, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional kotlin.jvm.functions.Function0<kotlin.Unit> container);
     method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getFilledShape();
     method public float getFocusedBorderThickness();
     method public float getMinHeight();
@@ -871,9 +872,9 @@
     method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getOutlinedShape();
     method public float getUnfocusedBorderThickness();
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.ui.Modifier indicatorLine(androidx.compose.ui.Modifier, boolean enabled, boolean isError, androidx.compose.foundation.interaction.InteractionSource interactionSource, androidx.compose.material3.TextFieldColors colors, optional float focusedIndicatorLineThickness, optional float unfocusedIndicatorLineThickness);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors outlinedTextFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedBorderColor, optional long unfocusedBorderColor, optional long disabledBorderColor, optional long errorBorderColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors outlinedTextFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedBorderColor, optional long unfocusedBorderColor, optional long disabledBorderColor, optional long errorBorderColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor, optional long focusedSupportingTextColor, optional long unfocusedSupportingTextColor, optional long disabledSupportingTextColor, optional long errorSupportingTextColor);
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.foundation.layout.PaddingValues outlinedTextFieldPadding(optional float start, optional float top, optional float end, optional float bottom);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors textFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedIndicatorColor, optional long unfocusedIndicatorColor, optional long disabledIndicatorColor, optional long errorIndicatorColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public androidx.compose.material3.TextFieldColors textFieldColors(optional long textColor, optional long disabledTextColor, optional long containerColor, optional long cursorColor, optional long errorCursorColor, optional androidx.compose.foundation.text.selection.TextSelectionColors selectionColors, optional long focusedIndicatorColor, optional long unfocusedIndicatorColor, optional long disabledIndicatorColor, optional long errorIndicatorColor, optional long focusedLeadingIconColor, optional long unfocusedLeadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long focusedTrailingIconColor, optional long unfocusedTrailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor, optional long focusedSupportingTextColor, optional long unfocusedSupportingTextColor, optional long disabledSupportingTextColor, optional long errorSupportingTextColor);
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.foundation.layout.PaddingValues textFieldWithLabelPadding(optional float start, optional float end, optional float top, optional float bottom);
     method @androidx.compose.material3.ExperimentalMaterial3Api public androidx.compose.foundation.layout.PaddingValues textFieldWithoutLabelPadding(optional float start, optional float top, optional float end, optional float bottom);
     property public final float FocusedBorderThickness;
@@ -892,8 +893,8 @@
   }
 
   public final class TextFieldKt {
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
-    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
+    method @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void TextField(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0<kotlin.Unit>? label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? placeholder, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional boolean isError, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.TextFieldColors colors);
   }
 
   public final class TextKt {
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TextFieldSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TextFieldSamples.kt
index a26aea6..9030eac 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TextFieldSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TextFieldSamples.kt
@@ -19,12 +19,9 @@
 package androidx.compose.material3.samples
 
 import androidx.annotation.Sampled
-import androidx.compose.foundation.background
 import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
@@ -36,12 +33,10 @@
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextField
 import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.material3.TextFieldDefaults.indicatorLine
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -50,7 +45,6 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
@@ -63,6 +57,7 @@
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 
 @Sampled
@@ -120,38 +115,37 @@
 @Sampled
 @Composable
 fun TextFieldWithErrorState() {
-    val errorMessage = "Email format is invalid"
+    val errorMessage = "Text input too long"
     var text by rememberSaveable { mutableStateOf("") }
     var isError by rememberSaveable { mutableStateOf(false) }
+    val charLimit = 10
 
     fun validate(text: String) {
-        isError = !text.contains('@')
+        isError = text.length > charLimit
     }
 
-    Column {
-        TextField(
-            value = text,
-            onValueChange = {
-                text = it
-                isError = false
-            },
-            singleLine = true,
-            label = { Text(if (isError) "Email*" else "Email") },
-            isError = isError,
-            keyboardActions = KeyboardActions { validate(text) },
-            modifier = Modifier.semantics {
-                // Provide localized description of the error
-                if (isError) error(errorMessage)
-            }
-        )
-        // Supporting text for error message.
-        Text(
-            text = errorMessage,
-            color = MaterialTheme.colorScheme.error,
-            style = MaterialTheme.typography.bodySmall,
-            modifier = Modifier.padding(start = 16.dp, top = 4.dp).alpha(if (isError) 1f else 0f)
-        )
-    }
+    TextField(
+        value = text,
+        onValueChange = {
+            text = it
+            validate(text)
+        },
+        singleLine = true,
+        label = { Text(if (isError) "Username*" else "Username") },
+        supportingText = {
+            Text(
+                modifier = Modifier.fillMaxWidth(),
+                text = "Limit: ${text.length}/$charLimit",
+                textAlign = TextAlign.End,
+            )
+        },
+        isError = isError,
+        keyboardActions = KeyboardActions { validate(text) },
+        modifier = Modifier.semantics {
+            // Provide localized description of the error
+            if (isError) error(errorMessage)
+        }
+    )
 }
 
 @Sampled
@@ -159,19 +153,14 @@
 fun TextFieldWithSupportingText() {
     var text by rememberSaveable { mutableStateOf("") }
 
-    Column {
-        TextField(
-            value = text,
-            onValueChange = { text = it },
-            label = { Text("Label") }
-        )
-        Text(
-            text = "Supporting text",
-            color = MaterialTheme.colorScheme.onSurface,
-            style = MaterialTheme.typography.bodySmall,
-            modifier = Modifier.padding(start = 16.dp, top = 4.dp)
-        )
-    }
+    TextField(
+        value = text,
+        onValueChange = { text = it },
+        label = { Text("Label") },
+        supportingText = {
+            Text("Supporting text that is long and perhaps goes onto another line.")
+        },
+    )
 }
 
 @Sampled
@@ -366,26 +355,10 @@
         val singleLine = true
         val passwordTransformation = PasswordVisualTransformation()
 
-        val colors = TextFieldDefaults.textFieldColors()
         BasicTextField(
             value = value,
             onValueChange = onValueChange,
-            modifier = modifier
-                .background(
-                    color = MaterialTheme.colorScheme.surfaceVariant,
-                    shape = RoundedCornerShape(
-                        topStart = 4.0.dp,
-                        topEnd = 4.0.dp,
-                        bottomEnd = 0.0.dp,
-                        bottomStart = 0.0.dp
-                    )
-                )
-                .indicatorLine(
-                    enabled = enabled,
-                    isError = false,
-                    interactionSource = interactionSource,
-                    colors = colors
-                ),
+            modifier = modifier,
             visualTransformation = passwordTransformation,
             // internal implementation of the BasicTextField will dispatch focus events
             interactionSource = interactionSource,
@@ -401,10 +374,11 @@
                 // same interaction source as the one passed to BasicTextField to read focus state
                 // for text field styling
                 interactionSource = interactionSource,
-                // keep vertical paddings but change the horizontal
+                supportingText = { Text("Supporting text") },
+                // keep horizontal paddings but change the vertical
                 contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding(
-                    start = 8.dp, end = 8.dp
-                )
+                    top = 8.dp, bottom = 8.dp
+                ),
             )
         }
     }
@@ -448,13 +422,16 @@
                 // same interaction source as the one passed to BasicTextField to read focus state
                 // for text field styling
                 interactionSource = interactionSource,
-                // keep vertical paddings but change the horizontal
+                supportingText = { Text("Supporting text") },
+                // keep horizontal paddings but change the vertical
                 contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding(
-                    start = 8.dp, end = 8.dp
+                    top = 8.dp, bottom = 8.dp
                 ),
+                // update border colors
+                colors = colors,
                 // update border thickness and shape
-                border = {
-                    TextFieldDefaults.BorderBox(
+                container = {
+                    TextFieldDefaults.OutlinedBorderContainerBox(
                         enabled = enabled,
                         isError = false,
                         colors = colors,
@@ -464,8 +441,6 @@
                         focusedBorderThickness = 4.dp
                     )
                 },
-                // update border colors
-                colors = colors
             )
         }
     }
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt
index cccb678..938e4be 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldScreenshotTest.kt
@@ -534,6 +534,37 @@
     }
 
     @Test
+    fun outlinedTextField_supportingText() {
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                value = "",
+                onValueChange = {},
+                modifier = Modifier.testTag(TextFieldTag).fillMaxWidth(),
+                singleLine = true,
+                supportingText = { Text("Supporting text") }
+            )
+        }
+
+        assertAgainstGolden("outlinedTextField_supportingText")
+    }
+
+    @Test
+    fun outlinedTextField_errorSupportingText() {
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                value = "",
+                onValueChange = {},
+                isError = true,
+                modifier = Modifier.testTag(TextFieldTag).fillMaxWidth(),
+                singleLine = true,
+                supportingText = { Text("Error supporting text") }
+            )
+        }
+
+        assertAgainstGolden("outlinedTextField_errorSupportingText")
+    }
+
+    @Test
     fun outlinedTextField_leadingTrailingIcons() {
         rule.setMaterialContent(lightColorScheme()) {
             OutlinedTextField(
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt
index f7edf54..d462ea1 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/OutlinedTextFieldTest.kt
@@ -789,8 +789,7 @@
         rule.runOnIdleWithDensity {
             val iconSize = 24.dp // default icon size
             assertThat(labelPosition.value?.x).isEqualTo(
-                (ExpectedPadding.roundToPx() + IconPadding.roundToPx() + iconSize.roundToPx())
-                    .toFloat()
+                (ExpectedPadding + IconPadding + iconSize).roundToPx().toFloat()
             )
         }
     }
@@ -862,6 +861,112 @@
     }
 
     @Test
+    fun testOutlinedTextField_supportingText_position() {
+        val tfSize = Ref<IntSize>()
+        val supportingSize = Ref<IntSize>()
+        val supportingPosition = Ref<Offset>()
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                value = "",
+                onValueChange = {},
+                modifier = Modifier.onGloballyPositioned {
+                    tfSize.value = it.size
+                },
+                supportingText = {
+                    Text(
+                        text = "Supporting",
+                        modifier = Modifier.onGloballyPositioned {
+                            supportingSize.value = it.size
+                            supportingPosition.value = it.positionInRoot()
+                        }
+                    )
+                }
+            )
+        }
+
+        rule.runOnIdleWithDensity {
+            assertThat(supportingPosition.value?.x).isEqualTo(
+                ExpectedPadding.roundToPx().toFloat()
+            )
+            assertThat(supportingPosition.value?.y).isEqualTo(
+                tfSize.value!!.height - supportingSize.value!!.height
+            )
+        }
+    }
+
+    @Test
+    fun testOutlinedTextField_supportingText_contributesToTextFieldMeasurements() {
+        val tfSize = Ref<IntSize>()
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                value = "",
+                onValueChange = {},
+                modifier = Modifier.onGloballyPositioned {
+                    tfSize.value = it.size
+                },
+                supportingText = { Text("Supporting") }
+            )
+        }
+
+        rule.runOnIdleWithDensity {
+            assertThat(tfSize.value!!.height).isGreaterThan(
+                ExpectedMinimumTextFieldHeight.roundToPx()
+            )
+        }
+    }
+
+    @Test
+    fun testOutlinedTextField_supportingText_clickFocusesTextField() {
+        var focused = false
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                modifier = Modifier.onFocusChanged { focused = it.isFocused },
+                value = "input",
+                onValueChange = {},
+                supportingText = { Text("Supporting") }
+            )
+        }
+
+        rule.onNodeWithText("Supporting").performClick()
+        rule.runOnIdle {
+            assertThat(focused).isTrue()
+        }
+    }
+
+    @Test
+    fun testOutlinedTextField_supportingText_colorAndStyle() {
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                value = "",
+                onValueChange = {},
+                supportingText = {
+                    assertThat(LocalTextStyle.current)
+                        .isEqualTo(MaterialTheme.typography.bodySmall)
+                    assertThat(LocalContentColor.current)
+                        .isEqualTo(MaterialTheme.colorScheme.onSurfaceVariant)
+                }
+            )
+        }
+    }
+
+    @Test
+    fun testOutlinedTextField_supportingText_error_colorAndStyle() {
+        rule.setMaterialContent(lightColorScheme()) {
+            OutlinedTextField(
+                value = "",
+                onValueChange = {},
+                isError = true,
+                supportingText = {
+                    assertThat(LocalTextStyle.current)
+                        .isEqualTo(MaterialTheme.typography.bodySmall)
+                    assertThat(LocalContentColor.current)
+                        .isEqualTo(MaterialTheme.colorScheme.error)
+                }
+            )
+        }
+    }
+
+    @Test
     fun testOutlinedTextField_imeActionAndKeyboardTypePropagatedDownstream() {
         val platformTextInputService = mock<PlatformTextInputService>()
         val textInputService = TextInputService(platformTextInputService)
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldDecorationBoxTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldDecorationBoxTest.kt
index 7290767..9e8f387 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldDecorationBoxTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldDecorationBoxTest.kt
@@ -355,8 +355,8 @@
                         visualTransformation = VisualTransformation.None,
                         interactionSource = interactionSource,
                         singleLine = singleLine,
-                        border = {
-                            TextFieldDefaults.BorderBox(
+                        container = {
+                            TextFieldDefaults.OutlinedBorderContainerBox(
                                 enabled = true,
                                 isError = false,
                                 colors = colors,
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldScreenshotTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldScreenshotTest.kt
index e502bd0..e10e87c 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldScreenshotTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldScreenshotTest.kt
@@ -525,6 +525,37 @@
     }
 
     @Test
+    fun textField_supportingText() {
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                value = "",
+                onValueChange = {},
+                modifier = Modifier.testTag(TextFieldTag).fillMaxWidth(),
+                singleLine = true,
+                supportingText = { Text("Supporting text") }
+            )
+        }
+
+        assertAgainstGolden("textField_supportingText")
+    }
+
+    @Test
+    fun textField_errorSupportingText() {
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                value = "",
+                onValueChange = {},
+                isError = true,
+                modifier = Modifier.testTag(TextFieldTag).fillMaxWidth(),
+                singleLine = true,
+                supportingText = { Text("Error supporting text") }
+            )
+        }
+
+        assertAgainstGolden("textField_errorSupportingText")
+    }
+
+    @Test
     fun textField_leadingTrailingIcons() {
         rule.setMaterialContent(lightColorScheme()) {
             TextField(
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldTest.kt
index 6da32cb..e8b7570 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TextFieldTest.kt
@@ -50,6 +50,7 @@
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.focus.focusTarget
+import androidx.compose.ui.focus.onFocusChanged
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
@@ -896,8 +897,7 @@
         rule.runOnIdleWithDensity {
             val iconSize = 24.dp // default icon size
             assertThat(labelPosition.value?.x).isEqualTo(
-                (ExpectedPadding.roundToPx() + IconPadding.roundToPx() + iconSize.roundToPx())
-                    .toFloat()
+                (ExpectedPadding + IconPadding + iconSize).roundToPx().toFloat()
             )
         }
     }
@@ -971,6 +971,112 @@
     }
 
     @Test
+    fun testTextField_supportingText_position() {
+        val tfSize = Ref<IntSize>()
+        val supportingSize = Ref<IntSize>()
+        val supportingPosition = Ref<Offset>()
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                value = "",
+                onValueChange = {},
+                modifier = Modifier.onGloballyPositioned {
+                    tfSize.value = it.size
+                },
+                supportingText = {
+                    Text(
+                        text = "Supporting",
+                        modifier = Modifier.onGloballyPositioned {
+                            supportingSize.value = it.size
+                            supportingPosition.value = it.positionInRoot()
+                        }
+                    )
+                }
+            )
+        }
+
+        rule.runOnIdleWithDensity {
+            assertThat(supportingPosition.value?.x).isEqualTo(
+                ExpectedPadding.roundToPx().toFloat()
+            )
+            assertThat(supportingPosition.value?.y).isEqualTo(
+                tfSize.value!!.height - supportingSize.value!!.height
+            )
+        }
+    }
+
+    @Test
+    fun testTextField_supportingText_contributesToTextFieldMeasurements() {
+        val tfSize = Ref<IntSize>()
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                value = "",
+                onValueChange = {},
+                modifier = Modifier.onGloballyPositioned {
+                    tfSize.value = it.size
+                },
+                supportingText = { Text("Supporting") }
+            )
+        }
+
+        rule.runOnIdleWithDensity {
+            assertThat(tfSize.value!!.height).isGreaterThan(
+                ExpectedDefaultTextFieldHeight.roundToPx()
+            )
+        }
+    }
+
+    @Test
+    fun testTextField_supportingText_clickFocusesTextField() {
+        var focused = false
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                modifier = Modifier.onFocusChanged { focused = it.isFocused },
+                value = "input",
+                onValueChange = {},
+                supportingText = { Text("Supporting") }
+            )
+        }
+
+        rule.onNodeWithText("Supporting").performClick()
+        rule.runOnIdle {
+            assertThat(focused).isTrue()
+        }
+    }
+
+    @Test
+    fun testTextField_supportingText_colorAndStyle() {
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                value = "",
+                onValueChange = {},
+                supportingText = {
+                    assertThat(LocalTextStyle.current)
+                        .isEqualTo(MaterialTheme.typography.bodySmall)
+                    assertThat(LocalContentColor.current)
+                        .isEqualTo(MaterialTheme.colorScheme.onSurfaceVariant)
+                }
+            )
+        }
+    }
+
+    @Test
+    fun testTextField_supportingText_error_colorAndStyle() {
+        rule.setMaterialContent(lightColorScheme()) {
+            TextField(
+                value = "",
+                onValueChange = {},
+                isError = true,
+                supportingText = {
+                    assertThat(LocalTextStyle.current)
+                        .isEqualTo(MaterialTheme.typography.bodySmall)
+                    assertThat(LocalContentColor.current)
+                        .isEqualTo(MaterialTheme.colorScheme.error)
+                }
+            )
+        }
+    }
+
+    @Test
     fun testTextField_imeActionAndKeyboardTypePropagatedDownstream() {
         val platformTextInputService = mock<PlatformTextInputService>()
         val textInputService = TextInputService(platformTextInputService)
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/OutlinedTextField.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/OutlinedTextField.kt
index e4c464f..3159618 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/OutlinedTextField.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/OutlinedTextField.kt
@@ -16,7 +16,6 @@
 
 package androidx.compose.material3
 
-import androidx.compose.foundation.background
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
@@ -103,6 +102,7 @@
  * container
  * @param trailingIcon the optional trailing icon to be displayed at the end of the text field
  * container
+ * @param supportingText the optional supporting text to be displayed below the text field
  * @param isError indicates if the text field's current value is in error. If set to true, the
  * label, bottom indicator and trailing icon by default will be displayed in error color
  * @param visualTransformation transforms the visual representation of the input [value]
@@ -141,6 +141,7 @@
     placeholder: @Composable (() -> Unit)? = null,
     leadingIcon: @Composable (() -> Unit)? = null,
     trailingIcon: @Composable (() -> Unit)? = null,
+    supportingText: @Composable (() -> Unit)? = null,
     isError: Boolean = false,
     visualTransformation: VisualTransformation = VisualTransformation.None,
     keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
@@ -170,7 +171,6 @@
             } else {
                 modifier
             }
-                .background(colors.containerColor().value, shape)
                 .defaultMinSize(
                     minWidth = TextFieldDefaults.MinWidth,
                     minHeight = TextFieldDefaults.MinHeight
@@ -195,13 +195,14 @@
                     label = label,
                     leadingIcon = leadingIcon,
                     trailingIcon = trailingIcon,
+                    supportingText = supportingText,
                     singleLine = singleLine,
                     enabled = enabled,
                     isError = isError,
                     interactionSource = interactionSource,
                     colors = colors,
-                    border = {
-                        TextFieldDefaults.BorderBox(
+                    container = {
+                        TextFieldDefaults.OutlinedBorderContainerBox(
                             enabled,
                             isError,
                             interactionSource,
@@ -252,6 +253,7 @@
  * container
  * @param trailingIcon the optional trailing icon to be displayed at the end of the text field
  * container
+ * @param supportingText the optional supporting text to be displayed below the text field
  * @param isError indicates if the text field's current value is in error state. If set to
  * true, the label, bottom indicator and trailing icon by default will be displayed in error color
  * @param visualTransformation transforms the visual representation of the input [value]
@@ -290,6 +292,7 @@
     placeholder: @Composable (() -> Unit)? = null,
     leadingIcon: @Composable (() -> Unit)? = null,
     trailingIcon: @Composable (() -> Unit)? = null,
+    supportingText: @Composable (() -> Unit)? = null,
     isError: Boolean = false,
     visualTransformation: VisualTransformation = VisualTransformation.None,
     keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
@@ -319,7 +322,6 @@
             } else {
                 modifier
             }
-                .background(colors.containerColor().value, shape)
                 .defaultMinSize(
                     minWidth = TextFieldDefaults.MinWidth,
                     minHeight = TextFieldDefaults.MinHeight
@@ -344,13 +346,14 @@
                     label = label,
                     leadingIcon = leadingIcon,
                     trailingIcon = trailingIcon,
+                    supportingText = supportingText,
                     singleLine = singleLine,
                     enabled = enabled,
                     isError = isError,
                     interactionSource = interactionSource,
                     colors = colors,
-                    border = {
-                        TextFieldDefaults.BorderBox(
+                    container = {
+                        TextFieldDefaults.OutlinedBorderContainerBox(
                             enabled,
                             isError,
                             interactionSource,
@@ -369,8 +372,9 @@
  * [OutlinedTextField].
  * It doesn't use Row to position the icons and middle part because label should not be
  * positioned in the middle part.
-\ */
+ */
 @Composable
+@ExperimentalMaterial3Api
 internal fun OutlinedTextFieldLayout(
     modifier: Modifier,
     textField: @Composable () -> Unit,
@@ -381,7 +385,8 @@
     singleLine: Boolean,
     animationProgress: Float,
     onLabelMeasured: (Size) -> Unit,
-    border: @Composable () -> Unit,
+    container: @Composable () -> Unit,
+    supporting: @Composable (() -> Unit)?,
     paddingValues: PaddingValues
 ) {
     val measurePolicy = remember(onLabelMeasured, singleLine, animationProgress, paddingValues) {
@@ -396,12 +401,7 @@
     Layout(
         modifier = modifier,
         content = {
-            // We use additional box here to place an outlined cutout border as a sibling after the
-            // rest of UI. This allows us to use Modifier.border to draw an outline on top of the
-            // text field. We can't use the border modifier directly on the IconsWithTextFieldLayout
-            // as we also need to do the clipping (to form the cutout) which should not affect
-            // the rest of text field UI
-            border()
+            container()
 
             if (leading != null) {
                 Box(
@@ -450,6 +450,13 @@
             if (label != null) {
                 Box(modifier = Modifier.layoutId(LabelId)) { label() }
             }
+
+            if (supporting != null) {
+                Box(Modifier
+                    .layoutId(SupportingId)
+                    .padding(TextFieldDefaults.supportingTextPadding())
+                ) { supporting() }
+            }
         },
         measurePolicy = measurePolicy
     )
@@ -465,18 +472,20 @@
         measurables: List<Measurable>,
         constraints: Constraints
     ): MeasureResult {
-        // used to calculate the constraints for measuring elements that will be placed in a row
         var occupiedSpaceHorizontally = 0
+        var occupiedSpaceVertically = 0
         val bottomPadding = paddingValues.calculateBottomPadding().roundToPx()
 
-        // measure leading icon
         val relaxedConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+        // measure leading icon
         val leadingPlaceable = measurables.find {
             it.layoutId == LeadingId
         }?.measure(relaxedConstraints)
         occupiedSpaceHorizontally += widthOrZero(
             leadingPlaceable
         )
+        occupiedSpaceVertically = max(occupiedSpaceVertically, heightOrZero(leadingPlaceable))
 
         // measure trailing icon
         val trailingPlaceable = measurables.find { it.layoutId == TrailingId }
@@ -484,6 +493,7 @@
         occupiedSpaceHorizontally += widthOrZero(
             trailingPlaceable
         )
+        occupiedSpaceVertically = max(occupiedSpaceVertically, heightOrZero(trailingPlaceable))
 
         // measure label
         val labelConstraints = relaxedConstraints.offset(
@@ -499,8 +509,9 @@
         }
 
         // measure text field
-        // on top we offset either by default padding or by label's half height if its too big
-        // minHeight must not be set to 0 due to how foundation TextField treats zero minHeight
+        // On top, we offset either by default padding or by label's half height if its too big.
+        // On bottom, we offset to make room for supporting text.
+        // minHeight must not be set to 0 due to how foundation TextField treats zero minHeight.
         val topPadding = max(
             heightOrZero(labelPlaceable) / 2,
             paddingValues.calculateTopPadding().roundToPx()
@@ -517,6 +528,20 @@
         val placeholderPlaceable =
             measurables.find { it.layoutId == PlaceholderId }?.measure(placeholderConstraints)
 
+        occupiedSpaceVertically = max(
+            occupiedSpaceVertically,
+            max(heightOrZero(textFieldPlaceable), heightOrZero(placeholderPlaceable)) +
+                topPadding + bottomPadding
+        )
+
+        // measure supporting text
+        val supportingConstraints = relaxedConstraints.offset(
+            vertical = -occupiedSpaceVertically
+        ).copy(minHeight = 0)
+        val supportingPlaceable =
+            measurables.find { it.layoutId == SupportingId }?.measure(supportingConstraints)
+        val supportingHeight = heightOrZero(supportingPlaceable)
+
         val width =
             calculateWidth(
                 widthOrZero(leadingPlaceable),
@@ -526,19 +551,21 @@
                 widthOrZero(placeholderPlaceable),
                 constraints
             )
-        val height =
+        val totalHeight =
             calculateHeight(
                 heightOrZero(leadingPlaceable),
                 heightOrZero(trailingPlaceable),
                 textFieldPlaceable.height,
                 heightOrZero(labelPlaceable),
                 heightOrZero(placeholderPlaceable),
+                heightOrZero(supportingPlaceable),
                 constraints,
                 density,
                 paddingValues
             )
+        val height = totalHeight - supportingHeight
 
-        val borderPlaceable = measurables.first { it.layoutId == BorderId }.measure(
+        val containerPlaceable = measurables.first { it.layoutId == ContainerId }.measure(
             Constraints(
                 minWidth = if (width != Constraints.Infinity) width else 0,
                 maxWidth = width,
@@ -546,16 +573,17 @@
                 maxHeight = height
             )
         )
-        return layout(width, height) {
+        return layout(width, totalHeight) {
             place(
-                height,
+                totalHeight,
                 width,
                 leadingPlaceable,
                 trailingPlaceable,
                 textFieldPlaceable,
                 labelPlaceable,
                 placeholderPlaceable,
-                borderPlaceable,
+                containerPlaceable,
+                supportingPlaceable,
                 animationProgress,
                 singleLine,
                 density,
@@ -649,12 +677,16 @@
         val placeholderHeight = measurables.find { it.layoutId == PlaceholderId }?.let {
             intrinsicMeasurer(it, width)
         } ?: 0
+        val supportingHeight = measurables.find { it.layoutId == SupportingId }?.let {
+            intrinsicMeasurer(it, width)
+        } ?: 0
         return calculateHeight(
             leadingPlaceableHeight = leadingHeight,
             trailingPlaceableHeight = trailingHeight,
             textFieldPlaceableHeight = textFieldHeight,
             labelPlaceableHeight = labelHeight,
             placeholderPlaceableHeight = placeholderHeight,
+            supportingPlaceableHeight = supportingHeight,
             constraints = ZeroConstraints,
             density = density,
             paddingValues = paddingValues
@@ -685,6 +717,8 @@
 
 /**
  * Calculate the height of the [OutlinedTextField] given all elements that should be placed inside.
+ * This includes the supporting text, if it exists, even though this element is not "visually"
+ * inside the text field.
  */
 private fun calculateHeight(
     leadingPlaceableHeight: Int,
@@ -692,11 +726,12 @@
     textFieldPlaceableHeight: Int,
     labelPlaceableHeight: Int,
     placeholderPlaceableHeight: Int,
+    supportingPlaceableHeight: Int,
     constraints: Constraints,
     density: Float,
     paddingValues: PaddingValues
 ): Int {
-    // middle section is defined as a height of the text field or placeholder ( whichever is
+    // middle section is defined as a height of the text field or placeholder (whichever is
     // taller) plus 16.dp or half height of the label if it is taller, given that the label
     // is vertically centered to the top edge of the resulting text field's container
     val inputFieldHeight = max(
@@ -715,7 +750,7 @@
             leadingPlaceableHeight,
             trailingPlaceableHeight,
             middleSectionHeight.roundToInt()
-        )
+        ) + supportingPlaceableHeight
     )
 }
 
@@ -724,20 +759,27 @@
  * the [OutlinedTextField]
  */
 private fun Placeable.PlacementScope.place(
-    height: Int,
+    totalHeight: Int,
     width: Int,
     leadingPlaceable: Placeable?,
     trailingPlaceable: Placeable?,
     textFieldPlaceable: Placeable,
     labelPlaceable: Placeable?,
     placeholderPlaceable: Placeable?,
-    borderPlaceable: Placeable,
+    containerPlaceable: Placeable,
+    supportingPlaceable: Placeable?,
     animationProgress: Float,
     singleLine: Boolean,
     density: Float,
     layoutDirection: LayoutDirection,
     paddingValues: PaddingValues
 ) {
+    // place container
+    containerPlaceable.place(IntOffset.Zero)
+
+    // Most elements should be positioned w.r.t the text field's "visual" height, i.e., excluding
+    // the supporting text on bottom
+    val height = totalHeight - heightOrZero(supportingPlaceable)
     val topPadding = (paddingValues.calculateTopPadding().value * density).roundToInt()
     val startPadding =
         (paddingValues.calculateStartPadding(layoutDirection).value * density).roundToInt()
@@ -798,8 +840,8 @@
         it.placeRelative(widthOrZero(leadingPlaceable), placeholderVerticalPosition)
     }
 
-    // place border
-    borderPlaceable.place(IntOffset.Zero)
+    // place supporting text
+    supportingPlaceable?.placeRelative(0, height)
 }
 
 internal fun Modifier.outlineCutout(labelSize: Size, paddingValues: PaddingValues) =
@@ -836,6 +878,4 @@
 need to add additional padding themselves
 */
 /* @VisibleForTesting */
-internal val OutlinedTextFieldTopPadding = 8.dp
-
-internal const val BorderId = "border"
\ No newline at end of file
+internal val OutlinedTextFieldTopPadding = 8.dp
\ No newline at end of file
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt
index 2c17d6c..f33b5bc 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt
@@ -17,7 +17,6 @@
 package androidx.compose.material3
 
 import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.background
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
@@ -30,7 +29,6 @@
 import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.foundation.text.selection.LocalTextSelectionColors
-import androidx.compose.material3.TextFieldDefaults.indicatorLine
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
@@ -60,6 +58,7 @@
 import androidx.compose.ui.text.input.VisualTransformation
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.coerceAtLeast
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.offset
@@ -128,6 +127,7 @@
  * container
  * @param trailingIcon the optional trailing icon to be displayed at the end of the text field
  * container
+ * @param supportingText the optional supporting text to be displayed below the text field
  * @param isError indicates if the text field's current value is in error. If set to true, the
  * label, bottom indicator and trailing icon by default will be displayed in error color
  * @param visualTransformation transforms the visual representation of the input [value]
@@ -166,6 +166,7 @@
     placeholder: @Composable (() -> Unit)? = null,
     leadingIcon: @Composable (() -> Unit)? = null,
     trailingIcon: @Composable (() -> Unit)? = null,
+    supportingText: @Composable (() -> Unit)? = null,
     isError: Boolean = false,
     visualTransformation: VisualTransformation = VisualTransformation.None,
     keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
@@ -187,8 +188,6 @@
         BasicTextField(
             value = value,
             modifier = modifier
-                .background(colors.containerColor().value, shape)
-                .indicatorLine(enabled, isError, interactionSource, colors)
                 .defaultMinSize(
                     minWidth = TextFieldDefaults.MinWidth,
                     minHeight = TextFieldDefaults.MinHeight
@@ -214,6 +213,8 @@
                     label = label,
                     leadingIcon = leadingIcon,
                     trailingIcon = trailingIcon,
+                    supportingText = supportingText,
+                    shape = shape,
                     singleLine = singleLine,
                     enabled = enabled,
                     isError = isError,
@@ -263,6 +264,7 @@
  * container
  * @param trailingIcon the optional trailing icon to be displayed at the end of the text field
  * container
+ * @param supportingText the optional supporting text to be displayed below the text field
  * @param isError indicates if the text field's current value is in error state. If set to
  * true, the label, bottom indicator and trailing icon by default will be displayed in error color
  * @param visualTransformation transforms the visual representation of the input [value].
@@ -301,6 +303,7 @@
     placeholder: @Composable (() -> Unit)? = null,
     leadingIcon: @Composable (() -> Unit)? = null,
     trailingIcon: @Composable (() -> Unit)? = null,
+    supportingText: @Composable (() -> Unit)? = null,
     isError: Boolean = false,
     visualTransformation: VisualTransformation = VisualTransformation.None,
     keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
@@ -322,8 +325,6 @@
         BasicTextField(
             value = value,
             modifier = modifier
-                .background(colors.containerColor().value, shape)
-                .indicatorLine(enabled, isError, interactionSource, colors)
                 .defaultMinSize(
                     minWidth = TextFieldDefaults.MinWidth,
                     minHeight = TextFieldDefaults.MinHeight
@@ -349,6 +350,8 @@
                     label = label,
                     leadingIcon = leadingIcon,
                     trailingIcon = trailingIcon,
+                    supportingText = supportingText,
+                    shape = shape,
                     singleLine = singleLine,
                     enabled = enabled,
                     isError = isError,
@@ -365,6 +368,7 @@
  * placeholder and the input field.
  */
 @Composable
+@ExperimentalMaterial3Api
 internal fun TextFieldLayout(
     modifier: Modifier,
     textField: @Composable () -> Unit,
@@ -374,6 +378,8 @@
     trailing: @Composable (() -> Unit)?,
     singleLine: Boolean,
     animationProgress: Float,
+    container: @Composable () -> Unit,
+    supporting: @Composable (() -> Unit)?,
     paddingValues: PaddingValues
 ) {
     val measurePolicy = remember(singleLine, animationProgress, paddingValues) {
@@ -383,6 +389,11 @@
     Layout(
         modifier = modifier,
         content = {
+            // The container is given as a Composable instead of a background modifier so that
+            // elements like supporting text can be placed outside of it while still contributing
+            // to the text field's measurements overall.
+            container()
+
             if (leading != null) {
                 Box(
                     modifier = Modifier
@@ -440,6 +451,13 @@
             ) {
                 textField()
             }
+
+            if (supporting != null) {
+                Box(Modifier
+                    .layoutId(SupportingId)
+                    .padding(TextFieldDefaults.supportingTextPadding())
+                ) { supporting() }
+            }
         },
         measurePolicy = measurePolicy
     )
@@ -460,14 +478,17 @@
         // padding between label and input text
         val topPadding = TextFieldTopPadding.roundToPx()
         var occupiedSpaceHorizontally = 0
+        var occupiedSpaceVertically = 0
+
+        val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
 
         // measure leading icon
-        val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
         val leadingPlaceable =
             measurables.find { it.layoutId == LeadingId }?.measure(looseConstraints)
         occupiedSpaceHorizontally += widthOrZero(
             leadingPlaceable
         )
+        occupiedSpaceVertically = max(occupiedSpaceVertically, heightOrZero(leadingPlaceable))
 
         // measure trailing icon
         val trailingPlaceable = measurables.find { it.layoutId == TrailingId }
@@ -475,6 +496,7 @@
         occupiedSpaceHorizontally += widthOrZero(
             trailingPlaceable
         )
+        occupiedSpaceVertically = max(occupiedSpaceVertically, heightOrZero(trailingPlaceable))
 
         // measure label
         val labelConstraints = looseConstraints
@@ -491,11 +513,12 @@
 
         // measure input field
         // input field is laid out differently depending on whether the label is present or not
-        val verticalConstraintOffset = if (labelPlaceable != null) {
-            -bottomPaddingValue - topPadding - effectiveLabelBaseline
+        val effectiveTopOffset = if (labelPlaceable != null) {
+            effectiveLabelBaseline + topPadding
         } else {
-            -topPaddingValue - bottomPaddingValue
+            topPaddingValue
         }
+        val verticalConstraintOffset = -effectiveTopOffset - bottomPaddingValue
         val textFieldConstraints = constraints
             .copy(minHeight = 0)
             .offset(
@@ -512,6 +535,20 @@
             .find { it.layoutId == PlaceholderId }
             ?.measure(placeholderConstraints)
 
+        occupiedSpaceVertically = max(
+            occupiedSpaceVertically,
+            max(heightOrZero(textFieldPlaceable), heightOrZero(placeholderPlaceable)) +
+                effectiveTopOffset + bottomPaddingValue
+        )
+
+        // measure supporting text
+        val supportingConstraints = looseConstraints.offset(
+            vertical = -occupiedSpaceVertically
+        ).copy(minHeight = 0)
+        val supportingPlaceable =
+            measurables.find { it.layoutId == SupportingId }?.measure(supportingConstraints)
+        val supportingHeight = heightOrZero(supportingPlaceable)
+
         val width = calculateWidth(
             widthOrZero(leadingPlaceable),
             widthOrZero(trailingPlaceable),
@@ -520,30 +557,43 @@
             widthOrZero(placeholderPlaceable),
             constraints
         )
-        val height = calculateHeight(
+        val totalHeight = calculateHeight(
             textFieldPlaceable.height,
             labelPlaceable != null,
             effectiveLabelBaseline,
             heightOrZero(leadingPlaceable),
             heightOrZero(trailingPlaceable),
             heightOrZero(placeholderPlaceable),
+            heightOrZero(supportingPlaceable),
             constraints,
             density,
             paddingValues
         )
+        val height = totalHeight - supportingHeight
 
-        return layout(width, height) {
+        val containerPlaceable = measurables.first { it.layoutId == ContainerId }.measure(
+            Constraints(
+                minWidth = if (width != Constraints.Infinity) width else 0,
+                maxWidth = width,
+                minHeight = if (height != Constraints.Infinity) height else 0,
+                maxHeight = height
+            )
+        )
+
+        return layout(width, totalHeight) {
             if (labelPlaceable != null) {
                 // label's final position is always relative to the baseline
                 val labelEndPosition = (topPaddingValue - lastBaseline).coerceAtLeast(0)
                 placeWithLabel(
                     width,
-                    height,
+                    totalHeight,
                     textFieldPlaceable,
                     labelPlaceable,
                     placeholderPlaceable,
                     leadingPlaceable,
                     trailingPlaceable,
+                    containerPlaceable,
+                    supportingPlaceable,
                     singleLine,
                     labelEndPosition,
                     effectiveLabelBaseline + topPadding,
@@ -553,11 +603,13 @@
             } else {
                 placeWithoutLabel(
                     width,
-                    height,
+                    totalHeight,
                     textFieldPlaceable,
                     placeholderPlaceable,
                     leadingPlaceable,
                     trailingPlaceable,
+                    containerPlaceable,
+                    supportingPlaceable,
                     singleLine,
                     density,
                     paddingValues
@@ -650,6 +702,9 @@
         val placeholderHeight = measurables.find { it.layoutId == PlaceholderId }?.let {
             intrinsicMeasurer(it, width)
         } ?: 0
+        val supportingHeight = measurables.find { it.layoutId == SupportingId }?.let {
+            intrinsicMeasurer(it, width)
+        } ?: 0
         return calculateHeight(
             textFieldHeight = textFieldHeight,
             hasLabel = labelHeight > 0,
@@ -657,6 +712,7 @@
             leadingHeight = leadingHeight,
             trailingHeight = trailingHeight,
             placeholderHeight = placeholderHeight,
+            supportingHeight = supportingHeight,
             constraints = ZeroConstraints,
             density = density,
             paddingValues = paddingValues
@@ -688,6 +744,7 @@
     leadingHeight: Int,
     trailingHeight: Int,
     placeholderHeight: Int,
+    supportingHeight: Int,
     constraints: Constraints,
     density: Float,
     paddingValues: PaddingValues
@@ -702,10 +759,13 @@
     } else {
         topPaddingValue + inputFieldHeight + bottomPaddingValue
     }
-    return maxOf(
-        middleSectionHeight.roundToInt(),
-        max(leadingHeight, trailingHeight),
-        constraints.minHeight
+    return max(
+        constraints.minHeight,
+        maxOf(
+            leadingHeight,
+            trailingHeight,
+            middleSectionHeight.roundToInt()
+        ) + supportingHeight
     )
 }
 
@@ -715,18 +775,27 @@
  */
 private fun Placeable.PlacementScope.placeWithLabel(
     width: Int,
-    height: Int,
+    totalHeight: Int,
     textfieldPlaceable: Placeable,
     labelPlaceable: Placeable?,
     placeholderPlaceable: Placeable?,
     leadingPlaceable: Placeable?,
     trailingPlaceable: Placeable?,
+    containerPlaceable: Placeable,
+    supportingPlaceable: Placeable?,
     singleLine: Boolean,
     labelEndPosition: Int,
     textPosition: Int,
     animationProgress: Float,
     density: Float
 ) {
+    // place container
+    containerPlaceable.place(IntOffset.Zero)
+
+    // Most elements should be positioned w.r.t the text field's "visual" height, i.e., excluding
+    // the supporting text on bottom
+    val height = totalHeight - heightOrZero(supportingPlaceable)
+
     leadingPlaceable?.placeRelative(
         0,
         Alignment.CenterVertically.align(leadingPlaceable.height, height)
@@ -753,6 +822,8 @@
     }
     textfieldPlaceable.placeRelative(widthOrZero(leadingPlaceable), textPosition)
     placeholderPlaceable?.placeRelative(widthOrZero(leadingPlaceable), textPosition)
+
+    supportingPlaceable?.placeRelative(0, height)
 }
 
 /**
@@ -761,15 +832,23 @@
  */
 private fun Placeable.PlacementScope.placeWithoutLabel(
     width: Int,
-    height: Int,
+    totalHeight: Int,
     textPlaceable: Placeable,
     placeholderPlaceable: Placeable?,
     leadingPlaceable: Placeable?,
     trailingPlaceable: Placeable?,
+    containerPlaceable: Placeable,
+    supportingPlaceable: Placeable?,
     singleLine: Boolean,
     density: Float,
     paddingValues: PaddingValues
 ) {
+    // place container
+    containerPlaceable.place(IntOffset.Zero)
+
+    // Most elements should be positioned w.r.t the text field's "visual" height, i.e., excluding
+    // the supporting text on bottom
+    val height = totalHeight - heightOrZero(supportingPlaceable)
     val topPadding = (paddingValues.calculateTopPadding().value * density).roundToInt()
 
     leadingPlaceable?.placeRelative(
@@ -805,6 +884,8 @@
             placeholderVerticalPosition
         )
     }
+
+    supportingPlaceable?.placeRelative(0, height)
 }
 
 /**
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
index fcbd9af..0cb9f1e 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
@@ -20,6 +20,7 @@
 import androidx.compose.animation.core.animateDpAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.InteractionSource
@@ -86,6 +87,33 @@
     val FocusedBorderThickness = 2.dp
 
     /**
+     * Composable that draws a default container for the content of [TextField], with an indicator
+     * line at the bottom. You can use it to draw a container for your custom text field based on
+     * [TextFieldDecorationBox]. [TextField] applies it automatically.
+     *
+     * @param enabled whether the text field is enabled
+     * @param isError whether the text field's current value is in error
+     * @param interactionSource the [InteractionSource] of this text field. Helps to determine if
+     * the text field is in focus or not
+     * @param colors [TextFieldColors] used to resolve colors of the text field
+     * @param shape shape of the container
+     */
+    @ExperimentalMaterial3Api
+    @Composable
+    fun FilledContainerBox(
+        enabled: Boolean,
+        isError: Boolean,
+        interactionSource: InteractionSource,
+        colors: TextFieldColors,
+        shape: Shape = filledShape,
+    ) {
+        Box(
+            Modifier
+                .background(colors.containerColor().value, shape)
+                .indicatorLine(enabled, isError, interactionSource, colors))
+    }
+
+    /**
      * A modifier to draw a default bottom indicator line in [TextField]. You can use this modifier
      * if you build your custom text field using [TextFieldDecorationBox] whilst the [TextField]
      * applies it automatically.
@@ -129,9 +157,9 @@
     }
 
     /**
-     * Composable that draws a default border stroke in [OutlinedTextField]. You can use it to
-     * draw a border stroke in your custom text field based on [OutlinedTextFieldDecorationBox].
-     * The [OutlinedTextField] applies it automatically.
+     * Composable that draws a default container for [OutlinedTextField] with a border stroke. You
+     * can use it to draw a border stroke in your custom text field based on
+     * [OutlinedTextFieldDecorationBox]. The [OutlinedTextField] applies it automatically.
      *
      * @param enabled whether the text field is enabled
      * @param isError whether the text field's current value is in error
@@ -145,7 +173,7 @@
      */
     @ExperimentalMaterial3Api
     @Composable
-    fun BorderBox(
+    fun OutlinedBorderContainerBox(
         enabled: Boolean,
         isError: Boolean,
         interactionSource: InteractionSource,
@@ -162,7 +190,10 @@
             focusedBorderThickness,
             unfocusedBorderThickness
         )
-        Box(Modifier.border(borderStroke.value, shape))
+        Box(
+            Modifier
+                .border(borderStroke.value, shape)
+                .background(colors.containerColor().value, shape))
     }
 
     /**
@@ -208,6 +239,19 @@
     ): PaddingValues = PaddingValues(start, top, end, bottom)
 
     /**
+     * Default padding applied to supporting text for both [TextField] and [OutlinedTextField].
+     * See [PaddingValues] for more details.
+     */
+    // TODO(246775477): consider making this public
+    @ExperimentalMaterial3Api
+    internal fun supportingTextPadding(
+        start: Dp = TextFieldPadding,
+        top: Dp = SupportingTopPadding,
+        end: Dp = TextFieldPadding,
+        bottom: Dp = 0.dp,
+    ): PaddingValues = PaddingValues(start, top, end, bottom)
+
+    /**
      * Creates a [TextFieldColors] that represents the default input text, container, and content
      * (including label, placeholder, leading and trailing icons) colors used in a [TextField].
      *
@@ -236,6 +280,13 @@
      * @param errorLabelColor the label color for this text field when in error state
      * @param placeholderColor the placeholder color for this text field
      * @param disabledPlaceholderColor the placeholder color for this text field when disabled
+     * @param focusedSupportingTextColor the supporting text color for this text field when focused
+     * @param unfocusedSupportingTextColor the supporting text color for this text field when not
+     * focused
+     * @param disabledSupportingTextColor the supporting text color for this text field when
+     * disabled
+     * @param errorSupportingTextColor the supporting text color for this text field when in error
+     * state
      */
     @ExperimentalMaterial3Api
     @Composable
@@ -269,7 +320,12 @@
         errorLabelColor: Color = FilledTextFieldTokens.ErrorLabelColor.toColor(),
         placeholderColor: Color = FilledTextFieldTokens.InputPlaceholderColor.toColor(),
         disabledPlaceholderColor: Color = FilledTextFieldTokens.DisabledInputColor.toColor()
-            .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity)
+            .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
+        focusedSupportingTextColor: Color = FilledTextFieldTokens.FocusSupportingColor.toColor(),
+        unfocusedSupportingTextColor: Color = FilledTextFieldTokens.SupportingColor.toColor(),
+        disabledSupportingTextColor: Color = FilledTextFieldTokens.DisabledSupportingColor.toColor()
+            .copy(alpha = FilledTextFieldTokens.DisabledSupportingOpacity),
+        errorSupportingTextColor: Color = FilledTextFieldTokens.ErrorSupportingColor.toColor(),
     ): TextFieldColors =
         TextFieldColors(
             textColor = textColor,
@@ -295,7 +351,11 @@
             disabledLabelColor = disabledLabelColor,
             errorLabelColor = errorLabelColor,
             placeholderColor = placeholderColor,
-            disabledPlaceholderColor = disabledPlaceholderColor
+            disabledPlaceholderColor = disabledPlaceholderColor,
+            focusedSupportingTextColor = focusedSupportingTextColor,
+            unfocusedSupportingTextColor = unfocusedSupportingTextColor,
+            disabledSupportingTextColor = disabledSupportingTextColor,
+            errorSupportingTextColor = errorSupportingTextColor,
         )
 
     /**
@@ -327,6 +387,13 @@
      * @param errorLabelColor the label color for this text field when in error state
      * @param placeholderColor the placeholder color for this text field
      * @param disabledPlaceholderColor the placeholder color for this text field when disabled
+     * @param focusedSupportingTextColor the supporting text color for this text field when focused
+     * @param unfocusedSupportingTextColor the supporting text color for this text field when not
+     * focused
+     * @param disabledSupportingTextColor the supporting text color for this text field when
+     * disabled
+     * @param errorSupportingTextColor the supporting text color for this text field when in error
+     * state
      */
     @ExperimentalMaterial3Api
     @Composable
@@ -360,7 +427,12 @@
         errorLabelColor: Color = OutlinedTextFieldTokens.ErrorLabelColor.toColor(),
         placeholderColor: Color = OutlinedTextFieldTokens.InputPlaceholderColor.toColor(),
         disabledPlaceholderColor: Color = OutlinedTextFieldTokens.DisabledInputColor.toColor()
-            .copy(alpha = OutlinedTextFieldTokens.DisabledInputOpacity)
+            .copy(alpha = OutlinedTextFieldTokens.DisabledInputOpacity),
+        focusedSupportingTextColor: Color = OutlinedTextFieldTokens.FocusSupportingColor.toColor(),
+        unfocusedSupportingTextColor: Color = OutlinedTextFieldTokens.SupportingColor.toColor(),
+        disabledSupportingTextColor: Color = OutlinedTextFieldTokens.DisabledSupportingColor
+            .toColor().copy(alpha = OutlinedTextFieldTokens.DisabledSupportingOpacity),
+        errorSupportingTextColor: Color = OutlinedTextFieldTokens.ErrorSupportingColor.toColor(),
     ): TextFieldColors =
         TextFieldColors(
             textColor = textColor,
@@ -386,7 +458,11 @@
             disabledLabelColor = disabledLabelColor,
             errorLabelColor = errorLabelColor,
             placeholderColor = placeholderColor,
-            disabledPlaceholderColor = disabledPlaceholderColor
+            disabledPlaceholderColor = disabledPlaceholderColor,
+            focusedSupportingTextColor = focusedSupportingTextColor,
+            unfocusedSupportingTextColor = unfocusedSupportingTextColor,
+            disabledSupportingTextColor = disabledSupportingTextColor,
+            errorSupportingTextColor = errorSupportingTextColor,
         )
 
     /**
@@ -433,6 +509,7 @@
      * field container
      * @param trailingIcon the optional trailing icon to be displayed at the end of the text field
      * container
+     * @param supportingText the optional supporting text to be displayed below the text field
      * @param colors [TextFieldColors] that will be used to resolve the colors used for this text
      * field in different states. See [TextFieldDefaults.textFieldColors].
      * @param contentPadding the spacing values to apply internally between the internals of text
@@ -443,6 +520,8 @@
      * padding will mean the distance from label's [last baseline][LastBaseline] to the top edge of
      * the container. All other paddings mean the distance from the corresponding edge of the
      * container to the corresponding edge of the closest to it element
+     * @param container the container to be drawn behind the text field. By default, this includes
+     * the bottom indicator line. Default colors for the container come from the [colors].
      */
     @Composable
     @ExperimentalMaterial3Api
@@ -458,13 +537,18 @@
         placeholder: @Composable (() -> Unit)? = null,
         leadingIcon: @Composable (() -> Unit)? = null,
         trailingIcon: @Composable (() -> Unit)? = null,
+        supportingText: @Composable (() -> Unit)? = null,
+        shape: Shape = filledShape,
         colors: TextFieldColors = textFieldColors(),
         contentPadding: PaddingValues =
             if (label == null) {
                 textFieldWithoutLabelPadding()
             } else {
                 textFieldWithLabelPadding()
-            }
+            },
+        container: @Composable () -> Unit = {
+            FilledContainerBox(enabled, isError, interactionSource, colors, shape)
+        }
     ) {
         CommonDecorationBox(
             type = TextFieldType.Filled,
@@ -475,12 +559,14 @@
             label = label,
             leadingIcon = leadingIcon,
             trailingIcon = trailingIcon,
+            supportingText = supportingText,
             singleLine = singleLine,
             enabled = enabled,
             isError = isError,
             interactionSource = interactionSource,
             colors = colors,
-            contentPadding = contentPadding
+            contentPadding = contentPadding,
+            container = container
         )
     }
 
@@ -493,7 +579,7 @@
      *
      * For example, if you need to create a dense outlined text field, use [contentPadding]
      * parameter to decrease the paddings around the input field. If you need to change the
-     * thickness of the border, use [border] parameter to achieve that.
+     * thickness of the border, use [container] parameter to achieve that.
      *
      * Example of custom text field based on [OutlinedTextFieldDecorationBox]:
      * @sample androidx.compose.material3.samples.CustomOutlinedTextFieldBasedOnDecorationBox
@@ -528,14 +614,16 @@
      * field container
      * @param trailingIcon the optional trailing icon to be displayed at the end of the text field
      * container
+     * @param supportingText the optional supporting text to be displayed below the text field
      * @param colors [TextFieldColors] that will be used to resolve the colors used for this text
      * field in different states. See [TextFieldDefaults.outlinedTextFieldColors].
-     * @param border the border to be drawn around the text field. The cutout to fit the [label]
-     * will be automatically added by the framework. Note that by default the color of the border
-     * comes from the [colors].
      * @param contentPadding the spacing values to apply internally between the internals of text
      * field and the decoration box container. You can use it to implement dense text fields or
      * simply to control horizontal padding. See [TextFieldDefaults.outlinedTextFieldPadding].
+     * @param container the container to be drawn behind the text field. By default, this is
+     * transparent and only includes a border. The cutout in the border to fit the [label] will be
+     * automatically added by the framework. Note that by default the color of the border comes from
+     * the [colors].
      */
     @Composable
     @ExperimentalMaterial3Api
@@ -551,10 +639,11 @@
         placeholder: @Composable (() -> Unit)? = null,
         leadingIcon: @Composable (() -> Unit)? = null,
         trailingIcon: @Composable (() -> Unit)? = null,
+        supportingText: @Composable (() -> Unit)? = null,
         colors: TextFieldColors = outlinedTextFieldColors(),
         contentPadding: PaddingValues = outlinedTextFieldPadding(),
-        border: @Composable () -> Unit = {
-            BorderBox(enabled, isError, interactionSource, colors)
+        container: @Composable () -> Unit = {
+            OutlinedBorderContainerBox(enabled, isError, interactionSource, colors)
         }
     ) {
         CommonDecorationBox(
@@ -566,13 +655,14 @@
             label = label,
             leadingIcon = leadingIcon,
             trailingIcon = trailingIcon,
+            supportingText = supportingText,
             singleLine = singleLine,
             enabled = enabled,
             isError = isError,
             interactionSource = interactionSource,
             colors = colors,
             contentPadding = contentPadding,
-            border = border
+            container = container
         )
     }
 }
@@ -611,7 +701,11 @@
     private val disabledLabelColor: Color,
     private val errorLabelColor: Color,
     private val placeholderColor: Color,
-    private val disabledPlaceholderColor: Color
+    private val disabledPlaceholderColor: Color,
+    private val focusedSupportingTextColor: Color,
+    private val unfocusedSupportingTextColor: Color,
+    private val disabledSupportingTextColor: Color,
+    private val errorSupportingTextColor: Color,
     ) {
     /**
      * Represents the color used for the leading icon of this text field.
@@ -742,6 +836,24 @@
         return rememberUpdatedState(if (enabled) textColor else disabledTextColor)
     }
 
+    @Composable
+    internal fun supportingTextColor(
+        enabled: Boolean,
+        isError: Boolean,
+        interactionSource: InteractionSource
+    ): State<Color> {
+        val focused by interactionSource.collectIsFocusedAsState()
+
+        return rememberUpdatedState(
+            when {
+                !enabled -> disabledSupportingTextColor
+                isError -> errorSupportingTextColor
+                focused -> focusedSupportingTextColor
+                else -> unfocusedSupportingTextColor
+            }
+        )
+    }
+
     /**
      * Represents the color used for the cursor of this text field.
      *
@@ -786,6 +898,10 @@
         if (errorLabelColor != other.errorLabelColor) return false
         if (placeholderColor != other.placeholderColor) return false
         if (disabledPlaceholderColor != other.disabledPlaceholderColor) return false
+        if (focusedSupportingTextColor != other.focusedSupportingTextColor) return false
+        if (unfocusedSupportingTextColor != other.unfocusedSupportingTextColor) return false
+        if (disabledSupportingTextColor != other.disabledSupportingTextColor) return false
+        if (errorSupportingTextColor != other.errorSupportingTextColor) return false
 
         return true
     }
@@ -815,6 +931,10 @@
         result = 31 * result + errorLabelColor.hashCode()
         result = 31 * result + placeholderColor.hashCode()
         result = 31 * result + disabledPlaceholderColor.hashCode()
+        result = 31 * result + focusedSupportingTextColor.hashCode()
+        result = 31 * result + unfocusedSupportingTextColor.hashCode()
+        result = 31 * result + disabledSupportingTextColor.hashCode()
+        result = 31 * result + errorSupportingTextColor.hashCode()
         return result
     }
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldImpl.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldImpl.kt
index b494c83..b7b633b 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldImpl.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldImpl.kt
@@ -69,13 +69,14 @@
     placeholder: @Composable (() -> Unit)? = null,
     leadingIcon: @Composable (() -> Unit)? = null,
     trailingIcon: @Composable (() -> Unit)? = null,
+    supportingText: @Composable (() -> Unit)? = null,
     singleLine: Boolean = false,
     enabled: Boolean = true,
     isError: Boolean = false,
     interactionSource: InteractionSource,
     contentPadding: PaddingValues,
     colors: TextFieldColors,
-    border: @Composable (() -> Unit)? = null
+    container: @Composable () -> Unit,
 ) {
     val transformedText = remember(value, visualTransformation) {
         visualTransformation.filter(AnnotatedString(value))
@@ -157,8 +158,23 @@
             }
         }
 
+        val supportingTextColor =
+            colors.supportingTextColor(enabled, isError, interactionSource).value
+        val decoratedSupporting: @Composable (() -> Unit)? = supportingText?.let {
+            @Composable {
+                Decoration(contentColor = supportingTextColor, typography = bodySmall, content = it)
+            }
+        }
+
         when (type) {
             TextFieldType.Filled -> {
+                val containerWithId: @Composable () -> Unit = {
+                    Box(Modifier.layoutId(ContainerId),
+                        propagateMinConstraints = true) {
+                        container()
+                    }
+                }
+
                 TextFieldLayout(
                     modifier = decorationBoxModifier,
                     textField = innerTextField,
@@ -166,6 +182,8 @@
                     label = decoratedLabel,
                     leading = decoratedLeading,
                     trailing = decoratedTrailing,
+                    container = containerWithId,
+                    supporting = decoratedSupporting,
                     singleLine = singleLine,
                     animationProgress = labelProgress,
                     paddingValues = contentPadding
@@ -174,12 +192,14 @@
             TextFieldType.Outlined -> {
                 // Outlined cutout
                 val labelSize = remember { mutableStateOf(Size.Zero) }
-                val drawBorder: @Composable () -> Unit = {
+                val borderContainerWithId: @Composable () -> Unit = {
                     Box(
-                        Modifier.layoutId(BorderId).outlineCutout(labelSize.value, contentPadding),
+                        Modifier
+                            .layoutId(ContainerId)
+                            .outlineCutout(labelSize.value, contentPadding),
                         propagateMinConstraints = true
                     ) {
-                        border?.invoke()
+                        container()
                     }
                 }
 
@@ -190,6 +210,7 @@
                     label = decoratedLabel,
                     leading = decoratedLeading,
                     trailing = decoratedTrailing,
+                    supporting = decoratedSupporting,
                     singleLine = singleLine,
                     onLabelMeasured = {
                         val labelWidth = it.width * labelProgress
@@ -201,7 +222,7 @@
                         }
                     },
                     animationProgress = labelProgress,
-                    border = drawBorder,
+                    container = borderContainerWithId,
                     paddingValues = contentPadding
                 )
             }
@@ -336,6 +357,8 @@
 internal const val LabelId = "Label"
 internal const val LeadingId = "Leading"
 internal const val TrailingId = "Trailing"
+internal const val SupportingId = "Supporting"
+internal const val ContainerId = "Container"
 internal val ZeroConstraints = Constraints(0, 0, 0, 0)
 
 internal const val AnimationDuration = 150
@@ -344,5 +367,6 @@
 
 internal val TextFieldPadding = 16.dp
 internal val HorizontalIconPadding = 12.dp
+internal val SupportingTopPadding = 4.dp
 
 internal val IconDefaultSizeModifier = Modifier.defaultMinSize(48.dp, 48.dp)
\ No newline at end of file
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/modifier/ModifierLocalSameLayoutNodeTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/modifier/ModifierLocalSameLayoutNodeTest.kt
index 413e05a..2120f41c 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/modifier/ModifierLocalSameLayoutNodeTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/modifier/ModifierLocalSameLayoutNodeTest.kt
@@ -236,6 +236,87 @@
     }
 
     @Test
+    fun modifierLocalProviderChanged_returnsDefaultValueBeforeNewValue() {
+        // Arrange.
+        val localString = modifierLocalOf { defaultValue }
+        val provider1value = "Provider1"
+        val provider2value = "Provider2"
+        var useFirstProvider by mutableStateOf(true)
+        val receivedValues = mutableListOf<String>()
+        rule.setContent {
+            Box(
+                Modifier
+                    .then(
+                        if (useFirstProvider) {
+                            Modifier.modifierLocalProvider(localString) { provider1value }
+                        } else {
+                            Modifier.modifierLocalProvider(localString) { provider2value }
+                        }
+                    )
+                    .modifierLocalConsumer { receivedValues.add(localString.current) }
+            )
+        }
+
+        // Act.
+        rule.runOnIdle { useFirstProvider = false }
+
+        // Assert.
+        rule.runOnIdle {
+            assertThat(receivedValues)
+                .containsExactly(provider1value, defaultValue, provider2value, provider2value)
+                .inOrder()
+        }
+    }
+
+    @Test
+    fun modifierLocalConsumer_returnsDefaultValueWhenModifierIsDisposed() {
+        // Arrange.
+        val modifierLocal = modifierLocalOf { defaultValue }
+        var hasProvider by mutableStateOf(true)
+        lateinit var receivedValue: String
+        rule.setContent {
+            Box(
+                Modifier
+                    .then(
+                        if (hasProvider) {
+                            Modifier.modifierLocalProvider(modifierLocal) { "ProvidedValue" }
+                        } else Modifier
+                    )
+                    .modifierLocalConsumer { receivedValue = modifierLocal.current }
+            )
+        }
+
+        // Act.
+        rule.runOnIdle { hasProvider = false }
+
+        // Assert.
+        rule.runOnIdle { assertThat(receivedValue).isEqualTo(defaultValue) }
+    }
+
+    @Test
+    fun modifierLocalConsumer_returnsDefaultValueWhenComposableIsDisposed() {
+        // Arrange.
+        val modifierLocal = modifierLocalOf { defaultValue }
+        var includeComposable by mutableStateOf(true)
+        lateinit var receivedValue: String
+        rule.setContent {
+            if (includeComposable) {
+                Box(
+                    Modifier
+                        .modifierLocalProvider(modifierLocal) { "ProvidedValue" }
+                        .modifierLocalConsumer { receivedValue = modifierLocal.current }
+                )
+            }
+        }
+
+        // Act.
+        rule.runOnIdle { includeComposable = false }
+
+        // Assert.
+        rule.runOnIdle { assertThat(receivedValue).isEqualTo(defaultValue) }
+    }
+
+    @Test
     fun modifierLocalProviderValueChanged() {
         // Arrange.
         val localString = modifierLocalOf { defaultValue }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/BackwardsCompatNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/BackwardsCompatNode.kt
index 76b26aa..ba36ff7 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/BackwardsCompatNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/BackwardsCompatNode.kt
@@ -92,13 +92,22 @@
 
     var element: Modifier.Element = element
         set(value) {
-            // TODO(lmr): do we need to do a detach type thing with the previous element?
+            if (isAttached) uninitializeModifier()
             field = value
             kindSet = calculateNodeKindSetFrom(value)
-            if (isAttached) onModifierUpdated(false)
+            if (isAttached) initializeModifier(false)
         }
 
+    override fun onAttach() {
+        initializeModifier(true)
+    }
+
     override fun onDetach() {
+        uninitializeModifier()
+    }
+
+    private fun uninitializeModifier() {
+        check(isAttached)
         val element = element
         if (isKind(Nodes.Locals)) {
             if (element is ModifierLocalProvider<*>) {
@@ -123,11 +132,7 @@
         }
     }
 
-    override fun onAttach() {
-        onModifierUpdated(true)
-    }
-
-    private fun onModifierUpdated(duringAttach: Boolean) {
+    private fun initializeModifier(duringAttach: Boolean) {
         check(isAttached)
         val element = element
         if (isKind(Nodes.Locals)) {
@@ -461,4 +466,4 @@
 
 private val updateFocusOrderModifierLocalConsumer = { it: BackwardsCompatNode ->
     it.updateFocusOrderModifierLocalConsumer()
-}
\ No newline at end of file
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index e8c82f9..f21850c 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -1287,18 +1287,6 @@
         }
     }
 
-    /**
-     * Comparator allowing to sort nodes by zIndex and placement order.
-     */
-    val ZComparator = Comparator<LayoutNode> { node1, node2 ->
-        if (node1.zIndex == node2.zIndex) {
-            // if zIndex is the same we use the placement order
-            node1.placeOrder.compareTo(node2.placeOrder)
-        } else {
-            node1.zIndex.compareTo(node2.zIndex)
-        }
-    }
-
     override val parentInfo: LayoutInfo?
         get() = parent
 
@@ -1339,6 +1327,18 @@
             override val minimumTouchTargetSize: DpSize
                 get() = DpSize.Zero
         }
+
+        /**
+         * Comparator allowing to sort nodes by zIndex and placement order.
+         */
+        internal val ZComparator = Comparator<LayoutNode> { node1, node2 ->
+            if (node1.zIndex == node2.zIndex) {
+                // if zIndex is the same we use the placement order
+                node1.placeOrder.compareTo(node2.placeOrder)
+            } else {
+                node1.zIndex.compareTo(node2.zIndex)
+            }
+        }
     }
 
     /**
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsSort.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsSort.kt
index a104d042..9fd19fe 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsSort.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsSort.kt
@@ -156,8 +156,13 @@
             return -1
         }
 
+        val zDifference = LayoutNode.ZComparator.compare(node, other.node)
+        if (zDifference != 0) {
+            return -zDifference
+        }
+
         // Break tie somehow
-        return -1
+        return node.semanticsId - other.node.semanticsId
     }
 }
 
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index 35b87e2..15519cb 100644
--- a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -2468,7 +2468,7 @@
 }
 
 @OptIn(InternalCoreApi::class)
-private class MockOwner(
+internal class MockOwner(
     val position: IntOffset = IntOffset.Zero,
     override val root: LayoutNode = LayoutNode()
 ) : Owner {
@@ -2691,7 +2691,7 @@
     hitPointerInputFilters.addAll(hitTestResult)
 }
 
-private fun LayoutNode(
+internal fun LayoutNode(
     x: Int,
     y: Int,
     x2: Int,
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/semantics/SemanticsSortTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/semantics/SemanticsSortTest.kt
new file mode 100644
index 0000000..47ac9fe4
--- /dev/null
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/semantics/SemanticsSortTest.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.ui.semantics
+
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.node.LayoutNode
+import androidx.compose.ui.node.MockOwner
+import androidx.compose.ui.node.SemanticsModifierNode
+import androidx.compose.ui.zIndex
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalComposeUiApi::class)
+@RunWith(JUnit4::class)
+class SemanticsSortTest {
+
+    @Test // regression test for b/207477257
+    fun compareDoesNotViolateComparatorContract() {
+        val root = LayoutNode(0, 0, 720, 1080)
+        repeat(32) { index ->
+            val child = if (index % 2 == 0) {
+                LayoutNode(0, 0, 0, 0)
+            } else {
+                val offset = if (index == 1 || index == 31) 100 else 0
+                LayoutNode(0, 0 - offset, 720, 30 - offset).also {
+                    it.insertAt(0, LayoutNode(0, 0, 100, 100, Modifier.semantics { }))
+                }
+            }
+            root.insertAt(index, child)
+        }
+
+        root.attach(MockOwner())
+        root.findOneLayerOfSemanticsWrappersSortedByBounds()
+
+        // expect - no crash happened
+    }
+
+    @Test
+    fun sortedByZOrderIfHasSameBounds() {
+        val root = LayoutNode(0, 0, 100, 100)
+        repeat(5) { index ->
+            root.insertAt(
+                index,
+                LayoutNode(
+                    0, 0, 100, 100,
+                    Modifier
+                        .semantics { set(LayoutNodeIndex, index) }
+                        .zIndex((index * 3 % 5).toFloat())
+                )
+            )
+        }
+        root.attach(MockOwner())
+        root.remeasure()
+        root.replace()
+        val result = root.findOneLayerOfSemanticsWrappersSortedByBounds()
+
+        assertThat(result[0].layoutNodeIndex()).isEqualTo(3)
+        assertThat(result[1].layoutNodeIndex()).isEqualTo(1)
+        assertThat(result[2].layoutNodeIndex()).isEqualTo(4)
+        assertThat(result[3].layoutNodeIndex()).isEqualTo(2)
+        assertThat(result[4].layoutNodeIndex()).isEqualTo(0)
+    }
+
+    private val LayoutNodeIndex = SemanticsPropertyKey<Int>("LayoutNodeIndex")
+
+    private fun SemanticsModifierNode.layoutNodeIndex(): Int {
+        return semanticsConfiguration[LayoutNodeIndex]
+    }
+}
diff --git a/core/core/src/main/java/androidx/core/app/NotificationCompat.java b/core/core/src/main/java/androidx/core/app/NotificationCompat.java
index cdfe78e..1e044ff 100644
--- a/core/core/src/main/java/androidx/core/app/NotificationCompat.java
+++ b/core/core/src/main/java/androidx/core/app/NotificationCompat.java
@@ -7102,14 +7102,14 @@
             }
 
             RemoteInput remoteInputCompat = remoteInput != null
-                    ? new RemoteInput(remoteInput.getResultKey(),
-                    remoteInput.getLabel(),
-                    remoteInput.getChoices(),
-                    remoteInput.getAllowFreeFormInput(),
+                    ? new RemoteInput(Api20Impl.getResultKey(remoteInput),
+                    Api20Impl.getLabel(remoteInput),
+                    Api20Impl.getChoices(remoteInput),
+                    Api20Impl.getAllowFreeFormInput(remoteInput),
                     Build.VERSION.SDK_INT >= 29
-                            ? remoteInput.getEditChoicesBeforeSending()
+                            ? Api29Impl.getEditChoicesBeforeSending(remoteInput)
                             : RemoteInput.EDIT_CHOICES_BEFORE_SENDING_AUTO,
-                    remoteInput.getExtras(),
+                    Api20Impl.getExtras(remoteInput),
                     null /* allowedDataTypes */)
                     : null;
 
@@ -7135,12 +7135,12 @@
             RemoteInput remoteInputCompat = uc.getRemoteInput();
             if (remoteInputCompat != null) {
                 android.app.RemoteInput remoteInput =
-                        new android.app.RemoteInput.Builder(remoteInputCompat.getResultKey())
+                        Api20Impl.build(new android.app.RemoteInput.Builder(
+                                remoteInputCompat.getResultKey())
                                 .setLabel(remoteInputCompat.getLabel())
                                 .setChoices(remoteInputCompat.getChoices())
                                 .setAllowFreeFormInput(remoteInputCompat.getAllowFreeFormInput())
-                                .addExtras(remoteInputCompat.getExtras())
-                                .build();
+                                .addExtras(remoteInputCompat.getExtras()));
                 b.putParcelable(KEY_REMOTE_INPUT, remoteInput);
             }
             b.putParcelable(KEY_ON_REPLY, uc.getReplyPendingIntent());
@@ -7430,6 +7430,64 @@
                 }
             }
         }
+
+        /**
+         * A class for wrapping calls to {@link Notification.CarExtender} methods which
+         * were added in API 20; these calls must be wrapped to avoid performance issues.
+         * See the UnsafeNewApiCall lint rule for more details.
+         */
+        @RequiresApi(20)
+        static class Api20Impl {
+            private Api20Impl() {
+                // This class is not instantiable.
+            }
+
+            @DoNotInline
+            static android.app.RemoteInput build(android.app.RemoteInput.Builder builder) {
+                return builder.build();
+            }
+
+            @DoNotInline
+            static String getResultKey(android.app.RemoteInput remoteInput) {
+                return remoteInput.getResultKey();
+            }
+
+            @DoNotInline
+            static CharSequence[] getChoices(android.app.RemoteInput remoteInput) {
+                return remoteInput.getChoices();
+            }
+
+            @DoNotInline
+            static CharSequence getLabel(android.app.RemoteInput remoteInput) {
+                return remoteInput.getLabel();
+            }
+
+            @DoNotInline
+            static boolean getAllowFreeFormInput(android.app.RemoteInput remoteInput) {
+                return remoteInput.getAllowFreeFormInput();
+            }
+
+            @DoNotInline
+            static Bundle getExtras(android.app.RemoteInput remoteInput) {
+                return remoteInput.getExtras();
+            }
+        }
+
+        /**
+         * A class for wrapping calls to {@link Notification.CarExtender} methods which
+         * were added in API 29; these calls must be wrapped to avoid performance issues.
+         * See the UnsafeNewApiCall lint rule for more details.
+         */
+        @RequiresApi(29)
+        static class Api29Impl {
+            private Api29Impl() { }
+
+            @DoNotInline
+            static int getEditChoicesBeforeSending(android.app.RemoteInput remoteInput) {
+                return remoteInput.getEditChoicesBeforeSending();
+            }
+
+        }
     }
 
     /**
@@ -8118,7 +8176,7 @@
      */
     public static @Nullable BubbleMetadata getBubbleMetadata(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 29) {
-            return BubbleMetadata.fromPlatform(notification.getBubbleMetadata());
+            return BubbleMetadata.fromPlatform(Api29Impl.getBubbleMetadata(notification));
         } else {
             return null;
         }
@@ -8128,7 +8186,7 @@
     @RequiresApi(20)
     static @NonNull Action getActionCompatFromAction(@NonNull Notification.Action action) {
         final RemoteInput[] remoteInputs;
-        final android.app.RemoteInput[] srcArray = action.getRemoteInputs();
+        final android.app.RemoteInput[] srcArray = Api20Impl.getRemoteInputs(action);
         if (srcArray == null) {
             remoteInputs = null;
         } else {
@@ -8136,59 +8194,62 @@
             for (int i = 0; i < srcArray.length; i++) {
                 android.app.RemoteInput src = srcArray[i];
                 remoteInputs[i] = new RemoteInput(
-                        src.getResultKey(),
-                        src.getLabel(),
-                        src.getChoices(),
-                        src.getAllowFreeFormInput(),
+                        Api20Impl.getResultKey(src),
+                        Api20Impl.getLabel(src),
+                        Api20Impl.getChoices(src),
+                        Api20Impl.getAllowFreeFormInput(src),
                         Build.VERSION.SDK_INT >= 29
-                                ? src.getEditChoicesBeforeSending()
+                                ? Api29Impl.getEditChoicesBeforeSending(src)
                                 : RemoteInput.EDIT_CHOICES_BEFORE_SENDING_AUTO,
-                        src.getExtras(),
+                        Api20Impl.getExtras(src),
                         null);
             }
         }
 
         final boolean allowGeneratedReplies;
         if (Build.VERSION.SDK_INT >= 24) {
-            allowGeneratedReplies = action.getExtras().getBoolean(
+            allowGeneratedReplies = Api20Impl.getExtras(action).getBoolean(
                     NotificationCompatJellybean.EXTRA_ALLOW_GENERATED_REPLIES)
-                    || action.getAllowGeneratedReplies();
+                    || Api24Impl.getAllowGeneratedReplies(action);
         } else {
-            allowGeneratedReplies = action.getExtras().getBoolean(
+            allowGeneratedReplies = Api20Impl.getExtras(action).getBoolean(
                     NotificationCompatJellybean.EXTRA_ALLOW_GENERATED_REPLIES);
         }
 
         final boolean showsUserInterface =
-                action.getExtras().getBoolean(Action.EXTRA_SHOWS_USER_INTERFACE, true);
+                Api20Impl.getExtras(action).getBoolean(Action.EXTRA_SHOWS_USER_INTERFACE, true);
 
         final @Action.SemanticAction int semanticAction;
         if (Build.VERSION.SDK_INT >= 28) {
-            semanticAction = action.getSemanticAction();
+            semanticAction = Api28Impl.getSemanticAction(action);
         } else {
-            semanticAction = action.getExtras().getInt(
+            semanticAction = Api20Impl.getExtras(action).getInt(
                     Action.EXTRA_SEMANTIC_ACTION, Action.SEMANTIC_ACTION_NONE);
         }
 
-        final boolean isContextual = Build.VERSION.SDK_INT >= 29 ? action.isContextual() : false;
+        final boolean isContextual = Build.VERSION.SDK_INT >= 29 ? Api29Impl.isContextual(action)
+                : false;
 
         final boolean authRequired =
-                Build.VERSION.SDK_INT >= 31 ? action.isAuthenticationRequired() : false;
+                Build.VERSION.SDK_INT >= 31 ? Api31Impl.isAuthenticationRequired(action) : false;
 
         if (Build.VERSION.SDK_INT >= 23) {
-            if (action.getIcon() == null && action.icon != 0) {
+            if (Api23Impl.getIcon(action) == null && action.icon != 0) {
                 return new Action(action.icon, action.title, action.actionIntent,
-                        action.getExtras(), remoteInputs, null, allowGeneratedReplies,
-                        semanticAction, showsUserInterface, isContextual, authRequired);
+                        Api20Impl.getExtras(action), remoteInputs, null,
+                        allowGeneratedReplies, semanticAction, showsUserInterface, isContextual,
+                        authRequired);
             }
-            IconCompat icon = action.getIcon() == null
-                    ? null : IconCompat.createFromIconOrNullIfZeroResId(action.getIcon());
-            return new Action(icon, action.title, action.actionIntent, action.getExtras(),
-                    remoteInputs, null, allowGeneratedReplies, semanticAction, showsUserInterface,
-                    isContextual, authRequired);
+            IconCompat icon = Api23Impl.getIcon(action) == null
+                    ? null : IconCompat.createFromIconOrNullIfZeroResId(Api23Impl.getIcon(action));
+            return new Action(icon, action.title, action.actionIntent, Api20Impl.getExtras(action),
+                    remoteInputs, null, allowGeneratedReplies, semanticAction,
+                    showsUserInterface, isContextual, authRequired);
         } else {
-            return new Action(action.icon, action.title, action.actionIntent, action.getExtras(),
-                    remoteInputs, null, allowGeneratedReplies, semanticAction, showsUserInterface,
-                    isContextual, authRequired);
+            return new Action(action.icon, action.title, action.actionIntent,
+                    Api20Impl.getExtras(action),
+                    remoteInputs, null, allowGeneratedReplies, semanticAction,
+                    showsUserInterface, isContextual, authRequired);
         }
     }
 
@@ -8303,7 +8364,7 @@
      */
     public static @Nullable String getGroup(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 20) {
-            return notification.getGroup();
+            return Api20Impl.getGroup(notification);
         } else if (Build.VERSION.SDK_INT >= 19) {
             return notification.extras.getString(NotificationCompatExtras.EXTRA_GROUP_KEY);
         } else if (Build.VERSION.SDK_INT >= 16) {
@@ -8411,7 +8472,7 @@
      */
     public static @Nullable String getSortKey(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 20) {
-            return notification.getSortKey();
+            return Api20Impl.getSortKey(notification);
         } else if (Build.VERSION.SDK_INT >= 19) {
             return notification.extras.getString(NotificationCompatExtras.EXTRA_SORT_KEY);
         } else if (Build.VERSION.SDK_INT >= 16) {
@@ -8427,7 +8488,7 @@
      */
     public static @Nullable String getChannelId(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 26) {
-            return notification.getChannelId();
+            return Api26Impl.getChannelId(notification);
         } else {
             return null;
         }
@@ -8439,7 +8500,7 @@
      */
     public static long getTimeoutAfter(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 26) {
-            return notification.getTimeoutAfter();
+            return Api26Impl.getTimeoutAfter(notification);
         } else {
             return 0;
         }
@@ -8452,19 +8513,19 @@
      */
     public static int getBadgeIconType(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 26) {
-            return notification.getBadgeIconType();
+            return Api26Impl.getBadgeIconType(notification);
         } else {
             return BADGE_ICON_NONE;
         }
     }
 
     /**
-     * Returns the {@link androidx.core.content.pm.ShortcutInfoCompat#getId() id} that this
+     * Returns the {@link ShortcutInfoCompat#getId() id} that this
      * notification supersedes, if any.
      */
     public static @Nullable String getShortcutId(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 26) {
-            return notification.getShortcutId();
+            return Api26Impl.getShortcutId(notification);
         } else {
             return null;
         }
@@ -8475,7 +8536,7 @@
      */
     public static @Nullable CharSequence getSettingsText(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 26) {
-            return notification.getSettingsText();
+            return Api26Impl.getSettingsText(notification);
         } else {
             return null;
         }
@@ -8491,7 +8552,7 @@
     @Nullable
     public static LocusIdCompat getLocusId(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 29) {
-            LocusId locusId = notification.getLocusId();
+            LocusId locusId = Api29Impl.getLocusId(notification);
             return locusId == null ? null : LocusIdCompat.toLocusIdCompat(locusId);
         } else {
             return null;
@@ -8506,7 +8567,7 @@
     @GroupAlertBehavior
     public static int getGroupAlertBehavior(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 26) {
-            return notification.getGroupAlertBehavior();
+            return Api26Impl.getGroupAlertBehavior(notification);
         } else {
             return GROUP_ALERT_ALL;
         }
@@ -8519,7 +8580,7 @@
     public static boolean getAllowSystemGeneratedContextualActions(
             @NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 29) {
-            return notification.getAllowSystemGeneratedContextualActions();
+            return Api29Impl.getAllowSystemGeneratedContextualActions(notification);
         } else {
             return false;
         }
@@ -8530,4 +8591,196 @@
     @SuppressWarnings("PrivateConstructorForUtilityClass")
     public NotificationCompat() {
     }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 20; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(20)
+    static class Api20Impl {
+        private Api20Impl() { }
+
+        @DoNotInline
+        static boolean getAllowFreeFormInput(android.app.RemoteInput remoteInput) {
+            return remoteInput.getAllowFreeFormInput();
+        }
+
+        @DoNotInline
+        static CharSequence[] getChoices(android.app.RemoteInput remoteInput) {
+            return remoteInput.getChoices();
+        }
+
+        @DoNotInline
+        static CharSequence getLabel(android.app.RemoteInput remoteInput) {
+            return remoteInput.getLabel();
+        }
+
+        @DoNotInline
+        static String getResultKey(android.app.RemoteInput remoteInput) {
+            return remoteInput.getResultKey();
+        }
+
+        @DoNotInline
+        static android.app.RemoteInput[] getRemoteInputs(Notification.Action action) {
+            return action.getRemoteInputs();
+        }
+
+        @DoNotInline
+        static String getSortKey(Notification notification) {
+            return notification.getSortKey();
+        }
+
+        @DoNotInline
+        static String getGroup(Notification notification) {
+            return notification.getGroup();
+        }
+
+        @DoNotInline
+        static Bundle getExtras(Notification.Action action) {
+            return action.getExtras();
+        }
+
+        @DoNotInline
+        static Bundle getExtras(android.app.RemoteInput remoteInput) {
+            return remoteInput.getExtras();
+        }
+    }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 23; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(23)
+    static class Api23Impl {
+        private Api23Impl() { }
+
+        @DoNotInline
+        static Icon getIcon(Notification.Action action) {
+            return action.getIcon();
+        }
+    }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 24; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(24)
+    static class Api24Impl {
+        private Api24Impl() { }
+
+        @DoNotInline
+        static boolean getAllowGeneratedReplies(Notification.Action action) {
+            return action.getAllowGeneratedReplies();
+        }
+
+    }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 26; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(26)
+    static class Api26Impl {
+        private Api26Impl() { }
+
+        @DoNotInline
+        static int getGroupAlertBehavior(Notification notification) {
+            return notification.getGroupAlertBehavior();
+        }
+
+        @DoNotInline
+        static CharSequence getSettingsText(Notification notification) {
+            return notification.getSettingsText();
+        }
+
+        @DoNotInline
+        static String getShortcutId(Notification notification) {
+            return notification.getShortcutId();
+        }
+
+        @DoNotInline
+        static int getBadgeIconType(Notification notification) {
+            return notification.getBadgeIconType();
+        }
+
+        @DoNotInline
+        static long getTimeoutAfter(Notification notification) {
+            return notification.getTimeoutAfter();
+        }
+
+        @DoNotInline
+        static String getChannelId(Notification notification) {
+            return notification.getChannelId();
+        }
+    }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 28; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(28)
+    static class Api28Impl {
+        private Api28Impl() { }
+
+        @DoNotInline
+        static int getSemanticAction(Notification.Action action) {
+            return action.getSemanticAction();
+        }
+    }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 29; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(29)
+    static class Api29Impl {
+        private Api29Impl() { }
+
+        @DoNotInline
+        static boolean getAllowSystemGeneratedContextualActions(Notification notification) {
+            return notification.getAllowSystemGeneratedContextualActions();
+        }
+
+        @DoNotInline
+        static LocusId getLocusId(Notification notification) {
+            return notification.getLocusId();
+        }
+
+        @DoNotInline
+        static boolean isContextual(Notification.Action action) {
+            return action.isContextual();
+        }
+
+        @DoNotInline
+        static int getEditChoicesBeforeSending(android.app.RemoteInput remoteInput) {
+            return remoteInput.getEditChoicesBeforeSending();
+        }
+
+        @DoNotInline
+        static Notification.BubbleMetadata getBubbleMetadata(Notification notification) {
+            return notification.getBubbleMetadata();
+        }
+    }
+
+    /**
+     * A class for wrapping calls to {@link Notification} methods which
+     * were added in API 31; these calls must be wrapped to avoid performance issues.
+     * See the UnsafeNewApiCall lint rule for more details.
+     */
+    @RequiresApi(31)
+    static class Api31Impl {
+        private Api31Impl() { }
+
+        @DoNotInline
+        static boolean isAuthenticationRequired(Notification.Action action) {
+            return action.isAuthenticationRequired();
+        }
+
+    }
 }
diff --git a/core/uwb/uwb/build.gradle b/core/uwb/uwb/build.gradle
index 508a3e5..85d148ce 100644
--- a/core/uwb/uwb/build.gradle
+++ b/core/uwb/uwb/build.gradle
@@ -41,6 +41,7 @@
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.espressoCore)
+    androidTestImplementation(libs.multidex)
 }
 
 androidx {
@@ -53,4 +54,7 @@
 
 android {
     namespace "androidx.core.uwb"
+    defaultConfig {
+        multiDexEnabled = true
+    }
 }
diff --git a/datastore/datastore-core-okio/src/nativeMain/kotlin/androidx/datastore/core/okio/Atomic.native.kt b/datastore/datastore-core-okio/src/nativeMain/kotlin/androidx/datastore/core/okio/Atomic.native.kt
index fd6e257..6498e6f 100644
--- a/datastore/datastore-core-okio/src/nativeMain/kotlin/androidx/datastore/core/okio/Atomic.native.kt
+++ b/datastore/datastore-core-okio/src/nativeMain/kotlin/androidx/datastore/core/okio/Atomic.native.kt
@@ -28,7 +28,7 @@
         return delegate.getAndIncrement()
     }
     actual fun decrementAndGet(): Int {
-        return delegate.getAndDecrement()
+        return delegate.decrementAndGet()
     }
     actual fun get(): Int = property
 
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index e943f94..e5a0d33 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -193,9 +193,10 @@
 # https://youtrack.jetbrains.com/issue/KT-52694/
 \- Task `:listTaskOutputs` of type `androidx\.build\.ListTaskOutputsTask`: invocation of 'Task\.project' at execution time is unsupported\.
 See https://docs\.gradle\.org/[0-9]+\.[0-9]+.*/userguide/configuration_cache\.html\#config_cache:requirements:use_project_during_execution
+\- Task `:docs\-public:dokkaKotlinDocs` of type `org\.jetbrains\.dokka\.gradle\.DokkaAndroidTask`: invocation of 'Task\.project' at execution time is unsupported\.
+\- Task `:docs\-tip\-of\-tree:dokkaKotlinDocs` of type `org\.jetbrains\.dokka\.gradle\.DokkaAndroidTask`: invocation of 'Task\.project' at execution time is unsupported\.
 # https://youtrack.jetbrains.com/issue/KT-52694
 \- Task \`:[:A-Za-z0-9#\-]+` of type \`org\.jetbrains\.kotlin\.gradle\.tooling\.BuildKotlinToolingMetadataTask\$FromKotlinExtension\`\: invocation of \'Task\.project\' at execution time is unsupported\.
-\- Task `[^ ]*validateProperties` of type `[^ ]*ValidatePropertiesTask`: invocation of 'Task\.project' at execution time is unsupported\.
 plus [0-9]+ more problems\. Please see the report for details\.
 See the complete report at file://\$SUPPORT/build/reports/configuration\-cache/[^/]*/[^/]*/configuration\-cache\-report\.html
 See the complete report at file://\$OUT_DIR/androidx/build/reports/configuration\-cache/[^ ]*/[^ ]*/configuration\-cache\-report\.html
@@ -456,8 +457,9 @@
 Run the build with '\-\-info' for more details\.
 # > Task :health:health-connect-client:testDebugUnitTest
 WARNING: An illegal reflective access operation has occurred
-WARNING: Illegal reflective access by org\.robolectric\.util\.ReflectionHelpers\$[0-9]+ \(file:\$CHECKOUT/prebuilts/androidx/external/org/robolectric/shadowapi/[0-9]+\.[0-9]+\.[0-9]+/shadowapi\-[0-9]+\.[0-9]+\.[0-9]+\.jar\) to field java\.io\.FileDescriptor\.fd
-WARNING: Please consider reporting this to the maintainers of org\.robolectric\.util\.ReflectionHelpers\$[0-9]+
+WARNING: Illegal reflective access by org\.robolectric\.util\.ReflectionHelpers\$[0-9]+ \(file:\$CHECKOUT/prebuilts/androidx/external/org/robolectric/shadowapi/.*/shadowapi\-.*\.jar\) to field java\.io\.FileDescriptor\.fd
+WARNING: Illegal reflective access by org\.robolectric\.util\.ReflectionHelpers \(file:\$CHECKOUT/prebuilts/androidx/external/org/robolectric/shadowapi/.*/shadowapi\-.*\.jar\) to method java\.lang\.Class\.getDeclaredFields0\(boolean\)
+WARNING: Please consider reporting this to the maintainers of org\.robolectric\.util\.ReflectionHelpers.*
 WARNING: Use \-\-illegal\-access=warn to enable warnings of further illegal reflective access operations
 WARNING: All illegal access operations will be denied in a future release
 # > Task :room:room-compiler-processing-testing:test
@@ -490,4 +492,4 @@
 For more detail on using Gradle, see https://docs\.gradle\.org/[0-9]+\.[0-9]+/userguide/command_line_interface\.html
 For troubleshooting, visit https://help\.gradle\.org
 # > Task :graphics:graphics-path:compileDebugKotlin
-w\: \$SUPPORT\/graphics\/graphics\-path\/src\/main\/java\/androidx\/graphics\/path\/Paths\.kt\: \([0-9]+\, [0-9]+\)\: Extension is shadowed by a member\: public open fun iterator\(\)\: PathIterator
+w\: \$SUPPORT\/graphics\/graphics\-path\/src\/main\/java\/androidx\/graphics\/path\/Paths\.kt\: \([0-9]+\, [0-9]+\)\: Extension is shadowed by a member\: public open fun iterator\(\)\: PathIterator
\ No newline at end of file
diff --git a/development/collection-consumer/.gitignore b/development/collection-consumer/.gitignore
new file mode 100644
index 0000000..e0d53d8
--- /dev/null
+++ b/development/collection-consumer/.gitignore
@@ -0,0 +1,3 @@
+.gradle
+.idea
+build
diff --git a/development/collection-consumer/README.md b/development/collection-consumer/README.md
new file mode 100644
index 0000000..75586e0
--- /dev/null
+++ b/development/collection-consumer/README.md
@@ -0,0 +1,33 @@
+## Testing collection KMP release
+
+This project helps to validate a newly-staged version of the KMP build of androidx.collection.  Here's how!
+
+### Stage the release
+- Create a new global release in production or autopush JetPad (TODO: more info about how)
+- Schedule a library group release attached to the global release for androidx.collect (TODO: ditto)
+- You cannot stage unless the _global_ ADMRS config allowlists the KMP targets.  If necessary, you may need to
+  create, have reviewed, and submit a CL like [this one](https://critique.corp.google.com/cl/474557118).
+  - It can take around 30 minutes between submission and updating the loaded allowlist in ADMRS, so be patient.
+- Then:
+  - Start with [prod](go/jetpad) or [autopush](go/jetpad-autopush)
+  - `Release Dates` > `Browse Release Date`
+  - Click `Release Information` for the global release you created above
+  - `Stage to ADMRS`
+  - `Compose BOM?`  No (not relevant to our test)
+  - Answer "Yes" to "really staging"
+- At this point, you will either see an error in the first ~20 seconds if something is wrong with our stuff, or
+  ADMRS will go quietly do things for a few minutes, resulting in an email to `mdb.jetpad-admins@google.com`.
+
+### Test the staged release
+- Check out this repo (if you haven't): `git clone sso://user/saff`
+- The email has [instructions](go/adt-redir) for how to set up a proxy server for the staged maven repo.
+- Once that's done, if necessary, edit the androidx.collection version in build.gradle.kts
+- To test JVM:
+  - `./gradlew installDist`
+  - `./build/install/collection-consumer/bin/collection-consumer`
+- To test native:
+  - `./gradlew nativeBinaries`
+  - `build/bin/native/releaseExecutable/collection-consumer.kexe`
+- You can look back at the stdout for the adt-redir proxy server to assure yourself that the androidx dependencies
+  are being loaded through the proxy.
+- Profit??!
\ No newline at end of file
diff --git a/development/collection-consumer/build.gradle.kts b/development/collection-consumer/build.gradle.kts
new file mode 100644
index 0000000..beef228
--- /dev/null
+++ b/development/collection-consumer/build.gradle.kts
@@ -0,0 +1,63 @@
+plugins {
+    kotlin("multiplatform") version "1.7.10"
+    application
+}
+
+group = "net.saff"
+version = "1.0-SNAPSHOT"
+
+repositories {
+    maven {
+        url = uri("http://localhost:1480")
+        isAllowInsecureProtocol = true
+    }
+    mavenCentral()
+    google()
+}
+
+kotlin {
+    val hostOs = System.getProperty("os.name")
+    val isMingwX64 = hostOs.startsWith("Windows")
+    val nativeTarget = when {
+        hostOs == "Mac OS X" -> macosX64("native")
+        hostOs == "Linux" -> linuxX64("native")
+        isMingwX64 -> mingwX64("native")
+        else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
+    }
+
+    nativeTarget.apply {
+        binaries {
+            executable {
+                entryPoint = "main"
+            }
+        }
+    }
+    jvm {
+        compilations.all {
+            kotlinOptions.jvmTarget = "1.8"
+        }
+        withJava()
+        testRuns["test"].executionTask.configure {
+            useJUnitPlatform()
+        }
+    }
+    sourceSets {
+        val nativeMain by getting
+        val commonMain by getting {
+            dependencies {
+                implementation("androidx.collection:collection:1.3.0-alpha03")
+            }
+        }
+        val nativeTest by getting
+        val jvmMain by getting
+        val jvmTest by getting {
+            dependencies {
+                implementation(kotlin("test"))
+            }
+        }
+    }
+}
+
+application {
+    mainClass.set("MainKt")
+}
diff --git a/development/collection-consumer/gradle.properties b/development/collection-consumer/gradle.properties
new file mode 100644
index 0000000..415b6be
--- /dev/null
+++ b/development/collection-consumer/gradle.properties
@@ -0,0 +1,5 @@
+kotlin.code.style=official
+kotlin.mpp.enableGranularSourceSetsMetadata=true
+kotlin.native.enableDependencyPropagation=false
+kotlin.js.generate.executable.default=false
+kotlin.native.binary.memoryModel=experimental
\ No newline at end of file
diff --git a/development/collection-consumer/gradle/wrapper/gradle-wrapper.jar b/development/collection-consumer/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..41d9927
--- /dev/null
+++ b/development/collection-consumer/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/development/collection-consumer/gradle/wrapper/gradle-wrapper.properties b/development/collection-consumer/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..aa991fc
--- /dev/null
+++ b/development/collection-consumer/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/development/collection-consumer/gradlew b/development/collection-consumer/gradlew
new file mode 100755
index 0000000..1b6c787
--- /dev/null
+++ b/development/collection-consumer/gradlew
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# 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
+#
+#      https://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.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+# Collect all arguments for the java command;
+#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+#     shell script including quotes and variable substitutions, so put them in
+#     double quotes to make sure that they get re-expanded; and
+#   * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/development/collection-consumer/gradlew.bat b/development/collection-consumer/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/development/collection-consumer/gradlew.bat
@@ -0,0 +1,89 @@
+@rem

+@rem Copyright 2015 the original author or authors.

+@rem

+@rem Licensed under the Apache License, Version 2.0 (the "License");

+@rem you may not use this file except in compliance with the License.

+@rem You may obtain a copy of the License at

+@rem

+@rem      https://www.apache.org/licenses/LICENSE-2.0

+@rem

+@rem Unless required by applicable law or agreed to in writing, software

+@rem distributed under the License is distributed on an "AS IS" BASIS,

+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+@rem See the License for the specific language governing permissions and

+@rem limitations under the License.

+@rem

+

+@if "%DEBUG%" == "" @echo off

+@rem ##########################################################################

+@rem

+@rem  Gradle startup script for Windows

+@rem

+@rem ##########################################################################

+

+@rem Set local scope for the variables with windows NT shell

+if "%OS%"=="Windows_NT" setlocal

+

+set DIRNAME=%~dp0

+if "%DIRNAME%" == "" set DIRNAME=.

+set APP_BASE_NAME=%~n0

+set APP_HOME=%DIRNAME%

+

+@rem Resolve any "." and ".." in APP_HOME to make it shorter.

+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

+

+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.

+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

+

+@rem Find java.exe

+if defined JAVA_HOME goto findJavaFromJavaHome

+

+set JAVA_EXE=java.exe

+%JAVA_EXE% -version >NUL 2>&1

+if "%ERRORLEVEL%" == "0" goto execute

+

+echo.

+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

+echo.

+echo Please set the JAVA_HOME variable in your environment to match the

+echo location of your Java installation.

+

+goto fail

+

+:findJavaFromJavaHome

+set JAVA_HOME=%JAVA_HOME:"=%

+set JAVA_EXE=%JAVA_HOME%/bin/java.exe

+

+if exist "%JAVA_EXE%" goto execute

+

+echo.

+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%

+echo.

+echo Please set the JAVA_HOME variable in your environment to match the

+echo location of your Java installation.

+

+goto fail

+

+:execute

+@rem Setup the command line

+

+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

+

+

+@rem Execute Gradle

+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*

+

+:end

+@rem End local scope for the variables with windows NT shell

+if "%ERRORLEVEL%"=="0" goto mainEnd

+

+:fail

+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of

+rem the _cmd.exe /c_ return code!

+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1

+exit /b 1

+

+:mainEnd

+if "%OS%"=="Windows_NT" endlocal

+

+:omega

diff --git a/development/collection-consumer/settings.gradle.kts b/development/collection-consumer/settings.gradle.kts
new file mode 100644
index 0000000..8268b87
--- /dev/null
+++ b/development/collection-consumer/settings.gradle.kts
@@ -0,0 +1,3 @@
+
+rootProject.name = "collection-consumer"
+
diff --git a/development/collection-consumer/src/commonMain/kotlin/list.kt b/development/collection-consumer/src/commonMain/kotlin/list.kt
new file mode 100644
index 0000000..6be7048
--- /dev/null
+++ b/development/collection-consumer/src/commonMain/kotlin/list.kt
@@ -0,0 +1,21 @@
+import androidx.collection.LongSparseArray
+import androidx.collection.LruCache
+
+fun runCache(): String {
+    val cache = object : LruCache<Int, String>(2) {
+        override fun create(key: Int): String? {
+            return "x".repeat(key)
+        }
+    }
+    cache[1]
+    cache[2]
+    cache[3]
+    return cache.snapshot().toString()
+}
+
+fun runSparseArray(): String {
+    val array = LongSparseArray<String>()
+    array.put(0L, "zero")
+    array.put(1L, "one")
+    return array.toString()
+}
\ No newline at end of file
diff --git a/development/collection-consumer/src/jvmMain/kotlin/Main.kt b/development/collection-consumer/src/jvmMain/kotlin/Main.kt
new file mode 100644
index 0000000..64fe0c0
--- /dev/null
+++ b/development/collection-consumer/src/jvmMain/kotlin/Main.kt
@@ -0,0 +1,7 @@
+fun main() {
+    println("Hello, Kotlin/JVM!")
+    println("LongSparseArray:")
+    println(runSparseArray())
+    println("LruCache:")
+    println(runCache())
+}
\ No newline at end of file
diff --git a/development/collection-consumer/src/nativeMain/kotlin/Main.kt b/development/collection-consumer/src/nativeMain/kotlin/Main.kt
new file mode 100644
index 0000000..b5e96e8
--- /dev/null
+++ b/development/collection-consumer/src/nativeMain/kotlin/Main.kt
@@ -0,0 +1,7 @@
+fun main() {
+    println("Hello, Kotlin/Native!")
+    println("LongSparseArray:")
+    println(runSparseArray())
+    println("LruCache:")
+    println(runCache())
+}
\ No newline at end of file
diff --git a/development/referenceDocs/stageReferenceDocsWithDackka.sh b/development/referenceDocs/stageReferenceDocsWithDackka.sh
index d197fd5..7d488c5 100755
--- a/development/referenceDocs/stageReferenceDocsWithDackka.sh
+++ b/development/referenceDocs/stageReferenceDocsWithDackka.sh
@@ -49,16 +49,8 @@
 # Each directory's spelling must match the library's directory in
 # frameworks/support.
 readonly javaLibraryDirsThatDontUseDackka=(
-  "androidx/draganddrop"
-  "androidx/dynamicanimation"
-  "androidx/enterprise"
-  "androidx/exifinterface"
 )
 readonly kotlinLibraryDirsThatDontUseDackka=(
-  "androidx/dynamicanimation"
-  "androidx/draganddrop"
-  "androidx/enterprise"
-  "androidx/exifinterface"
 )
 
 # Change directory to this script's location and store the directory
diff --git a/external/paparazzi/paparazzi-agent/build.gradle b/external/paparazzi/paparazzi-agent/build.gradle
new file mode 100644
index 0000000..dbe1124
--- /dev/null
+++ b/external/paparazzi/paparazzi-agent/build.gradle
@@ -0,0 +1,19 @@
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("kotlin")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(libs.junit)
+    implementation(libs.byteBuddy)
+    implementation(libs.byteBuddyAgent)
+    testImplementation(libs.assertj)
+}
+
+androidx {
+    name = "Paparazzi Agent - AndroidX Fork"
+    type = LibraryType.INTERNAL_HOST_TEST_LIBRARY
+}
diff --git a/external/paparazzi/paparazzi-agent/src/main/java/app/cash/paparazzi/agent/AgentTestRule.kt b/external/paparazzi/paparazzi-agent/src/main/java/app/cash/paparazzi/agent/AgentTestRule.kt
new file mode 100644
index 0000000..b79b5d5
--- /dev/null
+++ b/external/paparazzi/paparazzi-agent/src/main/java/app/cash/paparazzi/agent/AgentTestRule.kt
@@ -0,0 +1,20 @@
+package app.cash.paparazzi.agent
+
+import net.bytebuddy.agent.ByteBuddyAgent
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+class AgentTestRule : TestRule {
+  override fun apply(
+    base: Statement,
+    description: Description
+  ) = object : Statement() {
+    override fun evaluate() {
+      ByteBuddyAgent.install()
+      InterceptorRegistrar.registerMethodInterceptors()
+      // interceptors are statically retained until test process finishes, so no need to cleanup
+      base.evaluate()
+    }
+  }
+}
diff --git a/external/paparazzi/paparazzi-agent/src/main/java/app/cash/paparazzi/agent/InterceptorRegistrar.kt b/external/paparazzi/paparazzi-agent/src/main/java/app/cash/paparazzi/agent/InterceptorRegistrar.kt
new file mode 100644
index 0000000..b436835
--- /dev/null
+++ b/external/paparazzi/paparazzi-agent/src/main/java/app/cash/paparazzi/agent/InterceptorRegistrar.kt
@@ -0,0 +1,45 @@
+package app.cash.paparazzi.agent
+
+import net.bytebuddy.ByteBuddy
+import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
+import net.bytebuddy.implementation.MethodDelegation
+import net.bytebuddy.matcher.ElementMatchers
+
+object InterceptorRegistrar {
+  private val byteBuddy = ByteBuddy()
+  private val methodInterceptors = mutableListOf<() -> Unit>()
+
+  fun addMethodInterceptor(
+    receiver: Class<*>,
+    methodName: String,
+    interceptor: Class<*>
+  ) = addMethodInterceptors(receiver, setOf(methodName to interceptor))
+
+  fun addMethodInterceptors(
+    receiver: Class<*>,
+    methodNamesToInterceptors: Set<Pair<String, Class<*>>>
+  ) {
+    methodInterceptors += {
+      var builder = byteBuddy
+        .redefine(receiver)
+
+      methodNamesToInterceptors.forEach {
+        builder = builder
+          .method(ElementMatchers.named(it.first))
+          .intercept(MethodDelegation.to(it.second))
+      }
+
+      builder
+        .make()
+        .load(receiver.classLoader, ClassReloadingStrategy.fromInstalledAgent())
+    }
+  }
+
+  fun registerMethodInterceptors() {
+    methodInterceptors.forEach { it.invoke() }
+  }
+
+  fun clearMethodInterceptors() {
+    methodInterceptors.clear()
+  }
+}
diff --git a/external/paparazzi/paparazzi-agent/src/test/java/app/cash/paparazzi/agent/InterceptorRegistrarTest.kt b/external/paparazzi/paparazzi-agent/src/test/java/app/cash/paparazzi/agent/InterceptorRegistrarTest.kt
new file mode 100644
index 0000000..1113073
--- /dev/null
+++ b/external/paparazzi/paparazzi-agent/src/test/java/app/cash/paparazzi/agent/InterceptorRegistrarTest.kt
@@ -0,0 +1,66 @@
+package app.cash.paparazzi.agent
+
+import net.bytebuddy.agent.ByteBuddyAgent
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+class InterceptorRegistrarTest {
+  @Before
+  fun setup() {
+    InterceptorRegistrar.addMethodInterceptors(
+      Utils::class.java,
+      setOf(
+        "log1" to Interceptor1::class.java,
+        "log2" to Interceptor2::class.java
+      )
+    )
+
+    ByteBuddyAgent.install()
+    InterceptorRegistrar.registerMethodInterceptors()
+  }
+
+  @Test
+  fun test() {
+    Utils.log1()
+    Utils.log2()
+
+    assertThat(logs).containsExactly("intercept1", "intercept2")
+  }
+
+  @After
+  fun teardown() {
+    InterceptorRegistrar.clearMethodInterceptors()
+  }
+
+  object Utils {
+    fun log1() {
+      logs += "original1"
+    }
+
+    fun log2() {
+      logs += "original2"
+    }
+  }
+
+  object Interceptor1 {
+    @Suppress("unused")
+    @JvmStatic
+    fun intercept() {
+      logs += "intercept1"
+    }
+  }
+
+  object Interceptor2 {
+    @Suppress("unused")
+    @JvmStatic
+    fun intercept() {
+      logs += "intercept2"
+    }
+  }
+
+  companion object {
+    private val logs = mutableListOf<String>()
+  }
+}
diff --git a/external/paparazzi/paparazzi/build.gradle b/external/paparazzi/paparazzi/build.gradle
new file mode 100644
index 0000000..932b5f1
--- /dev/null
+++ b/external/paparazzi/paparazzi/build.gradle
@@ -0,0 +1,52 @@
+import androidx.build.LibraryType
+import org.gradle.api.artifacts.transform.TransformParameters.None
+import java.util.zip.ZipInputStream
+
+plugins {
+    id("AndroidXPlugin")
+    id("kotlin")
+    id("com.google.devtools.ksp")
+}
+
+androidx.configureAarAsJarForConfiguration("compileOnly")
+
+dependencies {
+    api("androidx.annotation:annotation:1.3.0")
+    api("com.android.tools.layoutlib:layoutlib-api:27.2.2")
+    api("com.android.tools:common:27.1.2")
+    api(libs.androidToolsNinepatch)
+    api("com.android.tools:sdk-common:26.6.4")
+    api(libs.guava)
+    api(libs.junit)
+    api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesCore)
+    api(libs.kxml2)
+    api(libs.okio)
+    api(libs.paparazziNativeJvm)
+    constraints {
+        implementation(libs.kotlinReflect) {
+            because("sdk-common depends on an old kotlin-reflect")
+        }
+    }
+
+    implementation(project(":external:paparazzi:paparazzi-agent"))
+    implementation(libs.jcodec)
+    implementation(libs.jcodecJavaSe)
+    implementation(libs.moshi)
+    implementation(libs.moshiAdapters)
+
+    compileOnlyAarAsJar("androidx.compose.runtime:runtime:1.2.1")
+    compileOnlyAarAsJar("androidx.compose.ui:ui:1.2.1")
+    compileOnly("androidx.lifecycle:lifecycle-common:2.5.0")
+    compileOnlyAarAsJar("androidx.lifecycle:lifecycle-runtime:2.5.0")
+    compileOnlyAarAsJar("androidx.savedstate:savedstate:1.2.0")
+
+    ksp(libs.moshiCodeGen)
+
+    testImplementation(libs.assertj)
+}
+
+androidx {
+    name = "Paparazzi - AndroidX Fork"
+    type = LibraryType.INTERNAL_HOST_TEST_LIBRARY
+}
\ No newline at end of file
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/DeviceConfig.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/DeviceConfig.kt
new file mode 100644
index 0000000..f6dd2a0
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/DeviceConfig.kt
@@ -0,0 +1,541 @@
+/*
+ * Copyright (C) 2014 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 app.cash.paparazzi
+
+import com.android.ide.common.rendering.api.HardwareConfig
+import com.android.ide.common.resources.configuration.CountryCodeQualifier
+import com.android.ide.common.resources.configuration.DensityQualifier
+import com.android.ide.common.resources.configuration.FolderConfiguration
+import com.android.ide.common.resources.configuration.KeyboardStateQualifier
+import com.android.ide.common.resources.configuration.LayoutDirectionQualifier
+import com.android.ide.common.resources.configuration.LocaleQualifier
+import com.android.ide.common.resources.configuration.NavigationMethodQualifier
+import com.android.ide.common.resources.configuration.NetworkCodeQualifier
+import com.android.ide.common.resources.configuration.NightModeQualifier
+import com.android.ide.common.resources.configuration.ScreenDimensionQualifier
+import com.android.ide.common.resources.configuration.ScreenOrientationQualifier
+import com.android.ide.common.resources.configuration.ScreenRatioQualifier
+import com.android.ide.common.resources.configuration.ScreenSizeQualifier
+import com.android.ide.common.resources.configuration.TextInputMethodQualifier
+import com.android.ide.common.resources.configuration.TouchScreenQualifier
+import com.android.ide.common.resources.configuration.UiModeQualifier
+import com.android.ide.common.resources.configuration.VersionQualifier
+import com.android.resources.Density
+import com.android.resources.Keyboard
+import com.android.resources.KeyboardState
+import com.android.resources.LayoutDirection
+import com.android.resources.Navigation
+import com.android.resources.NightMode
+import com.android.resources.NightMode.NOTNIGHT
+import com.android.resources.ScreenOrientation
+import com.android.resources.ScreenRatio
+import com.android.resources.ScreenSize
+import com.android.resources.TouchScreen
+import com.android.resources.UiMode
+import com.google.android.collect.Maps
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserException
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.util.Properties
+
+/**
+ * Provides [FolderConfiguration] and [HardwareConfig] for various devices. Also provides utility
+ * methods to parse `build.prop` and `attrs.xml` to generate the appropriate maps.
+ *
+ * Defaults are for a Nexus 4 device.
+ */
+data class DeviceConfig(
+  val screenHeight: Int = 1280,
+  val screenWidth: Int = 768,
+  val xdpi: Int = 320,
+  val ydpi: Int = 320,
+  val orientation: ScreenOrientation = ScreenOrientation.PORTRAIT,
+  val nightMode: NightMode = NOTNIGHT,
+  val density: Density = Density.XHIGH,
+  val fontScale: Float = 1f,
+  val layoutDirection: LayoutDirection = LayoutDirection.LTR,
+  val locale: String? = null,
+  val ratio: ScreenRatio = ScreenRatio.NOTLONG,
+  val size: ScreenSize = ScreenSize.NORMAL,
+  val keyboard: Keyboard = Keyboard.NOKEY,
+  val touchScreen: TouchScreen = TouchScreen.FINGER,
+  val keyboardState: KeyboardState = KeyboardState.SOFT,
+  val softButtons: Boolean = true,
+  val navigation: Navigation = Navigation.NONAV,
+  val released: String = "November 13, 2012"
+) {
+  val folderConfiguration: FolderConfiguration
+    get() = FolderConfiguration.createDefault()
+      .apply {
+        densityQualifier = DensityQualifier(density)
+        navigationMethodQualifier = NavigationMethodQualifier(navigation)
+        screenDimensionQualifier = when {
+          screenWidth > screenHeight -> ScreenDimensionQualifier(screenWidth, screenHeight)
+          else -> ScreenDimensionQualifier(screenHeight, screenWidth)
+        }
+        screenRatioQualifier = ScreenRatioQualifier(ratio)
+        screenSizeQualifier = ScreenSizeQualifier(size)
+        textInputMethodQualifier = TextInputMethodQualifier(keyboard)
+        touchTypeQualifier = TouchScreenQualifier(touchScreen)
+        keyboardStateQualifier = KeyboardStateQualifier(keyboardState)
+        screenOrientationQualifier = ScreenOrientationQualifier(orientation)
+
+        updateScreenWidthAndHeight()
+        uiModeQualifier = UiModeQualifier(UiMode.NORMAL)
+        nightModeQualifier = NightModeQualifier(nightMode)
+        countryCodeQualifier = CountryCodeQualifier()
+        layoutDirectionQualifier = LayoutDirectionQualifier(layoutDirection)
+        networkCodeQualifier = NetworkCodeQualifier()
+        localeQualifier = LocaleQualifier.getQualifier(locale ?: LocaleQualifier.FAKE_VALUE)
+        versionQualifier = VersionQualifier()
+      }
+
+  val hardwareConfig: HardwareConfig
+    get() = HardwareConfig(
+      screenWidth, screenHeight, density, xdpi.toFloat(), ydpi.toFloat(), size,
+      orientation, null, softButtons
+    )
+
+  /**
+   * Device specs per:
+   * https://android.googlesource.com/platform/tools/base/+/mirror-goog-studio-master-dev/sdklib/src/main/java/com/android/sdklib/devices/nexus.xml
+   *
+   * Release dates obtained from Wikipedia.
+   */
+
+  companion object {
+    @JvmField
+    val NEXUS_4 = DeviceConfig()
+
+    @JvmField
+    val NEXUS_5 = DeviceConfig(
+      screenHeight = 1920,
+      screenWidth = 1080,
+      xdpi = 445,
+      ydpi = 445,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.XXHIGH,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 31, 2013"
+    )
+
+    @JvmField
+    val NEXUS_7 = DeviceConfig(
+      screenHeight = 1920,
+      screenWidth = 1200,
+      xdpi = 323,
+      ydpi = 323,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.XHIGH,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.LARGE,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "July 26, 2013"
+    )
+
+    @JvmField
+    val NEXUS_10 = DeviceConfig(
+      screenHeight = 1600,
+      screenWidth = 2560,
+      xdpi = 300,
+      ydpi = 300,
+      orientation = ScreenOrientation.LANDSCAPE,
+      density = Density.XHIGH,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.XLARGE,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "November 13, 2012"
+    )
+
+    @JvmField
+    val NEXUS_5_LAND = DeviceConfig(
+      screenHeight = 1080,
+      screenWidth = 1920,
+      xdpi = 445,
+      ydpi = 445,
+      orientation = ScreenOrientation.LANDSCAPE,
+      density = Density.XXHIGH,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 31, 2013"
+    )
+
+    @JvmField
+    val NEXUS_7_2012 = DeviceConfig(
+      screenHeight = 1280,
+      screenWidth = 800,
+      xdpi = 195,
+      ydpi = 200,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.TV,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.LARGE,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "July 13, 2012"
+    )
+
+    @JvmField
+    val PIXEL_C = DeviceConfig(
+      screenHeight = 1800,
+      screenWidth = 2560,
+      xdpi = 308,
+      ydpi = 308,
+      orientation = ScreenOrientation.LANDSCAPE,
+      density = Density.XHIGH,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.XLARGE,
+      keyboard = Keyboard.QWERTY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "December 8, 2015"
+    )
+
+    @JvmField
+    val PIXEL = DeviceConfig(
+      screenHeight = 1920,
+      screenWidth = 1080,
+      xdpi = 440,
+      ydpi = 440,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_420,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 20, 2016"
+    )
+
+    @JvmField
+    val PIXEL_XL = DeviceConfig(
+      screenHeight = 2560,
+      screenWidth = 1440,
+      xdpi = 534,
+      ydpi = 534,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_560,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 20, 2016"
+    )
+
+    @JvmField
+    val PIXEL_2 = DeviceConfig(
+      screenHeight = 1920,
+      screenWidth = 1080,
+      xdpi = 442,
+      ydpi = 443,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_420,
+      ratio = ScreenRatio.NOTLONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 19, 2017"
+    )
+
+    @JvmField
+    val PIXEL_2_XL = DeviceConfig(
+      screenHeight = 2880,
+      screenWidth = 1440,
+      xdpi = 537,
+      ydpi = 537,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_560,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 19, 2017"
+    )
+
+    @JvmField
+    val PIXEL_3 = DeviceConfig(
+      screenHeight = 2160,
+      screenWidth = 1080,
+      xdpi = 442,
+      ydpi = 442,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_440,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 18, 2018"
+    )
+
+    @JvmField
+    val PIXEL_3_XL = DeviceConfig(
+      screenHeight = 2960,
+      screenWidth = 1440,
+      xdpi = 522,
+      ydpi = 522,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_560,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 18, 2018"
+    )
+
+    @JvmField
+    val PIXEL_3A = DeviceConfig(
+      screenHeight = 2220,
+      screenWidth = 1080,
+      xdpi = 442,
+      ydpi = 444,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_440,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "May 7, 2019"
+    )
+
+    @JvmField
+    val PIXEL_3A_XL = DeviceConfig(
+      screenHeight = 2160,
+      screenWidth = 1080,
+      xdpi = 397,
+      ydpi = 400,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_400,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "May 7, 2019"
+    )
+
+    @JvmField
+    val PIXEL_4 = DeviceConfig(
+      screenHeight = 2280,
+      screenWidth = 1080,
+      xdpi = 444,
+      ydpi = 444,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_440,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 24, 2019"
+    )
+
+    @JvmField
+    val PIXEL_4_XL = DeviceConfig(
+      screenHeight = 3040,
+      screenWidth = 1440,
+      xdpi = 537,
+      ydpi = 537,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_560,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 24, 2019"
+    )
+
+    @JvmField
+    val PIXEL_4A = DeviceConfig(
+      screenHeight = 2340,
+      screenWidth = 1080,
+      xdpi = 442,
+      ydpi = 444,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_440,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "August 20, 2020"
+    )
+
+    @JvmField
+    val PIXEL_5 = DeviceConfig(
+      screenHeight = 2340,
+      screenWidth = 1080,
+      xdpi = 442,
+      ydpi = 444,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_440,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 15, 2020"
+    )
+
+    @JvmField
+    val PIXEL_6 = DeviceConfig(
+      screenHeight = 2400,
+      screenWidth = 1080,
+      xdpi = 406,
+      ydpi = 411,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_420,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 28, 2021"
+    )
+
+    @JvmField
+    val PIXEL_6_PRO = DeviceConfig(
+      screenHeight = 3120,
+      screenWidth = 1440,
+      xdpi = 512,
+      ydpi = 512,
+      orientation = ScreenOrientation.PORTRAIT,
+      density = Density.DPI_560,
+      ratio = ScreenRatio.LONG,
+      size = ScreenSize.NORMAL,
+      keyboard = Keyboard.NOKEY,
+      touchScreen = TouchScreen.FINGER,
+      keyboardState = KeyboardState.SOFT,
+      softButtons = true,
+      navigation = Navigation.NONAV,
+      released = "October 28, 2021"
+    )
+
+    private const val TAG_ATTR = "attr"
+    private const val TAG_ENUM = "enum"
+    private const val TAG_FLAG = "flag"
+    private const val ATTR_NAME = "name"
+    private const val ATTR_VALUE = "value"
+
+    @Throws(IOException::class)
+    fun loadProperties(path: File): Map<String, String> {
+      val p = Properties()
+      val map = Maps.newHashMap<String, String>()
+      p.load(FileInputStream(path))
+      for (key in p.stringPropertyNames()) {
+        map[key] = p.getProperty(key)
+      }
+      return map
+    }
+
+    @Throws(IOException::class, XmlPullParserException::class)
+    fun getEnumMap(path: File): Map<String, Map<String, Int>> {
+      val map = mutableMapOf<String, MutableMap<String, Int>>()
+
+      val xmlPullParser = XmlPullParserFactory.newInstance()
+        .newPullParser()
+      xmlPullParser.setInput(FileInputStream(path), null)
+      var eventType = xmlPullParser.eventType
+      var attr: String? = null
+      while (eventType != XmlPullParser.END_DOCUMENT) {
+        if (eventType == XmlPullParser.START_TAG) {
+          if (TAG_ATTR == xmlPullParser.name) {
+            attr = xmlPullParser.getAttributeValue(null, ATTR_NAME)
+          } else if (TAG_ENUM == xmlPullParser.name || TAG_FLAG == xmlPullParser.name) {
+            val name = xmlPullParser.getAttributeValue(null, ATTR_NAME)
+            val value = xmlPullParser.getAttributeValue(null, ATTR_VALUE)
+            // Integer.decode cannot handle "ffffffff", see JDK issue 6624867
+            val i = (java.lang.Long.decode(value) as Long).toInt()
+            require(attr != null)
+            var attributeMap: MutableMap<String, Int>? = map[attr]
+            if (attributeMap == null) {
+              attributeMap = Maps.newHashMap()
+              map[attr] = attributeMap
+            }
+            attributeMap!![name] = i
+          }
+        } else if (eventType == XmlPullParser.END_TAG) {
+          if (TAG_ATTR == xmlPullParser.name) {
+            attr = null
+          }
+        }
+        eventType = xmlPullParser.next()
+      }
+
+      return map
+    }
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Environment.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Environment.kt
new file mode 100644
index 0000000..3597cde
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Environment.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import java.io.File
+import java.io.FileNotFoundException
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.util.Locale
+import kotlin.io.path.exists
+
+data class Environment(
+  val platformDir: String,
+  val appTestDir: String,
+  val resDir: String,
+  val assetsDir: String,
+  val packageName: String,
+  val compileSdkVersion: Int,
+  val resourcePackageNames: List<String>
+) {
+  init {
+    val platformDirPath = Path.of(platformDir)
+    if (!platformDirPath.exists()) {
+      val elements = platformDirPath.nameCount
+      val platform = platformDirPath.subpath(elements - 1, elements)
+      val platformVersion = platform.toString().split("-").last()
+      throw FileNotFoundException(
+        "Missing platform version $platformVersion. " +
+            "Install with sdkmanager --install \"platforms;$platform\""
+      )
+    }
+  }
+}
+
+@Suppress("unused")
+fun androidHome() = System.getenv("ANDROID_SDK_ROOT")
+  ?: System.getenv("ANDROID_HOME")
+  ?: androidSdkPath()
+
+fun detectEnvironment(): Environment {
+  checkInstalledJvm()
+
+  val resourcesFile = File(System.getProperty("paparazzi.test.resources"))
+  val configLines = resourcesFile.readLines()
+
+  val appTestDir = Paths.get(System.getProperty("user.dir"))
+  val androidHome = Paths.get(androidHome())
+  return Environment(
+    platformDir = androidHome.resolve(configLines[3]).toString(),
+    appTestDir = appTestDir.toString(),
+    resDir = appTestDir.resolve(configLines[1]).toString(),
+    assetsDir = appTestDir.resolve(configLines[4]).toString(),
+    packageName = configLines[0],
+    compileSdkVersion = configLines[2].toInt(),
+    resourcePackageNames = configLines[5].split(",")
+  )
+}
+
+private fun androidSdkPath(): String {
+  val osName = System.getProperty("os.name").lowercase(Locale.US)
+  val sdkPathDir = if (osName.startsWith("windows")) {
+    "\\AppData\\Local\\Android\\Sdk"
+  } else if (osName.startsWith("mac")) {
+    "/Library/Android/sdk"
+  } else {
+    "/Android/Sdk"
+  }
+  val homeDir = System.getProperty("user.home")
+  return homeDir + sdkPathDir
+}
+
+private fun checkInstalledJvm() {
+  val feature = try {
+    // Runtime#version() only available as of Java 9.
+    val version = Runtime::class.java.getMethod("version").invoke(null)
+    // Runtime.Version#feature() only available as of Java 10.
+    version.javaClass.getMethod("feature").invoke(version) as Int
+  } catch (e: NoSuchMethodException) {
+    -1
+  }
+
+  if (feature < 11) {
+    throw IllegalStateException(
+      "Unsupported JRE detected! Please install and run Paparazzi test suites on JDK 11+."
+    )
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Flags.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Flags.kt
new file mode 100644
index 0000000..62c0b62
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Flags.kt
@@ -0,0 +1,5 @@
+package app.cash.paparazzi
+
+object Flags {
+  const val DEBUG_LINKED_OBJECTS = "app.cash.paparazzi.debug.linked.objects"
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt
new file mode 100644
index 0000000..4c1eabc
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import app.cash.paparazzi.SnapshotHandler.FrameHandler
+import app.cash.paparazzi.internal.PaparazziJson
+import com.google.common.base.CharMatcher
+import okio.BufferedSink
+import okio.HashingSink
+import okio.blackholeSink
+import okio.buffer
+import okio.sink
+import okio.source
+import org.jcodec.api.awt.AWTSequenceEncoder
+import java.awt.image.BufferedImage
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+import javax.imageio.ImageIO
+
+/**
+ * Creates an HTML report that avoids writing files that have already been written.
+ *
+ * Images and videos are named by hashes of their contents. Paparazzi won't write two images or videos with the same
+ * contents. Note that the images/ directory includes the individual frames of each video.
+ *
+ * Runs are named by their date.
+ *
+ * ```
+ * images
+ *   088c60580f06efa95c37fd8e754074729ee74a06.png
+ *   93f9a81cb594280f4b3898d90dfad8c8ea969b01.png
+ *   22d37abd0841ba2a8d0bd635954baf7cbfaa269b.png
+ *   a4769e43cc5901ef28c0d46c46a44ea6429cbccc.png
+ * videos
+ *   d1cddc5da2224053f2af51f4e69a76de4e61fc41.mov
+ * runs
+ *   20190626002322_b9854e.js
+ *   20190626002345_b1e882.js
+ * index.html
+ * index.js
+ * paparazzi.js
+ * ```
+ */
+class HtmlReportWriter @JvmOverloads constructor(
+  private val runName: String = defaultRunName(),
+  private val rootDirectory: File = File("build/reports/paparazzi"),
+  snapshotRootDirectory: File = File("src/test/snapshots")
+) : SnapshotHandler {
+  private val runsDirectory: File = File(rootDirectory, "runs")
+  private val imagesDirectory: File = File(rootDirectory, "images")
+  private val videosDirectory: File = File(rootDirectory, "videos")
+
+  private val goldenImagesDirectory = File(snapshotRootDirectory, "images")
+  private val goldenVideosDirectory = File(snapshotRootDirectory, "videos")
+
+  private val shots = mutableListOf<Snapshot>()
+
+  private val isRecording: Boolean =
+    System.getProperty("paparazzi.test.record")?.toBoolean() == true
+
+  init {
+    runsDirectory.mkdirs()
+    imagesDirectory.mkdirs()
+    videosDirectory.mkdirs()
+    writeStaticFiles()
+    writeRunJs()
+    writeIndexJs()
+  }
+
+  override fun newFrameHandler(
+    snapshot: Snapshot,
+    frameCount: Int,
+    fps: Int
+  ): FrameHandler {
+    return object : FrameHandler {
+      val hashes = mutableListOf<String>()
+
+      override fun handle(image: BufferedImage) {
+        hashes += writeImage(image)
+      }
+
+      override fun close() {
+        if (hashes.isEmpty()) return
+
+        val shot = if (hashes.size == 1) {
+          val original = File(imagesDirectory, "${hashes[0]}.png")
+          if (isRecording) {
+            val goldenFile = File(goldenImagesDirectory, snapshot.toFileName("_", "png"))
+            original.copyTo(goldenFile, overwrite = true)
+          }
+          snapshot.copy(file = original.toJsonPath())
+        } else {
+          val hash = writeVideo(hashes, fps)
+
+          if (isRecording) {
+            for ((index, frameHash) in hashes.withIndex()) {
+              val originalFrame = File(imagesDirectory, "$frameHash.png")
+              val frameSnapshot = snapshot.copy(name = "${snapshot.name} $index")
+              val goldenFile = File(goldenImagesDirectory, frameSnapshot.toFileName("_", "png"))
+              if (!goldenFile.exists()) {
+                originalFrame.copyTo(goldenFile)
+              }
+            }
+          }
+          val original = File(videosDirectory, "$hash.mov")
+          if (isRecording) {
+            val goldenFile = File(goldenVideosDirectory, snapshot.toFileName("_", "mov"))
+            if (!goldenFile.exists()) {
+              original.copyTo(goldenFile)
+            }
+          }
+          snapshot.copy(file = original.toJsonPath())
+        }
+
+        shots += shot
+      }
+    }
+  }
+
+  /** Returns the hash of the image. */
+  private fun writeImage(image: BufferedImage): String {
+    val hash = hash(image)
+    val file = File(imagesDirectory, "$hash.png")
+    if (!file.exists()) {
+      file.writeAtomically(image)
+    }
+    return hash
+  }
+
+  /** Returns a SHA-1 hash of the pixels of [image]. */
+  private fun hash(image: BufferedImage): String {
+    val hashingSink = HashingSink.sha1(blackholeSink())
+    hashingSink.buffer().use { sink ->
+      for (y in 0 until image.height) {
+        for (x in 0 until image.width) {
+          sink.writeInt(image.getRGB(x, y))
+        }
+      }
+    }
+    return hashingSink.hash.hex()
+  }
+
+  private fun writeVideo(
+    frameHashes: List<String>,
+    fps: Int
+  ): String {
+    val hash = hash(frameHashes)
+    val file = File(videosDirectory, "$hash.mov")
+    if (!file.exists()) {
+      val tmpFile = File(videosDirectory, "$hash.mov.tmp")
+      val encoder = AWTSequenceEncoder.createSequenceEncoder(tmpFile, fps)
+      for (frameHash in frameHashes) {
+        val frame = ImageIO.read(File(imagesDirectory, "$frameHash.png"))
+        encoder.encodeImage(frame)
+      }
+      encoder.finish()
+      tmpFile.renameTo(file)
+    }
+    return hash
+  }
+
+  /** Returns a SHA-1 hash of [lines]. */
+  private fun hash(lines: List<String>): String {
+    val hashingSink = HashingSink.sha1(blackholeSink())
+    hashingSink.buffer().use { sink ->
+      for (hash in lines) {
+        sink.writeUtf8(hash)
+        sink.writeUtf8("\n")
+      }
+    }
+    return hashingSink.hash.hex()
+  }
+
+  /** Release all resources and block until everything has been written to the file system. */
+  override fun close() {
+    writeRunJs()
+  }
+
+  /**
+   * Emits the all runs index, which reads like JSON with an executable header.
+   *
+   * ```
+   * window.all_runs = [
+   *   "20190319153912aaab",
+   *   "20190319153917bcfe"
+   * ];
+   * ```
+   */
+  private fun writeIndexJs() {
+    val runNames = mutableListOf<String>()
+    val runs = runsDirectory.list().sorted()
+    for (run in runs) {
+      if (run.endsWith(".js")) {
+        runNames += run.substring(0, run.length - 3)
+      }
+    }
+
+    File(rootDirectory, "index.js").writeAtomically {
+      writeUtf8("window.all_runs = ")
+      PaparazziJson.listOfStringsAdapter.toJson(this, runNames)
+      writeUtf8(";")
+    }
+  }
+
+  /**
+   * Emits a run index, which reads like JSON with an executable header.
+   *
+   * ```
+   * window.runs["20190319153912aaab"] = [
+   *   {
+   *     "name": "loading",
+   *     "testName": "app.cash.CelebrityTest#testSettings",
+   *     "timestamp": "2019-03-20T10:27:43Z",
+   *     "tags": ["redesign"],
+   *     "file": "loading.png"
+   *   },
+   *   {
+   *     "name": "error",
+   *     "testName": "app.cash.CelebrityTest#testSettings",
+   *     "timestamp": "2019-03-20T10:27:43Z",
+   *     "tags": ["redesign"],
+   *     "file": "error.png"
+   *   }
+   * ];
+   * ```
+   */
+  private fun writeRunJs() {
+    val runJs = File(runsDirectory, "${runName.sanitizeForFilename()}.js")
+    runJs.writeAtomically {
+      writeUtf8("window.runs[\"$runName\"] = ")
+      PaparazziJson.listOfShotsAdapter.toJson(this, shots)
+      writeUtf8(";")
+    }
+  }
+
+  private fun writeStaticFiles() {
+    for (staticFile in listOf("index.html", "paparazzi.js")) {
+      File(rootDirectory, staticFile).writeAtomically {
+        writeAll(HtmlReportWriter::class.java.classLoader.getResourceAsStream(staticFile).source())
+      }
+    }
+  }
+
+  private fun File.writeAtomically(bufferedImage: BufferedImage) {
+    val tmpFile = File(parentFile, "$name.tmp")
+    ImageIO.write(bufferedImage, "PNG", tmpFile)
+    delete()
+    tmpFile.renameTo(this)
+  }
+
+  private fun File.writeAtomically(writerAction: BufferedSink.() -> Unit) {
+    val tmpFile = File(parentFile, "$name.tmp")
+    tmpFile.sink()
+      .buffer()
+      .use { sink ->
+        sink.writerAction()
+      }
+    delete()
+    tmpFile.renameTo(this)
+  }
+
+  private fun File.toJsonPath(): String = relativeTo(rootDirectory).invariantSeparatorsPath
+}
+
+internal fun defaultRunName(): String {
+  val now = Date()
+  val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(now)
+  val token = UUID.randomUUID().toString().substring(0, 6)
+  return "${timestamp}_$token"
+}
+
+internal val filenameSafeChars = CharMatcher.inRange('a', 'z')
+  .or(CharMatcher.inRange('0', '9'))
+  .or(CharMatcher.anyOf("_-.~@^()[]{}:;,"))
+
+internal fun String.sanitizeForFilename(): String? {
+  return filenameSafeChars.negate().replaceFrom(lowercase(Locale.US), '_')
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt
new file mode 100644
index 0000000..7694b0f
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt
@@ -0,0 +1,620 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import android.animation.AnimationHandler
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.os.Handler_Delegate
+import android.os.SystemClock_Delegate
+import android.util.AttributeSet
+import android.util.DisplayMetrics
+import android.view.BridgeInflater
+import android.view.Choreographer
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams
+import android.widget.FrameLayout
+import androidx.annotation.LayoutRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Recomposer
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import app.cash.paparazzi.agent.AgentTestRule
+import app.cash.paparazzi.agent.InterceptorRegistrar
+import app.cash.paparazzi.internal.ChoreographerDelegateInterceptor
+import app.cash.paparazzi.internal.EditModeInterceptor
+import app.cash.paparazzi.internal.IInputMethodManagerInterceptor
+import app.cash.paparazzi.internal.ImageUtils
+import app.cash.paparazzi.internal.MatrixMatrixMultiplicationInterceptor
+import app.cash.paparazzi.internal.MatrixVectorMultiplicationInterceptor
+import app.cash.paparazzi.internal.PaparazziCallback
+import app.cash.paparazzi.internal.PaparazziLogger
+import app.cash.paparazzi.internal.Renderer
+import app.cash.paparazzi.internal.ResourcesInterceptor
+import app.cash.paparazzi.internal.ServiceManagerInterceptor
+import app.cash.paparazzi.internal.SessionParamsBuilder
+import app.cash.paparazzi.internal.parsers.LayoutPullParser
+import com.android.ide.common.rendering.api.RenderSession
+import com.android.ide.common.rendering.api.Result
+import com.android.ide.common.rendering.api.Result.Status.ERROR_UNKNOWN
+import com.android.ide.common.rendering.api.SessionParams
+import com.android.ide.common.rendering.api.SessionParams.RenderingMode
+import com.android.internal.lang.System_Delegate
+import com.android.layoutlib.bridge.Bridge
+import com.android.layoutlib.bridge.Bridge.cleanupThread
+import com.android.layoutlib.bridge.Bridge.prepareThread
+import com.android.layoutlib.bridge.BridgeRenderSession
+import com.android.layoutlib.bridge.impl.RenderAction
+import com.android.layoutlib.bridge.impl.RenderSessionImpl
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import java.awt.image.BufferedImage
+import java.util.Date
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.ContinuationInterceptor
+
+class Paparazzi @JvmOverloads constructor(
+  private val environment: Environment = detectEnvironment(),
+  private val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5,
+  private val theme: String = "android:Theme.Material.NoActionBar.Fullscreen",
+  private val renderingMode: RenderingMode = RenderingMode.NORMAL,
+  private val appCompatEnabled: Boolean = true,
+  private val maxPercentDifference: Double = 0.1,
+  private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference),
+  private val renderExtensions: Set<RenderExtension> = setOf()
+) : TestRule {
+  private val logger = PaparazziLogger()
+  private lateinit var renderSession: RenderSessionImpl
+  private lateinit var bridgeRenderSession: RenderSession
+  private var testName: TestName? = null
+
+  val layoutInflater: LayoutInflater
+    get() = RenderAction.getCurrentContext().getSystemService("layout_inflater") as BridgeInflater
+
+  val resources: Resources
+    get() = RenderAction.getCurrentContext().resources
+
+  val context: Context
+    get() = RenderAction.getCurrentContext()
+
+  private val contentRoot = """
+        |<?xml version="1.0" encoding="utf-8"?>
+        |<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+        |              android:layout_width="match_parent"
+        |              android:layout_height="match_parent"/>
+  """.trimMargin()
+
+  override fun apply(
+    base: Statement,
+    description: Description
+  ): Statement {
+    val statement = object : Statement() {
+      override fun evaluate() {
+        prepare(description)
+        try {
+          base.evaluate()
+        } finally {
+          close()
+          logger.assertNoErrors()
+        }
+      }
+    }
+
+    return if (!isInitialized) {
+      registerFontLookupInterceptionIfResourceCompatDetected()
+      registerViewEditModeInterception()
+      registerMatrixMultiplyInterception()
+      registerChoreographerDelegateInterception()
+      registerServiceManagerInterception()
+      registerIInputMethodManagerInterception()
+
+      val outerRule = AgentTestRule()
+      outerRule.apply(statement, description)
+    } else {
+      statement
+    }
+  }
+
+  fun prepare(description: Description) {
+    forcePlatformSdkVersion(environment.compileSdkVersion)
+
+    val layoutlibCallback =
+      PaparazziCallback(logger, environment.packageName, environment.resourcePackageNames)
+    layoutlibCallback.initResources()
+
+    testName = description.toTestName()
+
+    if (!isInitialized) {
+      renderer = Renderer(environment, layoutlibCallback, logger, maxPercentDifference)
+      sessionParamsBuilder = renderer.prepare()
+    }
+
+    sessionParamsBuilder = sessionParamsBuilder
+      .copy(
+        layoutPullParser = LayoutPullParser.createFromString(contentRoot),
+        deviceConfig = deviceConfig,
+        renderingMode = renderingMode
+      )
+      .withTheme(theme)
+
+    val sessionParams = sessionParamsBuilder.build()
+    renderSession = createRenderSession(sessionParams)
+    prepareThread()
+    renderSession.init(sessionParams.timeout)
+    Bitmap.setDefaultDensity(DisplayMetrics.DENSITY_DEVICE_STABLE)
+
+    // requires LayoutInflater to be created, which is a side-effect of RenderSessionImpl.init()
+    if (appCompatEnabled) {
+      initializeAppCompatIfPresent()
+    }
+
+    bridgeRenderSession = createBridgeSession(renderSession, renderSession.inflate())
+  }
+
+  fun close() {
+    testName = null
+    renderSession.release()
+    bridgeRenderSession.dispose()
+    cleanupThread()
+    snapshotHandler.close()
+
+    renderer.dumpDelegates()
+  }
+
+  @Suppress("UNCHECKED_CAST")
+  fun <V : View> inflate(@LayoutRes layoutId: Int): V =
+    layoutInflater.inflate(layoutId, null) as V
+
+  fun snapshot(name: String? = null, composable: @Composable () -> Unit) {
+    val hostView = ComposeView(context)
+    // During onAttachedToWindow, AbstractComposeView will attempt to resolve its parent's
+    // CompositionContext, which requires first finding the "content view", then using that to
+    // find a root view with a ViewTreeLifecycleOwner
+    val parent = FrameLayout(context).apply { id = android.R.id.content }
+    parent.addView(hostView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+    PaparazziComposeOwner.register(parent)
+    hostView.setContent(composable)
+
+    try {
+      snapshot(parent, name)
+    } finally {
+      forceReleaseComposeReferenceLeaks()
+    }
+  }
+
+  @JvmOverloads
+  fun snapshot(view: View, name: String? = null) {
+    takeSnapshots(view, name, 0, -1, 1)
+  }
+
+  @JvmOverloads
+  fun gif(
+    view: View,
+    name: String? = null,
+    start: Long = 0L,
+    end: Long = 500L,
+    fps: Int = 30
+  ) {
+    // Add one to the frame count so we get the last frame. Otherwise a 1 second, 60 FPS animation
+    // our 60th frame will be at time 983 ms, and we want our last frame to be 1,000 ms. This gets
+    // us 61 frames for a 1 second animation, 121 frames for a 2 second animation, etc.
+    val durationMillis = (end - start).toInt()
+    val frameCount = (durationMillis * fps) / 1000 + 1
+    val startNanos = TimeUnit.MILLISECONDS.toNanos(start)
+    takeSnapshots(view, name, startNanos, fps, frameCount)
+  }
+
+  fun unsafeUpdateConfig(
+    deviceConfig: DeviceConfig? = null,
+    theme: String? = null,
+    renderingMode: RenderingMode? = null
+  ) {
+    require(deviceConfig != null || theme != null || renderingMode != null) {
+      "Calling unsafeUpdateConfig requires at least one non-null argument."
+    }
+
+    renderSession.release()
+    bridgeRenderSession.dispose()
+    cleanupThread()
+
+    sessionParamsBuilder = sessionParamsBuilder
+      .copy(
+        // Required to reset underlying parser stream
+        layoutPullParser = LayoutPullParser.createFromString(contentRoot)
+      )
+
+    if (deviceConfig != null) {
+      sessionParamsBuilder = sessionParamsBuilder.copy(deviceConfig = deviceConfig)
+    }
+
+    if (theme != null) {
+      sessionParamsBuilder = sessionParamsBuilder.withTheme(theme)
+    }
+
+    if (renderingMode != null) {
+      sessionParamsBuilder = sessionParamsBuilder.copy(renderingMode = renderingMode)
+    }
+
+    val sessionParams = sessionParamsBuilder.build()
+    renderSession = createRenderSession(sessionParams)
+    prepareThread()
+    renderSession.init(sessionParams.timeout)
+    Bitmap.setDefaultDensity(DisplayMetrics.DENSITY_DEVICE_STABLE)
+    bridgeRenderSession = createBridgeSession(renderSession, renderSession.inflate())
+  }
+
+  private fun takeSnapshots(
+    view: View,
+    name: String?,
+    startNanos: Long,
+    fps: Int,
+    frameCount: Int
+  ) {
+    val snapshot = Snapshot(name, testName!!, Date())
+
+    val frameHandler = snapshotHandler.newFrameHandler(snapshot, frameCount, fps)
+    frameHandler.use {
+      val viewGroup = bridgeRenderSession.rootViews[0].viewObject as ViewGroup
+      val modifiedView = renderExtensions.fold(view) { view, renderExtension ->
+        renderExtension.renderView(view)
+      }
+
+      System_Delegate.setBootTimeNanos(0L)
+      try {
+        withTime(0L) {
+          // Initialize the choreographer at time=0.
+        }
+
+        viewGroup.addView(modifiedView)
+        for (frame in 0 until frameCount) {
+          val nowNanos = (startNanos + (frame * 1_000_000_000.0 / fps)).toLong()
+          withTime(nowNanos) {
+            val result = renderSession.render(true)
+            if (result.status == ERROR_UNKNOWN) {
+              throw result.exception
+            }
+
+            val image = bridgeRenderSession.image
+            frameHandler.handle(scaleImage(image))
+          }
+        }
+      } finally {
+        viewGroup.removeView(modifiedView)
+        AnimationHandler.sAnimatorHandler.set(null)
+      }
+    }
+  }
+
+  private fun withTime(
+    timeNanos: Long,
+    block: () -> Unit
+  ) {
+    val frameNanos = TIME_OFFSET_NANOS + timeNanos
+
+    // Execute the block at the requested time.
+    System_Delegate.setNanosTime(frameNanos)
+
+    val choreographer = Choreographer.getInstance()
+    val areCallbacksRunningField = choreographer::class.java.getDeclaredField("mCallbacksRunning")
+    areCallbacksRunningField.isAccessible = true
+
+    try {
+      areCallbacksRunningField.setBoolean(choreographer, true)
+
+      // https://android.googlesource.com/platform/frameworks/layoutlib/+/d58aa4703369e109b24419548f38b422d5a44738/bridge/src/com/android/layoutlib/bridge/BridgeRenderSession.java#171
+      // BridgeRenderSession.executeCallbacks aggressively tears down the main Looper and BridgeContext, so we call the static delegates ourselves.
+      Handler_Delegate.executeCallbacks()
+      val currentTimeMs = SystemClock_Delegate.uptimeMillis()
+      val choreographerCallbacks =
+        RenderAction.getCurrentContext().sessionInteractiveData.choreographerCallbacks
+      choreographerCallbacks.execute(currentTimeMs, Bridge.getLog())
+
+      block()
+    } catch (e: Throwable) {
+      Bridge.getLog().error("broken", "Failed executing Choreographer#doFrame", e, null, null)
+      throw e
+    } finally {
+      areCallbacksRunningField.setBoolean(choreographer, false)
+    }
+  }
+
+  private fun createRenderSession(sessionParams: SessionParams): RenderSessionImpl {
+    val renderSession = RenderSessionImpl(sessionParams)
+    renderSession.setElapsedFrameTimeNanos(0L)
+    RenderSessionImpl::class.java
+      .getDeclaredField("mFirstFrameExecuted")
+      .apply {
+        isAccessible = true
+        set(renderSession, true)
+      }
+    return renderSession
+  }
+
+  private fun createBridgeSession(
+    renderSession: RenderSessionImpl,
+    result: Result
+  ): BridgeRenderSession {
+    try {
+      val bridgeSessionClass = Class.forName("com.android.layoutlib.bridge.BridgeRenderSession")
+      val constructor =
+        bridgeSessionClass.getDeclaredConstructor(RenderSessionImpl::class.java, Result::class.java)
+      constructor.isAccessible = true
+      return constructor.newInstance(renderSession, result) as BridgeRenderSession
+    } catch (e: Exception) {
+      throw RuntimeException(e)
+    }
+  }
+
+  private fun scaleImage(image: BufferedImage): BufferedImage {
+    val scale = ImageUtils.getThumbnailScale(image)
+    return ImageUtils.scale(image, scale, scale)
+  }
+
+  private fun Description.toTestName(): TestName {
+    val fullQualifiedName = className
+    val packageName = fullQualifiedName.substringBeforeLast('.', missingDelimiterValue = "")
+    val className = fullQualifiedName.substringAfterLast('.')
+    return TestName(packageName, className, methodName)
+  }
+
+  private fun forcePlatformSdkVersion(compileSdkVersion: Int) {
+    val buildVersionClass = try {
+      Paparazzi::class.java.classLoader.loadClass("android.os.Build\$VERSION")
+    } catch (e: ClassNotFoundException) {
+      // Project unit tests don't load Android platform code
+      return
+    }
+    buildVersionClass
+      .getFieldReflectively("SDK_INT")
+      .setStaticValue(compileSdkVersion)
+  }
+
+  private fun initializeAppCompatIfPresent() {
+    lateinit var appCompatDelegateClass: Class<*>
+    try {
+      // See androidx.appcompat.widget.AppCompatDrawableManager#preload()
+      val appCompatDrawableManagerClass =
+        Class.forName("androidx.appcompat.widget.AppCompatDrawableManager")
+      val preloadMethod = appCompatDrawableManagerClass.getMethod("preload")
+      preloadMethod.invoke(null)
+
+      appCompatDelegateClass = Class.forName("androidx.appcompat.app.AppCompatDelegate")
+    } catch (e: ClassNotFoundException) {
+      logger.verbose("AppCompat not found on classpath")
+      return
+    }
+
+    // See androidx.appcompat.app.AppCompatDelegateImpl#installViewFactory()
+    if (layoutInflater.factory == null) {
+      layoutInflater.factory2 = object : LayoutInflater.Factory2 {
+        override fun onCreateView(
+          parent: View?,
+          name: String,
+          context: Context,
+          attrs: AttributeSet
+        ): View? {
+          val appCompatViewInflaterClass =
+            Class.forName("androidx.appcompat.app.AppCompatViewInflater")
+
+          val createViewMethod = appCompatViewInflaterClass
+            .getDeclaredMethod(
+              "createView",
+              View::class.java,
+              String::class.java,
+              Context::class.java,
+              AttributeSet::class.java,
+              Boolean::class.javaPrimitiveType,
+              Boolean::class.javaPrimitiveType,
+              Boolean::class.javaPrimitiveType,
+              Boolean::class.javaPrimitiveType
+            )
+            .apply { isAccessible = true }
+
+          val inheritContext = true
+          val readAndroidTheme = true
+          val readAppTheme = true
+          val wrapContext = true
+
+          val newAppCompatViewInflaterInstance = appCompatViewInflaterClass
+            .getConstructor()
+            .newInstance()
+
+          return createViewMethod.invoke(
+            newAppCompatViewInflaterInstance, parent, name, context, attrs,
+            inheritContext, readAndroidTheme, readAppTheme, wrapContext
+          ) as View?
+        }
+
+        override fun onCreateView(
+          name: String,
+          context: Context,
+          attrs: AttributeSet
+        ): View? = onCreateView(null, name, context, attrs)
+      }
+    } else {
+      if (!appCompatDelegateClass.isAssignableFrom(layoutInflater.factory2::class.java)) {
+        throw IllegalStateException(
+          "The LayoutInflater already has a Factory installed so we can not install AppCompat's"
+        )
+      }
+    }
+  }
+
+  /**
+   * Current workaround for supporting custom fonts when constructing views in code. This check
+   * may be used or expanded to support other cases requiring similar method interception
+   * techniques.
+   *
+   * See:
+   * https://github.com/cashapp/paparazzi/issues/119
+   * https://issuetracker.google.com/issues/156065472
+   */
+  private fun registerFontLookupInterceptionIfResourceCompatDetected() {
+    try {
+      val resourcesCompatClass = Class.forName("androidx.core.content.res.ResourcesCompat")
+      InterceptorRegistrar.addMethodInterceptor(
+        resourcesCompatClass,
+        "getFont",
+        ResourcesInterceptor::class.java
+      )
+    } catch (e: ClassNotFoundException) {
+      logger.verbose("ResourceCompat not found on classpath")
+    }
+  }
+
+  private fun registerServiceManagerInterception() {
+    val serviceManager = Class.forName("android.os.ServiceManager")
+    InterceptorRegistrar.addMethodInterceptor(
+      serviceManager,
+      "getServiceOrThrow",
+      ServiceManagerInterceptor::class.java
+    )
+  }
+
+  private fun registerIInputMethodManagerInterception() {
+    val iimm = Class.forName("com.android.internal.view.IInputMethodManager\$Stub")
+    InterceptorRegistrar.addMethodInterceptor(
+      iimm,
+      "asInterface",
+      IInputMethodManagerInterceptor::class.java
+    )
+  }
+
+  private fun registerViewEditModeInterception() {
+    val viewClass = Class.forName("android.view.View")
+    InterceptorRegistrar.addMethodInterceptor(
+      viewClass,
+      "isInEditMode",
+      EditModeInterceptor::class.java
+    )
+  }
+
+  private fun registerMatrixMultiplyInterception() {
+    val matrixClass = Class.forName("android.opengl.Matrix")
+    InterceptorRegistrar.addMethodInterceptors(
+      matrixClass,
+      setOf(
+        "multiplyMM" to MatrixMatrixMultiplicationInterceptor::class.java,
+        "multiplyMV" to MatrixVectorMultiplicationInterceptor::class.java
+      )
+    )
+  }
+
+  private fun registerChoreographerDelegateInterception() {
+    val choreographerDelegateClass = Class.forName("android.view.Choreographer_Delegate")
+    InterceptorRegistrar.addMethodInterceptor(
+      choreographerDelegateClass,
+      "getFrameTimeNanos",
+      ChoreographerDelegateInterceptor::class.java
+    )
+  }
+
+  private fun forceReleaseComposeReferenceLeaks() {
+    val snapshotClass = Class.forName("androidx.compose.runtime.snapshots.SnapshotKt")
+    val applyObservers = snapshotClass
+      .getDeclaredField("applyObservers")
+      .apply { isAccessible = true }
+      .get(null) as MutableList<*>
+    val applyObserver = applyObservers.getOrNull(0)
+    if (applyObserver != null) {
+      val recomposer = applyObserver.javaClass
+        .getDeclaredField("this\$0")
+        .apply { isAccessible = true }
+        .get(applyObserver) as Recomposer
+      val compositionInvalidations = recomposer.javaClass
+        .getDeclaredField("compositionInvalidations")
+        .apply { isAccessible = true }
+        .get(recomposer) as MutableList<*>
+      val snapshotInvalidations = recomposer.javaClass
+        .getDeclaredField("snapshotInvalidations")
+        .apply { isAccessible = true }
+        .get(recomposer) as MutableList<*>
+      compositionInvalidations.clear()
+      snapshotInvalidations.clear()
+      applyObservers.clear()
+    }
+
+    val dispatcher =
+      AndroidUiDispatcher.CurrentThread[ContinuationInterceptor] as AndroidUiDispatcher
+    val toRunTrampolined = dispatcher.javaClass
+      .getDeclaredField("toRunTrampolined")
+      .apply { isAccessible = true }
+      .get(dispatcher) as ArrayDeque<*>
+    toRunTrampolined.clear()
+    // Upon reference leaks being fixed, verify we don't need to reset these values for
+    // AndroidUiDispatcher to continue dispatching between tests.
+    dispatcher.javaClass
+      .getDeclaredField("scheduledTrampolineDispatch")
+      .apply { isAccessible = true }
+      .set(dispatcher, false)
+    dispatcher.javaClass
+      .getDeclaredField("scheduledFrameDispatch")
+      .apply { isAccessible = true }
+      .set(dispatcher, false)
+  }
+
+  private class PaparazziComposeOwner private constructor() :
+    LifecycleOwner, SavedStateRegistryOwner {
+    private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
+    private val savedStateRegistryController = SavedStateRegistryController.create(this)
+
+    override fun getLifecycle(): Lifecycle = lifecycleRegistry
+    override val savedStateRegistry: SavedStateRegistry =
+      savedStateRegistryController.savedStateRegistry
+
+    companion object {
+      fun register(view: View) {
+        val owner = PaparazziComposeOwner()
+        owner.savedStateRegistryController.performRestore(null)
+        owner.lifecycleRegistry.currentState = Lifecycle.State.CREATED
+        ViewTreeLifecycleOwner.set(view, owner)
+        view.setViewTreeSavedStateRegistryOwner(owner)
+      }
+    }
+  }
+
+  companion object {
+    /** The choreographer doesn't like 0 as a frame time, so start an hour later. */
+    internal val TIME_OFFSET_NANOS = TimeUnit.HOURS.toNanos(1L)
+
+    internal lateinit var renderer: Renderer
+    internal val isInitialized get() = ::renderer.isInitialized
+
+    internal lateinit var sessionParamsBuilder: SessionParamsBuilder
+
+    private val isVerifying: Boolean =
+      System.getProperty("paparazzi.test.verify")?.toBoolean() == true
+
+    private fun determineHandler(maxPercentDifference: Double): SnapshotHandler =
+      if (isVerifying) {
+        SnapshotVerifier(maxPercentDifference)
+      } else {
+        HtmlReportWriter()
+      }
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Reflections.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Reflections.kt
new file mode 100644
index 0000000..6742499
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Reflections.kt
@@ -0,0 +1,62 @@
+package app.cash.paparazzi
+
+import sun.misc.Unsafe
+import java.lang.reflect.Field
+import java.lang.reflect.Modifier
+import java.security.AccessController
+import java.security.PrivilegedAction
+
+/**
+ * Inspired by and ported from:
+ * https://github.com/powermock/powermock/commit/fc092c5d7e339d01e079184a2a0e88b5c46fc0e8
+ * https://github.com/powermock/powermock/commit/bd92bcc5329c4981cf09dece5c3eafcf92fe49ff
+ */
+internal fun Class<*>.getFieldReflectively(fieldName: String): Field =
+  try {
+    this.getDeclaredField(fieldName).also { it.isAccessible = true }
+  } catch (e: NoSuchFieldException) {
+    throw RuntimeException("Field '$fieldName' was not found in class $name.")
+  }
+
+internal fun Field.setStaticValue(value: Any) {
+  try {
+    this.isAccessible = true
+    val isFinalModifierPresent = this.modifiers and Modifier.FINAL == Modifier.FINAL
+    if (isFinalModifierPresent) {
+      AccessController.doPrivileged<Any?>(
+        PrivilegedAction {
+          try {
+            val unsafe = Unsafe::class.java.getFieldReflectively("theUnsafe").get(null) as Unsafe
+            val offset = unsafe.staticFieldOffset(this)
+            val base = unsafe.staticFieldBase(this)
+            unsafe.setFieldValue(this, base, offset, value)
+            null
+          } catch (t: Throwable) {
+            throw RuntimeException(t)
+          }
+        }
+      )
+    } else {
+      this.set(null, value)
+    }
+  } catch (ex: SecurityException) {
+    throw RuntimeException(ex)
+  } catch (ex: IllegalAccessException) {
+    throw RuntimeException(ex)
+  } catch (ex: IllegalArgumentException) {
+    throw RuntimeException(ex)
+  }
+}
+
+internal fun Unsafe.setFieldValue(field: Field, base: Any, offset: Long, value: Any) =
+  when (field.type) {
+    Integer.TYPE -> this.putInt(base, offset, (value as Int))
+    java.lang.Short.TYPE -> this.putShort(base, offset, (value as Short))
+    java.lang.Long.TYPE -> this.putLong(base, offset, (value as Long))
+    java.lang.Byte.TYPE -> this.putByte(base, offset, (value as Byte))
+    java.lang.Boolean.TYPE -> this.putBoolean(base, offset, (value as Boolean))
+    java.lang.Float.TYPE -> this.putFloat(base, offset, (value as Float))
+    java.lang.Double.TYPE -> this.putDouble(base, offset, (value as Double))
+    Character.TYPE -> this.putChar(base, offset, (value as Char))
+    else -> this.putObject(base, offset, value)
+  }
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/RenderExtension.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/RenderExtension.kt
new file mode 100644
index 0000000..d0d12b2
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/RenderExtension.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import android.view.View
+
+/**
+ * An extension for overlaying additional information on top of each rendered frame.
+ */
+interface RenderExtension {
+  /**
+   * Allows this extension to modify the view hierarchy represented by [contentView].
+   *
+   * Returns the root view of the modified hierarchy.
+   */
+  fun renderView(contentView: View): View
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt
new file mode 100644
index 0000000..85501f7
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import com.squareup.moshi.JsonClass
+import java.util.Date
+import java.util.Locale
+
+@JsonClass(generateAdapter = true)
+data class Snapshot(
+  val name: String?,
+  val testName: TestName,
+  val timestamp: Date,
+  val tags: List<String> = listOf(),
+  val file: String? = null
+)
+
+internal fun Snapshot.toFileName(
+  delimiter: String = "_",
+  extension: String
+): String {
+  val formattedLabel = if (name != null) {
+    "$delimiter${name.lowercase(Locale.US).replace("\\s".toRegex(), delimiter)}"
+  } else {
+    ""
+  }
+  return "${testName.packageName}${delimiter}${testName.className}" +
+      "${delimiter}${testName.methodName}$formattedLabel.$extension"
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/SnapshotHandler.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/SnapshotHandler.kt
new file mode 100644
index 0000000..5067f25
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/SnapshotHandler.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import java.awt.image.BufferedImage
+import java.io.Closeable
+
+interface SnapshotHandler : Closeable {
+  fun newFrameHandler(
+    snapshot: Snapshot,
+    frameCount: Int,
+    fps: Int
+  ): FrameHandler
+
+  interface FrameHandler : Closeable {
+    fun handle(image: BufferedImage)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt
new file mode 100644
index 0000000..564cd2e
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2020 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import app.cash.paparazzi.SnapshotHandler.FrameHandler
+import app.cash.paparazzi.internal.ImageUtils
+import java.awt.image.BufferedImage
+import java.io.File
+import javax.imageio.ImageIO
+
+class SnapshotVerifier @JvmOverloads constructor(
+  private val maxPercentDifference: Double,
+  rootDirectory: File = File("src/test/snapshots")
+) : SnapshotHandler {
+  private val imagesDirectory: File = File(rootDirectory, "images")
+  private val videosDirectory: File = File(rootDirectory, "videos")
+
+  init {
+    imagesDirectory.mkdirs()
+    videosDirectory.mkdirs()
+  }
+
+  override fun newFrameHandler(
+    snapshot: Snapshot,
+    frameCount: Int,
+    fps: Int
+  ): FrameHandler {
+    return object : FrameHandler {
+      override fun handle(image: BufferedImage) {
+        // Note: does not handle videos or its frames at the moment
+        val expected = File(imagesDirectory, snapshot.toFileName(extension = "png"))
+        if (!expected.exists()) {
+          throw AssertionError("File $expected does not exist")
+        }
+
+        val goldenImage = ImageIO.read(expected)
+        ImageUtils.assertImageSimilar(
+          relativePath = expected.path,
+          image = image,
+          goldenImage = goldenImage,
+          maxPercentDifferent = maxPercentDifference
+        )
+      }
+
+      override fun close() = Unit
+    }
+  }
+
+  override fun close() = Unit
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/TestName.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/TestName.kt
new file mode 100644
index 0000000..2ab7963
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/TestName.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+data class TestName(
+  val packageName: String,
+  val className: String,
+  val methodName: String
+)
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/accessibility/AccessibilityRenderExtension.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/accessibility/AccessibilityRenderExtension.kt
new file mode 100644
index 0000000..f6f16aa
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/accessibility/AccessibilityRenderExtension.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.accessibility
+
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
+import android.util.TypedValue
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import app.cash.paparazzi.RenderExtension
+import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_DESCRIPTION_BACKGROUND_COLOR
+import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_RECT_SIZE
+import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_RENDER_ALPHA
+import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_TEXT_COLOR
+import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_TEXT_SIZE
+import app.cash.paparazzi.accessibility.RenderSettings.getColor
+import app.cash.paparazzi.accessibility.RenderSettings.toColorInt
+import app.cash.paparazzi.accessibility.RenderSettings.withAlpha
+
+class AccessibilityRenderExtension : RenderExtension {
+  override fun renderView(
+    contentView: View
+  ): View {
+    val accessibilityViews = contentView.findAccessibilityViews()
+    accessibilityViews.forEach { view ->
+      val color = getColor(view)
+      val colorInt = color.toColorInt()
+
+      val colorDrawable = GradientDrawable(
+        GradientDrawable.Orientation.TOP_BOTTOM,
+        intArrayOf(colorInt, colorInt)
+      ).apply {
+        setStroke(2, color.withAlpha(DEFAULT_RENDER_ALPHA * 2).toColorInt())
+      }
+
+      view.foreground = view.foreground?.let { drawable ->
+        // If there is an existing foreground layer the color on top of it.
+        LayerDrawable(arrayOf(drawable, colorDrawable))
+      } ?: colorDrawable
+    }
+
+    return LinearLayout(contentView.context).apply {
+      orientation = LinearLayout.HORIZONTAL
+      weightSum = 2f
+      layoutParams = ViewGroup.LayoutParams(
+        ViewGroup.LayoutParams.MATCH_PARENT,
+        ViewGroup.LayoutParams.MATCH_PARENT
+      )
+
+      val contentLayoutParams = contentView.layoutParams ?: generateLayoutParams(null)
+      addView(
+        contentView,
+        LinearLayout.LayoutParams(
+          contentLayoutParams.width,
+          contentLayoutParams.height,
+          1f
+        )
+      )
+      addView(
+        buildAccessibilityView(contentView),
+        LinearLayout.LayoutParams(
+          ViewGroup.LayoutParams.MATCH_PARENT,
+          ViewGroup.LayoutParams.MATCH_PARENT,
+          1f
+        )
+      )
+    }
+  }
+
+  private fun View.findAccessibilityViews(): List<View> {
+    val accessibilityViews = mutableListOf<View>()
+    if (isImportantForAccessibility && !iterableTextForAccessibility.isNullOrBlank()) {
+      accessibilityViews.add(this)
+    }
+
+    if (this is ViewGroup) {
+      (0 until childCount).forEach {
+        accessibilityViews += getChildAt(it).findAccessibilityViews()
+      }
+    }
+
+    return accessibilityViews
+  }
+
+  private fun buildAccessibilityView(contentView: View): View {
+    val linearLayout = LinearLayout(contentView.context).apply {
+      orientation = LinearLayout.VERTICAL
+      setBackgroundColor(DEFAULT_DESCRIPTION_BACKGROUND_COLOR.toColorInt())
+    }
+
+    fun renderAccessibility(view: View) {
+      if (view.isImportantForAccessibility && !view.iterableTextForAccessibility.isNullOrBlank()) {
+        linearLayout.addView(buildAccessibilityRow(view, view.iterableTextForAccessibility))
+      }
+
+      if (view is ViewGroup) {
+        (0 until view.childCount).forEach {
+          renderAccessibility(view.getChildAt(it))
+        }
+      }
+    }
+
+    renderAccessibility(contentView)
+    return linearLayout
+  }
+
+  private fun buildAccessibilityRow(view: View, iterableTextForAccessibility: CharSequence): View {
+    val context = view.context
+    val color = getColor(view).toColorInt()
+    val margin = view.dip(8)
+    val innerMargin = view.dip(4)
+
+    return LinearLayout(context).apply {
+      orientation = LinearLayout.HORIZONTAL
+      layoutParams = ViewGroup.LayoutParams(
+        ViewGroup.LayoutParams.MATCH_PARENT,
+        ViewGroup.LayoutParams.WRAP_CONTENT
+      )
+      setPaddingRelative(margin, innerMargin, margin, innerMargin)
+
+      addView(
+        View(context).apply {
+          layoutParams = ViewGroup.LayoutParams(dip(DEFAULT_RECT_SIZE), dip(DEFAULT_RECT_SIZE))
+          background = GradientDrawable(
+            GradientDrawable.Orientation.TOP_BOTTOM,
+            intArrayOf(color, color)
+          ).apply {
+            cornerRadius = dip(DEFAULT_RECT_SIZE / 4f)
+          }
+          setPaddingRelative(innerMargin, innerMargin, innerMargin, innerMargin)
+        }
+      )
+      addView(
+        TextView(context).apply {
+          layoutParams = ViewGroup.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            ViewGroup.LayoutParams.WRAP_CONTENT
+          )
+          text = iterableTextForAccessibility
+          textSize = DEFAULT_TEXT_SIZE
+          setTextColor(DEFAULT_TEXT_COLOR.toColorInt())
+          setPaddingRelative(innerMargin, 0, innerMargin, 0)
+        }
+      )
+    }
+  }
+}
+
+private fun View.dip(value: Float): Float =
+  TypedValue.applyDimension(
+    TypedValue.COMPLEX_UNIT_DIP,
+    value,
+    resources.displayMetrics
+  )
+
+private fun View.dip(value: Int): Int = dip(value.toFloat()).toInt()
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/accessibility/RenderSettings.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/accessibility/RenderSettings.kt
new file mode 100644
index 0000000..768fc6e
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/accessibility/RenderSettings.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.accessibility
+
+import android.view.View
+import java.awt.Color
+
+internal object RenderSettings {
+  const val DEFAULT_RENDER_ALPHA = 40
+  val DEFAULT_RENDER_COLORS = listOf(
+    Color.RED,
+    Color.GREEN,
+    Color.BLUE,
+    Color.YELLOW,
+    Color.ORANGE,
+    Color.MAGENTA,
+    Color.CYAN,
+    Color.PINK
+  )
+  val DEFAULT_TEXT_COLOR: Color = Color.BLACK
+  val DEFAULT_DESCRIPTION_BACKGROUND_COLOR: Color = Color.WHITE
+  const val DEFAULT_TEXT_SIZE: Float = 10f
+  const val DEFAULT_RECT_SIZE: Int = 16
+
+  private val colorMap = mutableMapOf<Int, Color>()
+
+  fun getColor(view: View): Color {
+    val key = "${view::class.simpleName}(${view.iterableTextForAccessibility})"
+    return getColor(key)
+  }
+
+  private fun getColor(key: String): Color {
+    val hashCode = key.hashCode()
+    return colorMap.getOrPut(hashCode) {
+      nextColor(hashCode).withAlpha(DEFAULT_RENDER_ALPHA)
+    }
+  }
+
+  private fun nextColor(hashCode: Int): Color {
+    return DEFAULT_RENDER_COLORS[colorIndex(hashCode)]
+  }
+
+  private fun colorIndex(hashCode: Int): Int {
+    val size = DEFAULT_RENDER_COLORS.size
+    val i = hashCode % size
+    return if (i < 0) i + size else i
+  }
+
+  internal fun Color.toColorInt(): Int =
+    android.graphics.Color.argb(alpha, red, green, blue)
+
+  internal fun Color.withAlpha(alpha: Int): Color {
+    return Color(red, green, blue, alpha)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ChoreographerDelegateInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ChoreographerDelegateInterceptor.kt
new file mode 100644
index 0000000..57b7bf1
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ChoreographerDelegateInterceptor.kt
@@ -0,0 +1,12 @@
+package app.cash.paparazzi.internal
+
+import android.view.Choreographer
+import com.android.internal.lang.System_Delegate
+
+object ChoreographerDelegateInterceptor {
+  @Suppress("unused")
+  @JvmStatic
+  fun intercept(
+    @Suppress("UNUSED_PARAMETER") choreographer: Choreographer
+  ): Long = System_Delegate.nanoTime()
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/EditModeInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/EditModeInterceptor.kt
new file mode 100644
index 0000000..c464a04
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/EditModeInterceptor.kt
@@ -0,0 +1,6 @@
+package app.cash.paparazzi.internal
+
+object EditModeInterceptor {
+  @JvmStatic
+  fun intercept(): Boolean = false
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/Gc.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/Gc.kt
new file mode 100644
index 0000000..5bbd25c
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/Gc.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2016 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 app.cash.paparazzi.internal
+
+import java.lang.ref.WeakReference
+
+internal object Gc {
+  fun gc() {
+    // See RuntimeUtil#gc in jlibs (http://jlibs.in/)
+    var obj: Any? = Any()
+    val ref = WeakReference<Any>(obj)
+
+    @Suppress("UNUSED_VAlUE")
+    obj = null
+    while (ref.get() != null) {
+      System.gc()
+      System.runFinalization()
+    }
+
+    System.gc()
+    System.runFinalization()
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/IInputMethodManagerInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/IInputMethodManagerInterceptor.kt
new file mode 100644
index 0000000..1908949
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/IInputMethodManagerInterceptor.kt
@@ -0,0 +1,16 @@
+package app.cash.paparazzi.internal
+
+import android.os.IBinder
+import com.android.internal.view.IInputMethodManager
+
+/**
+ * With [ServiceManagerInterceptor] returning null for the service, we must override the logic
+ * in [com.android.internal.view.IInputMethodManager.Stub.asInterface] to return the default
+ * implementation of [IInputMethodManager].
+ */
+object IInputMethodManagerInterceptor {
+  @Suppress("unused")
+  @JvmStatic
+  fun interceptAsInterface(@Suppress("UNUSED_PARAMETER") obj: IBinder?): IInputMethodManager =
+    IInputMethodManager.Default()
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ImageUtils.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ImageUtils.kt
new file mode 100644
index 0000000..8406d6d3
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ImageUtils.kt
@@ -0,0 +1,357 @@
+/*
+ * Copyright (C) 2016 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 app.cash.paparazzi.internal
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import java.awt.AlphaComposite
+import java.awt.Color
+import java.awt.Graphics2D
+import java.awt.RenderingHints.KEY_ANTIALIASING
+import java.awt.RenderingHints.KEY_INTERPOLATION
+import java.awt.RenderingHints.KEY_RENDERING
+import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
+import java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR
+import java.awt.RenderingHints.VALUE_RENDER_QUALITY
+import java.awt.image.BufferedImage
+import java.awt.image.BufferedImage.TYPE_INT_ARGB
+import java.io.File
+import java.io.File.separatorChar
+import java.io.IOException
+import javax.imageio.ImageIO
+import kotlin.math.max
+
+/**
+ * Utilities related to image processing.
+ */
+internal object ImageUtils {
+  /**
+   * Normally, this test will fail when there is a missing thumbnail. However, when
+   * you create creating a new test, it's useful to be able to turn this off such that
+   * you can generate all the missing thumbnails in one go, rather than having to run
+   * the test repeatedly to get to each new render assertion generating its thumbnail.
+   */
+  private val FAIL_ON_MISSING_THUMBNAIL = true
+
+  private const val THUMBNAIL_SIZE = 1000
+
+  /** Directory where to write the thumbnails and deltas. */
+  private val failureDir: File
+    get() {
+      val workingDirString = System.getProperty("user.dir")
+      val failureDir = File(workingDirString, "out/failures")
+      failureDir.mkdirs()
+      return failureDir
+    }
+
+  @Throws(IOException::class)
+  fun requireSimilar(
+    relativePath: String,
+    image: BufferedImage,
+    maxPercentDifference: Double
+  ) {
+    val scale = getThumbnailScale(image)
+    val thumbnail = scale(image, scale, scale)
+
+    val `is` = ImageUtils::class.java.classLoader.getResourceAsStream(relativePath)
+    if (`is` ==
+      null
+    ) {
+      var message = "Unable to load golden thumbnail: $relativePath\n"
+      message = saveImageAndAppendMessage(thumbnail, message, relativePath)
+      if (FAIL_ON_MISSING_THUMBNAIL) {
+        fail(message)
+      } else {
+        println(message)
+      }
+    } else {
+      try {
+        val goldenImage = ImageIO.read(`is`)
+        assertImageSimilar(
+          relativePath,
+          goldenImage,
+          thumbnail,
+          maxPercentDifference
+        )
+      } finally {
+        `is`.close()
+      }
+    }
+  }
+
+  @Throws(IOException::class)
+  fun assertImageSimilar(
+    relativePath: String,
+    goldenImage: BufferedImage,
+    image: BufferedImage,
+    maxPercentDifferent: Double
+  ) {
+    @Suppress("NAME_SHADOWING") var goldenImage = goldenImage
+    if (goldenImage.type != TYPE_INT_ARGB) {
+      val temp = BufferedImage(
+        goldenImage.width,
+        goldenImage.height,
+        TYPE_INT_ARGB
+      )
+      temp.graphics.drawImage(goldenImage, 0, 0, null)
+      goldenImage = temp
+    }
+    assertEquals(TYPE_INT_ARGB.toLong(), goldenImage.type.toLong())
+
+    val imageWidth = Math.min(goldenImage.width, image.width)
+    val imageHeight = Math.min(goldenImage.height, image.height)
+
+    // Blur the images to account for the scenarios where there are pixel
+    // differences
+    // in where a sharp edge occurs
+    // goldenImage = blur(goldenImage, 6);
+    // image = blur(image, 6);
+
+    val width = 3 * imageWidth
+    val deltaImage = BufferedImage(width, imageHeight, TYPE_INT_ARGB)
+    val g = deltaImage.graphics
+
+    // Compute delta map
+    var delta: Long = 0
+    for (y in 0 until imageHeight) {
+      for (x in 0 until imageWidth) {
+        val goldenRgb = goldenImage.getRGB(x, y)
+        val rgb = image.getRGB(x, y)
+        if (goldenRgb == rgb) {
+          deltaImage.setRGB(imageWidth + x, y, 0x00808080)
+          continue
+        }
+
+        // If the pixels have no opacity, don't delta colors at all
+        if (goldenRgb and -0x1000000 == 0 && rgb and -0x1000000 == 0) {
+          deltaImage.setRGB(imageWidth + x, y, 0x00808080)
+          continue
+        }
+
+        val deltaR = (rgb and 0xFF0000).ushr(16) - (goldenRgb and 0xFF0000).ushr(16)
+        val newR = 128 + deltaR and 0xFF
+        val deltaG = (rgb and 0x00FF00).ushr(8) - (goldenRgb and 0x00FF00).ushr(8)
+        val newG = 128 + deltaG and 0xFF
+        val deltaB = (rgb and 0x0000FF) - (goldenRgb and 0x0000FF)
+        val newB = 128 + deltaB and 0xFF
+
+        val avgAlpha =
+          ((goldenRgb and -0x1000000).ushr(24) + (rgb and -0x1000000).ushr(24)) / 2 shl 24
+
+        val newRGB = avgAlpha or (newR shl 16) or (newG shl 8) or newB
+        deltaImage.setRGB(imageWidth + x, y, newRGB)
+
+        delta += Math.abs(deltaR)
+          .toLong()
+        delta += Math.abs(deltaG)
+          .toLong()
+        delta += Math.abs(deltaB)
+          .toLong()
+      }
+    }
+
+    // 3 different colors, 256 color levels
+    val total = imageHeight.toLong() * imageWidth.toLong() * 3L * 256L
+    val percentDifference = (delta * 100 / total.toDouble()).toFloat()
+
+    var error: String? = null
+    val imageName = getName(relativePath)
+    if (percentDifference > maxPercentDifferent) {
+      error = String.format("Images differ (by %.1f%%)", percentDifference)
+    } else if (Math.abs(goldenImage.width - image.width) >= 2) {
+      error = "Widths differ too much for " + imageName + ": " +
+        goldenImage.width + "x" + goldenImage.height +
+        "vs" + image.width + "x" + image.height
+    } else if (Math.abs(goldenImage.height - image.height) >= 2) {
+      error = "Heights differ too much for " + imageName + ": " +
+        goldenImage.width + "x" + goldenImage.height +
+        "vs" + image.width + "x" + image.height
+    }
+
+    if (error != null) {
+      // Expected on the left
+      // Golden on the right
+      g.drawImage(goldenImage, 0, 0, null)
+      g.drawImage(image, 2 * imageWidth, 0, null)
+
+      // Labels
+      if (imageWidth > 80) {
+        g.color = Color.RED
+        g.drawString("Expected", 10, 20)
+        g.drawString("Actual", 2 * imageWidth + 10, 20)
+      }
+
+      val output = File(failureDir, "delta-$imageName")
+      if (output.exists()) {
+        val deleted = output.delete()
+        assertTrue(deleted)
+      }
+      ImageIO.write(deltaImage, "PNG", output)
+      error += " - see details in file://" + output.path + "\n"
+      error = saveImageAndAppendMessage(image, error, relativePath)
+      println(error)
+      fail(error)
+    }
+
+    g.dispose()
+  }
+
+  /**
+   * Resize the given image
+   *
+   * @param source the image to be scaled
+   * @param xScale x scale
+   * @param yScale y scale
+   * @return the scaled image
+   */
+  fun scale(
+    source: BufferedImage,
+    xScale: Double,
+    yScale: Double
+  ): BufferedImage {
+    @Suppress("NAME_SHADOWING") var source = source
+
+    var sourceWidth = source.width
+    var sourceHeight = source.height
+    val destWidth = Math.max(1, (xScale * sourceWidth).toInt())
+    val destHeight = Math.max(1, (yScale * sourceHeight).toInt())
+    var imageType = source.type
+    if (imageType == BufferedImage.TYPE_CUSTOM) {
+      imageType = BufferedImage.TYPE_INT_ARGB
+    }
+    if (xScale > 0.5 && yScale > 0.5) {
+      val scaled = BufferedImage(destWidth, destHeight, imageType)
+      val g2 = scaled.createGraphics()
+      g2.composite = AlphaComposite.Src
+      g2.color = Color(0, true)
+      g2.fillRect(0, 0, destWidth, destHeight)
+      if (xScale == 1.0 && yScale == 1.0) {
+        g2.drawImage(source, 0, 0, null)
+      } else {
+        setRenderingHints(g2)
+        g2.drawImage(source, 0, 0, destWidth, destHeight, 0, 0, sourceWidth, sourceHeight, null)
+      }
+      g2.dispose()
+      return scaled
+    } else {
+      // When creating a thumbnail, using the above code doesn't work very well;
+      // you get some visible artifacts, especially for text. Instead use the
+      // technique of repeatedly scaling the image into half; this will cause
+      // proper averaging of neighboring pixels, and will typically (for the kinds
+      // of screen sizes used by this utility method in the layout editor) take
+      // about 3-4 iterations to get the result since we are logarithmically reducing
+      // the size. Besides, each successive pass in operating on much fewer pixels
+      // (a reduction of 4 in each pass).
+      //
+      // However, we may not be resizing to a size that can be reached exactly by
+      // successively diving in half. Therefore, once we're within a factor of 2 of
+      // the final size, we can do a resize to the exact target size.
+      // However, we can get even better results if we perform this final resize
+      // up front. Let's say we're going from width 1000 to a destination width of 85.
+      // The first approach would cause a resize from 1000 to 500 to 250 to 125, and
+      // then a resize from 125 to 85. That last resize can distort/blur a lot.
+      // Instead, we can start with the destination width, 85, and double it
+      // successfully until we're close to the initial size: 85, then 170,
+      // then 340, and finally 680. (The next one, 1360, is larger than 1000).
+      // So, now we *start* the thumbnail operation by resizing from width 1000 to
+      // width 680, which will preserve a lot of visual details such as text.
+      // Then we can successively resize the image in half, 680 to 340 to 170 to 85.
+      // We end up with the expected final size, but we've been doing an exact
+      // divide-in-half resizing operation at the end so there is less distortion.
+
+      var iterations = 0 // Number of halving operations to perform after the initial resize
+      var nearestWidth = destWidth // Width closest to source width that = 2^x, x is integer
+      var nearestHeight = destHeight
+      while (nearestWidth < sourceWidth / 2) {
+        nearestWidth *= 2
+        nearestHeight *= 2
+        iterations++
+      }
+
+      var scaled = BufferedImage(nearestWidth, nearestHeight, imageType)
+
+      var g2 = scaled.createGraphics()
+      setRenderingHints(g2)
+      g2.drawImage(source, 0, 0, nearestWidth, nearestHeight, 0, 0, sourceWidth, sourceHeight, null)
+      g2.dispose()
+
+      sourceWidth = nearestWidth
+      sourceHeight = nearestHeight
+      source = scaled
+
+      for (iteration in iterations - 1 downTo 0) {
+        val halfWidth = sourceWidth / 2
+        val halfHeight = sourceHeight / 2
+        scaled = BufferedImage(halfWidth, halfHeight, imageType)
+        g2 = scaled.createGraphics()
+        setRenderingHints(g2)
+        g2.drawImage(source, 0, 0, halfWidth, halfHeight, 0, 0, sourceWidth, sourceHeight, null)
+        g2.dispose()
+
+        sourceWidth = halfWidth
+        sourceHeight = halfHeight
+        source = scaled
+        iterations--
+      }
+      return scaled
+    }
+  }
+
+  fun getThumbnailScale(image: BufferedImage): Double {
+    val maxDimension = max(image.width, image.height)
+    return THUMBNAIL_SIZE / maxDimension.toDouble()
+  }
+
+  private fun setRenderingHints(g2: Graphics2D) {
+    g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR)
+    g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY)
+    g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
+  }
+
+  /**
+   * Saves the generated thumbnail image and appends the info message to an initial message
+   */
+  @Throws(IOException::class)
+  private fun saveImageAndAppendMessage(
+    image: BufferedImage,
+    initialMessage: String,
+    relativePath: String
+  ): String {
+    @Suppress("NAME_SHADOWING") var initialMessage = initialMessage
+    val output = File(
+      failureDir,
+      getName(relativePath)
+    )
+    if (output.exists()) {
+      val deleted = output.delete()
+      assertTrue(deleted)
+    }
+    ImageIO.write(image, "PNG", output)
+    initialMessage += "Thumbnail for current rendering stored at file://" + output.path
+    //        initialMessage += "\nRun the following command to accept the changes:\n";
+    //        initialMessage += String.format("mv %1$s %2$s", output.getPath(),
+    //                ImageUtils.class.getResource(relativePath).getPath());
+    // The above has been commented out, since the destination path returned is in out dir
+    // and it makes the tests pass without the code being actually checked in.
+    return initialMessage
+  }
+
+  private fun getName(relativePath: String): String {
+    return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/MatrixMatrixMultiplicationInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/MatrixMatrixMultiplicationInterceptor.kt
new file mode 100644
index 0000000..41994c2d
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/MatrixMatrixMultiplicationInterceptor.kt
@@ -0,0 +1,43 @@
+package app.cash.paparazzi.internal
+
+// Sampled from https://cs.android.com/android/platform/superproject/+/master:external/robolectric-shadows/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java;l=10-67
+object MatrixMatrixMultiplicationInterceptor {
+  @Suppress("unused")
+  @JvmStatic
+  fun intercept(
+    result: FloatArray,
+    resultOffset: Int,
+    lhs: FloatArray,
+    lhsOffset: Int,
+    rhs: FloatArray,
+    rhsOffset: Int
+  ) {
+    require(resultOffset + 16 <= result.size) { "resultOffset + 16 > result.length" }
+    require(lhsOffset + 16 <= lhs.size) { "lhsOffset + 16 > lhs.length" }
+    require(rhsOffset + 16 <= rhs.size) { "rhsOffset + 16 > rhs.length" }
+    for (i in 0..3) {
+      val rhs_i0 = rhs[I(i, 0, rhsOffset)]
+      var ri0 = lhs[I(0, 0, lhsOffset)] * rhs_i0
+      var ri1 = lhs[I(0, 1, lhsOffset)] * rhs_i0
+      var ri2 = lhs[I(0, 2, lhsOffset)] * rhs_i0
+      var ri3 = lhs[I(0, 3, lhsOffset)] * rhs_i0
+      for (j in 1..3) {
+        val rhs_ij = rhs[I(i, j, rhsOffset)]
+        ri0 += lhs[I(j, 0, lhsOffset)] * rhs_ij
+        ri1 += lhs[I(j, 1, lhsOffset)] * rhs_ij
+        ri2 += lhs[I(j, 2, lhsOffset)] * rhs_ij
+        ri3 += lhs[I(j, 3, lhsOffset)] * rhs_ij
+      }
+      result[I(i, 0, resultOffset)] = ri0
+      result[I(i, 1, resultOffset)] = ri1
+      result[I(i, 2, resultOffset)] = ri2
+      result[I(i, 3, resultOffset)] = ri3
+    }
+  }
+
+  @Suppress("FunctionName")
+  private fun I(i: Int, j: Int, offset: Int): Int {
+    // #define I(_i, _j) ((_j)+ 4*(_i))
+    return offset + j + 4 * i
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/MatrixVectorMultiplicationInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/MatrixVectorMultiplicationInterceptor.kt
new file mode 100644
index 0000000..403413e
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/MatrixVectorMultiplicationInterceptor.kt
@@ -0,0 +1,41 @@
+package app.cash.paparazzi.internal
+
+// Sampled from https://cs.android.com/android/platform/superproject/+/master:external/robolectric-shadows/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java;l=69-121
+object MatrixVectorMultiplicationInterceptor {
+  @Suppress("unused")
+  @JvmStatic
+  fun intercept(
+    resultVec: FloatArray,
+    resultVecOffset: Int,
+    lhsMat: FloatArray,
+    lhsMatOffset: Int,
+    rhsVec: FloatArray,
+    rhsVecOffset: Int
+  ) {
+    require(resultVecOffset + 4 <= resultVec.size) { "resultOffset + 4 > result.length" }
+    require(lhsMatOffset + 16 <= lhsMat.size) { "lhsOffset + 16 > lhs.length" }
+    require(rhsVecOffset + 4 <= rhsVec.size) { "rhsOffset + 4 > rhs.length" }
+    val x = rhsVec[rhsVecOffset + 0]
+    val y = rhsVec[rhsVecOffset + 1]
+    val z = rhsVec[rhsVecOffset + 2]
+    val w = rhsVec[rhsVecOffset + 3]
+    resultVec[resultVecOffset + 0] =
+      lhsMat[I(0, 0, lhsMatOffset)] * x + lhsMat[I(1, 0, lhsMatOffset)] * y +
+          lhsMat[I(2, 0, lhsMatOffset)] * z + lhsMat[I(3, 0, lhsMatOffset)] * w
+    resultVec[resultVecOffset + 1] =
+      lhsMat[I(0, 1, lhsMatOffset)] * x + lhsMat[I(1, 1, lhsMatOffset)] * y +
+          lhsMat[I(2, 1, lhsMatOffset)] * z + lhsMat[I(3, 1, lhsMatOffset)] * w
+    resultVec[resultVecOffset + 2] =
+      lhsMat[I(0, 2, lhsMatOffset)] * x + lhsMat[I(1, 2, lhsMatOffset)] * y +
+          lhsMat[I(2, 2, lhsMatOffset)] * z + lhsMat[I(3, 2, lhsMatOffset)] * w
+    resultVec[resultVecOffset + 3] =
+      lhsMat[I(0, 3, lhsMatOffset)] * x + lhsMat[I(1, 3, lhsMatOffset)] * y +
+          lhsMat[I(2, 3, lhsMatOffset)] * z + lhsMat[I(3, 3, lhsMatOffset)] * w
+  }
+
+  @Suppress("FunctionName")
+  private fun I(i: Int, j: Int, offset: Int): Int {
+    // #define I(_i, _j) ((_j)+ 4*(_i))
+    return offset + j + 4 * i
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziAssetRepository.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziAssetRepository.kt
new file mode 100644
index 0000000..72bcf85
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziAssetRepository.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2017 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 app.cash.paparazzi.internal
+
+import com.android.ide.common.rendering.api.AssetRepository
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+
+internal class PaparazziAssetRepository(private val assetPath: String) : AssetRepository() {
+  @Throws(FileNotFoundException::class)
+  private fun open(path: String): InputStream? {
+    val asset = File(path)
+    return when {
+      asset.isFile -> FileInputStream(asset)
+      else -> null
+    }
+  }
+
+  override fun isSupported(): Boolean = true
+
+  @Throws(IOException::class)
+  override fun openAsset(
+    path: String,
+    mode: Int
+  ): InputStream? = open("$assetPath/$path")
+
+  @Throws(IOException::class)
+  override fun openNonAsset(
+    cookie: Int,
+    path: String,
+    mode: Int
+  ): InputStream? = open(path)
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziCallback.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziCallback.kt
new file mode 100644
index 0000000..0e5420d
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziCallback.kt
@@ -0,0 +1,222 @@
+/*
+ * Copyright (C) 2014 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 app.cash.paparazzi.internal
+
+import app.cash.paparazzi.internal.parsers.LayoutPullParser
+import app.cash.paparazzi.internal.parsers.TagSnapshot
+import com.android.ide.common.rendering.api.ActionBarCallback
+import com.android.ide.common.rendering.api.AdapterBinding
+import com.android.ide.common.rendering.api.ILayoutPullParser
+import com.android.ide.common.rendering.api.LayoutlibCallback
+import com.android.ide.common.rendering.api.ResourceNamespace.RES_AUTO
+import com.android.ide.common.rendering.api.ResourceReference
+import com.android.ide.common.rendering.api.ResourceValue
+import com.android.ide.common.rendering.api.SessionParams.Key
+import com.android.layoutlib.bridge.android.RenderParamsFlags
+import com.android.resources.ResourceType
+import com.android.resources.ResourceType.STYLE
+import com.google.common.io.ByteStreams
+import org.kxml2.io.KXmlParser
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserException
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.lang.reflect.Modifier
+
+internal class PaparazziCallback(
+  private val logger: PaparazziLogger,
+  private val packageName: String,
+  private val resourcePackageNames: List<String>
+) : LayoutlibCallback() {
+  private val projectResources = mutableMapOf<Int, ResourceReference>()
+  private val resources = mutableMapOf<ResourceReference, Int>()
+  private val actionBarCallback = ActionBarCallback()
+  private val aaptDeclaredResources = mutableMapOf<String, TagSnapshot>()
+
+  private var adaptiveIconMaskPath: String? = null
+  private var highQualityShadow = false
+  private var enableShadow = true
+  private val loadedClasses = mutableMapOf<String, Class<*>>()
+
+  @Throws(ClassNotFoundException::class)
+  fun initResources() {
+    for (rPackageName in resourcePackageNames) {
+      val rClass = Class.forName("$rPackageName.R")
+      for (resourceClass in rClass.declaredClasses) {
+        val resourceType = ResourceType.fromClassName(resourceClass.simpleName) ?: continue
+
+        for (field in resourceClass.declaredFields) {
+          if (!Modifier.isStatic(field.modifiers)) continue
+
+          // May not be final in library projects.
+          val type = field.type
+          try {
+            if (type == Int::class.javaPrimitiveType) {
+              val value = field.get(null) as Int
+              val reference = ResourceReference(RES_AUTO, resourceType, field.name)
+              projectResources[value] = reference
+              resources[reference] = value
+            } else if (type.isArray && type.componentType == Int::class.javaPrimitiveType) {
+              // Ignore.
+            } else {
+              logger.error(null, "Unknown field type in R class: $type")
+            }
+          } catch (e: IllegalAccessException) {
+            logger.error(e, "Malformed R class: %1\$s", "$rPackageName.R")
+          }
+        }
+      }
+    }
+  }
+
+  @Throws(Exception::class)
+  override fun loadView(
+    name: String,
+    constructorSignature: Array<Class<*>>,
+    constructorArgs: Array<Any>
+  ): Any? {
+    val viewClass = Class.forName(name)
+    val viewConstructor = viewClass.getConstructor(*constructorSignature)
+    viewConstructor.isAccessible = true
+    return viewConstructor.newInstance(*constructorArgs)
+  }
+
+  override fun resolveResourceId(id: Int): ResourceReference? = projectResources[id]
+
+  override fun getOrGenerateResourceId(resource: ResourceReference): Int {
+    // Workaround: We load our resource map from fields in R.class, which are named using Java
+    // class conventions.  Therefore, we need to similarly transform style naming conventions
+    // that contain periods (e.g., Widget.AppCompat.TextView) to avoid false lookup misses.
+    // Long-term: Perhaps parse and load resource names from file system directly?
+    val resourceKey =
+      if (resource.resourceType == STYLE) resource.transformStyleResource() else resource
+    return resources[resourceKey] ?: 0
+  }
+
+  override fun getParser(layoutResource: ResourceValue): ILayoutPullParser? {
+    try {
+      val value = layoutResource.value ?: return null
+      if (aaptDeclaredResources.isNotEmpty() && layoutResource.resourceType == ResourceType.AAPT) {
+        val aaptResource = aaptDeclaredResources.getValue(value)
+        return LayoutPullParser.createFromAaptResource(aaptResource)
+      }
+
+      return LayoutPullParser.createFromFile(File(layoutResource.value))
+        .also {
+          // For parser of elements included in this parser, publish any aapt declared values
+          aaptDeclaredResources.putAll(it.getAaptDeclaredAttrs())
+        }
+    } catch (e: FileNotFoundException) {
+      return null
+    }
+  }
+
+  override fun getAdapterItemValue(
+    adapterView: ResourceReference,
+    adapterCookie: Any,
+    itemRef: ResourceReference,
+    fullPosition: Int,
+    positionPerType: Int,
+    fullParentPosition: Int,
+    parentPositionPerType: Int,
+    viewRef: ResourceReference,
+    viewAttribute: ViewAttribute,
+    defaultValue: Any
+  ): Any? = null
+
+  override fun getAdapterBinding(
+    adapterViewRef: ResourceReference,
+    adapterCookie: Any,
+    viewObject: Any
+  ): AdapterBinding? = null
+
+  override fun getActionBarCallback(): ActionBarCallback = actionBarCallback
+
+  override fun createXmlParserForPsiFile(fileName: String): XmlPullParser? =
+    createXmlParserForFile(fileName)
+
+  override fun createXmlParserForFile(fileName: String): XmlPullParser? {
+    try {
+      FileInputStream(fileName).use { fileStream ->
+        // Read data fully to memory to be able to close the file stream.
+        val byteOutputStream = ByteArrayOutputStream()
+        ByteStreams.copy(fileStream, byteOutputStream)
+        val parser = KXmlParser()
+        parser.setInput(ByteArrayInputStream(byteOutputStream.toByteArray()), null)
+        parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
+        return parser
+      }
+    } catch (e: IOException) {
+      return null
+    } catch (e: XmlPullParserException) {
+      return null
+    }
+  }
+
+  override fun createXmlParser(): XmlPullParser = KXmlParser()
+
+  @Suppress("UNCHECKED_CAST")
+  override fun <T> getFlag(key: Key<T>?): T? {
+    return when (key) {
+      RenderParamsFlags.FLAG_KEY_APPLICATION_PACKAGE -> packageName as T
+      RenderParamsFlags.FLAG_KEY_ADAPTIVE_ICON_MASK_PATH -> adaptiveIconMaskPath as T?
+      RenderParamsFlags.FLAG_RENDER_HIGH_QUALITY_SHADOW -> highQualityShadow as T
+      RenderParamsFlags.FLAG_ENABLE_SHADOW -> enableShadow as T
+      else -> null
+    }
+  }
+
+  fun setAdaptiveIconMaskPath(adaptiveIconMaskPath: String) {
+    this.adaptiveIconMaskPath = adaptiveIconMaskPath
+  }
+
+  fun setHighQualityShadow(highQualityShadow: Boolean) {
+    this.highQualityShadow = highQualityShadow
+  }
+
+  fun setEnableShadow(enableShadow: Boolean) {
+    this.enableShadow = enableShadow
+  }
+
+  override fun findClass(name: String): Class<*> {
+    val clazz = loadedClasses[name]
+    logger.verbose("loadClassA($name)")
+
+    try {
+      if (clazz != null) {
+        return clazz
+      }
+      val clazz2 = Class.forName(name)
+      logger.verbose("loadClassB($name)")
+      loadedClasses[name] = clazz2
+      return clazz2
+    } catch (e: LinkageError) {
+      throw ClassNotFoundException("error loading class $name", e)
+    } catch (e: ExceptionInInitializerError) {
+      throw ClassNotFoundException("error loading class $name", e)
+    } catch (e: ClassNotFoundException) {
+      throw ClassNotFoundException("error loading class $name", e)
+    }
+  }
+
+  private fun ResourceReference.transformStyleResource() =
+    ResourceReference.style(namespace, name.replace('.', '_'))
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziJson.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziJson.kt
new file mode 100644
index 0000000..6739e81
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziJson.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal
+
+import app.cash.paparazzi.Snapshot
+import app.cash.paparazzi.TestName
+import com.squareup.moshi.FromJson
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.ToJson
+import com.squareup.moshi.Types
+import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
+import java.util.Date
+
+internal object PaparazziJson {
+  val moshi = Moshi.Builder()
+    .add(Date::class.java, Rfc3339DateJsonAdapter())
+    .add(this)
+    .build()!!
+
+  val listOfShotsAdapter: JsonAdapter<List<Snapshot>> =
+    moshi
+      .adapter<List<Snapshot>>(
+        Types.newParameterizedType(List::class.java, Snapshot::class.java)
+      )
+      .indent("  ")
+
+  val listOfStringsAdapter: JsonAdapter<List<String>> =
+    moshi
+      .adapter<List<String>>(
+        Types.newParameterizedType(List::class.java, String::class.java)
+      )
+      .indent("  ")
+
+  @ToJson
+  fun testNameToJson(testName: TestName): String {
+    return "${testName.packageName}.${testName.className}#${testName.methodName}"
+  }
+
+  @FromJson
+  fun testNameFromJson(json: String): TestName {
+    val regex = Regex("(.*)\\.([^.]*)#([^.]*)")
+    val (packageName, className, methodName) = regex.matchEntire(json)!!.destructured
+    return TestName(packageName, className, methodName)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziLogger.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziLogger.kt
new file mode 100644
index 0000000..01595f7
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/PaparazziLogger.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal
+
+import app.cash.paparazzi.Paparazzi
+import com.android.ide.common.rendering.api.ILayoutLog
+import com.android.utils.ILogger
+import java.io.PrintStream
+import java.io.PrintWriter
+import java.util.logging.Level
+import java.util.logging.Logger
+import java.util.logging.Logger.getLogger
+
+/**
+ * This logger delegates to java.util.Logging.
+ */
+internal class PaparazziLogger : ILayoutLog, ILogger {
+  private val logger: Logger = getLogger(Paparazzi::class.java.name)
+  private val errors = mutableListOf<Throwable>()
+
+  override fun error(
+    throwable: Throwable?,
+    format: String?,
+    vararg args: Any
+  ) {
+    logger.log(Level.SEVERE, format?.format(args), throwable)
+    if (throwable != null) {
+      errors += throwable
+    }
+  }
+
+  override fun warning(
+    format: String,
+    vararg args: Any
+  ) {
+    logger.log(Level.WARNING, format, args)
+  }
+
+  override fun info(
+    format: String,
+    vararg args: Any
+  ) {
+    logger.log(Level.INFO, format, args)
+  }
+
+  override fun verbose(
+    format: String,
+    vararg args: Any
+  ) {
+    logger.log(Level.FINE, format, args)
+  }
+
+  override fun fidelityWarning(
+    tag: String?,
+    message: String?,
+    throwable: Throwable?,
+    cookie: Any?,
+    data: Any?
+  ) {
+    logger.log(Level.WARNING, "$tag: $message", throwable)
+  }
+
+  override fun error(
+    tag: String?,
+    message: String?,
+    viewCookie: Any?,
+    data: Any?
+  ) {
+    logger.log(Level.SEVERE, "$tag: $message")
+  }
+
+  override fun error(
+    tag: String?,
+    message: String?,
+    throwable: Throwable?,
+    viewCookie: Any?,
+    data: Any?
+  ) {
+    logger.log(Level.SEVERE, "$tag: $message", throwable)
+    if (throwable != null) {
+      errors += throwable
+    }
+  }
+
+  override fun warning(
+    tag: String?,
+    message: String?,
+    viewCookie: Any?,
+    data: Any?
+  ) {
+    logger.log(Level.WARNING, "$tag: $message")
+  }
+
+  override fun logAndroidFramework(priority: Int, tag: String?, message: String?) {
+    logger.log(Level.INFO, "$tag [$priority]: $message")
+  }
+
+  fun assertNoErrors() {
+    when (errors.size) {
+      0 -> return
+      1 -> throw errors[0]
+      else -> throw MultipleFailuresException(errors)
+    }
+  }
+
+  internal class MultipleFailuresException(private val causes: List<Throwable>) : Exception() {
+    init {
+      require(causes.isNotEmpty()) { "List of Throwables must not be empty" }
+    }
+
+    override val message: String
+      get() = buildString {
+        appendLine(String.format("There were %d errors:", causes.size))
+        causes.forEach { e ->
+          appendLine(String.format("%n  %s: %s", e.javaClass.name, e.message))
+          e.stackTrace.forEach { traceElement ->
+            appendLine("\tat $traceElement")
+          }
+        }
+      }
+
+    override fun printStackTrace() {
+      causes.forEach { e ->
+        e.printStackTrace()
+      }
+    }
+
+    override fun printStackTrace(s: PrintStream) {
+      causes.forEach { e ->
+        e.printStackTrace(s)
+      }
+    }
+
+    override fun printStackTrace(s: PrintWriter) {
+      causes.forEach { e ->
+        e.printStackTrace(s)
+      }
+    }
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/RenderResult.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/RenderResult.kt
new file mode 100644
index 0000000..cbf05df
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/RenderResult.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2016 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 app.cash.paparazzi.internal
+
+import com.android.ide.common.rendering.api.RenderSession
+import com.android.ide.common.rendering.api.Result
+import com.android.ide.common.rendering.api.ViewInfo
+import java.awt.image.BufferedImage
+
+internal data class RenderResult(
+  val result: Result,
+  val systemViews: List<ViewInfo>,
+  val rootViews: List<ViewInfo>,
+  val image: BufferedImage
+)
+
+internal fun RenderSession.toResult(): RenderResult {
+  return RenderResult(result, systemRootViews.toList(), rootViews.toList(), image)
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/Renderer.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/Renderer.kt
new file mode 100644
index 0000000..588ba51
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/Renderer.kt
@@ -0,0 +1,223 @@
+/*
+ * Copyright (C) 2016 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 app.cash.paparazzi.internal
+
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Environment
+import app.cash.paparazzi.Flags
+import app.cash.paparazzi.internal.parsers.LayoutPullParser
+import com.android.ide.common.rendering.api.SessionParams
+import com.android.io.FolderWrapper
+import com.android.layoutlib.bridge.Bridge
+import com.android.layoutlib.bridge.android.RenderParamsFlags
+import com.android.layoutlib.bridge.impl.DelegateManager
+import java.awt.image.BufferedImage
+import java.io.Closeable
+import java.io.File
+import java.io.IOException
+import java.util.Locale
+
+/** View rendering. */
+internal class Renderer(
+  private val environment: Environment,
+  private val layoutlibCallback: PaparazziCallback,
+  private val logger: PaparazziLogger,
+  private val maxPercentDifference: Double
+) : Closeable {
+  private var bridge: Bridge? = null
+  private lateinit var sessionParamsBuilder: SessionParamsBuilder
+
+  /** Initialize the bridge and the resource maps. */
+  fun prepare(): SessionParamsBuilder {
+    val platformDataResDir = File("${environment.platformDir}/data/res")
+
+    @Suppress("DEPRECATION")
+    val frameworkResources = com.android.ide.common.resources.deprecated.FrameworkResources(
+      FolderWrapper(platformDataResDir)
+    ).apply {
+      loadResources()
+      loadPublicResources(logger)
+    }
+
+    @Suppress("DEPRECATION")
+    val projectResources = object : com.android.ide.common.resources.deprecated.ResourceRepository(
+      FolderWrapper(environment.resDir),
+      false
+    ) {
+      override fun createResourceItem(
+        name: String
+      ): com.android.ide.common.resources.deprecated.ResourceItem {
+        return com.android.ide.common.resources.deprecated.ResourceItem(name)
+      }
+    }
+    projectResources.loadResources()
+
+    sessionParamsBuilder = SessionParamsBuilder(
+      layoutlibCallback = layoutlibCallback,
+      logger = logger,
+      frameworkResources = frameworkResources,
+      projectResources = projectResources,
+      assetRepository = PaparazziAssetRepository(environment.assetsDir)
+    )
+      .plusFlag(RenderParamsFlags.FLAG_DO_NOT_RENDER_ON_CREATE, true)
+      .withTheme("AppTheme", true)
+
+    val platformDataRoot = System.getProperty("paparazzi.platform.data.root")
+      ?: throw RuntimeException("Missing system property for 'paparazzi.platform.data.root'")
+    val platformDataDir = File(platformDataRoot, "data")
+    val fontLocation = File(platformDataDir, "fonts")
+    val nativeLibLocation = File(platformDataDir, getNativeLibDir())
+    val icuLocation = File(platformDataDir, "icu" + File.separator + "icudt68l.dat")
+    val buildProp = File(environment.platformDir, "build.prop")
+    val attrs = File(platformDataResDir, "values" + File.separator + "attrs.xml")
+    val systemProperties = DeviceConfig.loadProperties(buildProp) + mapOf(
+      // We want Choreographer.USE_FRAME_TIME to be false so it uses System_Delegate.nanoTime()
+      "debug.choreographer.frametime" to "false"
+    )
+    bridge = Bridge().apply {
+      check(
+        init(
+          systemProperties,
+          fontLocation,
+          nativeLibLocation.path,
+          icuLocation.path,
+          DeviceConfig.getEnumMap(attrs),
+          logger
+        )
+      ) { "Failed to init Bridge." }
+    }
+    Bridge.getLock()
+      .lock()
+    try {
+      Bridge.setLog(logger)
+    } finally {
+      Bridge.getLock()
+        .unlock()
+    }
+
+    return sessionParamsBuilder
+  }
+
+  private fun getNativeLibDir(): String {
+    val osName = System.getProperty("os.name").lowercase(Locale.US)
+    val osLabel = when {
+      osName.startsWith("windows") -> "win"
+      osName.startsWith("mac") -> {
+        val osArch = System.getProperty("os.arch").lowercase(Locale.US)
+        if (osArch.startsWith("x86")) "mac" else "mac-arm"
+      }
+      else -> "linux"
+    }
+    return "$osLabel/lib64"
+  }
+
+  override fun close() {
+    bridge = null
+
+    Gc.gc()
+
+    dumpDelegates()
+  }
+
+  fun dumpDelegates() {
+    if (System.getProperty(Flags.DEBUG_LINKED_OBJECTS) != null) {
+      println("Objects still linked from the DelegateManager:")
+      DelegateManager.dump(System.out)
+    }
+  }
+
+  fun render(
+    bridge: com.android.ide.common.rendering.api.Bridge,
+    params: SessionParams,
+    frameTimeNanos: Long
+  ): RenderResult {
+    val session = bridge.createSession(params)
+
+    try {
+      if (frameTimeNanos != -1L) {
+        session.setElapsedFrameTimeNanos(frameTimeNanos)
+      }
+
+      if (!session.result.isSuccess) {
+        logger.error(session.result.exception, session.result.errorMessage)
+      } else {
+        // Render the session with a timeout of 50s.
+        val renderResult = session.render(50000)
+        if (!renderResult.isSuccess) {
+          logger.error(session.result.exception, session.result.errorMessage)
+        }
+      }
+
+      return session.toResult()
+    } finally {
+      session.dispose()
+    }
+  }
+
+  /** Compares the golden image with the passed image. */
+  fun verify(
+    goldenImageName: String,
+    image: BufferedImage
+  ) {
+    try {
+      val goldenImagePath = environment.appTestDir + "/golden/" + goldenImageName
+      ImageUtils.requireSimilar(goldenImagePath, image, maxPercentDifference)
+    } catch (e: IOException) {
+      logger.error(e, e.message)
+    }
+  }
+
+  /**
+   * Create a new rendering session and test that rendering the given layout doesn't throw any
+   * exceptions and matches the provided image.
+   *
+   * If frameTimeNanos is >= 0 a frame will be executed during the rendering. The time indicates
+   * how far in the future is.
+   */
+  @JvmOverloads
+  fun renderAndVerify(
+    sessionParams: SessionParams,
+    goldenFileName: String,
+    frameTimeNanos: Long = -1
+  ): RenderResult {
+    val result = render(bridge!!, sessionParams, frameTimeNanos)
+    verify(goldenFileName, result.image)
+    return result
+  }
+
+  fun createParserFromPath(layoutPath: String): LayoutPullParser =
+    LayoutPullParser.createFromPath("${environment.resDir}/layout/$layoutPath")
+
+  /**
+   * Create a new rendering session and test that rendering the given layout on given device
+   * doesn't throw any exceptions and matches the provided image.
+   */
+  @JvmOverloads
+  fun renderAndVerify(
+    layoutFileName: String,
+    goldenFileName: String,
+    deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5
+  ): RenderResult {
+    val sessionParams = sessionParamsBuilder
+      .copy(
+        layoutPullParser = createParserFromPath(layoutFileName),
+        deviceConfig = deviceConfig
+      )
+      .build()
+    return renderAndVerify(sessionParams, goldenFileName)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ResourcesInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ResourcesInterceptor.kt
new file mode 100644
index 0000000..a7f14c8
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ResourcesInterceptor.kt
@@ -0,0 +1,14 @@
+package app.cash.paparazzi.internal
+
+import android.content.Context
+import android.graphics.Typeface
+
+object ResourcesInterceptor {
+  @JvmStatic
+  fun intercept(
+    context: Context,
+    resId: Int
+  ): Typeface? {
+    return context.resources.getFont(resId)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ServiceManagerInterceptor.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ServiceManagerInterceptor.kt
new file mode 100644
index 0000000..4726fad
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/ServiceManagerInterceptor.kt
@@ -0,0 +1,17 @@
+package app.cash.paparazzi.internal
+
+import android.os.IBinder
+
+/**
+ * The ImeTracing class attempts to initialize its [mService field in its constructor](https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/util/imetracing/ImeTracing.java;l=60).
+ *
+ * Unfortunately, [layoutlib's version of ServiceManager](https://cs.android.com/android/platform/superproject/+/master:frameworks/layoutlib/bridge/src/android/os/ServiceManager.java;l=37)
+ * throws an exception immediately.
+ *
+ * This interceptor overrides ServiceManager.getServiceOrThrow to simply return null instead.
+ */
+object ServiceManagerInterceptor {
+  @Suppress("unused")
+  @JvmStatic
+  fun interceptGetServiceOrThrow(@Suppress("UNUSED_PARAMETER") name: String): IBinder? = null
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/SessionParamsBuilder.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/SessionParamsBuilder.kt
new file mode 100644
index 0000000..ea263b6
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/SessionParamsBuilder.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2017 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 app.cash.paparazzi.internal
+
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.internal.parsers.LayoutPullParser
+import com.android.SdkConstants
+import com.android.ide.common.rendering.api.AssetRepository
+import com.android.ide.common.rendering.api.ResourceNamespace
+import com.android.ide.common.rendering.api.ResourceReference
+import com.android.ide.common.rendering.api.SessionParams
+import com.android.ide.common.rendering.api.SessionParams.Key
+import com.android.ide.common.rendering.api.SessionParams.RenderingMode
+import com.android.ide.common.resources.ResourceResolver
+import com.android.ide.common.resources.ResourceValueMap
+import com.android.layoutlib.bridge.Bridge
+import com.android.resources.LayoutDirection
+import com.android.resources.ResourceType
+
+/** Creates [SessionParams] objects. */
+internal data class SessionParamsBuilder(
+  private val layoutlibCallback: PaparazziCallback,
+  private val logger: PaparazziLogger,
+  @Suppress("DEPRECATION")
+  private val frameworkResources: com.android.ide.common.resources.deprecated.ResourceRepository,
+  private val assetRepository: AssetRepository,
+  @Suppress("DEPRECATION")
+  private val projectResources: com.android.ide.common.resources.deprecated.ResourceRepository,
+  private val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5,
+  private val renderingMode: RenderingMode = RenderingMode.NORMAL,
+  private val targetSdk: Int = 22,
+  private val flags: Map<Key<*>, Any> = mapOf(),
+  private val themeName: String? = null,
+  private val isProjectTheme: Boolean = false,
+  private val layoutPullParser: LayoutPullParser? = null,
+  private val projectKey: Any? = null,
+  private val minSdk: Int = 0,
+  private val decor: Boolean = true
+) {
+  fun withTheme(
+    themeName: String,
+    isProjectTheme: Boolean
+  ): SessionParamsBuilder {
+    return copy(themeName = themeName, isProjectTheme = isProjectTheme)
+  }
+
+  fun withTheme(themeName: String): SessionParamsBuilder {
+    return when {
+      themeName.startsWith(SdkConstants.PREFIX_ANDROID) -> {
+        withTheme(themeName.substring(SdkConstants.PREFIX_ANDROID.length), false)
+      }
+      else -> withTheme(themeName, true)
+    }
+  }
+
+  fun plusFlag(
+    flag: SessionParams.Key<*>,
+    value: Any
+  ) = copy(flags = flags + (flag to value))
+
+  fun build(): SessionParams {
+    require(themeName != null)
+
+    val folderConfiguration = deviceConfig.folderConfiguration
+
+    @Suppress("DEPRECATION")
+    val resourceResolver = ResourceResolver.create(
+      mapOf<ResourceNamespace, Map<ResourceType, ResourceValueMap>>(
+        ResourceNamespace.ANDROID to frameworkResources.getConfiguredResources(
+          folderConfiguration
+        ),
+        ResourceNamespace.TODO() to projectResources.getConfiguredResources(
+          folderConfiguration
+        )
+      ),
+      ResourceReference(
+        ResourceNamespace.fromBoolean(!isProjectTheme),
+        ResourceType.STYLE,
+        themeName
+      )
+    )
+
+    val result = SessionParams(
+      layoutPullParser, renderingMode, projectKey /* for caching */,
+      deviceConfig.hardwareConfig, resourceResolver, layoutlibCallback, minSdk, targetSdk, logger
+    )
+    result.fontScale = deviceConfig.fontScale
+
+    val localeQualifier = folderConfiguration.localeQualifier
+    val layoutDirectionQualifier = folderConfiguration.layoutDirectionQualifier
+    // https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/rendering/RenderTask.java;l=645
+    if (
+      LayoutDirection.RTL == layoutDirectionQualifier.value &&
+      !Bridge.isLocaleRtl(localeQualifier.tag)
+    ) {
+      result.locale = "ur"
+    } else {
+      result.locale = localeQualifier.tag
+    }
+    result.setRtlSupport(true)
+
+    for ((key, value) in flags) {
+      @Suppress("UNCHECKED_CAST")
+      result.setFlag(key as Key<Any>, value)
+    }
+    result.setAssetRepository(assetRepository)
+
+    if (!decor) {
+      result.setForceNoDecor()
+    }
+
+    return result
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AaptAttrParser.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AaptAttrParser.kt
new file mode 100644
index 0000000..3c62aa8
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AaptAttrParser.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+/**
+ * Copied from https://cs.android.com/android-studio/platform/tools/adt/idea/+/858f81bb7c350bc7a05daad36edefd21f74c8cef:android/src/com/android/tools/idea/rendering/parsers/AaptAttrParser.java
+ *
+ * Interface for parsers that support declaration of inlined {@code aapt:attr} attributes
+ */
+interface AaptAttrParser {
+  /**
+   * Returns a [Map] that contains all the `aapt:attr` elements declared in this or any
+   * children parsers. This list can be used to resolve `@aapt/_aapt` references into this parser.
+   */
+  fun getAaptDeclaredAttrs(): Map<String, TagSnapshot>
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AaptAttrSnapshot.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AaptAttrSnapshot.kt
new file mode 100644
index 0000000..4105eee
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AaptAttrSnapshot.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+import com.android.SdkConstants.AAPT_ATTR_PREFIX
+import com.android.SdkConstants.AAPT_PREFIX
+
+/**
+ * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-master-dev:android/src/com/android/tools/idea/rendering/parsers/AaptAttrAttributeSnapshot.java
+ *
+ * Aapt attributes are attributes that instead of containing a reference, contain the inlined value
+ * of the reference. This snapshot will generate a dynamic reference that will be used by the
+ * resource resolution to be able to retrieve the inlined value.
+ */
+class AaptAttrSnapshot(
+  override val namespace: String,
+  override val prefix: String,
+  override val name: String,
+  val id: String,
+  val bundledTag: TagSnapshot
+) : AttributeSnapshot(namespace, prefix, name, "${AAPT_ATTR_PREFIX}$AAPT_PREFIX$id")
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AttributeSnapshot.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AttributeSnapshot.kt
new file mode 100644
index 0000000..50b658c
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/AttributeSnapshot.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+/**
+ * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-master-dev:android/src/com/android/tools/idea/rendering/parsers/AttributeSnapshot.java
+ *
+ * A snapshot of an attribute value pulled from an XML resource.
+ * Used in conjunction with [TagSnapshot].
+ */
+open class AttributeSnapshot(
+  open val namespace: String,
+  open val prefix: String?,
+  open val name: String,
+  open val value: String
+) {
+  override fun toString() = "$name: $value"
+
+  // since data classes can't be subclassed
+  override fun equals(other: Any?): Boolean {
+    if (this === other) return true
+    if (javaClass != other?.javaClass) return false
+
+    other as AttributeSnapshot
+
+    if (namespace != other.namespace) return false
+    if (prefix != other.prefix) return false
+    if (name != other.name) return false
+    if (value != other.value) return false
+
+    return true
+  }
+
+  override fun hashCode(): Int {
+    var result = namespace.hashCode()
+    result = 31 * result + prefix.hashCode()
+    result = 31 * result + name.hashCode()
+    result = 31 * result + value.hashCode()
+    return result
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/InMemoryParser.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/InMemoryParser.kt
new file mode 100644
index 0000000..5b709fb
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/InMemoryParser.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+import org.kxml2.io.KXmlParser
+import org.xmlpull.v1.XmlPullParserException
+
+/**
+ * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/858f81bb7c350bc7a05daad36edefd21f74c8cef:android/src/com/android/tools/idea/rendering/parsers/LayoutPullParser.java;bpv=0;bpt=0
+ *
+ * A parser implementation that walks an in-memory XML DOM tree.
+ */
+abstract class InMemoryParser : KXmlParser() {
+  abstract fun rootTag(): TagSnapshot
+
+  private val nodeStack = mutableListOf<TagSnapshot>()
+  private var parsingState = START_DOCUMENT
+
+  override fun getAttributeCount(): Int {
+    val tag = getCurrentNode() ?: return 0
+    return tag.attributes.size
+  }
+
+  override fun getAttributeName(i: Int): String? {
+    val attribute = getAttribute(i) ?: return null
+    return attribute.name
+  }
+
+  override fun getAttributeNamespace(i: Int): String {
+    val attribute = getAttribute(i) ?: return ""
+    return attribute.namespace
+  }
+
+  override fun getAttributePrefix(i: Int): String? {
+    val attribute = getAttribute(i) ?: return null
+    return attribute.prefix
+  }
+
+  override fun getAttributeValue(i: Int): String? {
+    val attribute = getAttribute(i) ?: return null
+    return attribute.value
+  }
+
+  override fun getAttributeValue(
+    namespace: String?,
+    name: String?
+  ): String? {
+    val tag = getCurrentNode() ?: return null
+    return tag.attributes.find { it.name == name }?.value
+  }
+
+  override fun getDepth(): Int = nodeStack.size
+
+  override fun getName(): String? {
+    if (parsingState == START_TAG || parsingState == END_TAG) {
+      // Should only be called when START_TAG
+      val currentNode = getCurrentNode()!!
+      return currentNode.name
+    }
+    return null
+  }
+
+  @Throws(XmlPullParserException::class)
+  override fun next(): Int {
+    when (parsingState) {
+      END_DOCUMENT -> throw XmlPullParserException("Nothing after the end")
+      START_DOCUMENT -> onNextFromStartDocument()
+      START_TAG -> onNextFromStartTag()
+      END_TAG -> onNextFromEndTag()
+    }
+    return parsingState
+  }
+
+  private fun getCurrentNode(): TagSnapshot? = nodeStack.lastOrNull()
+
+  private fun getAttribute(i: Int): AttributeSnapshot? {
+    if (parsingState != START_TAG) {
+      throw IndexOutOfBoundsException()
+    }
+    val tag = getCurrentNode() ?: return null
+    return tag.attributes[i]
+  }
+
+  private fun push(node: TagSnapshot) {
+    nodeStack.add(node)
+  }
+
+  private fun pop(): TagSnapshot = nodeStack.removeLast()
+
+  private fun onNextFromStartDocument() {
+    val rootTag = rootTag()
+    @Suppress("SENSELESS_COMPARISON")
+    parsingState = if (rootTag != null) {
+      push(rootTag)
+      START_TAG
+    } else {
+      END_DOCUMENT
+    }
+  }
+
+  private fun onNextFromStartTag() {
+    // get the current node, and look for text or children (children first)
+    // Should only be called when START_TAG
+    val node = getCurrentNode()!!
+    val children = node.children
+    parsingState = if (children.isNotEmpty()) {
+      // move to the new child, and don't change the state.
+      push(children[0])
+
+      // in case the current state is CURRENT_DOC, we set the proper state.
+      START_TAG
+    } else {
+      if (parsingState == START_DOCUMENT) {
+        // this handles the case where there's no node.
+        END_DOCUMENT
+      } else {
+        END_TAG
+      }
+    }
+  }
+
+  private fun onNextFromEndTag() {
+    // look for a sibling. if no sibling, go back to the parent
+    // Should only be called when END_TAG
+    var node = getCurrentNode()!!
+    val sibling = node.next
+    if (sibling != null) {
+      node = sibling
+      // to go to the sibling, we need to remove the current node,
+      pop()
+      // and add its sibling.
+      push(node)
+      parsingState = START_TAG
+    } else {
+      // move back to the parent
+      pop()
+
+      // we have only one element left (myRoot), then we're done with the document.
+      parsingState = if (nodeStack.isEmpty()) {
+        END_DOCUMENT
+      } else {
+        END_TAG
+      }
+    }
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/LayoutPullParser.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/LayoutPullParser.kt
new file mode 100644
index 0000000..dd4a7f1
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/LayoutPullParser.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2014 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 app.cash.paparazzi.internal.parsers
+
+import com.android.SdkConstants.ATTR_IGNORE
+import com.android.SdkConstants.EXPANDABLE_LIST_VIEW
+import com.android.SdkConstants.GRID_VIEW
+import com.android.SdkConstants.LIST_VIEW
+import com.android.SdkConstants.SPINNER
+import com.android.SdkConstants.TOOLS_URI
+import com.android.ide.common.rendering.api.ILayoutPullParser
+import com.android.ide.common.rendering.api.ResourceNamespace
+import okio.buffer
+import okio.source
+import org.xmlpull.v1.XmlPullParserException
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOError
+import java.io.InputStream
+import java.nio.charset.Charset
+
+/**
+ * A layout parser that holds an in-memory tree of a given resource for subsequent traversal
+ * during the inflation process
+ */
+internal class LayoutPullParser : InMemoryParser, AaptAttrParser, ILayoutPullParser {
+  private constructor(inputStream: InputStream) : super() {
+    try {
+      val buffer = inputStream.source().buffer()
+
+      setFeature(FEATURE_PROCESS_NAMESPACES, true)
+      setInput(buffer.peek().inputStream(), null)
+
+      // IntelliJ uses XmlFile/PsiFile to parse tag snapshots,
+      // leaving XmlPullParser for Android to parse resources as usual
+      // Here, we use the same XmlPullParser approach for both, which means
+      // we need reinitialize the document stream between the two passes.
+      val resourceParser = ResourceParser(buffer.inputStream())
+      root = resourceParser.createTagSnapshot()
+
+      // Obtain a list of all the aapt declared attributes
+      declaredAaptAttrs = findDeclaredAaptAttrs(root)
+    } catch (e: XmlPullParserException) {
+      throw IOError(e)
+    }
+  }
+
+  private constructor(aaptResource: TagSnapshot) : super() {
+    root = aaptResource
+    declaredAaptAttrs = emptyMap()
+  }
+
+  private val root: TagSnapshot
+  private val declaredAaptAttrs: Map<String, TagSnapshot>
+
+  private var layoutNamespace = ResourceNamespace.RES_AUTO
+
+  override fun rootTag() = root
+
+  @Suppress("SENSELESS_COMPARISON")
+  override fun getViewCookie(): Any? {
+    // TODO: Implement this properly.
+    val name = super.getName() ?: return null
+
+    // Store tools attributes if this looks like a layout we'll need adapter view
+    // bindings for in the LayoutlibCallback.
+    if (LIST_VIEW == name || EXPANDABLE_LIST_VIEW == name || GRID_VIEW == name || SPINNER == name) {
+      var map: MutableMap<String, String>? = null
+      val count = attributeCount
+      for (i in 0 until count) {
+        val namespace = getAttributeNamespace(i)
+        if (namespace != null && namespace == TOOLS_URI) {
+          val attribute = getAttributeName(i)!!
+          if (attribute == ATTR_IGNORE) {
+            continue
+          }
+          if (map == null) {
+            map = HashMap(4)
+          }
+          map[attribute] = getAttributeValue(i)!!
+        }
+      }
+
+      return map
+    }
+
+    return null
+  }
+
+  override fun getLayoutNamespace(): ResourceNamespace = layoutNamespace
+
+  override fun getAaptDeclaredAttrs(): Map<String, TagSnapshot> = declaredAaptAttrs
+
+  fun setLayoutNamespace(layoutNamespace: ResourceNamespace) {
+    this.layoutNamespace = layoutNamespace
+  }
+
+  private fun findDeclaredAaptAttrs(tag: TagSnapshot): Map<String, TagSnapshot> {
+    if (!tag.hasDeclaredAaptAttrs) {
+      // Nor tag or any of the children has any aapt:attr declarations, we can stop here.
+      return emptyMap()
+    }
+
+    return buildMap {
+      tag.attributes
+        .filterIsInstance<AaptAttrSnapshot>()
+        .forEach { attr ->
+          val bundledTag = attr.bundledTag
+          put(attr.id, bundledTag)
+          for (child in bundledTag.children) {
+            putAll(findDeclaredAaptAttrs(child))
+          }
+        }
+      for (child in tag.children) {
+        putAll(findDeclaredAaptAttrs(child))
+      }
+    }
+  }
+
+  companion object {
+    @Throws(FileNotFoundException::class)
+    fun createFromFile(layoutFile: File) = LayoutPullParser(FileInputStream(layoutFile))
+
+    /**
+     * @param layoutPath Must start with '/' and be relative to test resources.
+     */
+    fun createFromPath(layoutPath: String): LayoutPullParser {
+      @Suppress("NAME_SHADOWING") var layoutPath = layoutPath
+      if (layoutPath.startsWith("/")) {
+        layoutPath = layoutPath.substring(1)
+      }
+
+      return LayoutPullParser(
+        LayoutPullParser::class.java.classLoader!!.getResourceAsStream(layoutPath)
+      )
+    }
+
+    fun createFromString(contents: String): LayoutPullParser {
+      return LayoutPullParser(
+        ByteArrayInputStream(contents.toByteArray(Charset.forName("UTF-8")))
+      )
+    }
+
+    fun createFromAaptResource(aaptResource: TagSnapshot): LayoutPullParser {
+      return LayoutPullParser(aaptResource)
+    }
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/ResourceParser.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/ResourceParser.kt
new file mode 100644
index 0000000..574e18d
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/ResourceParser.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+import com.android.SdkConstants.AAPT_URI
+import com.android.SdkConstants.TAG_ATTR
+import org.kxml2.io.KXmlParser
+import java.io.InputStream
+
+/**
+ * An XML resource parser that creates a tree of [TagSnapshot]s
+ */
+class ResourceParser(inputStream: InputStream) : KXmlParser() {
+  init {
+    setFeature(FEATURE_PROCESS_NAMESPACES, true)
+    setInput(inputStream, null)
+
+    require(START_DOCUMENT, null, null)
+    next()
+  }
+
+  fun createTagSnapshot(): TagSnapshot {
+    require(START_TAG, null, null)
+
+    // need to store now, since TagSnapshot is created on end tag after parser mark has moved
+    val tagName = name
+    val tagNamespace = namespace
+    val prefix = prefix
+
+    val attributes = createAttributesForTag()
+
+    var hasDeclaredAaptAttrs = false
+    var last: TagSnapshot? = null
+    val children = mutableListOf<TagSnapshot>()
+    while (eventType != END_DOCUMENT) {
+      when (next()) {
+        START_TAG -> {
+          if (AAPT_URI == namespace) {
+            if (TAG_ATTR == name) {
+              val attrAttribute = createAttrTagSnapshot()
+              if (attrAttribute != null) {
+                attributes += attrAttribute
+                hasDeclaredAaptAttrs = true
+              }
+            }
+            // Since we save the aapt:attr tags as an attribute, we do not save them as a child element. Skip.
+          } else {
+            val child = createTagSnapshot()
+            hasDeclaredAaptAttrs = hasDeclaredAaptAttrs || child.hasDeclaredAaptAttrs
+            children += child
+            if (last != null) {
+              last.next = child
+            }
+            last = child
+          }
+        }
+        END_TAG -> {
+          return TagSnapshot(
+            tagName,
+            tagNamespace,
+            prefix,
+            attributes,
+            children.toList(),
+            hasDeclaredAaptAttrs
+          )
+        }
+      }
+    }
+
+    throw IllegalStateException("We should never reach here")
+  }
+
+  private fun createAttrTagSnapshot(): AaptAttrSnapshot? {
+    require(START_TAG, null, "attr")
+
+    val name = getAttributeValue(null, "name") ?: return null
+    val prefix = findPrefixByQualifiedName(name)
+    val namespace = getNamespace(prefix)
+    val localName = findLocalNameByQualifiedName(name)
+    val id = (++uniqueId).toString()
+
+    var bundleTagSnapshot: TagSnapshot? = null
+    loop@ while (eventType != END_TAG) {
+      when (nextTag()) {
+        START_TAG -> {
+          bundleTagSnapshot = createTagSnapshot()
+        }
+        END_TAG -> {
+          break@loop
+        }
+      }
+    }
+
+    return if (bundleTagSnapshot != null) {
+      // swallow end tag
+      nextTag()
+      require(END_TAG, null, "attr")
+
+      AaptAttrSnapshot(namespace, prefix, localName, id, bundleTagSnapshot)
+    } else {
+      null
+    }
+  }
+
+  private fun findPrefixByQualifiedName(name: String): String {
+    val prefixEnd = name.indexOf(':')
+    return if (prefixEnd > 0) {
+      name.substring(0, prefixEnd)
+    } else ""
+  }
+
+  private fun findLocalNameByQualifiedName(name: String): String {
+    return name.substring(name.indexOf(':') + 1)
+  }
+
+  private fun createAttributesForTag(): MutableList<AttributeSnapshot> {
+    return buildList {
+      for (i in 0 until attributeCount) {
+        add(
+          AttributeSnapshot(
+            getAttributeNamespace(i),
+            getAttributePrefix(i),
+            getAttributeName(i),
+            getAttributeValue(i)
+          )
+        )
+      }
+    }.toMutableList()
+  }
+
+  companion object {
+    private var uniqueId = 0L
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/TagSnapshot.kt b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/TagSnapshot.kt
new file mode 100644
index 0000000..19cab20
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/internal/parsers/TagSnapshot.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+/**
+ * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-master-dev:android/src/com/android/tools/idea/rendering/parsers/TagSnapshot.java
+ *
+ * A snapshot of the state of an xml tag.
+ *
+ * Used by the rendering architecture to be able to hold a consistent view of
+ * the layout files across a long rendering operation without holding read locks,
+ * as well as to for example let the property sheet evaluate and paint the values
+ * of properties as they were at the time of rendering, not as they are at the current
+ * instant.
+ */
+data class TagSnapshot(
+  val name: String,
+  val namespace: String,
+  val prefix: String?,
+  val attributes: List<AttributeSnapshot>,
+  val children: List<TagSnapshot>,
+  val hasDeclaredAaptAttrs: Boolean = false
+) {
+  var next: TagSnapshot? = null
+
+  @Suppress("unused") // Used for debugging
+  fun printFormatted(): String {
+    indent++
+    val output = """
+      |$name:
+      |${pad(indent + 1)}attributes: ${print(attributes)}
+      |${pad(indent + 1)}children: ${print(children)} 
+    """.trimMargin()
+    indent--
+    return output
+  }
+
+  private fun print(children: List<*>): String {
+    if (children.isEmpty()) return children.toString()
+
+    indent++
+    val output = children.joinToString(
+      prefix = "[\n${pad(indent + 1)}",
+      separator = "\n${pad(indent + 1)}",
+      postfix = "\n${pad(indent)}]"
+    )
+    indent--
+    return output
+  }
+
+  private fun pad(length: Int): String = "  ".repeat(length)
+
+  companion object {
+    var indent = -1
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/main/resources/index.html b/external/paparazzi/paparazzi/src/main/resources/index.html
new file mode 100644
index 0000000..327f8b0
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/resources/index.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <style type="text/css">
+    body {
+      background: #212121;
+      text-align: center;
+    }
+
+    .screen {
+      margin: 0.2em;
+      display: inline-block;
+      position: relative;
+      overflow: hidden;
+    }
+
+    .screen img, .screen video {
+      width: 300px;
+    }
+
+    div.overlay__hovered {
+        display: block;
+    }
+
+    .overlay {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      background: rgba(25, 0, 237, .68);
+      color: #fff;
+      opacity: 0;
+      display: none;
+      transition: 150ms;
+
+       -webkit-animation-name: slideIn;
+       -webkit-animation-duration: 0.4s;
+      animation-name: slideIn;
+      animation-duration: 0.4s;
+      animation-fill-mode: forwards;
+    }
+
+    @-webkit-keyframes slideIn {
+      from {bottom: -300px; opacity: 0}
+      to {bottom: 0; opacity: 1}
+    }
+
+    @keyframes slideIn {
+      from {bottom: -300px; opacity: 0}
+      to {bottom: 0; opacity: 1}
+    }
+
+    .test__details {
+      text-align: left;
+      padding-left: 1em;
+    }
+
+    .test__details__name,
+    .test__details__class,
+    .test__details__package {
+      line-height: 0.7em;
+    }
+
+    .test__details__timestamp {
+      margin-top: 2em;
+    }
+
+    .test__details__selector {
+      width: 16px;
+      height: 16px;
+      border-radius: 50%;
+      display: inline-block;
+      border: 2px solid #fff;
+      margin: .1em;
+      margin-bottom: 1.3em;
+      margin-top: 1em;
+    }
+
+    .test__details__selector:hover {
+      background-color: white;
+    }
+    </style>
+</head>
+
+<script src="index.js"></script>
+<script src="paparazzi.js"></script>
+
+<body onload="bootstrap()">
+  <span id="rootContainer"/>
+</body>
+</html>
diff --git a/external/paparazzi/paparazzi/src/main/resources/paparazzi.js b/external/paparazzi/paparazzi/src/main/resources/paparazzi.js
new file mode 100644
index 0000000..5c12ab1
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/main/resources/paparazzi.js
@@ -0,0 +1,222 @@
+window.runs = {};
+
+class Run {
+  constructor(id, data) {
+    this.id = id;
+    // TODO(oldergod) which entries are required/optional?
+    this.data = data;
+  }
+}
+
+class Shot {
+  constructor(name, test) {
+    this.name = name;
+    this.test = test;
+
+    [, this.package, this.clazz, this.method] = Shot.TestMethodRegex.exec(test);
+
+    this.runs = [];
+  }
+
+  static get TestMethodRegex() {
+    return /^(.*)\.(.*)#(.*)$/;
+  }
+
+  addRun(runId, file, timestamp) {
+    this.runs.push(
+      {
+        'id': runId,
+        'file': file,
+        'timestamp': timestamp
+      }
+    );
+
+    if (file.endsWith('.png')) {
+      this.img.src = file;
+      this.img.style.display = 'inline';
+      this.video.style.display = 'none';
+    } else {
+      this.video.src = file;
+      this.video.style.display = 'inline';
+      this.img.style.display = 'none';
+    }
+    this.timestampP.innerText = timestamp;
+
+    const circle = document.createElement('div');
+    circle.classList.add('test__details__selector', `run-${runId}`);
+    circle.onmouseover = function (e) {
+      if (file.endsWith('.png')) {
+        this.img.src = file;
+      } else {
+        this.video.src = file;
+      }
+
+      for (let shot of Object.values(paparazziRenderer.shots)) {
+        let found = false;
+        for (let run of shot.runs) {
+          if (runId == run.id) {
+            shot.img.src = run.file;
+            shot.timestampP.innerText = run.timestamp;
+
+            found = true;
+            break;
+          }
+        }
+        shot.img.style.opacity = found ? "1" : "0.3";
+      }
+    }.bind(this);
+    this.overlayDiv.appendChild(circle);
+  }
+
+  removeRun(runId) {
+    const index = this.runs.indexOf((run) => run.id == runId);
+    if (index == -1) return;
+
+    this.runs.splice(index, 1);
+  }
+
+  inflate() {
+    const screenDiv = document.createElement('div');
+    screenDiv.classList.add('screen');
+
+    document.rootContainer.appendChild(screenDiv);
+
+    const img = document.createElement('img');
+    const video = document.createElement('video');
+    video.autoplay = 'autoplay';
+    video.muted = 'muted';
+    video.loop = 'loop';
+
+    const overlayDiv = document.createElement('div');
+    overlayDiv.classList.add('overlay');
+
+    screenDiv.appendChild(img);
+    screenDiv.appendChild(video);
+    screenDiv.appendChild(overlayDiv);
+    screenDiv.onmouseover = function (e) {
+      overlayDiv.classList.add('overlay__hovered');
+    }.bind(this);
+    screenDiv.onmouseout = function (e) {
+      overlayDiv.classList.remove('overlay__hovered');
+    }.bind(this);
+
+    const nameP = document.createElement('p');
+    nameP.classList.add('test__details', 'test__details__name');
+
+    const classP = document.createElement('p');
+    classP.classList.add('test__details', 'test__details__class');
+
+    const packageP = document.createElement('p');
+    packageP.classList.add('test__details', 'test__details__package');
+
+    const timestampP = document.createElement('p');
+    timestampP.classList.add('test__details', 'test__details__timestamp');
+
+    overlayDiv.appendChild(nameP);
+    overlayDiv.appendChild(classP);
+    overlayDiv.appendChild(packageP);
+    overlayDiv.appendChild(timestampP);
+
+    nameP.innerText = this.method;
+    if (this.name !== undefined) {
+      nameP.innerText += ` ${this.name}`;
+    }
+    classP.innerText = this.clazz;
+    packageP.innerText = this.package;
+
+    // hold references to the DOM for later updates
+    this.img = img;
+    this.video = video;
+    this.timestampP = timestampP;
+    this.overlayDiv = overlayDiv;
+  }
+}
+
+class PaparazziRenderer {
+  constructor() {
+    // Used for content comparison for we only re-render the updated ones.
+    this.currentRuns = {};
+    // Used to store runs we know won't be updated anymore.
+    this.lockedRunIds = [];
+    this.shots = {}; // Key is `${test}${name}`, Value is a Shot.
+  }
+
+  start() {
+    this.loadRunScript('index.js');
+    for (let runId of window.all_runs) {
+      this.loadRunScript(`runs/${runId}.js`);
+    }
+    setInterval(this.refresh.bind(this), 100);
+  }
+
+  render(run) {
+    if (this.currentRuns[run.id]
+      && JSON.stringify(this.currentRuns[run.id]) == JSON.stringify(run)) {
+      // This run didn't change.
+      return;
+    }
+    this.currentRuns[run.id] = run;
+    console.log('rendering', run);
+
+    for (let datum of run.data) {
+      const key = `${datum.testName}${datum.name}`;
+      let shot = this.shots[key];
+      if (!shot) {
+        console.log('New shot detected', shot);
+        shot = new Shot(datum.name, datum.testName);
+        this.shots[key] = shot;
+        shot.inflate();
+      } else {
+        //shot.removeRun(run.id);
+      }
+
+      console.log('Adding run to shot', shot);
+      shot.addRun(run.id, datum.file, datum.timestamp);
+
+      // TODO setup listeners for filters/hovering, etc
+    }
+  }
+
+  renderAll() {
+    this.loadRunScript('index.js');
+    for (let runId of window.all_runs) {
+      if (this.lockedRunIds.includes(runId)) {
+        continue;
+      }
+      // The js loading is async so the rendering can happen in the next refresh
+      this.loadRunScript(`runs/${runId}.js`);
+
+      this.render(new Run(runId, window.runs[runId]));
+
+      const lastRunId = window.all_runs[window.all_runs.length - 1];
+      if (runId != lastRunId) {
+        // This run isn't the last run so we know it ain't gonna be updated.
+        this.lockedRunIds.push(runId);
+        delete this.currentRuns[runId];
+      }
+    }
+  }
+
+  refresh() {
+    if (window.all_runs.length == 0) return;
+
+    this.renderAll();
+  }
+
+  loadRunScript(js) {
+    const script = document.createElement('script');
+    script.src = js;
+    script.onload = function () {
+      this.remove();
+    }
+    document.head.appendChild(script);
+  }
+}
+
+const paparazziRenderer = new PaparazziRenderer();
+console.log(paparazziRenderer);
+
+function bootstrap() {
+  document.rootContainer = document.getElementById('rootContainer');
+  paparazziRenderer.start();
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt
new file mode 100644
index 0000000..dc38b1c
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.awt.image.BufferedImage
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.BasicFileAttributes
+import java.nio.file.attribute.FileTime
+import java.time.Instant
+import java.util.Date
+
+class HtmlReportWriterTest {
+  @get:Rule
+  val reportRoot: TemporaryFolder = TemporaryFolder()
+
+  @get:Rule
+  val snapshotRoot: TemporaryFolder = TemporaryFolder()
+
+  private val anyImage = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)
+  private val anyImageHash = "9069ca78e7450a285173431b3e52c5c25299e473"
+
+  @Test
+  fun happyPath() {
+    val htmlReportWriter = HtmlReportWriter("run_one", reportRoot.root)
+    htmlReportWriter.use {
+      val frameHandler = htmlReportWriter.newFrameHandler(
+        Snapshot(
+          name = "loading",
+          testName = TestName("app.cash.paparazzi", "CelebrityTest", "testSettings"),
+          timestamp = Instant.parse("2019-03-20T10:27:43Z").toDate(),
+          tags = listOf("redesign")
+        ),
+        1,
+        -1
+      )
+      frameHandler.use {
+        frameHandler.handle(anyImage)
+      }
+    }
+
+    assertThat(File("${reportRoot.root}/index.js")).hasContent(
+      """
+        |window.all_runs = [
+        |  "run_one"
+        |];
+        |
+      """.trimMargin()
+    )
+
+    assertThat(File("${reportRoot.root}/runs/run_one.js")).hasContent(
+      """
+        |window.runs["run_one"] = [
+        |  {
+        |    "name": "loading",
+        |    "testName": "app.cash.paparazzi.CelebrityTest#testSettings",
+        |    "timestamp": "2019-03-20T10:27:43.000Z",
+        |    "tags": [
+        |      "redesign"
+        |    ],
+        |    "file": "images/$anyImageHash.png"
+        |  }
+        |];
+        |
+      """.trimMargin()
+    )
+  }
+
+  @Test
+  fun sanitizeForFilename() {
+    assertThat("0 Dollars".sanitizeForFilename()).isEqualTo("0_dollars")
+    assertThat("`!#$%&*+=|\\'\"<>?/".sanitizeForFilename()).isEqualTo("_________________")
+    assertThat("~@^()[]{}:;,.".sanitizeForFilename()).isEqualTo("~@^()[]{}:;,.")
+  }
+
+  @Test
+  fun noSnapshotOnFailure() {
+    val htmlReportWriter = HtmlReportWriter("run_one", reportRoot.root)
+    htmlReportWriter.use {
+      val frameHandler = htmlReportWriter.newFrameHandler(
+        snapshot = Snapshot(
+          name = "loading",
+          testName = TestName("app.cash.paparazzi", "CelebrityTest", "testSettings"),
+          timestamp = Instant.parse("2019-03-20T10:27:43Z").toDate()
+        ),
+        frameCount = 4,
+        fps = -1
+      )
+      frameHandler.use {
+        // intentionally empty, to simulate no content written on exception
+      }
+    }
+
+    assertThat(File(reportRoot.root, "images")).isEmptyDirectory
+    assertThat(File(reportRoot.root, "videos")).isEmptyDirectory
+  }
+
+  @Test
+  fun alwaysOverwriteOnRecord() {
+    // set record mode
+    System.setProperty("paparazzi.test.record", "true")
+
+    val htmlReportWriter = HtmlReportWriter("record_run", reportRoot.root, snapshotRoot.root)
+    htmlReportWriter.use {
+      val now = Instant.parse("2021-02-23T10:27:43Z")
+      val snapshot = Snapshot(
+        name = "test",
+        testName = TestName("app.cash.paparazzi", "HomeView", "testSettings"),
+        timestamp = now.toDate()
+      )
+      val file =
+        File("${snapshotRoot.root}/images/app.cash.paparazzi_HomeView_testSettings_test.png")
+      val golden = file.toPath()
+
+      // precondition
+      assertThat(golden).doesNotExist()
+
+      // take 1
+      val frameHandler1 = htmlReportWriter.newFrameHandler(
+        snapshot = snapshot,
+        frameCount = 1,
+        fps = -1
+      )
+      frameHandler1.use { frameHandler1.handle(anyImage) }
+      assertThat(golden).exists()
+      val timeFirstWrite = golden.lastModifiedTime()
+
+      // I know....but guarantees writes won't happen in same tick
+      Thread.sleep(100)
+
+      // take 2
+      val frameHandler2 = htmlReportWriter.newFrameHandler(
+        snapshot = snapshot.copy(timestamp = now.plusSeconds(1).toDate()),
+        frameCount = 1,
+        fps = -1
+      )
+      frameHandler2.use { frameHandler2.handle(anyImage) }
+      assertThat(golden).exists()
+      val timeOverwrite = golden.lastModifiedTime()
+
+      // should always overwrite
+      assertThat(timeOverwrite).isGreaterThan(timeFirstWrite)
+    }
+  }
+
+  private fun Instant.toDate() = Date(toEpochMilli())
+
+  private fun Path.lastModifiedTime(): FileTime {
+    return Files.readAttributes(this, BasicFileAttributes::class.java).lastModifiedTime()
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/PaparazziTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/PaparazziTest.kt
new file mode 100644
index 0000000..498d456
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/PaparazziTest.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+import android.animation.AnimationHandler
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ValueAnimator
+import android.graphics.Canvas
+import android.graphics.Color
+import android.view.Choreographer
+import android.view.Choreographer.CALLBACK_ANIMATION
+import android.view.View
+import android.view.animation.LinearInterpolator
+import android.widget.Button
+import com.android.internal.lang.System_Delegate
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.TimeUnit
+
+class PaparazziTest {
+  @get:Rule
+  val paparazzi = Paparazzi()
+
+  @Ignore("b/245941625")
+  @Test
+  fun drawCalls() {
+    val log = mutableListOf<String>()
+
+    val view = object : View(paparazzi.context) {
+      override fun onDraw(canvas: Canvas) {
+        log += "onDraw time=$time"
+      }
+    }
+
+    paparazzi.snapshot(view)
+
+    assertThat(log).containsExactly("onDraw time=0")
+  }
+
+  @Ignore("b/245941625")
+  @Test
+  fun resetsAnimationHandler() {
+    assertThat(AnimationHandler.sAnimatorHandler.get()).isNull()
+
+    // Why Button?  Because it sets a StateListAnimator on window attach
+    // See https://github.com/cashapp/paparazzi/pull/319
+    paparazzi.snapshot(Button(paparazzi.context))
+
+    assertThat(AnimationHandler.sAnimatorHandler.get()).isNull()
+  }
+
+  @Ignore("b/245941625")
+  @Test
+  fun animationEvents() {
+    val log = mutableListOf<String>()
+
+    val animator = ValueAnimator.ofFloat(0.0f, 1.0f)
+    animator.addListener(object : AnimatorListenerAdapter() {
+      override fun onAnimationStart(animation: Animator) {
+        log += "onAnimationStart time=$time animationElapsed=${animator.animatedValue}"
+      }
+
+      override fun onAnimationEnd(animation: Animator) {
+        log += "onAnimationEnd time=$time animationElapsed=${animator.animatedValue}"
+      }
+    })
+
+    val view = object : View(paparazzi.context) {
+      override fun onDraw(canvas: Canvas) {
+        log += "onDraw time=$time animationElapsed=${animator.animatedValue}"
+      }
+    }
+
+    animator.addUpdateListener {
+      log += "onAnimationUpdate time=$time animationElapsed=${animator.animatedValue}"
+
+      val colorComponent = it.animatedFraction
+      view.setBackgroundColor(Color.argb(1f, colorComponent, colorComponent, colorComponent))
+    }
+
+    animator.startDelay = 2_000L
+    animator.duration = 1_000L
+    animator.interpolator = LinearInterpolator()
+    animator.start()
+
+    paparazzi.gif(view, start = 1_000L, end = 4_000L, fps = 4)
+
+    assertThat(log).containsExactly(
+      "onDraw time=1000 animationElapsed=0.0",
+      "onAnimationStart time=2000 animationElapsed=0.0",
+      "onAnimationUpdate time=2000 animationElapsed=0.0",
+      "onDraw time=2000 animationElapsed=0.0",
+      "onAnimationUpdate time=2250 animationElapsed=0.25",
+      "onDraw time=2250 animationElapsed=0.25",
+      "onAnimationUpdate time=2500 animationElapsed=0.5",
+      "onDraw time=2500 animationElapsed=0.5",
+      "onAnimationUpdate time=2750 animationElapsed=0.75",
+      "onDraw time=2750 animationElapsed=0.75",
+      "onAnimationUpdate time=3000 animationElapsed=1.0",
+      "onAnimationEnd time=3000 animationElapsed=1.0",
+      "onDraw time=3000 animationElapsed=1.0"
+    )
+  }
+
+  @Test
+  @Ignore
+  fun frameCallbacksExecutedAfterLayout() {
+    val log = mutableListOf<String>()
+
+    val view = object : View(paparazzi.context) {
+      override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        Choreographer.getInstance()
+          .postCallback(
+            CALLBACK_ANIMATION,
+            { log += "view width=$width height=$height" },
+            false
+          )
+      }
+    }
+
+    paparazzi.snapshot(view)
+
+    assertThat(log).containsExactly("view width=1080 height=1776")
+  }
+
+  @Ignore("b/245941625")
+  @Test
+  fun throwsRenderingExceptions() {
+    val view = object : View(paparazzi.context) {
+      override fun onAttachedToWindow() {
+        throw Throwable("Oops")
+      }
+    }
+
+    val thrown = try {
+      paparazzi.snapshot(view)
+      false
+    } catch (exception: Throwable) {
+      true
+    }
+
+    assertThat(thrown).isTrue
+  }
+
+  private val time: Long
+    get() {
+      return TimeUnit.NANOSECONDS.toMillis(System_Delegate.nanoTime() - Paparazzi.TIME_OFFSET_NANOS)
+    }
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/R.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/R.kt
new file mode 100644
index 0000000..5827e33
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/R.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi
+
+/** Simulate an empty R.java for this package. */
+class R
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/accessibility/AccessibilityRenderExtensionTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/accessibility/AccessibilityRenderExtensionTest.kt
new file mode 100644
index 0000000..6399c8c
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/accessibility/AccessibilityRenderExtensionTest.kt
@@ -0,0 +1,152 @@
+package app.cash.paparazzi.accessibility
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.GradientDrawable.OVAL
+import android.graphics.drawable.GradientDrawable.Orientation.TL_BR
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Paparazzi
+import app.cash.paparazzi.Snapshot
+import app.cash.paparazzi.SnapshotHandler
+import app.cash.paparazzi.internal.ImageUtils
+import org.junit.Rule
+import org.junit.Test
+import java.awt.image.BufferedImage
+import java.io.File
+import javax.imageio.ImageIO
+import org.junit.Ignore
+
+class AccessibilityRenderExtensionTest {
+  private val snapshotHandler = TestSnapshotVerifier()
+
+  @get:Rule
+  val paparazzi = Paparazzi(
+    deviceConfig = DeviceConfig.NEXUS_5.copy(
+      // Needed to render accessibility content next to main content.
+      screenWidth = DeviceConfig.NEXUS_5.screenWidth * 2,
+      softButtons = false
+    ),
+    snapshotHandler = snapshotHandler,
+    renderExtensions = setOf(AccessibilityRenderExtension())
+  )
+
+  @Ignore("b/245941625")
+  @Test
+  fun `verify baseline`() {
+    val view = buildView(paparazzi.context)
+    paparazzi.snapshot(view, name = "accessibility")
+  }
+
+  @Ignore("b/245941625")
+  @Test
+  fun `test without layout params set`() {
+    val view = buildView(paparazzi.context, null)
+    paparazzi.snapshot(view, name = "without-layout-params")
+  }
+
+  @Ignore("b/245941625")
+  @Test
+  fun `verify changing view hierarchy order doesn't change accessibility colors`() {
+    val view = buildView(paparazzi.context).apply {
+      addView(
+        View(context).apply { contentDescription = "Empty View" },
+        0,
+        LinearLayout.LayoutParams(0, 0)
+      )
+    }
+    paparazzi.snapshot(view, name = "accessibility-new-view")
+  }
+
+  private fun buildView(
+    context: Context,
+    rootLayoutParams: ViewGroup.LayoutParams? = ViewGroup.LayoutParams(
+      ViewGroup.LayoutParams.MATCH_PARENT,
+      ViewGroup.LayoutParams.MATCH_PARENT
+    )
+  ) =
+    LinearLayout(context).apply {
+      orientation = LinearLayout.VERTICAL
+      rootLayoutParams?.let { layoutParams = it }
+      addView(
+        TextView(context).apply {
+          id = 1
+          text = "Text View Sample"
+        }
+      )
+
+      addView(
+        View(context).apply {
+          id = 2
+          layoutParams = LinearLayout.LayoutParams(100, 100)
+          contentDescription = "Content Description Sample"
+        }
+      )
+
+      addView(
+        View(context).apply {
+          id = 3
+          layoutParams = LinearLayout.LayoutParams(100, 100).apply {
+            setMarginsRelative(20, 20, 20, 20)
+          }
+          contentDescription = "Margin Sample"
+        }
+      )
+
+      addView(
+        View(context).apply {
+          id = 4
+          layoutParams = LinearLayout.LayoutParams(100, 100).apply {
+            setMarginsRelative(20, 20, 20, 20)
+          }
+          foreground = GradientDrawable(TL_BR, intArrayOf(Color.YELLOW, Color.BLUE)).apply {
+            shape = OVAL
+          }
+          contentDescription = "Foreground Drawable"
+        }
+      )
+
+      addView(
+        Button(context).apply {
+          id = 5
+          layoutParams = LinearLayout.LayoutParams(
+            ViewGroup.LayoutParams.WRAP_CONTENT,
+            ViewGroup.LayoutParams.WRAP_CONTENT
+          ).apply {
+            gravity = Gravity.CENTER
+          }
+          text = "Button Sample"
+        }
+      )
+    }
+
+  private class TestSnapshotVerifier : SnapshotHandler {
+    override fun newFrameHandler(
+      snapshot: Snapshot,
+      frameCount: Int,
+      fps: Int
+    ): SnapshotHandler.FrameHandler {
+      return object : SnapshotHandler.FrameHandler {
+        override fun handle(image: BufferedImage) {
+          val expected = File("src/test/resources/${snapshot.name}.png")
+          ImageUtils.assertImageSimilar(
+            relativePath = expected.path,
+            image = image,
+            goldenImage = ImageIO.read(expected),
+            maxPercentDifferent = 0.1
+          )
+        }
+
+        override fun close() = Unit
+      }
+    }
+
+    override fun close() = Unit
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/PaparazziJsonTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/PaparazziJsonTest.kt
new file mode 100644
index 0000000..9752037
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/PaparazziJsonTest.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2019 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal
+
+import app.cash.paparazzi.TestName
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+
+class PaparazziJsonTest {
+  @Test
+  fun testName() {
+    val adapter = PaparazziJson.moshi.adapter(TestName::class.java)
+    val testName = TestName("app.cash.paparazzi", "CelebrityTest", "testSettings")
+    val json = "\"app.cash.paparazzi.CelebrityTest#testSettings\""
+    assertThat(adapter.toJson(testName)).isEqualTo(json)
+    assertThat(adapter.fromJson(json)).isEqualTo(testName)
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/PaparazziLoggerTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/PaparazziLoggerTest.kt
new file mode 100644
index 0000000..e1ab916
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/PaparazziLoggerTest.kt
@@ -0,0 +1,50 @@
+package app.cash.paparazzi.internal
+
+import app.cash.paparazzi.internal.PaparazziLogger.MultipleFailuresException
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.fail
+import org.junit.Test
+import java.io.FileNotFoundException
+
+class PaparazziLoggerTest {
+  @Test
+  fun testNoErrors() {
+    val logger = PaparazziLogger()
+
+    try {
+      logger.assertNoErrors()
+    } catch (ignored: Exception) {
+      fail("Did not expect exception to be thrown: $ignored")
+    }
+  }
+
+  @Test
+  fun testSingleError() {
+    val logger = PaparazziLogger()
+    logger.error(FileNotFoundException("error1"), null)
+
+    try {
+      logger.assertNoErrors()
+      fail("Expected exception to be thrown")
+    } catch (ignored: Exception) {
+      assertThat(ignored).isInstanceOf(FileNotFoundException::class.java)
+    }
+  }
+
+  @Test
+  fun testMultipleErrors() {
+    val logger = PaparazziLogger()
+    logger.error(FileNotFoundException("error1"), null)
+    logger.error("tag", null, IllegalStateException("error2"), null, null)
+
+    try {
+      logger.assertNoErrors()
+      fail("Expected exceptions to be thrown")
+    } catch (ignored: Exception) {
+      assertThat(ignored).isInstanceOf(MultipleFailuresException::class.java)
+      assertThat(ignored.message).contains("There were 2 errors:")
+      assertThat(ignored.message).contains("java.io.FileNotFoundException: error1")
+      assertThat(ignored.message).contains("java.lang.IllegalStateException: error2")
+    }
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/parsers/InMemoryParserTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/parsers/InMemoryParserTest.kt
new file mode 100644
index 0000000..9a032e3
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/parsers/InMemoryParserTest.kt
@@ -0,0 +1,98 @@
+package app.cash.paparazzi.internal.parsers
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import org.xmlpull.v1.XmlPullParserException
+
+class InMemoryParserTest {
+  @Test
+  fun parse() {
+    val root = parseResourceTree("plus_sign.xml")
+    val parser = RealInMemoryParser(root)
+
+    assertThat(parser.name).isNull() // START_DOCUMENT
+    assertThat(parser.depth).isEqualTo(0)
+
+    parser.next() // START_TAG = "vector"
+
+    assertThat(parser.name).isEqualTo(VECTOR_TAG_NAME)
+    assertThat(parser.depth).isEqualTo(1)
+    assertThat(parser.attributeCount).isEqualTo(4)
+
+    assertThat(parser.getAttributeName(0)).isEqualTo("height")
+    assertThat(parser.getAttributeName(1)).isEqualTo("viewportHeight")
+    assertThat(parser.getAttributeName(2)).isEqualTo("viewportWidth")
+    assertThat(parser.getAttributeName(3)).isEqualTo("width")
+
+    assertThat(parser.getAttributeNamespace(0)).isEqualTo(ANDROID_NAMESPACE)
+    assertThat(parser.getAttributeNamespace(1)).isEqualTo(ANDROID_NAMESPACE)
+    assertThat(parser.getAttributeNamespace(2)).isEqualTo(ANDROID_NAMESPACE)
+    assertThat(parser.getAttributeNamespace(3)).isEqualTo(ANDROID_NAMESPACE)
+
+    assertThat(parser.getAttributePrefix(0)).isEqualTo(ANDROID_PREFIX)
+    assertThat(parser.getAttributePrefix(1)).isEqualTo(ANDROID_PREFIX)
+    assertThat(parser.getAttributePrefix(2)).isEqualTo(ANDROID_PREFIX)
+    assertThat(parser.getAttributePrefix(3)).isEqualTo(ANDROID_PREFIX)
+
+    assertThat(parser.getAttributeValue(0)).isEqualTo("24dp")
+    assertThat(parser.getAttributeValue(1)).isEqualTo("40")
+    assertThat(parser.getAttributeValue(2)).isEqualTo("40")
+    assertThat(parser.getAttributeValue(3)).isEqualTo("24dp")
+
+    parser.next() // START_TAG = "path"
+
+    assertThat(parser.name).isEqualTo(PATH_TAG_NAME)
+    assertThat(parser.depth).isEqualTo(2)
+    assertThat(parser.attributeCount).isEqualTo(2)
+
+    assertThat(parser.getAttributeName(0)).isEqualTo(FILL_COLOR_ATTR_NAME)
+    assertThat(parser.getAttributeName(1)).isEqualTo(PATH_DATA_ATTR_NAME)
+
+    assertThat(parser.getAttributeNamespace(0)).isEqualTo(ANDROID_NAMESPACE)
+    assertThat(parser.getAttributeNamespace(1)).isEqualTo(ANDROID_NAMESPACE)
+
+    assertThat(parser.getAttributePrefix(0)).isEqualTo(ANDROID_PREFIX)
+    assertThat(parser.getAttributePrefix(1)).isEqualTo(ANDROID_PREFIX)
+
+    assertThat(parser.getAttributeValue(0)).isEqualTo("#999999")
+    assertThat(parser.getAttributeValue(1)).isNotNull // pathData
+
+    parser.next() // END_TAG = "path"
+
+    assertThat(parser.name).isEqualTo(PATH_TAG_NAME)
+    assertThat(parser.depth).isEqualTo(2)
+
+    parser.next() // END_TAG = "vector"
+
+    assertThat(parser.name).isEqualTo(VECTOR_TAG_NAME)
+    assertThat(parser.depth).isEqualTo(1)
+
+    parser.next() // END_DOCUMENT
+    assertThat(parser.name).isNull() // START_DOCUMENT
+    assertThat(parser.depth).isEqualTo(0)
+
+    try {
+      parser.next()
+    } catch (expected: XmlPullParserException) {
+    }
+  }
+
+  private fun parseResourceTree(resourceId: String): TagSnapshot {
+    val resourceInputStream = javaClass.classLoader.getResourceAsStream(resourceId)!!
+    return ResourceParser(resourceInputStream).createTagSnapshot()
+  }
+
+  class RealInMemoryParser(private val root: TagSnapshot) : InMemoryParser() {
+    override fun rootTag(): TagSnapshot = root
+  }
+
+  companion object {
+    const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
+    const val ANDROID_PREFIX = "android"
+
+    const val VECTOR_TAG_NAME = "vector"
+    const val PATH_TAG_NAME = "path"
+    const val PATH_DATA_ATTR_NAME = "pathData"
+    const val FILL_COLOR_ATTR_NAME = "fillColor"
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/parsers/ResourceParserTest.kt b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/parsers/ResourceParserTest.kt
new file mode 100644
index 0000000..67402f9
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/java/app/cash/paparazzi/internal/parsers/ResourceParserTest.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2021 Square, Inc.
+ *
+ * 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 app.cash.paparazzi.internal.parsers
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+
+class ResourceParserTest {
+  @Test
+  fun parseResource() {
+    val root = parseResourceTree("plus_sign.xml")
+    assertThat(root.namespace).isEmpty()
+    assertThat(root.prefix).isNull()
+    assertThat(root.name).isEqualTo(VECTOR_TAG_NAME)
+    assertThat(root.hasDeclaredAaptAttrs).isEqualTo(false)
+    assertThat(root.next).isNull()
+    assertThat(root.attributes).containsExactly(
+      AttributeSnapshot(ANDROID_NAMESPACE, ANDROID_PREFIX, "height", "24dp"),
+      AttributeSnapshot(ANDROID_NAMESPACE, ANDROID_PREFIX, "viewportHeight", "40"),
+      AttributeSnapshot(ANDROID_NAMESPACE, ANDROID_PREFIX, "viewportWidth", "40"),
+      AttributeSnapshot(ANDROID_NAMESPACE, ANDROID_PREFIX, "width", "24dp")
+    )
+
+    val pathElement = root.children.single()
+    with(pathElement) {
+      assertThat(namespace).isEmpty()
+      assertThat(prefix).isNull()
+      assertThat(name).isEqualTo(PATH_TAG_NAME)
+      assertThat(hasDeclaredAaptAttrs).isEqualTo(false)
+      assertThat(next).isNull()
+    }
+
+    val pathAttributes = pathElement.attributes
+    assertThat(pathAttributes).hasSize(2)
+    assertThat(pathAttributes[0]).isEqualTo(
+      AttributeSnapshot(ANDROID_NAMESPACE, ANDROID_PREFIX, FILL_COLOR_ATTR_NAME, "#999999")
+    )
+
+    with(pathAttributes[1]) {
+      assertThat(namespace).isEqualTo(ANDROID_NAMESPACE)
+      assertThat(prefix).isEqualTo(ANDROID_PREFIX)
+      assertThat(name).isEqualTo(PATH_DATA_ATTR_NAME)
+      assertThat(value).isNotEmpty // don't care about pathData precision
+    }
+  }
+
+  @Test
+  fun parseAaptAttrTags() {
+    // Since #parseResource covers the basics, this test can be more targeted
+
+    val root = parseResourceTree("card_chip.xml")
+    assertThat(root.name).isEqualTo(VECTOR_TAG_NAME)
+    assertThat(root.hasDeclaredAaptAttrs).isEqualTo(true)
+    assertThat(root.next).isNull()
+
+    assertThat(root.children).hasSize(2)
+
+    val outerPathElement = root.children[0]
+    assertThat(outerPathElement.hasDeclaredAaptAttrs).isEqualTo(false)
+
+    val groupElement = root.children[1]
+    assertThat(groupElement.hasDeclaredAaptAttrs).isEqualTo(true)
+
+    assertThat(outerPathElement.next).isEqualTo(groupElement)
+
+    val clipPathElement = groupElement.children[0]
+    assertThat(clipPathElement.hasDeclaredAaptAttrs).isEqualTo(false)
+
+    val innerPathElement1 = groupElement.children[1]
+    assertThat(innerPathElement1.hasDeclaredAaptAttrs).isEqualTo(true)
+    with(innerPathElement1.attributes[0]) {
+      assertThat(name).isEqualTo(PATH_DATA_ATTR_NAME)
+      assertThat(this).isNotInstanceOf(AaptAttrSnapshot::class.java)
+    }
+    with(innerPathElement1.attributes[1] as AaptAttrSnapshot) {
+      assertThat(name).isEqualTo(FILL_COLOR_ATTR_NAME)
+      assertThat(id).isEqualTo("1")
+      assertThat(value).isEqualTo("@aapt:_aapt/aapt1")
+      assertThat(bundledTag.name).isEqualTo(GRADIENT_TAG_NAME) // 🎉
+    }
+
+    val innerPathElement2 = groupElement.children[2]
+    assertThat(innerPathElement2.hasDeclaredAaptAttrs).isEqualTo(true)
+    with(innerPathElement2.attributes[0]) {
+      assertThat(name).isEqualTo(PATH_DATA_ATTR_NAME)
+      assertThat(this).isNotInstanceOf(AaptAttrSnapshot::class.java)
+    }
+    with(innerPathElement2.attributes[1] as AaptAttrSnapshot) {
+      assertThat(name).isEqualTo(FILL_COLOR_ATTR_NAME)
+      assertThat(id).isEqualTo("2")
+      assertThat(value).isEqualTo("@aapt:_aapt/aapt2")
+      assertThat(bundledTag.name).isEqualTo(GRADIENT_TAG_NAME) // 🎉
+    }
+
+    val innerPathElement3 = groupElement.children[3]
+    assertThat(innerPathElement3.hasDeclaredAaptAttrs).isEqualTo(true)
+    with(innerPathElement3.attributes[0]) {
+      assertThat(name).isEqualTo(PATH_DATA_ATTR_NAME)
+      assertThat(this).isNotInstanceOf(AaptAttrSnapshot::class.java)
+    }
+    with(innerPathElement3.attributes[1]) {
+      assertThat(name).isEqualTo(FILL_TYPE_ATTR_NAME)
+      assertThat(this).isNotInstanceOf(AaptAttrSnapshot::class.java)
+    }
+    with(innerPathElement3.attributes[2] as AaptAttrSnapshot) {
+      assertThat(name).isEqualTo(FILL_COLOR_ATTR_NAME)
+      assertThat(id).isEqualTo("3")
+      assertThat(value).isEqualTo("@aapt:_aapt/aapt3")
+      assertThat(bundledTag.name).isEqualTo(GRADIENT_TAG_NAME) // 🎉
+    }
+  }
+
+  private fun parseResourceTree(resourceId: String): TagSnapshot {
+    val resourceInputStream = javaClass.classLoader.getResourceAsStream(resourceId)!!
+    return ResourceParser(resourceInputStream).createTagSnapshot()
+  }
+
+  companion object {
+    const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
+    const val ANDROID_PREFIX = "android"
+
+    const val VECTOR_TAG_NAME = "vector"
+    const val PATH_TAG_NAME = "path"
+    const val PATH_DATA_ATTR_NAME = "pathData"
+    const val FILL_COLOR_ATTR_NAME = "fillColor"
+    const val FILL_TYPE_ATTR_NAME = "fillType"
+    const val GRADIENT_TAG_NAME = "gradient"
+  }
+}
diff --git a/external/paparazzi/paparazzi/src/test/resources/accessibility-new-view.png b/external/paparazzi/paparazzi/src/test/resources/accessibility-new-view.png
new file mode 100644
index 0000000..57f25b5a
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/resources/accessibility-new-view.png
Binary files differ
diff --git a/external/paparazzi/paparazzi/src/test/resources/accessibility.png b/external/paparazzi/paparazzi/src/test/resources/accessibility.png
new file mode 100644
index 0000000..d7f3a16
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/resources/accessibility.png
Binary files differ
diff --git a/external/paparazzi/paparazzi/src/test/resources/card_chip.xml b/external/paparazzi/paparazzi/src/test/resources/card_chip.xml
new file mode 100644
index 0000000..a9cb398
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/resources/card_chip.xml
@@ -0,0 +1,37 @@
+<!--Copyright Square, Inc.-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="42dp" android:height="34dp" android:viewportWidth="42" android:viewportHeight="34">
+    <path android:pathData="M36.795 0H5.205C2.333 0 0 2.384 0 5.318v22.374c0 2.934 2.333 5.318 5.205 5.318h31.59c2.872 0 5.205-2.384 5.205-5.318V5.318C41.955 2.384 39.622 0 36.795 0z" android:fillColor="#666666"/>
+    <group>
+        <clip-path android:pathData="M0.314 21.549h11.981c0.404 0 0.808 0.183 1.122 0.504 1.48 1.559 3.5 2.338 6.058 2.338h0.224c2.378 0 3.814-0.825 3.814-2.109 0-1.192-1.211-1.742-4.711-3.072-3.097-1.146-7.18-3.163-7.45-7.748H0.27v0.459h0.404v9.17H0.27v0.458h0.045zm20.866 7.977c5.519-0.504 8.93-3.53 8.93-7.977 0-4.31-3.994-6.281-7.898-7.611-2.02-0.78-3.5-1.421-4.128-2.476h-6.058c0.27 4.172 4.083 6.052 7 7.107 3.32 1.237 5.115 1.97 5.115 3.713 0 1.742-1.705 2.797-4.487 2.797H19.43c-2.737 0-4.936-0.871-6.507-2.568-0.18-0.183-0.403-0.275-0.628-0.275H0.27v0.459h0.403v4.997c0 1.238 0.494 2.384 1.257 3.21L1.705 31.13l0.314 0.32 0.27-0.274c0.807 0.687 1.795 1.1 2.916 1.1h9.02l0.493-2.568c0.045-0.137-0.045-0.229-0.18-0.275-1.704-0.504-3.275-1.33-4.666-2.475-0.134-0.138-0.18-0.321-0.045-0.459 0.135-0.137 0.314-0.183 0.449-0.046 1.346 1.055 2.827 1.88 4.442 2.338 0.449 0.138 0.718 0.596 0.629 1.055l-0.45 2.43h5.026l0.45-2.063c0.089-0.367 0.403-0.642 0.807-0.688zM0.314 10.774h10.994c0.135-5.364 4.801-7.61 9.154-7.931 0.09-0.046 0.18-0.092 0.18-0.23L21 0.644H5.205c-1.121 0-2.109 0.412-2.916 1.1l-0.27-0.23L1.705 1.88 1.93 2.11C1.167 2.934 0.673 4.08 0.673 5.318v4.997H0.27v0.458h0.045zm41.327 11.417H30.738c-0.315 4.447-3.904 7.518-9.513 7.977-0.09 0-0.18 0.092-0.225 0.183l-0.404 1.926H36.75c1.122 0 2.11-0.413 2.917-1.1l0.27 0.275 0.313-0.321-0.224-0.23c0.808-0.825 1.301-1.97 1.301-3.209v-4.997h0.36L41.64 22.19zm-0.314-11.875V5.319c0-1.238-0.493-2.385-1.301-3.21L40.25 1.88l-0.314-0.367-0.224 0.23c-0.808-0.688-1.795-1.1-2.917-1.1h-9.333l-0.539 2.75c-0.044 0.092 0.045 0.23 0.135 0.275 1.212 0.458 2.378 1.1 3.455 1.834 0.135 0.092 0.18 0.32 0.09 0.458-0.045 0.092-0.18 0.138-0.27 0.138-0.044 0-0.134 0-0.179-0.046-1.032-0.688-2.109-1.284-3.276-1.696-0.403-0.138-0.673-0.596-0.583-1.009l0.539-2.613h-5.16l-0.36 2.017c-0.09 0.413-0.403 0.688-0.807 0.734-4.039 0.32-8.391 2.384-8.481 7.29h5.878c-0.045-0.138-0.045-0.276-0.045-0.413 0-1.467 1.436-2.384 3.904-2.384 1.795 0 4.577 0.642 6.641 2.476 0.224 0.183 0.494 0.32 0.763 0.32h12.564v-0.458h-0.404zm0 1.146H29.122c-0.404 0-0.852-0.183-1.211-0.458-1.93-1.697-4.533-2.339-6.193-2.339-0.538 0-3.23 0.092-3.23 1.697 0 1.33 1.57 2.017 3.948 2.934 4.128 1.421 8.302 3.484 8.302 8.253h10.948V21.09h-0.359v-9.17h0.36v-0.458h-0.36z"/>
+        <path android:pathData="M0.314 21.549h11.981c0.404 0 0.808 0.183 1.122 0.504 1.48 1.559 3.5 2.338 6.058 2.338h0.224c2.378 0 3.814-0.825 3.814-2.109 0-1.192-1.211-1.742-4.711-3.072-3.097-1.146-7.18-3.163-7.45-7.748H0.27v0.459h0.404v9.17H0.27v0.458h0.045zm20.866 7.977c5.519-0.504 8.93-3.53 8.93-7.977 0-4.31-3.994-6.281-7.898-7.611-2.02-0.78-3.5-1.421-4.128-2.476h-6.058c0.27 4.172 4.083 6.052 7 7.107 3.32 1.237 5.115 1.97 5.115 3.713 0 1.742-1.705 2.797-4.487 2.797H19.43c-2.737 0-4.936-0.871-6.507-2.568-0.18-0.183-0.403-0.275-0.628-0.275H0.27v0.459h0.403v4.997c0 1.238 0.494 2.384 1.257 3.21L1.705 31.13l0.314 0.32 0.27-0.274c0.807 0.687 1.795 1.1 2.916 1.1h9.02l0.493-2.568c0.045-0.137-0.045-0.229-0.18-0.275-1.704-0.504-3.275-1.33-4.666-2.475-0.134-0.138-0.18-0.321-0.045-0.459 0.135-0.137 0.314-0.183 0.449-0.046 1.346 1.055 2.827 1.88 4.442 2.338 0.449 0.138 0.718 0.596 0.629 1.055l-0.45 2.43h5.026l0.45-2.063c0.089-0.367 0.403-0.642 0.807-0.688zM0.314 10.774h10.994c0.135-5.364 4.801-7.61 9.154-7.931 0.09-0.046 0.18-0.092 0.18-0.23L21 0.644H5.205c-1.121 0-2.109 0.412-2.916 1.1l-0.27-0.23L1.705 1.88 1.93 2.11C1.167 2.934 0.673 4.08 0.673 5.318v4.997H0.27v0.458h0.045zm41.327 11.417H30.738c-0.315 4.447-3.904 7.518-9.513 7.977-0.09 0-0.18 0.092-0.225 0.183l-0.404 1.926H36.75c1.122 0 2.11-0.413 2.917-1.1l0.27 0.275 0.313-0.321-0.224-0.23c0.808-0.825 1.301-1.97 1.301-3.209v-4.997h0.36L41.64 22.19zm-0.314-11.875V5.319c0-1.238-0.493-2.385-1.301-3.21L40.25 1.88l-0.314-0.367-0.224 0.23c-0.808-0.688-1.795-1.1-2.917-1.1h-9.333l-0.539 2.75c-0.044 0.092 0.045 0.23 0.135 0.275 1.212 0.458 2.378 1.1 3.455 1.834 0.135 0.092 0.18 0.32 0.09 0.458-0.045 0.092-0.18 0.138-0.27 0.138-0.044 0-0.134 0-0.179-0.046-1.032-0.688-2.109-1.284-3.276-1.696-0.403-0.138-0.673-0.596-0.583-1.009l0.539-2.613h-5.16l-0.36 2.017c-0.09 0.413-0.403 0.688-0.807 0.734-4.039 0.32-8.391 2.384-8.481 7.29h5.878c-0.045-0.138-0.045-0.276-0.045-0.413 0-1.467 1.436-2.384 3.904-2.384 1.795 0 4.577 0.642 6.641 2.476 0.224 0.183 0.494 0.32 0.763 0.32h12.564v-0.458h-0.404zm0 1.146H29.122c-0.404 0-0.852-0.183-1.211-0.458-1.93-1.697-4.533-2.339-6.193-2.339-0.538 0-3.23 0.092-3.23 1.697 0 1.33 1.57 2.017 3.948 2.934 4.128 1.421 8.302 3.484 8.302 8.253h10.948V21.09h-0.359v-9.17h0.36v-0.458h-0.36z">
+            <aapt:attr name="android:fillColor">
+                <gradient android:startY="3.4928" android:startX="1.38832" android:endY="46.3417" android:endX="68.7929" android:type="linear">
+                    <item android:offset="0" android:color="#FFFFFFFF"/>
+                    <item android:offset="0.4081" android:color="#FFC7B299"/>
+                    <item android:offset="1" android:color="#FF998675"/>
+                </gradient>
+            </aapt:attr>
+        </path>
+        <path android:pathData="M0.314 21.549h11.981c0.404 0 0.808 0.183 1.122 0.504 1.48 1.559 3.5 2.338 6.058 2.338h0.224c2.378 0 3.814-0.825 3.814-2.109 0-1.192-1.211-1.742-4.711-3.072-3.097-1.146-7.18-3.163-7.45-7.748H0.27v0.459h0.404v9.17H0.27v0.458h0.045zm20.866 7.977c5.519-0.504 8.93-3.53 8.93-7.977 0-4.31-3.994-6.281-7.898-7.611-2.02-0.78-3.5-1.421-4.128-2.476h-6.058c0.27 4.172 4.083 6.052 7 7.107 3.32 1.237 5.115 1.97 5.115 3.713 0 1.742-1.705 2.797-4.487 2.797H19.43c-2.737 0-4.936-0.871-6.507-2.568-0.18-0.183-0.403-0.275-0.628-0.275H0.27v0.459h0.403v4.997c0 1.238 0.494 2.384 1.257 3.21L1.705 31.13l0.314 0.32 0.27-0.274c0.807 0.687 1.795 1.1 2.916 1.1h9.02l0.493-2.568c0.045-0.137-0.045-0.229-0.18-0.275-1.704-0.504-3.275-1.33-4.666-2.475-0.134-0.138-0.18-0.321-0.045-0.459 0.135-0.137 0.314-0.183 0.449-0.046 1.346 1.055 2.827 1.88 4.442 2.338 0.449 0.138 0.718 0.596 0.629 1.055l-0.45 2.43h5.026l0.45-2.063c0.089-0.367 0.403-0.642 0.807-0.688zM0.314 10.774h10.994c0.135-5.364 4.801-7.61 9.154-7.931 0.09-0.046 0.18-0.092 0.18-0.23L21 0.644H5.205c-1.121 0-2.109 0.412-2.916 1.1l-0.27-0.23L1.705 1.88 1.93 2.11C1.167 2.934 0.673 4.08 0.673 5.318v4.997H0.27v0.458h0.045zm41.327 11.417H30.738c-0.315 4.447-3.904 7.518-9.513 7.977-0.09 0-0.18 0.092-0.225 0.183l-0.404 1.926H36.75c1.122 0 2.11-0.413 2.917-1.1l0.27 0.275 0.313-0.321-0.224-0.23c0.808-0.825 1.301-1.97 1.301-3.209v-4.997h0.36L41.64 22.19zm-0.314-11.875V5.319c0-1.238-0.493-2.385-1.301-3.21L40.25 1.88l-0.314-0.367-0.224 0.23c-0.808-0.688-1.795-1.1-2.917-1.1h-9.333l-0.539 2.75c-0.044 0.092 0.045 0.23 0.135 0.275 1.212 0.458 2.378 1.1 3.455 1.834 0.135 0.092 0.18 0.32 0.09 0.458-0.045 0.092-0.18 0.138-0.27 0.138-0.044 0-0.134 0-0.179-0.046-1.032-0.688-2.109-1.284-3.276-1.696-0.403-0.138-0.673-0.596-0.583-1.009l0.539-2.613h-5.16l-0.36 2.017c-0.09 0.413-0.403 0.688-0.807 0.734-4.039 0.32-8.391 2.384-8.481 7.29h5.878c-0.045-0.138-0.045-0.276-0.045-0.413 0-1.467 1.436-2.384 3.904-2.384 1.795 0 4.577 0.642 6.641 2.476 0.224 0.183 0.494 0.32 0.763 0.32h12.564v-0.458h-0.404zm0 1.146H29.122c-0.404 0-0.852-0.183-1.211-0.458-1.93-1.697-4.533-2.339-6.193-2.339-0.538 0-3.23 0.092-3.23 1.697 0 1.33 1.57 2.017 3.948 2.934 4.128 1.421 8.302 3.484 8.302 8.253h10.948V21.09h-0.359v-9.17h0.36v-0.458h-0.36z">
+            <aapt:attr name="android:fillColor">
+                <gradient android:startY="31.3798" android:startX="42.1542" android:endY="-9.12049" android:endX="13.8976" android:type="linear">
+                    <item android:offset="0" android:color="#FFF1EAE2"/>
+                    <item android:offset="0.149949" android:color="#FFF3ECE3"/>
+                    <item android:offset="0.319891" android:color="#FFF4EDE4"/>
+                    <item android:offset="0.509302" android:color="#FFEFE6DB"/>
+                    <item android:offset="0.707905" android:color="#FFECE4DB"/>
+                    <item android:offset="1" android:color="#FFF1F1F1"/>
+                </gradient>
+            </aapt:attr>
+        </path>
+        <path android:pathData="M1.25 0.5C1.08 0.775 1.166 1 1.441 1S2.08 0.775 2.25 0.5C2.42 0.225 2.334 0 2.059 0S1.42 0.225 1.25 0.5zM24 1.03c0 0.565 0.225 0.89 0.5 0.72 0.275-0.17 0.5-0.633 0.5-1.03C25 0.324 24.775 0 24.5 0S24 0.464 24 1.03zm4-0.78c0 0.138 0.113 0.25 0.25 0.25s0.25-0.112 0.25-0.25S28.387 0 28.25 0 28 0.113 28 0.25zm-8.181 2C19.661 2.663 19.749 3 20.016 3 20.282 3 20.5 2.663 20.5 2.25S20.412 1.5 20.303 1.5c-0.108 0-0.326 0.337-0.484 0.75zM24 2.75C24 2.888 24.113 3 24.25 3s0.25-0.112 0.25-0.25-0.113-0.25-0.25-0.25S24 2.612 24 2.75zm-4.76 1.78c-0.45 1.42 0.05 1.796 0.721 0.543 0.326-0.609 0.357-1.127 0.077-1.3-0.256-0.158-0.615 0.182-0.797 0.757zM22.83 4c-0.977 3.02-0.495 6.254 0.669 4.5 0.274-0.412 0.448-1.088 0.387-1.5-0.06-0.412 0.046-0.998 0.236-1.301 0.191-0.303 0.033-0.978-0.351-1.5C23.19 3.412 23.03 3.378 22.829 4zm2.895 1.875C24.304 9.252 23.56 14.5 24.5 14.5c0.275 0 0.5-0.575 0.5-1.278C25 11.64 26.04 7.34 26.504 7c0.188-0.138 0.493-0.869 0.678-1.625 0.501-2.046-0.536-1.69-1.457 0.5zM32 4.75C32 4.888 32.112 5 32.25 5s0.25-0.112 0.25-0.25-0.112-0.25-0.25-0.25S32 4.612 32 4.75zM0.508 5.875c0.004 0.206 0.217 0.706 0.473 1.11 0.576 0.911 1.251 0.341 0.846-0.714-0.298-0.779-1.333-1.09-1.32-0.396zM13 6.029c0 0.292 0.225 0.391 0.5 0.221S14 5.842 14 5.72c0-0.12-0.225-0.22-0.5-0.22S13 5.739 13 6.03zm20.54 0.173c-0.415 0.5-0.424 0.753-0.031 0.885C34.584 7.444 29.094 22 27.884 22c-0.223 0-1.383-0.9-2.576-2-3.16-2.913-3.467-2.449-0.421 0.636l2.603 2.635-1.295 1.398c-1.703 1.835-1.092 2.398 0.719 0.663 1.881-1.802 5.678-9.57 7.284-14.905C34.914 8.05 35.5 5.97 35.5 5.804c0-0.58-1.382-0.298-1.96 0.399zM2.876 6.33c0.344 0.139 0.906 0.139 1.25 0S4.188 6.079 3.5 6.079 2.531 6.193 2.875 6.331zM5 6.5C5 6.775 5.225 7 5.5 7S6 6.775 6 6.5 5.775 6 5.5 6 5 6.225 5 6.5zm10.5 0.25C15.5 6.888 15.613 7 15.75 7S16 6.888 16 6.75 15.887 6.5 15.75 6.5 15.5 6.612 15.5 6.75zM13 7.25c0 0.138 0.113 0.25 0.25 0.25s0.25-0.112 0.25-0.25S13.387 7 13.25 7 13 7.112 13 7.25zM8.5 8.146C6.987 8.373 5.02 8.447 4.128 8.311 3.098 8.155 2.245 8.327 1.79 8.783 1.395 9.177 0.83 9.5 0.535 9.5 0.241 9.5 0 10.063 0 10.75 0 11.943 0.136 12 2.966 12c2.761 0 3.044 0.11 4.096 1.587 0.656 0.922 1.216 1.329 1.336 0.971 0.112-0.338-0.191-1.053-0.674-1.587-0.484-0.534-0.788-0.983-0.676-0.998 0.111-0.015 1.954-0.21 4.096-0.436 2.142-0.224 3.995-0.51 4.12-0.634 0.124-0.124-0.05-0.81-0.387-1.524-0.723-1.532-2.324-1.841-6.377-1.233zM16 7.75C16 7.888 16.113 8 16.25 8s0.25-0.112 0.25-0.25-0.113-0.25-0.25-0.25S16 7.612 16 7.75zm12.278 2.778c-1.78 3.966-1.386 4.36 0.472 0.472 0.785-1.643 1.308-3.108 1.162-3.255-0.146-0.146-0.882 1.106-1.634 2.783zM13.334 8.834c-0.184 0.183-0.484 0.183-0.668 0C12.483 8.65 12.633 8.5 13 8.5s0.517 0.15 0.334 0.334zm4.968 0.541c-1.22 4.022-1.857 8.726-1.305 9.62 0.555 0.898 1.384-2.032 1.761-6.225 0.172-1.915 0.423-3.658 0.557-3.875 0.134-0.217 0.021-0.395-0.252-0.395-0.272 0-0.615 0.394-0.761 0.875zM4.5 9.25c0 0.137-0.112 0.25-0.25 0.25S4 9.387 4 9.25 4.112 9 4.25 9 4.5 9.113 4.5 9.25zm2 0c0 0.137-0.112 0.25-0.25 0.25S6 9.387 6 9.25 6.112 9 6.25 9 6.5 9.113 6.5 9.25zm4.375 0.108c-0.756 0.114-1.993 0.114-2.75 0C7.369 9.243 7.987 9.15 9.5 9.15c1.512 0 2.132 0.093 1.375 0.208zM22.08 11.93c-0.698 2.33-0.737 3.226-0.123 2.847 0.494-0.305 1.282-4.204 0.923-4.564-0.121-0.12-0.481 0.652-0.8 1.717zM3.5 13.5c0 0.367 0.15 0.517 0.333 0.334 0.183-0.184 0.183-0.484 0-0.668C3.65 12.983 3.5 13.133 3.5 13.5zm1-0.25c0 0.137 0.112 0.25 0.25 0.25S5 13.387 5 13.25 4.888 13 4.75 13 4.5 13.113 4.5 13.25zm28.334 2.584c-0.184 0.183-0.334 0.565-0.334 0.85 0 0.313 0.233 0.283 0.592-0.076 0.325-0.325 0.475-0.708 0.333-0.85-0.142-0.142-0.408-0.108-0.591 0.076zM15.5 16.25c0 0.137 0.113 0.25 0.25 0.25S16 16.387 16 16.25 15.887 16 15.75 16s-0.25 0.113-0.25 0.25zm3.959 2.375c-0.023 0.344-0.137 1.2-0.254 1.902-0.156 0.938 0.188 1.662 1.291 2.719 1.452 1.391 2.13 3.2 0.919 2.452-0.399-0.247-0.479-0.085-0.25 0.511 0.184 0.48 0.335 1.305 0.335 1.832 0 0.527 0.242 0.959 0.538 0.959 0.296 0 0.497-0.619 0.447-1.375-0.139-2.099-0.066-2.203 1.091-1.584 0.928 0.497 1.031 0.46 0.75-0.274C24.146 25.301 24 24.824 24 24.71c0-0.115-0.281-0.212-0.625-0.215-0.343-0.003-1.336-0.736-2.204-1.631-1.391-1.431-1.5-1.736-0.91-2.543 0.683-0.934 0.495-2.32-0.314-2.32-0.246 0-0.465 0.282-0.488 0.625zM6.75 21c-0.17 0.275-0.07 0.5 0.22 0.5 0.292 0 0.53-0.225 0.53-0.5s-0.099-0.5-0.22-0.5c-0.122 0-0.36 0.225-0.53 0.5zm9 0.09c-0.825 0.245-2.328 0.325-3.339 0.177-1.258-0.183-2.075-0.032-2.586 0.48-0.412 0.411-1.159 0.823-1.661 0.916C5.802 23.1 2.816 24.16 3.378 24.36c0.345 0.124 0.501 0.431 0.345 0.683C3.569 25.294 4.13 25.5 4.971 25.5 5.81 25.5 6.5 25.725 6.5 26s-0.956 0.503-2.125 0.508C2.819 26.514 2.45 26.645 3 27c0.413 0.267 1.699 0.488 2.86 0.492 1.7 0.006 2.05 0.158 1.81 0.788-0.165 0.428-0.6 0.663-0.968 0.521-0.369-0.14-0.803-0.041-0.965 0.222C5.574 29.285 5.932 29.5 6.532 29.5c0.856 0 0.97 0.145 0.534 0.671-0.715 0.861-0.279 1.291 0.977 0.963 0.696-0.183 0.898-0.534 0.688-1.196C8.528 29.304 8.676 29 9.186 29c0.415 0 0.892-0.223 1.06-0.494 0.175-0.284-0.026-0.367-0.471-0.197-1.216 0.467-0.908-0.593 0.349-1.199l1.125-0.543-1.125-0.033C8.85 26.496 8.62 25.753 9.75 25.319c0.412-0.158 0.75-0.621 0.75-1.03 0-0.658 2.703-1.778 4.31-1.786 0.585-0.003 1.464 4.422 1.266 6.372C16.014 29.494 16.197 30 16.482 30 16.791 30 17 28.576 17 26.47c0-2.225-0.184-3.415-0.5-3.22-0.786 0.485-0.585-0.328 0.33-1.338 1.061-1.173 0.792-1.377-1.08-0.822zm25.285 0.994c-0.019 0.459-0.171 1.19-0.338 1.625-0.2 0.519-0.028 0.791 0.5 0.791 0.875 0 0.97-0.698 0.303-2.25-0.343-0.799-0.437-0.832-0.465-0.166zM10.5 22.25c0 0.137-0.113 0.25-0.25 0.25S10 22.387 10 22.25 10.113 22 10.25 22s0.25 0.113 0.25 0.25zM39 23.5c0 0.366 0.15 0.517 0.334 0.334 0.182-0.184 0.182-0.484 0-0.668C39.15 22.983 39 23.134 39 23.5zM8.634 24.328c-0.338 0.137-1.013 0.146-1.5 0.018-0.486-0.127-0.209-0.238 0.616-0.248 0.825-0.01 1.223 0.093 0.884 0.23zM5 24.75C5 24.887 4.888 25 4.75 25S4.5 24.887 4.5 24.75s0.112-0.25 0.25-0.25S5 24.613 5 24.75zm26.5 0.75c0 0.275 0.238 0.5 0.53 0.5 0.29 0 0.39-0.225 0.22-0.5S31.842 25 31.72 25c-0.121 0-0.22 0.225-0.22 0.5zm6.822 0.54c-0.857 0.826-0.87 1.012-0.164 2.427C38.58 29.311 38.74 30 38.517 30c-0.223 0-0.375 0.394-0.337 0.875 0.048 0.605-0.355 0.924-1.305 1.033-0.86 0.099-1.375-0.079-1.375-0.475 0-0.465-0.148-0.485-0.556-0.076C33.995 32.303 34.96 33 37.225 33c1.465 0 2.26-0.243 2.455-0.75 0.158-0.413 0.547-0.75 0.864-0.75 0.416 0 0.404 0.21-0.045 0.75-0.52 0.627-0.448 0.75 0.439 0.75C41.717 33 42 32.689 42 31.834c0-1.512-0.486-1.968-1.341-1.258-0.852 0.706-1.284 0.331-0.867-0.755C39.965 29.37 40.532 29 41.053 29c1.19 0 1.244-0.85 0.072-1.155-0.834-0.218-0.834-0.232 0-0.287C42.063 27.496 42.429 25 41.5 25c-0.275 0-0.5 0.225-0.5 0.5 0 0.609-1.35 0.67-1.584 0.073-0.091-0.236-0.583-0.026-1.094 0.466zM8 25.72c0 0.122-0.225 0.36-0.5 0.53C7.225 26.42 7 26.32 7 26.03c0-0.292 0.225-0.53 0.5-0.53S8 25.599 8 25.72zm15 1.03c0 0.137 0.113 0.25 0.25 0.25s0.25-0.113 0.25-0.25-0.113-0.25-0.25-0.25S23 26.613 23 26.75zM1.5 27.5c0 0.366 0.15 0.517 0.333 0.334 0.184-0.184 0.184-0.484 0-0.668C1.65 26.983 1.5 27.134 1.5 27.5zm23.532 0.29c0.325 0.39 0.913 0.71 1.308 0.71 1.006 0 0.39-0.823-0.861-1.15-0.85-0.223-0.93-0.144-0.447 0.44zm-10.28 1.335c-0.18 0.894-0.429 1.992-0.554 2.441-0.126 0.448 0.06 0.926 0.412 1.06 0.465 0.178 0.667-0.492 0.736-2.44 0.11-3.047-0.114-3.446-0.594-1.061zm8.341 0.282c-0.078 1.37 0.023 1.65 0.393 1.093 0.603-0.907 0.664-2.349 0.114-2.688-0.22-0.136-0.448 0.582-0.507 1.595zM24.5 29.25c0 0.137 0.113 0.25 0.25 0.25S25 29.387 25 29.25 24.887 29 24.75 29s-0.25 0.113-0.25 0.25zm1.166 0.084c0.184 0.183 0.484 0.183 0.668 0C26.517 29.15 26.366 29 26 29s-0.517 0.15-0.334 0.334zM28 29.75c0 0.137 0.113 0.25 0.25 0.25s0.25-0.113 0.25-0.25-0.113-0.25-0.25-0.25S28 29.613 28 29.75zM1 30.5c0 0.366 0.15 0.517 0.333 0.334 0.183-0.184 0.183-0.484 0-0.668C1.15 29.983 1 30.134 1 30.5zm28-0.25c0 0.137 0.113 0.25 0.25 0.25s0.25-0.113 0.25-0.25S29.387 30 29.25 30 29 30.113 29 30.25zm-7.5 1.5c0 0.137 0.113 0.25 0.25 0.25S22 31.887 22 31.75s-0.113-0.25-0.25-0.25-0.25 0.113-0.25 0.25zm-14 0.75c0.982 0.635 2.5 0.635 2.5 0 0-0.275-0.731-0.496-1.625-0.492C7.142 32.013 6.931 32.132 7.5 32.5zm17.5 0c0.97 0.627 1.5 0.627 1.5 0 0-0.275-0.506-0.496-1.125-0.492-0.91 0.006-0.982 0.1-0.375 0.492z" android:fillType="evenOdd">
+            <aapt:attr name="android:fillColor">
+                <gradient android:startY="1.5" android:startX="32.5" android:endY="37.0292" android:endX="-0.969948" android:type="linear">
+                    <item android:offset="0" android:color="#FFECE3DA"/>
+                    <item android:offset="0.208782" android:color="#FFEBE2D6"/>
+                    <item android:offset="1" android:color="#FFEBE2D9"/>
+                </gradient>
+            </aapt:attr>
+        </path>
+    </group>
+</vector>
diff --git a/external/paparazzi/paparazzi/src/test/resources/plus_sign.xml b/external/paparazzi/paparazzi/src/test/resources/plus_sign.xml
new file mode 100644
index 0000000..15567605
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/resources/plus_sign.xml
@@ -0,0 +1,3 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="40" android:viewportWidth="40" android:width="24dp">
+    <path android:fillColor="#999999" android:pathData="M21.5 14c0-0.828-0.672-1.5-1.5-1.5s-1.5 0.672-1.5 1.5v4.5H14c-0.828 0-1.5 0.672-1.5 1.5s0.672 1.5 1.5 1.5h4.5V26c0 0.828 0.672 1.5 1.5 1.5s1.5-0.672 1.5-1.5v-4.5H26c0.828 0 1.5-0.672 1.5-1.5s-0.672-1.5-1.5-1.5h-4.5V14z"/>
+</vector>
diff --git a/external/paparazzi/paparazzi/src/test/resources/without-layout-params.png b/external/paparazzi/paparazzi/src/test/resources/without-layout-params.png
new file mode 100644
index 0000000..e3da5cb
--- /dev/null
+++ b/external/paparazzi/paparazzi/src/test/resources/without-layout-params.png
Binary files differ
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GalleryTemplateLayouts.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GalleryTemplateLayouts.kt
index d59b2d4..555356f 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GalleryTemplateLayouts.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GalleryTemplateLayouts.kt
@@ -158,8 +158,12 @@
 
 @Composable
 private fun HeaderAndTextBlocks(data: GalleryTemplateData, modifier: GlanceModifier) {
+    // TODO(b/247613894): Weird that we add space after header block but not after text block. For
+    //  consistency should we handle all the spacing here rather than in the sub-template functions?
     Column(modifier = modifier) {
         HeaderBlockTemplate(data.header)
+        // TODO(b/247613894): Should this space always be added? The blocks below may be empty,
+        //  which would lead to extra spacing at the bottom
         Spacer(modifier = GlanceModifier.height(16.dp).defaultWeight())
         TextBlockTemplate(data.mainTextBlock)
         ActionBlockTemplate(data.mainActionBlock)
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GlanceAppWidgetTemplates.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GlanceAppWidgetTemplates.kt
index 09fed4a..c267412 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GlanceAppWidgetTemplates.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/GlanceAppWidgetTemplates.kt
@@ -92,6 +92,9 @@
             )
         }
         actionButton?.let {
+            if (headerIcon != null || header != null) {
+                Spacer(modifier = GlanceModifier.width(8.dp))
+            }
             AppWidgetTemplateButton(
                 actionButton,
                 GlanceModifier.height(48.dp).width(48.dp)
@@ -229,6 +232,8 @@
 @Composable
 internal fun ActionBlockTemplate(actionBlock: ActionBlock?) {
     if (actionBlock?.actionButtons?.isNotEmpty() == true) {
+        // TODO(b/247613894): Leading space here is error prone, space is usually added at a higher
+        //  level depending on context. Adding space here may lead to double spacing
         Spacer(modifier = GlanceModifier.height(16.dp))
         Row {
             actionBlock.actionButtons.forEach { button ->
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/ListTemplateLayouts.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/ListTemplateLayouts.kt
index 0c4ef2f..61c626f 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/ListTemplateLayouts.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/template/ListTemplateLayouts.kt
@@ -79,6 +79,7 @@
     Column(modifier = createTopLevelModifier()) {
         if (data.listStyle == ListStyle.Full) {
             HeaderBlockTemplate(data.headerBlock)
+            // TODO(b/247613894): Do not add this spacing if header block is empty
             Spacer(modifier = GlanceModifier.height(16.dp))
         }
         LazyColumn {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 40b185d..f03ee19 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -23,6 +23,7 @@
 atomicFu = "0.17.0"
 autoService = "1.0-rc6"
 autoValue = "1.6.3"
+byteBuddy = "1.12.10"
 asm = "9.3"
 cmake = "3.22.1"
 dagger = "2.42"
@@ -31,6 +32,7 @@
 guavaJre = "31.1-jre"
 hilt = "2.42"
 incap = "0.2"
+jcodec = "0.2.5"
 kotlin = "1.7.10"
 kotlinBenchmark = "0.4.5"
 kotlinNative = "1.7.10"
@@ -42,9 +44,10 @@
 leakcanary = "2.8.1"
 metalava = "1.0.0-alpha06"
 mockito = "2.25.0"
+moshi = "1.13.0"
 protobuf = "3.19.4"
 paparazzi = "1.0.0"
-paparazziNative = "2022.1.1-canary-f5f9f71"
+paparazziNative = "2021.1.1-573f070"
 skiko = "0.7.7"
 sqldelight = "1.3.0"
 retrofit = "2.7.2"
@@ -54,6 +57,7 @@
 androidAccessibilityFramework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version = { strictly = "2.1" } }
 androidBuilderModelMin = { module = "com.android.tools.build:builder-model", version.ref = "androidGradlePluginMin" }
 androidGradlePluginz = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" }
+androidLayoutlibApi = { module = "com.android.tools.layoutlib:layoutlib-api", version.ref = "androidLint" }
 androidLint = { module = "com.android.tools.lint:lint", version.ref = "androidLint" }
 androidLintMin = { module = "com.android.tools.lint:lint", version.ref = "androidLintMin" }
 androidLintApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLint" }
@@ -63,6 +67,8 @@
 androidLintChecksMin = { module = "com.android.tools.lint:lint-checks", version.ref = "androidLintMin" }
 androidLintTests = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLint" }
 androidToolsCommon = { module = "com.android.tools:common", version.ref = "androidLint" }
+androidToolsNinepatch = { module = "com.android.tools:ninepatch", version.ref = "androidLint" }
+androidToolsSdkCommon = { module = "com.android.tools:sdk-common", version.ref = "androidLint" }
 autoCommon = { module = "com.google.auto:auto-common", version = "0.11" }
 atomicFu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicFu" }
 atomicFuPluginz = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", version.ref = "atomicFu" }
@@ -75,9 +81,11 @@
 apacheAnt = { module = "org.apache.ant:ant", version = "1.10.11" }
 apacheCommonsCodec = { module = "commons-codec:commons-codec", version = "1.15" }
 apacheCommonIo = { module = "commons-io:commons-io", version = "2.4" }
-assertj = { module = "org.assertj:assertj-core", version = "3.11.1" }
+assertj = { module = "org.assertj:assertj-core", version = "3.23.1" }
 asm = { module = "org.ow2.asm:asm", version.ref = "asm"}
 asmCommons = { module = "org.ow2.asm:asm-commons", version.ref = "asm"}
+byteBuddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" }
+byteBuddyAgent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" }
 checkerframework = { module = "org.checkerframework:checker-qual", version = "2.5.3" }
 checkmark = { module = "net.saff.checkmark:checkmark", version = "0.1.6" }
 constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.0.1"}
@@ -105,6 +113,8 @@
 hiltCore = { module = "com.google.dagger:hilt-core", version.ref = "hilt" }
 intellijAnnotations = { module = "com.intellij:annotations", version = "12.0" }
 javapoet = { module = "com.squareup:javapoet", version = "1.13.0" }
+jcodec = { module = "org.jcodec:jcodec", version.ref = "jcodec" }
+jcodecJavaSe = { module = "org.jcodec:jcodec-javase", version.ref = "jcodec" }
 json = { module = "org.json:json", version = "20180813" }
 jsonSimple = { module = "com.googlecode.json-simple:json-simple", version = "1.1" }
 jsqlparser = { module = "com.github.jsqlparser:jsqlparser", version = "3.1" }
@@ -153,15 +163,20 @@
 ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "ksp" }
 kspApi = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
 kspGradlePluginz = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" }
+kxml2 = { module = "net.sf.kxml:kxml2", version = "2.3.0" }
 leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
 leakcanaryInstrumentation = { module = "com.squareup.leakcanary:leakcanary-android-instrumentation", version.ref = "leakcanary" }
 material = { module = "com.google.android.material:material", version = "1.2.1" }
 metalava = { module = "com.android.tools.metalava:metalava", version.ref = "metalava" }
 mlkitBarcode = { module = "com.google.mlkit:barcode-scanning", version = "17.0.2" }
 mockitoCore = { module = "org.mockito:mockito-core", version.ref = "mockito" }
+mockitoCore4 = { module = "org.mockito:mockito-core", version = "4.8.0" }
 mockitoAndroid = { module = "org.mockito:mockito-android", version.ref = "mockito" }
 mockitoKotlin = { module = "com.nhaarman.mockitokotlin2:mockito-kotlin", version = "2.1.0" }
 mockitoKotlin4 = { module = "org.mockito.kotlin:mockito-kotlin", version = "4.0.0" }
+moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
+moshiAdapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" }
+moshiCodeGen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
 multidex = { module = "androidx.multidex:multidex", version = "2.0.1" }
 nullaway = { module = "com.uber.nullaway:nullaway", version = "0.3.7" }
 okhttpMockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version = "3.14.7" }
@@ -182,7 +197,7 @@
 reactiveStreams = { module = "org.reactivestreams:reactive-streams", version = "1.0.0" }
 retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
 retrofitConverterWire = { module = "com.squareup.retrofit2:converter-wire", version.ref = "retrofit" }
-robolectric = { module = "org.robolectric:robolectric", version = "4.8.1" }
+robolectric = { module = "org.robolectric:robolectric", version = "4.9-alpha-1" }
 rxjava2 = { module = "io.reactivex.rxjava2:rxjava", version = "2.2.9" }
 rxjava3 = { module = "io.reactivex.rxjava3:rxjava", version = "3.0.0" }
 shadow = { module = "gradle.plugin.com.github.johnrengelman:shadow", version = "7.1.1" }
diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys
index 53064d2..46c9570 100644
--- a/gradle/verification-keyring.keys
+++ b/gradle/verification-keyring.keys
@@ -502,6 +502,43 @@
 -----END PGP PUBLIC KEY BLOCK-----
 
 
+pub    0CC0B712FEE75827
+uid    AssertJ <assertj@assertj.github.io>
+
+sub    A9E4161147556D82
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v1.68
+
+mQENBF+EGtgBCAC/KXNQAl1rz3VBbqm6ssjzR+5Su1QWHI7oYDS+YHCLOaqfE3jO
+zQd+8iNgniVNtX2n7bt1hido5B94VmaqD+zjjSu2UV/eZoYhCOQ5NgvxIr7WZe9t
+DkhOppJoLqZJxK0EcTWMhOdJddIiXvK1KsC+pohW38+AXEamRKgKyFA/7F9G2c4U
+ZPB1+t5tujNn7RGq7H1N7ECV10Aou50DQBc0RaJmXVamWTUuQsWr/762yn3ZS/uf
+kFBZnXiQWJ5AL3pFGcmj4gQJhG6E5nmZsvUxVGSNftaK/fOX5Njv9EQUAsKYi8Iw
+1vf1Y/CgzM8FfWY7hHtk1QlCUq2CSg3ecNPFABEBAAG0I0Fzc2VydEogPGFzc2Vy
+dGpAYXNzZXJ0ai5naXRodWIuaW8+iQFUBBMBCAA+FiEEvmhRMq/SdA2QlfkEDMC3
+Ev7nWCcFAl+EGtgCGwMFCQPCZwAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQ
+DMC3Ev7nWCdblAf/ce799ep4iw8iuAvCuFOXD8SgQoq93IW+3DhVSvitU5KUNOYP
+WeoqYBrVCg2ru8BRoyRBw94l8cN/nZPXQdotRsGxgN14zJDATsYZtJAG3Dx4RG5t
+5wlGt9mFevn+/iGy7fkI7F7C3/S8P1+u8VBx1dXqekYU6IGg17Kwu66rQ2t7Cdfb
+/aJaeWQk+Cu2JasNSX5v3mCBJzNuJ5ndIr0IrpxefxDwy/T1C9pHMHu7y0tpyLFZ
++zY+xFzZrj4oW2AbO6EdJSqNIHu5B6vu4vMiCHYqi00+nkLhBFrZDJw3CJKhkmEg
+Jjo01Tq6pTNZ8qfeS7UbuENC4QZEPYn9sBkjT7kBDQRfhBrYAQgAxWYnUkMDpaaA
+4JD+4j4voJo8uDC+o+vV98zJI6Ifoj7FGKUpxJoosokIq/otELtD4zA0E5hYDSxQ
+C7bKKPloKUw1cKZiSzkQ7D4kadIWjWc8u1WDKuRW5y/K0j/isiygqH0gp5hkckul
+6svR145DDf8sKDlPK0T689Hrq604DPpoFy6ZfFUZpDNQQQANKK/wSIJcvN8EUyUt
+EP92j6/J3o0deDqy4MsLiyrN4wwHNp+S9PeKS9ptIYCD6O0h0obos6LJQGAU9hWy
+W0ExkbuvbuNG53U0Z3wKXeakkNMcUMSImF7Z/4NEtw6VXyltrQ1XDc/GZt3okXNG
+7CgxdFRudQARAQABiQE8BBgBCAAmFiEEvmhRMq/SdA2QlfkEDMC3Ev7nWCcFAl+E
+GtgCGwwFCQPCZwAACgkQDMC3Ev7nWCe3vQf+Or6z9TbStAdD5WNavPT9Bqlrb3kr
+13Hzb5y/Wg5HQuvDfnDmnMz+D5UlzbfEeUTEV0kS9JNegRI+8KhDZset+NuNZ0i9
+Oyq1nKRenlPS3NXR0sVsTuAhOWNHNPnLrSJnZw7iIdBehk6pTunWBEuv3ZTViK53
+K+aENoaC+ODfVAZWoH4TIsh37fzQ7ZHPibEd6AmC3Q7iaiPn7cEeRPKhMnxtBPIj
+bELkQZ+hoUCIC5Xyztnz0+eyU3SrtDV0HDbmmYIm3FuTmY3eNBc+YVKolRKXAzFP
+C9huWCZN1CvesEtUVkh7UJ9c5KCeuno0IAm24ivH8EDjFDqOeDInez2wxg==
+=+MWL
+-----END PGP PUBLIC KEY BLOCK-----
+
+
 pub    0D3B328562A119A7
 sub    C45D01093DCFC371
 -----BEGIN PGP PUBLIC KEY BLOCK-----
@@ -2820,6 +2857,64 @@
 -----END PGP PUBLIC KEY BLOCK-----
 
 
+pub    3F00DB67AE236E2E
+uid    Pete Bentley <prb@google.com>
+
+sub    6B7EF7B18190F4A9
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v1.68
+
+mQINBF2KLsIBEADgVw/j0Loslv+pBDEfYemeObeKCWBhEdAiGznT23XFb4eOa4oL
+Yk8FTL5SYV+Ylm5Pv4zUGV1JUggzb4mS5+/k0kl2OHzZpJTLz45E9Qe4KI5vk6jT
+zBVJGdB6X1EXeQNozZZwuKHTDoFSTqT+oYpjUB3kRoP04Cm1vL9NdLvYwabv0BfI
+/e63QyJ60B8tTxVzEiN2u4VxSwrW/Vku3LT/wky/jgdwDUrwR7Elf189BPUlchtG
+fLZJJoJwlBd7h/wo7ik+KpUkDrMhMUkPTcC+aferQiAc2S53H7Zeu2S49F34qDLm
+dp3d89ImVgzplpBiGBlryy571YU5dafo/fsVuiB0FINTqzSvs/RLTIFwubmSdXGj
+/UaNZYtRRFG8bkqal8VuDsUikuPMez7VF5/KLGRzL9uonEfFiV7c5uUEk4VDlVSK
+4v6cEw0yyRpxIwh5C9IvLKpplpJajBXLeMKoep8+VP8+VpdrFd/hHW/MOl2uYVpM
+mHhyXoSg+Gf6My7PQw65dC2VrdWoYpGeyVK2BD1wBcw8/HJDJTJT7SQDLJ11oDSf
+JzuwtfVT8sMfl/m1vaJJvkW3RPqkgqiyhr+PwdXALHQLV48tlUVu3uEG6xK+hT24
+8pPqC/vL/IECzd8BQF310Cne2dU3V8ykJQfGg5Vu7LExE8jMfna5Ipz/GQARAQAB
+tB1QZXRlIEJlbnRsZXkgPHByYkBnb29nbGUuY29tPokCTgQTAQoAOBYhBBWXqyMb
+et1+FLHZxD8A22euI24uBQJdii7CAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
+AAoJED8A22euI24uy/0P/jIuaB5KnoBIbZvH8eO7yg5hog4nHP2kT7CF6iYUL5Rt
+v/NEWVbSgcWHdwucoy4aENEA2cSTMfG5vzlkbDfg8BezJyRA0qax1Lc9Q4MgDtGS
+1GDFY46xD6X2Y/vgAYd2uu9EqEugked7VMjbmeoQOz9tcIJwK3Nc5tuHqH8YwGDa
+bXyuWCyNnH1OJwU/0lymsEplgcyo29N2cZfGQUC980maRERzIO90PKWZ4kMoFiIR
+pjLjbN2ZqTKi6JMgGMwXrKxc1BLi9LA6rJAFJHT/FVX8z4D6cyIOsahq+PhL5kN3
+wznzyT3VeapeC+ybSr9+MuYeSPdMEx9sW7j7bdbvr92bkpBfH2IC2SUkQeY5oy4+
+DCIQYV2PxqKhr+Oy14Cg3EzQT+u/JwpQnvIBipn2ISXZCGnMIwYLw3viiK/Jvr+F
+V+fmGvFYMxja5M6/zPpg1fJkNhhCGrrAWydxNfb+YERSSlQis4c4sLp0L6QWWY35
+gAwMpvMGYrYkaOnlI38ZWcvuzKUYlaX2Us01eMAJ9l2zN0bIQOn9Z77D4YcROLhO
+mWOn5yUDaziYPc2mXhoFCkmyBzxqJw0m5z5YRinf80gI4uLtPlLEBBYBxO/nE20j
+vcAVk8sH0CT/1uWj2M6K5NuD1sGfxHV0GXB6yQPPO0cCsVWGDa1nmKa21XouYHTE
+uQINBF2KLsIBEADRy0X4ZSnuFgg3pHmjPJraPsVpCmxDuuRcgpbP8DwS+t99us2w
+bjfzkUjT3glkKz4iVWay65B2uss088vOe9evcH5lssUctLjFYDuSlQOm3SOXwZgD
+4CsmCr82D4PwxuQlevxh/XVQXZ++CnF6f8jNDCtIwBO8+AysdYVV+BEPrRuLS1uG
+ySaE/Vchi7sUPVq79HOAOY55HxVWkzxRVKXdI2AtVol6drx9s8TL1F3wBLcWavyl
+WXNqp5x6zt3n0LbHgBMWKe04dUYKO3VwYddPgPo3n5zfy/D2X4IEf/spKc6RMs2i
+kbTIxRVw6kNGk+hgr9XO4zyEYwIbirfvwp9u8HgFAnR7CdQacVv0cNUEzP6/4hUZ
+5uqtL8QveazqOTWbe3j6W6mVPfR5jKThHWmJgihtmY59hGLTDKewcmaj8QF3Syx0
+b/bwtSU8HyKA3E4iKD7avfX5Ql61+kuIrbfCMMoBsxuYix1r0Q7G3pfHubXBbOM8
+i/AfULEypRMM0LoKZjJMbhaebwKhxlyAf5+9eZDIn2BHUkzg3+g5bNnQoE4n7UMh
+kt3kMR0IvDoyMKwT1dvDoJEa61+FU4Z66k8F18HfjcE/oxnvBOzBUf1KWWm+ZG6a
+XT0wdH9bME2htQKt9s6FWN9QV12nemHaro3ViiEiSB9BrN8jQfgqbhmWpwARAQAB
+iQI2BBgBCgAgFiEEFZerIxt63X4UsdnEPwDbZ64jbi4FAl2KLsICGwwACgkQPwDb
+Z64jbi52whAAgRI6Ag+wKbYh8Soi3Nye65z/E3KOUwCKrHZJnG0bheYHZ48y91d5
+aFeKVZGPdSgL/MQvWIV2Gje1mPLi9KtgNKqTNpWscSN8KsqVc3uOovBliCLRExno
+7jE+3A+42ms6T6yaig2oLXTbmI23Xj7m0C+nP+Q1t0RxSndq+0fzRQTWfybNOMd5
+5Q8d45Kasku5nvdPXSRjXOovJRKherARX2NMt5MImpPTF3SDg8UQ/bmM72VXsrDR
+Dl3iOAGgp6/ie758QfYaa0wYOxAskCWwXIQmPLbP3UFIQFbzgvzSfy8OKutLNe4+
+mr+DLRR/CeOPIFmOWImr0DerD3gq17OWQf0KqCVQl/fXJWJFmglswLqum1A6/Sjr
+Ove2hxaXmrM9GJg5sOv36ldYFwwZEMxxH29BIBMVwpPM5+xydx8l8c4UAwTnIUjP
+4wJtz71d+4DrCskswXkMSLiGJ623y9izHGled3/98vUPVMoM1pT1BML5arjpYOH1
+S+nlOqBXU3TZ0KGijUYh4GBS5MFpFiM8N7Ne8ctBWd4g0uHifv0+3/UDnd5va8da
+rmOUSu1D9cJPP5w6PfopRo9f1ltpPop5pwdyXoQDpyelwvA5XeNLuroOY+3j+xmu
+k4MTJ2V6vm2gqOJY4UhHt5Pw0MQQp7Uya0naw4mtdoQNp4gFtpWBfvo=
+=ZR2N
+-----END PGP PUBLIC KEY BLOCK-----
+
+
 pub    3F36885C24DF4B75
 sub    97859F2FE8EAEB26
 -----BEGIN PGP PUBLIC KEY BLOCK-----
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index f3f391d..08a256f 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -69,6 +69,7 @@
          <trusted-key id="12d16069219c90212a974d119ae296fd02e9f65b" group="org.apache.commons" name="commons-math3"/>
          <trusted-key id="147b691a19097624902f4ea9689cbe64f4bc997f" group="^org[.]mockito($|([.].*))" regex="true"/>
          <trusted-key id="151ba00a46886a5f95441a0f5d67bffcba1f9a39" group="com.google.gradle" name="osdetector-gradle-plugin"/>
+         <trusted-key id="1597ab231b7add7e14b1d9c43f00db67ae236e2e" group="org.conscrypt"/>
          <trusted-key id="160a7a9cf46221a56b06ad64461a804f2609fd89" group="com.github.shyiko.klob" name="klob"/>
          <trusted-key id="1861c322c56014b2" group="commons-lang"/>
          <trusted-key id="190d5a957ff22273e601f7a7c92c5fec70161c62" group="org.codehaus.mojo"/>
@@ -369,6 +370,7 @@
          <trusted-key id="bcc135fc7ed8214f823d73e97fe9900f412d622e" group="com.google.flatbuffers"/>
          <trusted-key id="bdb5fa4fe719d787fb3d3197f6d4a1d411e9d1ae" group="com.google.guava"/>
          <trusted-key id="be096e29edb8d141" group="net.sf.proguard"/>
+         <trusted-key id="be685132afd2740d9095f9040cc0b712fee75827" group="org.assertj"/>
          <trusted-key id="beabcfbee059e4e5" group="com.github.siom79.japicmp"/>
          <trusted-key id="bede11eaf1164480" group="org.hamcrest"/>
          <trusted-key id="bf984b4145ea13f7" group="com.squareup" name="javapoet"/>
diff --git a/health/connect/connect-client/samples/build.gradle b/health/connect/connect-client/samples/build.gradle
index c4a7974..7e451e2 100644
--- a/health/connect/connect-client/samples/build.gradle
+++ b/health/connect/connect-client/samples/build.gradle
@@ -29,8 +29,8 @@
 
     compileOnly projectOrArtifact(":annotation:annotation-sampled")
     implementation project(":health:connect:connect-client")
-    implementation project(":appcompat:appcompat")
-    implementation project(":activity:activity")
+    implementation ("androidx.appcompat:appcompat:1.6.0-rc01")
+    implementation ("androidx.activity:activity:1.6.0")
 }
 
 androidx {
diff --git a/lint-checks/src/main/java/androidx/build/lint/BanUncheckedReflection.kt b/lint-checks/src/main/java/androidx/build/lint/BanUncheckedReflection.kt
index 62f8608..44a724e 100644
--- a/lint-checks/src/main/java/androidx/build/lint/BanUncheckedReflection.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/BanUncheckedReflection.kt
@@ -50,14 +50,16 @@
         // Skip if this isn't a call to `Method.invoke`.
         if (!context.evaluator.isMemberInClass(method, METHOD_REFLECTION_CLASS)) return
 
-        // Flag if the call isn't inside an SDK_INT check.
+        // Flag if the call isn't inside or preceded by an SDK_INT check.
         if (!VersionChecks.isWithinVersionCheckConditional(
                 context,
                 node,
                 HIGHEST_KNOWN_API,
                 false
             ) &&
-            !VersionChecks.isWithinVersionCheckConditional(context, node, 1, true)
+            !VersionChecks.isWithinVersionCheckConditional(context, node, 1, true) &&
+            !VersionChecks.isPrecededByVersionCheckExit(context, node, HIGHEST_KNOWN_API) &&
+            !VersionChecks.isPrecededByVersionCheckExit(context, node, 1)
         ) {
             val incident = Incident(context)
                 .issue(ISSUE)
diff --git a/lint-checks/src/test/java/androidx/build/lint/BanUncheckedReflectionTest.kt b/lint-checks/src/test/java/androidx/build/lint/BanUncheckedReflectionTest.kt
index 77e54f7..7536d47 100644
--- a/lint-checks/src/test/java/androidx/build/lint/BanUncheckedReflectionTest.kt
+++ b/lint-checks/src/test/java/androidx/build/lint/BanUncheckedReflectionTest.kt
@@ -19,6 +19,7 @@
 package androidx.build.lint
 
 import androidx.build.lint.Stubs.Companion.RestrictTo
+import com.android.tools.lint.checks.infrastructure.TestMode
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
@@ -77,7 +78,12 @@
         """.trimIndent()
         /* ktlint-enable max-line-length */
 
-        check(*input).expect(expected)
+        lint()
+            .files(*input)
+            // TODO: b/247135738 re-enable IF_TO_WHEN mode
+            .skipTestModes(TestMode.IF_TO_WHEN)
+            .run()
+            .expect(expected)
     }
 
     @Test
@@ -103,12 +109,27 @@
             RestrictTo
         )
 
-        /* ktlint-disable max-line-length */
-        val expected = """
-No warnings.
-        """.trimIndent()
-        /* ktlint-enable max-line-length */
+        check(*input).expectClean()
+    }
 
-        check(*input).expect(expected)
+    @Test
+    fun `Checked reflection using preceding if with return`() {
+        val input = kotlin("""
+            package androidx.foo
+
+            import android.os.Build
+
+            fun forceEnablePlatformTracing() {
+                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return
+                if (Build.VERSION.SDK_INT >= 29) return
+                val method = android.os.Trace::class.java.getMethod(
+                    "setAppTracingAllowed",
+                    Boolean::class.javaPrimitiveType
+                )
+                method.invoke(null, true)
+            }
+        """.trimIndent())
+
+        check(input).expectClean()
     }
 }
diff --git a/paging/paging-common/api/current.txt b/paging/paging-common/api/current.txt
index deab1f9..4496d11 100644
--- a/paging/paging-common/api/current.txt
+++ b/paging/paging-common/api/current.txt
@@ -379,7 +379,7 @@
     ctor public PagingSource.LoadResult.Invalid();
   }
 
-  public static final class PagingSource.LoadResult.Page<Key, Value> extends androidx.paging.PagingSource.LoadResult<Key,Value> {
+  public static final class PagingSource.LoadResult.Page<Key, Value> extends androidx.paging.PagingSource.LoadResult<Key,Value> implements java.lang.Iterable<Value> kotlin.jvm.internal.markers.KMappedMarker {
     ctor public PagingSource.LoadResult.Page(java.util.List<? extends Value> data, Key? prevKey, Key? nextKey, optional @IntRange(from=androidx.paging.PagingSource.LoadResult.Page.COUNT_UNDEFINED.toLong()) int itemsBefore, optional @IntRange(from=androidx.paging.PagingSource.LoadResult.Page.COUNT_UNDEFINED.toLong()) int itemsAfter);
     ctor public PagingSource.LoadResult.Page(java.util.List<? extends Value> data, Key? prevKey, Key? nextKey);
     method public java.util.List<Value> component1();
@@ -393,6 +393,7 @@
     method public int getItemsBefore();
     method public Key? getNextKey();
     method public Key? getPrevKey();
+    method public java.util.Iterator<Value> iterator();
     property public final java.util.List<Value> data;
     property public final int itemsAfter;
     property public final int itemsBefore;
diff --git a/paging/paging-common/api/public_plus_experimental_current.txt b/paging/paging-common/api/public_plus_experimental_current.txt
index 548cd7e..8782062 100644
--- a/paging/paging-common/api/public_plus_experimental_current.txt
+++ b/paging/paging-common/api/public_plus_experimental_current.txt
@@ -383,7 +383,7 @@
     ctor public PagingSource.LoadResult.Invalid();
   }
 
-  public static final class PagingSource.LoadResult.Page<Key, Value> extends androidx.paging.PagingSource.LoadResult<Key,Value> {
+  public static final class PagingSource.LoadResult.Page<Key, Value> extends androidx.paging.PagingSource.LoadResult<Key,Value> implements java.lang.Iterable<Value> kotlin.jvm.internal.markers.KMappedMarker {
     ctor public PagingSource.LoadResult.Page(java.util.List<? extends Value> data, Key? prevKey, Key? nextKey, optional @IntRange(from=androidx.paging.PagingSource.LoadResult.Page.COUNT_UNDEFINED.toLong()) int itemsBefore, optional @IntRange(from=androidx.paging.PagingSource.LoadResult.Page.COUNT_UNDEFINED.toLong()) int itemsAfter);
     ctor public PagingSource.LoadResult.Page(java.util.List<? extends Value> data, Key? prevKey, Key? nextKey);
     method public java.util.List<Value> component1();
@@ -397,6 +397,7 @@
     method public int getItemsBefore();
     method public Key? getNextKey();
     method public Key? getPrevKey();
+    method public java.util.Iterator<Value> iterator();
     property public final java.util.List<Value> data;
     property public final int itemsAfter;
     property public final int itemsBefore;
diff --git a/paging/paging-common/api/restricted_current.txt b/paging/paging-common/api/restricted_current.txt
index deab1f9..4496d11 100644
--- a/paging/paging-common/api/restricted_current.txt
+++ b/paging/paging-common/api/restricted_current.txt
@@ -379,7 +379,7 @@
     ctor public PagingSource.LoadResult.Invalid();
   }
 
-  public static final class PagingSource.LoadResult.Page<Key, Value> extends androidx.paging.PagingSource.LoadResult<Key,Value> {
+  public static final class PagingSource.LoadResult.Page<Key, Value> extends androidx.paging.PagingSource.LoadResult<Key,Value> implements java.lang.Iterable<Value> kotlin.jvm.internal.markers.KMappedMarker {
     ctor public PagingSource.LoadResult.Page(java.util.List<? extends Value> data, Key? prevKey, Key? nextKey, optional @IntRange(from=androidx.paging.PagingSource.LoadResult.Page.COUNT_UNDEFINED.toLong()) int itemsBefore, optional @IntRange(from=androidx.paging.PagingSource.LoadResult.Page.COUNT_UNDEFINED.toLong()) int itemsAfter);
     ctor public PagingSource.LoadResult.Page(java.util.List<? extends Value> data, Key? prevKey, Key? nextKey);
     method public java.util.List<Value> component1();
@@ -393,6 +393,7 @@
     method public int getItemsBefore();
     method public Key? getNextKey();
     method public Key? getPrevKey();
+    method public java.util.Iterator<Value> iterator();
     property public final java.util.List<Value> data;
     property public final int itemsAfter;
     property public final int itemsBefore;
diff --git a/paging/paging-common/src/main/kotlin/androidx/paging/PagingSource.kt b/paging/paging-common/src/main/kotlin/androidx/paging/PagingSource.kt
index 8feda0d..23ff581 100644
--- a/paging/paging-common/src/main/kotlin/androidx/paging/PagingSource.kt
+++ b/paging/paging-common/src/main/kotlin/androidx/paging/PagingSource.kt
@@ -240,6 +240,8 @@
         /**
          * Success result object for [PagingSource.load].
          *
+         * As a convenience, iterating on this object will iterate through its loaded [data].
+         *
          * @sample androidx.paging.samples.pageKeyedPage
          * @sample androidx.paging.samples.pageIndexedPage
          */
@@ -267,7 +269,7 @@
              */
             @IntRange(from = COUNT_UNDEFINED.toLong())
             val itemsAfter: Int = COUNT_UNDEFINED
-        ) : LoadResult<Key, Value>() {
+        ) : LoadResult<Key, Value>(), Iterable<Value> {
 
             /**
              * Success result object for [PagingSource.load].
@@ -293,6 +295,11 @@
                     "itemsAfter cannot be negative"
                 }
             }
+
+            override fun iterator(): Iterator<Value> {
+                return data.listIterator()
+            }
+
             override fun toString(): String {
                 return """LoadResult.Page(
                     |   data size: ${data.size}
diff --git a/paging/paging-common/src/test/kotlin/androidx/paging/PagingSourceTest.kt b/paging/paging-common/src/test/kotlin/androidx/paging/PagingSourceTest.kt
index 4277314..e2d59c1 100644
--- a/paging/paging-common/src/test/kotlin/androidx/paging/PagingSourceTest.kt
+++ b/paging/paging-common/src/test/kotlin/androidx/paging/PagingSourceTest.kt
@@ -261,6 +261,42 @@
         assertThat(invalidateCalls).isEqualTo(3)
     }
 
+    @Test
+    fun page_iterator() {
+        val dataSource = ItemDataSource()
+
+        runBlocking {
+            val pages = mutableListOf<LoadResult.Page<Key, Item>>()
+
+            // first page
+            val key = ITEMS_BY_NAME_ID[5].key()
+            val params = LoadParams.Append(key, 5, false)
+            val page = dataSource.load(params) as LoadResult.Page
+            pages.add(page)
+
+            val iterator = page.iterator()
+            var startIndex = 6
+            val endIndex = 11
+            // iterate normally
+            while (iterator.hasNext() && startIndex < endIndex) {
+                val item = iterator.next()
+                assertThat(item).isEqualTo(ITEMS_BY_NAME_ID[startIndex++])
+            }
+
+            // second page
+            val params2 = LoadParams.Append(
+                ITEMS_BY_NAME_ID[10].key(), 5, false
+            )
+            val page2 = dataSource.load(params2) as LoadResult.Page
+            pages.add(page2)
+
+            // iterate through list of pages
+            assertThat(pages.flatten()).containsExactlyElementsIn(
+                ITEMS_BY_NAME_ID.subList(6, 16)
+            ).inOrder()
+        }
+    }
+
     data class Key(val name: String, val id: Int)
 
     data class Item(
diff --git a/paging/paging-testing/api/current.txt b/paging/paging-testing/api/current.txt
index 7da64c1..acdc8f2 100644
--- a/paging/paging-testing/api/current.txt
+++ b/paging/paging-testing/api/current.txt
@@ -1,5 +1,5 @@
 // Signature format: 4.0
-package  {
+package androidx.paging.testing {
 
   public final class TestPager<Key, Value> {
     ctor public TestPager(androidx.paging.PagingSource<Key,Value> pagingSource, androidx.paging.PagingConfig config);
diff --git a/paging/paging-testing/api/public_plus_experimental_current.txt b/paging/paging-testing/api/public_plus_experimental_current.txt
index 7da64c1..acdc8f2 100644
--- a/paging/paging-testing/api/public_plus_experimental_current.txt
+++ b/paging/paging-testing/api/public_plus_experimental_current.txt
@@ -1,5 +1,5 @@
 // Signature format: 4.0
-package  {
+package androidx.paging.testing {
 
   public final class TestPager<Key, Value> {
     ctor public TestPager(androidx.paging.PagingSource<Key,Value> pagingSource, androidx.paging.PagingConfig config);
diff --git a/paging/paging-testing/api/restricted_current.txt b/paging/paging-testing/api/restricted_current.txt
index 7da64c1..acdc8f2 100644
--- a/paging/paging-testing/api/restricted_current.txt
+++ b/paging/paging-testing/api/restricted_current.txt
@@ -1,5 +1,5 @@
 // Signature format: 4.0
-package  {
+package androidx.paging.testing {
 
   public final class TestPager<Key, Value> {
     ctor public TestPager(androidx.paging.PagingSource<Key,Value> pagingSource, androidx.paging.PagingConfig config);
diff --git a/paging/paging-testing/src/main/java/androidx/paging/TestPager.kt b/paging/paging-testing/src/main/java/androidx/paging/testing/TestPager.kt
similarity index 99%
rename from paging/paging-testing/src/main/java/androidx/paging/TestPager.kt
rename to paging/paging-testing/src/main/java/androidx/paging/testing/TestPager.kt
index 03f99f7..4c7a1b8 100644
--- a/paging/paging-testing/src/main/java/androidx/paging/TestPager.kt
+++ b/paging/paging-testing/src/main/java/androidx/paging/testing/TestPager.kt
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+package androidx.paging.testing
+
 import androidx.paging.LoadType
 import androidx.paging.LoadType.APPEND
 import androidx.paging.LoadType.PREPEND
diff --git a/paging/paging-testing/src/test/kotlin/androidx/paging/TestPagerTest.kt b/paging/paging-testing/src/test/kotlin/androidx/paging/testing/TestPagerTest.kt
similarity index 99%
rename from paging/paging-testing/src/test/kotlin/androidx/paging/TestPagerTest.kt
rename to paging/paging-testing/src/test/kotlin/androidx/paging/testing/TestPagerTest.kt
index ae04046..07112b1 100644
--- a/paging/paging-testing/src/test/kotlin/androidx/paging/TestPagerTest.kt
+++ b/paging/paging-testing/src/test/kotlin/androidx/paging/testing/TestPagerTest.kt
@@ -14,10 +14,11 @@
  * limitations under the License.
  */
 
-package androidx.paging
+package androidx.paging.testing
 
+import androidx.paging.PagingConfig
 import androidx.paging.PagingSource.LoadResult
-import TestPager
+import androidx.paging.TestPagingSource
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.assertFailsWith
 import kotlinx.coroutines.ExperimentalCoroutinesApi
diff --git a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/LinearLayoutManagerSnappingTest.java b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/LinearLayoutManagerSnappingTest.java
index 3dcd8a9..e927958 100644
--- a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/LinearLayoutManagerSnappingTest.java
+++ b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/LinearLayoutManagerSnappingTest.java
@@ -26,6 +26,7 @@
 import androidx.annotation.Nullable;
 import androidx.test.filters.LargeTest;
 
+import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -101,6 +102,7 @@
         mLayoutManager.assertNoCallbacks("There should be no callbacks after some time", 3);
     }
 
+    @Ignore("b/243183159")
     @Test
     public void snapOnScrollSameView() throws Throwable {
         final Config config = (Config) mConfig.clone();
@@ -123,6 +125,7 @@
         assertCenterAligned(viewAfterFling);
     }
 
+    @Ignore("b/243183159")
     @Test
     public void snapOnScrollNextView() throws Throwable {
         final Config config = (Config) mConfig.clone();
@@ -145,6 +148,7 @@
         assertCenterAligned(viewAfterFling);
     }
 
+    @Ignore("b/243183159")
     @Test
     public void snapOnFlingSameView() throws Throwable {
         final Config config = (Config) mConfig.clone();
@@ -170,6 +174,7 @@
         assertCenterAligned(viewAfterFling);
     }
 
+    @Ignore("b/243183159")
     @Test
     public void snapOnFlingNextView() throws Throwable {
         final Config config = (Config) mConfig.clone();
diff --git a/room/room-runtime/src/main/java/androidx/room/TransactionExecutor.kt b/room/room-runtime/src/main/java/androidx/room/TransactionExecutor.kt
index c143951..965fdad 100644
--- a/room/room-runtime/src/main/java/androidx/room/TransactionExecutor.kt
+++ b/room/room-runtime/src/main/java/androidx/room/TransactionExecutor.kt
@@ -15,7 +15,6 @@
  */
 package androidx.room
 
-import android.annotation.SuppressLint
 import java.util.ArrayDeque
 import java.util.concurrent.Executor
 
@@ -45,8 +44,6 @@
         }
     }
 
-    @SuppressLint("BanSynchronizedMethods")
-    @Synchronized
     fun scheduleNext() {
         synchronized(syncLock) {
             if (tasks.poll().also { active = it } != null) {
diff --git a/settings.gradle b/settings.gradle
index c401d9d..41cca61 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -635,6 +635,8 @@
 includeProject(":enterprise:enterprise-feedback", [BuildType.MAIN])
 includeProject(":enterprise:enterprise-feedback-testing", [BuildType.MAIN])
 includeProject(":exifinterface:exifinterface", [BuildType.MAIN])
+includeProject(":external:paparazzi:paparazzi", [BuildType.MAIN])
+includeProject(":external:paparazzi:paparazzi-agent", [BuildType.MAIN])
 includeProject(":fakeannotations", [BuildType.MAIN])
 includeProject(":fragment:fragment", [BuildType.MAIN, BuildType.FLAN, BuildType.WEAR])
 includeProject(":fragment:fragment-ktx", [BuildType.MAIN, BuildType.FLAN])
diff --git a/sqlite/sqlite/build.gradle b/sqlite/sqlite/build.gradle
index ca04999..2a4b26b 100644
--- a/sqlite/sqlite/build.gradle
+++ b/sqlite/sqlite/build.gradle
@@ -27,6 +27,8 @@
     implementation(libs.kotlinStdlib)
     testImplementation(libs.junit)
     testImplementation(libs.mockitoCore)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.truth)
 }
 
 android {
diff --git a/sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteQueryBuilder.kt b/sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteQueryBuilder.kt
index 9bf6265..044a86f 100644
--- a/sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteQueryBuilder.kt
+++ b/sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteQueryBuilder.kt
@@ -118,7 +118,7 @@
      * @return a new query
      */
     fun create(): SupportSQLiteQuery {
-        require(groupBy?.isNotEmpty() == true || having?.isEmpty() == true) {
+        require(!groupBy.isNullOrEmpty() || having.isNullOrEmpty()) {
             "HAVING clauses are only permitted when using a groupBy clause"
         }
         val query = buildString(120) {
@@ -129,9 +129,9 @@
             if (columns?.size != 0) {
                 appendColumns(columns!!)
             } else {
-                append(" * ")
+                append("* ")
             }
-            append(" FROM ")
+            append("FROM ")
             append(table)
             appendClause(" WHERE ", selection)
             appendClause(" GROUP BY ", groupBy)
diff --git a/sqlite/sqlite/src/test/java/androidx/sqlite/db/SupportSQLiteQueryBuilderTest.kt b/sqlite/sqlite/src/test/java/androidx/sqlite/db/SupportSQLiteQueryBuilderTest.kt
new file mode 100644
index 0000000..cd0e65e
--- /dev/null
+++ b/sqlite/sqlite/src/test/java/androidx/sqlite/db/SupportSQLiteQueryBuilderTest.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ * 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.sqlite.db
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFails
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class SupportSQLiteQueryBuilderTest {
+    @Test
+    fun null_groupBy_and_having_throws_error() {
+        val error = assertFails {
+            SupportSQLiteQueryBuilder.builder("Books")
+                .having(">100")
+                .create()
+        }.message
+        assertThat(error).isEqualTo("HAVING clauses are only permitted when using a groupBy clause")
+    }
+
+    @Test
+    fun groupBy_and_having_does_not_throw_error() {
+        val query = SupportSQLiteQueryBuilder.builder("Books")
+            .columns(arrayOf("name", "pages"))
+            .groupBy("pages")
+            .having(">100")
+            .create()
+        assertThat(query.sql).isEqualTo("SELECT name, pages FROM Books GROUP BY pages HAVING >100")
+    }
+
+    @Test
+    fun select_star_groupBy_and_having_does_not_throw_error() {
+        val query = SupportSQLiteQueryBuilder.builder("Books")
+            .columns(emptyArray())
+            .groupBy("pages")
+            .having(">100")
+            .create()
+        assertThat(query.sql).isEqualTo("SELECT * FROM Books GROUP BY pages HAVING >100")
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt
index 971069d..f1b3668 100644
--- a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt
+++ b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt
@@ -29,9 +29,9 @@
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Button
@@ -62,7 +62,7 @@
 fun FeaturedCarousel() {
     val carouselState = remember { CarouselState(0) }
     Carousel(
-        modifier = Modifier.height(130.dp).width(950.dp).border(1.dp, Color.Black),
+        modifier = Modifier.height(130.dp).fillMaxWidth().border(1.dp, Color.Black),
         carouselState = carouselState,
         slideCount = mediaItems.size
     ) { SampleFrame(it) }
diff --git a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/SampleImmersiveList.kt b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/SampleImmersiveList.kt
index 45dffab..cab1518 100644
--- a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/SampleImmersiveList.kt
+++ b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/SampleImmersiveList.kt
@@ -56,7 +56,7 @@
     ImmersiveList(
         modifier = Modifier
             .height(130.dp)
-            .width(950.dp)
+            .fillMaxWidth()
             .border(1.dp, Color.Black),
         background = { index, _ ->
             AnimatedContent(targetState = index) { SampleBackground(it) } },
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt
index 3ecc680..16787a1 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt
@@ -38,6 +38,7 @@
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicText
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -50,6 +51,8 @@
 import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.focus.onFocusChanged
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.SemanticsActions
 import androidx.compose.ui.test.assertIsDisplayed
@@ -60,8 +63,10 @@
 import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.test.onParent
 import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.test.filters.FlakyTest
+import androidx.test.platform.app.InstrumentationRegistry
 import androidx.tv.material.ExperimentalTvMaterialApi
 import org.junit.Rule
 import org.junit.Test
@@ -333,13 +338,92 @@
         rule.onNodeWithText("PLAY").assertIsFocused()
     }
 
+    @Test
+    fun carousel_manualScrolling_ltr() {
+        rule.setContent {
+            Content {
+                TestButton("Button ${it + 1}")
+            }
+        }
+
+        // Assert that slide 1 is in view
+        rule.onNodeWithText("Button 1").assertIsDisplayed()
+
+        // advance time
+        rule.mainClock.advanceTimeBy(delayBetweenSlides)
+        rule.mainClock.advanceTimeBy(animationTime)
+
+        // go right once
+        performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT)
+
+        // Wait for slide to load
+        rule.mainClock.advanceTimeBy(animationTime)
+
+        // Assert that slide 2 is in view
+        rule.onNodeWithText("Button 2").assertIsDisplayed()
+
+        // go left once
+        performKeyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT)
+
+        // Wait for slide to load
+        rule.mainClock.advanceTimeBy(delayBetweenSlides)
+        rule.mainClock.advanceTimeBy(animationTime)
+
+        // Assert that slide 1 is in view
+        rule.onNodeWithText("Button 1").assertIsDisplayed()
+    }
+
+    @Test
+    fun carousel_manualScrolling_rtl() {
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                Content {
+                    TestButton("Button ${it + 1}")
+                }
+            }
+        }
+
+        // Assert that slide 1 is in view
+        rule.onNodeWithText("Button 1").assertIsDisplayed()
+
+        // advance time
+        rule.mainClock.advanceTimeBy(delayBetweenSlides)
+        rule.mainClock.advanceTimeBy(animationTime)
+
+        // go right once
+        performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT)
+
+        // Wait for slide to load
+        rule.mainClock.advanceTimeBy(animationTime)
+
+        // Assert that slide 2 is in view
+        rule.onNodeWithText("Button 2").assertIsDisplayed()
+
+        // go left once
+        performKeyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT)
+
+        // Wait for slide to load
+        rule.mainClock.advanceTimeBy(delayBetweenSlides)
+        rule.mainClock.advanceTimeBy(animationTime)
+
+        // Assert that slide 1 is in view
+        rule.onNodeWithText("Button 1").assertIsDisplayed()
+    }
+
+    private fun performKeyPress(keyCode: Int, count: Int = 1) {
+        for (i in 1..count) {
+            InstrumentationRegistry
+                .getInstrumentation()
+                .sendKeyDownUpSync(keyCode)
+        }
+    }
+
     @Composable
     fun Content(
         carouselState: CarouselState = remember { CarouselState() },
-        content: @Composable (index: Int) -> Unit = { BasicText(text = "Text ${it + 1}")
-        }
+        slideCount: Int = 3,
+        content: @Composable (index: Int) -> Unit = { BasicText(text = "Text ${it + 1}") }
     ) {
-        val slideCount = 3
         LazyColumn {
             item {
                 Carousel(
@@ -475,7 +559,7 @@
     }
 
     @Test
-    fun carousel_zeroSlideCount_drawsSomething() {
+    fun carousel_zeroSlideCount_doesntCrash() {
         val testTag = "emptyCarousel"
         rule.setContent {
             Carousel(slideCount = 0, modifier = Modifier.testTag(testTag)) {}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt b/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt
index d579e94a..cc77d09 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt
@@ -46,10 +46,16 @@
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.focus.onFocusChanged
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onPlaced
 import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.tv.material.ExperimentalTvMaterialApi
 import java.lang.Math.floorMod
@@ -95,24 +101,83 @@
     CarouselStateUpdater(carouselState, slideCount)
     var focusState: FocusState? by remember { mutableStateOf(null) }
     val focusManager = LocalFocusManager.current
+    val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
+    val focusRequester = remember { FocusRequester() }
+    var isAutoScrollActive by remember { mutableStateOf(false) }
 
     AutoScrollSideEffect(
         timeToDisplaySlideMillis,
         slideCount,
         carouselState,
-        focusState)
+        focusState,
+        onAutoScrollChange = { isAutoScrollActive = it })
     Box(modifier = modifier
+        .focusRequester(focusRequester)
         .onFocusChanged {
             focusState = it
-            if (it.isFocused) {
+            if (it.isFocused && isAutoScrollActive) {
                 focusManager.moveFocus(FocusDirection.Enter)
             }
         }
+        .focusProperties {
+            exit = {
+                val showPreviousSlideAndGetFocusRequester = {
+                    if (carouselState
+                            .isFirstSlide()
+                            .not()
+                    ) {
+                        carouselState.moveToPreviousSlide(slideCount)
+                        focusRequester
+                    } else {
+                        FocusRequester.Default
+                    }
+                }
+                val showNextSlideAndGetFocusRequester = {
+                    if (carouselState
+                            .isLastSlide(slideCount)
+                            .not()
+                    ) {
+                        carouselState.moveToNextSlide(slideCount)
+                        focusRequester
+                    } else {
+                        FocusRequester.Default
+                    }
+                }
+                when (it) {
+                    FocusDirection.Left -> {
+                        if (isLtr) {
+                            showPreviousSlideAndGetFocusRequester()
+                        } else {
+                            showNextSlideAndGetFocusRequester()
+                        }
+                    }
+                    FocusDirection.Right -> {
+                        if (isLtr) {
+                            showNextSlideAndGetFocusRequester()
+                        } else {
+                            showPreviousSlideAndGetFocusRequester()
+                        }
+                    }
+                    else -> FocusRequester.Default
+                }
+            }
+        }
         .focusable()) {
         AnimatedContent(
             targetState = carouselState.slideIndex,
             transitionSpec = { enterTransition.with(exitTransition) }
-        ) { content.invoke(it) }
+        ) {
+            Box(
+                modifier = Modifier
+                    .onPlaced {
+                        if (isAutoScrollActive.not()) {
+                            focusManager.moveFocus(FocusDirection.Enter)
+                        }
+                    }
+            ) {
+                content.invoke(it)
+            }
+        }
         this.carouselIndicator()
     }
 }
@@ -123,14 +188,16 @@
     timeToDisplaySlideMillis: Long,
     slideCount: Int,
     carouselState: CarouselState,
-    focusState: FocusState?
+    focusState: FocusState?,
+    onAutoScrollChange: (isAutoScrollActive: Boolean) -> Unit = {},
 ) {
     val currentTimeToDisplaySlideMillis by rememberUpdatedState(timeToDisplaySlideMillis)
     val currentSlideCount by rememberUpdatedState(slideCount)
     val carouselIsFocused = focusState?.isFocused ?: false
     val carouselHasFocus = focusState?.hasFocus ?: false
+    val doAutoScroll = (carouselIsFocused || carouselHasFocus).not()
 
-    if (!(carouselIsFocused || carouselHasFocus)) {
+    if (doAutoScroll) {
         LaunchedEffect(carouselState) {
             while (true) {
                 yield()
@@ -139,10 +206,11 @@
                     snapshotFlow { carouselState.activePauseHandlesCount }
                         .first { pauseHandleCount -> pauseHandleCount == 0 }
                 }
-                carouselState.nextSlide(currentSlideCount)
+                carouselState.moveToNextSlide(currentSlideCount)
             }
         }
     }
+    onAutoScrollChange(doAutoScroll)
 }
 
 @OptIn(ExperimentalTvMaterialApi::class)
@@ -186,10 +254,24 @@
         return ScrollPauseHandleImpl(this)
     }
 
-    internal fun nextSlide(slideCount: Int) {
-        if (slideCount != 0) {
-            slideIndex = floorMod(slideIndex + 1, slideCount)
-        }
+    internal fun isFirstSlide() = slideIndex == 0
+
+    internal fun isLastSlide(slideCount: Int) = slideIndex == slideCount - 1
+
+    internal fun moveToPreviousSlide(slideCount: Int) {
+        // No slides available for carousel
+        if (slideCount == 0) return
+
+        // Go to previous slide
+        slideIndex = floorMod(slideIndex - 1, slideCount)
+    }
+
+    internal fun moveToNextSlide(slideCount: Int) {
+        // No slides available for carousel
+        if (slideCount == 0) return
+
+        // Go to next slide
+        slideIndex = floorMod(slideIndex + 1, slideCount)
     }
 }
 
diff --git a/window/window/build.gradle b/window/window/build.gradle
index c8f0b4d..7eb8cff 100644
--- a/window/window/build.gradle
+++ b/window/window/build.gradle
@@ -80,6 +80,12 @@
     samples(project(":window:window-samples"))
 }
 
+tasks.withType(KotlinCompile).configureEach {
+    kotlinOptions {
+        freeCompilerArgs += ["-Xjvm-default=all"]
+    }
+}
+
 androidx {
     name = "Jetpack WindowManager Library"
     publish = Publish.SNAPSHOT_AND_RELEASE